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
Errnos that only ever mean "this connect is still in flight", never what actually went wrong with it.
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).
Class methods
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.
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.
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.
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).
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.
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.