Bug #22223
openSocket.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)
Description
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)¶
require "socket"
Socket.tcp("127.0.0.1", 3999, connect_timeout: 1)
# => #<Socket ...> (expected: Errno::ECONNREFUSED)
Socket.tcp("127.0.0.1", 3999, connect_timeout: 1, fast_fallback: false)
# => #<Socket ...> (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:
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.
No data to display