module

Krikri::PluginHelpers::SocketConnect

SocketConnect - TCP connect helpers that report the error the kernel actually recorded, plus the Python-shaped text Ansible's messages are built from.

Why this exists: Crystal 1.21.1's polling event loop (crystal/event_loop/polling.cr, connect) learns the outcome of a non-blocking connect from getsockopt(SO_ERROR) but then raises it via Socket::ConnectError.from_errno("connect"), which re-reads the live libc errno instead of the value it just fetched. Anything the event loop did in between (epoll_wait, a non-blocking read) leaves a different errno behind, so a refused connect surfaces as [Errno 11] Resource temporarily unavailable (EAGAIN) or [Errno 115] Operation now in progress (EINPROGRESS) rather than ECONNREFUSED. 1.21.0 got this right by re-issuing connect(2) after the wait, which makes the kernel report the real error on the second call. (Reading SO_ERROR afterwards does not help either: on Linux getsockopt CLEARS it, so by the time the exception reaches us it is already gone.)

So we never let Crystal's #connect make the syscall for us: we issue connect(2) on a non-blocking socket, wait for writability ourselves and read SO_ERROR before anything else can consume it. That is what makes open below report ECONNREFUSED, and probe_errno - which repeats the connect against a target whose socket we no longer own, for the HTTP::Client-based plugins - report it too.

Constants

IN_FLIGHT_ERRNOS = {Errno::EAGAIN, Errno::EWOULDBLOCK, Errno::EINPROGRESS, Errno::EALREADY}

Errnos that only ever mean "this connect is still in flight", never what actually went wrong with it.

PROBE_TIMEOUT = 5.seconds

Upper bound on the re-probe in urlopen_error_text: a refused or unreachable target answers instantly, so this only bounds the targets that hang - which are the ones the original failure already tells us about (Crystal raises IO::TimeoutError for those, no probe at all).

SO_ERROR_OPTION = 4

Class methods

open(host : String, port, connect_timeout : Time::Span | Nil = nil) : Socket

Connects to host:port and returns the connected socket, raising Socket::ConnectError carrying the REAL errno on failure (or IO::TimeoutError when the connect outlives connect_timeout, the same shape TCPSocket.new raises). Every resolved address is tried, like TCPSocket.new and Python's socket.create_connection.

Source
probe_errno(host : String, port, timeout : Time::Span = PROBE_TIMEOUT) : Errno | Nil

Re-runs the connect to host:port purely to learn what the kernel said, then closes the socket: nil when the connect succeeded (the port came back up between the original attempt and this one) or when it timed out. Used by the HTTP::Client-based plugins, whose socket is owned by the client and therefore already gone - with its SO_ERROR read and discarded - by the time the exception reaches us.

Source
python_error_text(errno : Errno) : String

Python's str(OSError): "[Errno 111] Connection refused". Built from the same libc strerror Python's os.strerror uses, so the text matches Ansible's on the same host.

Source
python_exception_name(errno : Errno) : String

Python's exception class for an OSError carrying errno - the part Ansible's messages put in parentheses (dnspython's OSError shape for nsupdate, urlopen's URLError text for uri/get_url).

Source
urlopen_dns_error_text(ex : Socket::Addrinfo::Error) : String | Nil

Python's str(socket.gaierror) inside urllib's URLError text for a name-resolution failure: "<urlopen error [Errno -2] Name or service not known>" (live-verified vs ansible-core 2.19.11 on a uri request to an unresolvable hostname, agaffney.synology_dsm's Login, issue #399). Crystal reports the same failure as Socket::Addrinfo::Error with its OWN wording ("Hostname lookup for X failed: No address found"); the EAI_* code rides in os_error as a negative value, and Ansible's message echoes gai_strerror's text for exactly that code - produced here from the same host's libc, so glibc, musl and macOS each get their own wording just like the Python side does.

Source
urlopen_error_text(host : String, port, ex : Exception) : String | Nil

urllib's URLError str() - "<urlopen error [Errno 111] Connection refused>" - for a connect that failed, or nil when ex is not a connect failure (or its real errno could not be recovered), so the caller can keep its own message. A name-resolution failure is NOT one: getaddrinfo carries its own errno, which the caller already reports correctly.

Source
urlopen_error_text(url : URI, ex : Exception) : String | Nil

Same, for the URL an HTTP::Client was pointed at: the port default mirrors the client's own (explicit port, else 443 for https and 80 otherwise), so the re-probe hits the same endpoint the request did.

Source