From: "k0kubun (Takashi Kokubun) via ruby-core" Date: 2026-09-14T20:16:52+00:00 Subject: [ruby-core:126712] [Ruby Bug#22223] Socket.tcp with connect_timeout returns a phantom "connected" socket for a refused connection on macOS 27 (kernel answers EISCONN on connect retry; SO_ERROR never consulted) Issue #22223 has been updated by k0kubun (Takashi Kokubun). Backport changed from 3.3: DONTNEED, 3.4: DONE, 4.0: REQUIRED to 3.3: DONTNEED, 3.4: DONE, 4.0: DONE ruby_4_0 commit:791000ba79aec5297c394417036de1ba1566fc1c. ---------------------------------------- Bug #22223: Socket.tcp with connect_timeout returns a phantom "connected" socket for a refused connection on macOS 27 (kernel answers EISCONN on connect retry; SO_ERROR never consulted) https://bugs.ruby-lang.org/issues/22223#change-119010 * Author: tdehnke (Tony Dehnke) * Status: Closed * ruby -v: ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [arm64-darwin27] * Backport: 3.3: DONTNEED, 3.4: DONE, 4.0: DONE ---------------------------------------- ## Summary On macOS 27.0 (beta, build 26A5388g, arm64 ��� M5), `Socket.tcp(host, port, connect_timeout: X)` returns a socket object instead of raising `Errno::ECONNREFUSED` when nothing is listening on the port. The returned socket is not usable (first `write` raises `Errno::EPIPE`). The blocking paths (`connect_timeout: nil`, `TCPSocket.new`) raise `Errno::ECONNREFUSED` correctly. ������ Note: this is a macOS **beta** build; the kernel behavior may change before release. Reporting anyway because Ruby's connect idiom is not robust against it, and the failure is silent (phantom success, not an error). ## Repro (no listener on port 3999) ```ruby require "socket" Socket.tcp("127.0.0.1", 3999, connect_timeout: 1) # => # (expected: Errno::ECONNREFUSED) Socket.tcp("127.0.0.1", 3999, connect_timeout: 1, fast_fallback: false) # => # (same ��� not Happy-Eyeballs-specific) Socket.tcp("127.0.0.1", 3999, connect_timeout: nil) # blocking path # => Errno::ECONNREFUSED (correct) TCPSocket.new("127.0.0.1", 3999) # => Errno::ECONNREFUSED (correct) s = Socket.tcp("127.0.0.1", 3999, connect_timeout: 1) s.write("x") # => Errno::EPIPE ��� the "connected" socket was never connected ``` Reproduced identically on ruby 3.4.5 (arm64-darwin27) and ruby 3.4.10 (arm64-darwin27). Other tools on the same machine (curl, a blocking C connect) report Connection refused correctly and instantly. ## Root cause observed The classic nonblocking idiom ��� `connect_nonblock` ��� wait writable ��� retry `connect_nonblock`, treating `Errno::EISCONN` as success (as recommended in Ruby's own `connect_nonblock` documentation, and as used by `Socket.tcp`'s connect_timeout path) ��� is broken by a Darwin 27 kernel behavior change. C-level demonstration on a refused nonblocking connect: ``` first connect: r=-1 errno=36 (Operation now in progress) poll: r=1 revents=0x10 # POLLHUP ��� yet the fd is reported "ready" retry connect: r=-1 errno=56 (Socket is already connected) # EISCONN, on a REFUSED socket SO_ERROR: 61 (Connection refused) # the truth is still available here ``` So the kernel answers the retry-`connect(2)` with `EISCONN` instead of `ECONNREFUSED`, and Ruby's idiom takes that as "connected". `getsockopt(SO_ERROR)` still reports `ECONNREFUSED` ��� checking it after writability (before/instead of trusting the connect-retry result) detects the failure correctly. Ruby-level equivalent: ```ruby sock = Socket.new(:INET, :STREAM) addr = Socket.sockaddr_in(3999, "127.0.0.1") begin sock.connect_nonblock(addr) rescue IO::WaitWritable sock.wait_writable(2) # returns "writable" begin sock.connect_nonblock(addr) # raises Errno::EISCONN (!) rescue Errno::EISCONN sock.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int # => 61 ECONNREFUSED end end ``` ## Impact Any code probing "is something listening?" via `Socket.tcp` with a `connect_timeout` silently gets a false positive on this platform. Found in the wild: shakapacker's `Shakapacker::DevServer#running?` (probe with `connect_timeout: 0.1`) reported a webpack dev server that wasn't there, so its Rails middleware proxied every asset request into a dead port ��� every page 502s with no error pointing anywhere near the cause. ## Suggested direction After waiting for writability in the connect_timeout / HEv2 paths (and possibly in the documented `connect_nonblock` idiom), consult `getsockopt(SO_ERROR)` rather than relying on the retry-`connect` returning `EISCONN` ��� SO_ERROR carries the correct `ECONNREFUSED` even when the kernel misanswers the retry. (Also plausibly worth an Apple Feedback report; the poll `revents=POLLHUP`-as-ready + `EISCONN`-on-refused combination looks like an OS regression.) ## Environment - macOS 27.0 (beta), build 26A5388g, Apple Silicon (M5), Darwin 27 - ruby 3.4.5 (2025-07-16 revision 20cda200d3) +PRISM [arm64-darwin27] ��� also ruby 3.4.10 [arm64-darwin27] - Possibly related (different symptom, same neighborhood): Bug #21104 (Net::HTTP failures on macOS ��� 3.4, hardware-dependent, RUBY_TCP_NO_FAST_FALLBACK workaround). Unlike #21104, this repro is NOT affected by `fast_fallback: false`. -- https://bugs.ruby-lang.org/ ______________________________________________ ruby-core mailing list -- ruby-core@ml.ruby-lang.org To unsubscribe send an email to ruby-core-leave@ml.ruby-lang.org ruby-core info -- https://ml.ruby-lang.org/mailman3/lists/ruby-core.ml.ruby-lang.org/