Project

General

Profile

Actions

Bug #22223

open

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)

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)

Added by tdehnke (Tony Dehnke) 3 days ago.

Status:
Open
Assignee:
-
Target version:
-
ruby -v:
ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [arm64-darwin27]
[ruby-core:126222]

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

Actions

Also available in: PDF Atom