Socket connection error after macOS 26.7 update

Hola folks,

I’m looking for folks who are running macOS and have, or are about to, update to macOS 26.7 (still Tahoe) to run a test. Here’s what happened:

  • I have several Crystal apps that use HTTP to talk to local servers
  • The local servers are accessible via localhost and 127.0.0.1
  • Until two days ago, all my apps worked fine talking to localhost as host.
  • After the macOS point update, the same apps, not re-compiled, shipped binaries, all failed with a timeout error.
  • Switching to 127.0.0.1 they worked; but no longer with localhost.
  • But curl worked fine with both.

Intriguing, I thought! I pulled out my dweeb hat and empty pipe and leaned my unused violin nearby and sat down to some sleuthing. ;-)

  • Addrinfo.resolve returns two entries for localhost: an v6 address ::1 and v4 address 127.0.0.1
  • TCPSocket.new gets these two and then calls Socket#connect which goes through them and is supposed to go past a failing ::1 and land on 127.0.0.1, which it did in fact do before my macOS update.
  • However now it “thinks” it succeeded and returns a socket that then fails immediately when the HTTP::Client tries to write to it.

I’m the first person to line up and say “it must be me, can’t be Apple, seems too basic.” (I call this the “Oracle problem”, back in the day when fellow devs ran into SQL problems they blamed it on Oracle, but it was never Oracle.)

With help from Claude Opus I learned about how curl does something called “happy eyeballs” or RFC 8305. That alone was worth running this issue. But this still didn’t feel right. After some more research I was able to write a test program in Crystal that tries to extract all the different error codes and confirm / deny the issue.

Also, since this was affecting shipping software I needed to ship a fix, so I worked out a monkey-patched shard that I was able to use in my projects and ship updates for others using my apps. I wasn’t sure if it affected everyone but I couldn’t take a chance.

I’ll paste the test program below, I can’t attach .cr file. It turns out it’s not just me. A colleague hadn’t updated yet, so he ran the test before and after and boom. So I am now looking for more data. Anyone who is willing to run the program, instructions are in the comment of the program, would be greatly appreciated. :folded_hands:

If you have the same problem you will get the following verdict:

== Verdict
  FAIL  closed port raises
  FAIL  localhost falls back
  FAIL  2nd connect reports refusal
  AFFECTED: Crystal does not report refused connections on this system.

If you don’t have the problem you will get this:

== Verdict
  ok    closed port raises
  ok    localhost falls back
  ok    2nd connect reports refusal
  NOT AFFECTED

Here’s the test script to save and run.

# Checks whether Crystal's Socket#connect misses refused connections, as seen
# on macOS 26.7. Standalone: does not use socket_connect_fix and needs no
# running server.
#
# Usage:
#   crystal run connect_refusal_check.cr
#   crystal run -Devloop=libevent connect_refusal_check.cr
#
# Check 1 decides the verdict. Check 2 shows the user-visible symptom and needs
# localhost to resolve another address before 127.0.0.1. Check 3 shows what the
# kernel returns, independent of Crystal. Exits 1 if affected, 0 if not, 2 if
# check 1 could not run.
require "socket"

{% if flag?(:darwin) || flag?(:bsd) %}
  SO_ERROR = 0x1007
{% elsif flag?(:linux) %}
  SO_ERROR = 4
{% else %}
  {% raise "SO_ERROR constant unknown for this platform" %}
{% end %}

# Returns trimmed stdout of *cmd*, or "n/a" if it cannot run.
def command_output(cmd : String, args : Array(String)) : String
  io = IO::Memory.new
  status = Process.run(cmd, args, output: io, error: Process::Redirect::Close)
  status.success? ? io.to_s.strip.gsub('\n', ' ') : "n/a"
rescue
  "n/a"
end

# Returns the kernel's pending error on *fd* and clears it.
def pending_error(fd) : Int32
  value = 0
  len = LibC::SocklenT.new(sizeof(Int32))
  LibC.getsockopt(fd, LibC::SOL_SOCKET, SO_ERROR, pointerof(value), pointerof(len))
  value
end

# Returns a port on *host* that nothing is listening on.
def closed_port(host : String) : Int32
  server = TCPServer.new(host, 0)
  port = server.local_address.port
  server.close
  port
end

def errno_text(n : Int32) : String
  n == 0 ? "0" : "#{n} #{Errno.new(n)}"
end

results = {} of String => Bool?

puts "== Environment"
puts "  macOS     #{command_output("sw_vers", ["-productVersion"])} (#{command_output("sw_vers", ["-buildVersion"])})"
puts "  kernel    #{command_output("uname", ["-sr"])}"
puts "  arch      #{command_output("uname", ["-m"])}"
puts "  crystal   #{Crystal::VERSION}"
puts "  evloop    #{Crystal::EventLoop.backend_class}"

puts "\n== Check 1: Socket#connect to a closed 127.0.0.1 port"
begin
  port = closed_port("127.0.0.1")
  sock = TCPSocket.new(Socket::Family::INET)
  begin
    sock.connect(Socket::IPAddress.new("127.0.0.1", port))
    err = pending_error(sock.fd)
    puts "  connect returned success; SO_ERROR=#{errno_text(err)}"
    results["closed port raises"] = false
  rescue ex : Socket::ConnectError
    puts "  connect raised #{ex.class}: #{ex.message}"
    results["closed port raises"] = true
  ensure
    sock.close
  end
rescue ex
  puts "  could not run: #{ex.class}: #{ex.message}"
  results["closed port raises"] = nil
end

puts "\n== Check 2: TCPSocket.new(\"localhost\") with an IPv4-only server"
begin
  order = Socket::Addrinfo.tcp("localhost", 80).map(&.ip_address.address)
  puts "  localhost resolves to #{order.join(", ")}"
  if order.first? == "127.0.0.1" || !order.includes?("127.0.0.1")
    puts "  skipped: needs another address before 127.0.0.1"
    results["localhost falls back"] = nil
  else
    server = TCPServer.new("127.0.0.1", 0)
    begin
      client = TCPSocket.new("localhost", server.local_address.port, connect_timeout: 3)
      begin
        peer = client.remote_address.address
        puts "  connected to #{peer}"
        results["localhost falls back"] = true
      rescue ex
        puts "  returned a socket that fails on use: #{ex.class}: #{ex.message}"
        results["localhost falls back"] = false
      ensure
        client.close
      end
    rescue ex
      puts "  raised #{ex.class}: #{ex.message}"
      results["localhost falls back"] = false
    ensure
      server.close
    end
  end
rescue ex
  puts "  could not run: #{ex.class}: #{ex.message}"
  results["localhost falls back"] = nil
end

puts "\n== Check 3: raw libc non-blocking connect trace (no Crystal event loop)"
begin
  port = closed_port("127.0.0.1")
  addr = Socket::IPAddress.new("127.0.0.1", port)
  fd = LibC.socket(LibC::AF_INET, LibC::SOCK_STREAM, 0)
  raise "socket() failed: #{Errno.value}" if fd == -1
  LibC.fcntl(fd, LibC::F_SETFL, LibC.fcntl(fd, LibC::F_GETFL, 0) | LibC::O_NONBLOCK)

  ret1 = LibC.connect(fd, addr.to_unsafe, addr.size)
  e1 = ret1 == -1 ? Errno.value.value : 0
  sleep 100.milliseconds
  ret2 = LibC.connect(fd, addr.to_unsafe, addr.size)
  e2 = ret2 == -1 ? Errno.value.value : 0
  so = pending_error(fd)
  LibC.close(fd)

  puts "  1st connect() = #{ret1}, errno #{errno_text(e1)}"
  puts "  2nd connect() = #{ret2}, errno #{errno_text(e2)}"
  puts "  SO_ERROR after = #{errno_text(so)}"
  results["2nd connect reports refusal"] = ret1 == -1 && e1 == Errno::ECONNREFUSED.value || e2 == Errno::ECONNREFUSED.value
rescue ex
  puts "  could not run: #{ex.class}: #{ex.message}"
  results["2nd connect reports refusal"] = nil
end

puts "\n== Verdict"
results.each do |name, ok|
  mark = ok.nil? ? "SKIP" : (ok ? "ok  " : "FAIL")
  puts "  #{mark}  #{name}"
end

case results["closed port raises"]
when false
  puts "  AFFECTED: Crystal does not report refused connections on this system."
  exit 1
when true
  puts "  NOT AFFECTED"
  exit 0
else
  puts "  INCONCLUSIVE: check 1 could not run."
  exit 2
end

Cheers.

PS. The GitHub repo with the workaround/fix shard is here: GitHub - nogginly/socket_connect_fix.cr: Monkey-patched workaround for when Crystal's Socket#connect reports a refused connection as successful. · GitHub

Some results from running your test program on a mac mini M1.

Before upgrading.

% crystal run test_socket.cr 
== Environment
  macOS     26.6.2 (25G83)
  kernel    Darwin 25.6.0
  arch      arm64
  crystal   1.21.0
  evloop    Crystal::EventLoop::Kqueue

== Check 1: Socket#connect to a closed 127.0.0.1 port
  connect raised Socket::ConnectError: connect: Connection refused

== Check 2: TCPSocket.new("localhost") with an IPv4-only server
  localhost resolves to ::1, 127.0.0.1
  connected to 127.0.0.1

== Check 3: raw libc non-blocking connect trace (no Crystal event loop)
  1st connect() = -1, errno 36 EINPROGRESS
  2nd connect() = -1, errno 61 ECONNREFUSED
  SO_ERROR after = 0

== Verdict
  ok    closed port raises
  ok    localhost falls back
  ok    2nd connect reports refusal
  NOT AFFECTED

After upgrading macOS.

ld: warning: object file (/Users/peterj/.cache/crystal/Volumes-TOSHIBA EXT-work-play-crystal-test-socket-test_socket.cr/L-ibU-nwind5858R-easonC-ode.o0.o) was built for newer 'macOS' version (28.0) than being linked (27.0)
ld: warning: object file (/Users/peterj/.cache/crystal/Volumes-TOSHIBA EXT-work-play-crystal-test-socket-test_socket.cr/T-uple40S-tring44-ef230bc2db84fb8ce6dcaa21e11a1560.o0.o) was built for newer 'macOS' version (28.0) than being linked (27.0)
ld: warning: object file (/Users/peterj/.cache/crystal/Volumes-TOSHIBA EXT-work-play-crystal-test-socket-test_socket.cr/L-ibG-C-5858S-tackB-ase.o0.o) was built for newer 'macOS' version (28.0) than being linked (27.0)
ld: warning: object file (/Users/peterj/.cache/crystal/Volumes-TOSHIBA EXT-work-play-crystal-test-socket-test_socket.cr/T-uple40U-I-nt6441.o0.o) was built for newer 'macOS' version (28.0) than being linked (27.0)
== Environment
  macOS     27.0 (26A428)
  kernel    Darwin 27.0.0
  arch      arm64
  crystal   1.21.0
  evloop    Crystal::EventLoop::Kqueue

== Check 1: Socket#connect to a closed 127.0.0.1 port
  connect returned success; SO_ERROR=61 ECONNREFUSED

== Check 2: TCPSocket.new("localhost") with an IPv4-only server
  localhost resolves to ::1, 127.0.0.1
  returned a socket that fails on use: Socket::Error: getpeername: Invalid argument

== Check 3: raw libc non-blocking connect trace (no Crystal event loop)
  1st connect() = -1, errno 36 EINPROGRESS
  2nd connect() = -1, errno 56 EISCONN
  SO_ERROR after = 61 ECONNREFUSED

== Verdict
  FAIL  closed port raises
  FAIL  localhost falls back
  FAIL  2nd connect reports refusal
  AFFECTED: Crystal does not report refused connections on this system.

ld: warning: object file (/Users/peterj/.cache/crystal/Volumes-TOSHIBA EXT-work-play-crystal-test-socket-test_socket.cr/L-ibU-nwind5858R-easonC-ode.o0.o) was built for newer 'macOS' version (28.0) than being linked (27.0)
ld: warning: object file (/Users/peterj/.cache/crystal/Volumes-TOSHIBA EXT-work-play-crystal-test-socket-test_socket.cr/T-uple40S-tring44-ef230bc2db84fb8ce6dcaa21e11a1560.o0.o) was built for newer 'macOS' version (28.0) than being linked (27.0)
ld: warning: object file (/Users/peterj/.cache/crystal/Volumes-TOSHIBA EXT-work-play-crystal-test-socket-test_socket.cr/L-ibG-C-5858S-tackB-ase.o0.o) was built for newer 'macOS' version (28.0) than being linked (27.0)
ld: warning: object file (/Users/peterj/.cache/crystal/Volumes-TOSHIBA EXT-work-play-crystal-test-socket-test_socket.cr/T-uple40U-I-nt6441.o0.o) was built for newer 'macOS' version (28.0) than being linked (27.0)
== Environment
  macOS     27.0 (26A428)
  kernel    Darwin 27.0.0
  arch      arm64
  crystal   1.21.0
  evloop    Crystal::EventLoop::LibEvent

== Check 1: Socket#connect to a closed 127.0.0.1 port
  connect returned success; SO_ERROR=61 ECONNREFUSED

== Check 2: TCPSocket.new("localhost") with an IPv4-only server
  localhost resolves to ::1, 127.0.0.1
  returned a socket that fails on use: Socket::Error: getpeername: Invalid argument

== Check 3: raw libc non-blocking connect trace (no Crystal event loop)
  1st connect() = -1, errno 36 EINPROGRESS
  2nd connect() = -1, errno 56 EISCONN
  SO_ERROR after = 61 ECONNREFUSED

== Verdict
  FAIL  closed port raises
  FAIL  localhost falls back
  FAIL  2nd connect reports refusal
  AFFECTED: Crystal does not report refused connections on this system.

So it behaved as you described but with a truck load of loader warnings, most of which I have omitted from the transcripts.

Hope that you find this useful.

Could you test the raw libc behaviour without O_NONBLOCK to get a full picture?

Also I understand the behaviour with kqueue event loop (default) and libevent are identical (works before 26.7, broken after). Is that correct?

Here’s the console transcript for 27.0 with O_NONBLOCK removed.

ld: warning: object file (/Users/peterj/.cache/crystal/Volumes-TOSHIBA EXT-work-play-crystal-test-socket-test_socket_2.cr/L-ibG-C-5858S-tackB-ase.o0.o) was built for newer 'macOS' version (28.0) than being linked (27.0)
ld: warning: object file (/Users/peterj/.cache/crystal/Volumes-TOSHIBA EXT-work-play-crystal-test-socket-test_socket_2.cr/T-uple40U-I-nt6441.o0.o) was built for newer 'macOS' version (28.0) than being linked (27.0)
== Environment
  macOS     27.0 (26A428)
  kernel    Darwin 27.0.0
  arch      arm64
  crystal   1.21.0
  evloop    Crystal::EventLoop::Kqueue

== Check 1: Socket#connect to a closed 127.0.0.1 port
  connect returned success; SO_ERROR=61 ECONNREFUSED

== Check 2: TCPSocket.new("localhost") with an IPv4-only server
  localhost resolves to ::1, 127.0.0.1
  returned a socket that fails on use: Socket::Error: getpeername: Invalid argument

== Check 3: raw libc connect trace (no Crystal event loop)
  1st connect() = -1, errno 61 ECONNREFUSED
  2nd connect() = -1, errno 56 EISCONN
  SO_ERROR after = 0

== Verdict
  FAIL  closed port raises
  FAIL  localhost falls back
  ok    2nd connect reports refusal
  AFFECTED: Crystal does not report refused connections on this system.

I no longer have a 26.7 system so I cannot answer your other question.