Bug #22286
openClosing a TCPServer while another thread is blocked in accept can discard a just-accepted connection's file descriptor, leaving the peer connected to nothing
Description
Closing a TCPServer while another thread is blocked in accept can discard a just-accepted connection's file descriptor, leaving the peer connected to nothing.
The script to reproduce the issue in each iteration binds a listener, starts a thread blocked in accept, connects a client, then tears the listener down in one of three orders. It then closes whatever the accept thread actually received and checks whether the client sees the peer go away
within half a second. After a GC it reports how many descriptors the process still holds beyond its starting count. Results on Ruby 4.0.6, Linux 6.18.
┌─────────────────────┬─────────────────────┬─────────────────────┐
│ mode │ stuck clients / 300 │ leaked fds after GC │
├─────────────────────┼─────────────────────┼─────────────────────┤
│ close │ 89 │ 89 │
├─────────────────────┼─────────────────────┼─────────────────────┤
│ shutdown │ 127 │ 127 │
├─────────────────────┼─────────────────────┼─────────────────────┤
│ shutdown-join-close │ 0 │ 0 │
└─────────────────────┴─────────────────────┴─────────────────────┘
Every stuck client is one the accept thread never handed back, and the leaked descriptor count matches. So accept(2) returned a socket, but Ruby raised the "closed in another thread" interrupt in that thread before wrapping the descriptor, and the descriptor was dropped. Joining the accept thread before calling close avoids the issue.
Run the script with:
Files
Updated by destahd (Gabriel Rocha) 26 days ago
I can confirm this on macOS too, and it isn't new. I ran into it with a small local TCP service that closes its listener from another thread during shutdown. Every so often a client would just sit there waiting, and it turned out to be exactly what's described here.
Environments tested:
ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin27]
ruby 2.6.10p210 (2022-04-12 revision 67958) [universal.arm64e-darwin26] (macOS system ruby)
Two things to add to the original report:
- Thread#kill on the accepting thread loses the descriptor the same way, not just IO#close.
- IO.select followed by accept_nonblock does not leak, which helps narrow down where the descriptor gets dropped.
My repro script is at the bottom and runs on both 2.6 and 4.0. Each iteration binds a listener on an ephemeral port and starts one accept thread that closes whatever it accepts. Four client threads connect in a loop, and after a random 0-5 ms delay the listener gets torn down in one of three ways. At the end the script runs GC, then uses lsof to count server-side descriptors that are still open for any of the listener ports. It also counts open BasicSocket objects, to show that nothing on the Ruby side still owns them.
Results, 300 iterations per row:
ruby mode accept leaked fds open socket objects
4.0.7 close blocking 179/300 0
4.0.7 kill blocking 174/300 0
4.0.7 close_then_kill blocking 179/300 0
4.0.7 close nonblock 0/300 0
4.0.7 kill nonblock 0/300 0
4.0.7 close_then_kill nonblock 0/300 0
2.6.10 close blocking 133/300 0
2.6.10 kill blocking 114/300 0
2.6.10 close_then_kill blocking 122/300 0
2.6.10 close nonblock 0/300 0
2.6.10 kill nonblock 0-1/300 0 (*)
2.6.10 close_then_kill nonblock 0/300 0
(*) On 2.6.10, kill combined with nonblock occasionally left a single descriptor behind (1/300 in 2 of 4 runs). I haven't tracked that one down and it may be something separate. I never saw it on 4.0.7.
On the server side the leaked descriptors sit in CLOSE_WAIT because the client eventually closed its end. They survive GC and no Ruby object refers to them, so nothing will ever close them. A client without a read timeout just hangs.
Here is where it seems to happen, reading v4.0.7.
TCPServer#accept goes through rsock_s_accept, which runs accept(2) through rb_io_blocking_region and wraps the new fd only after that returns:
https://github.com/ruby/ruby/blob/v4.0.7/ext/socket/init.c#L728
https://github.com/ruby/ruby/blob/v4.0.7/ext/socket/init.c#L753
In rb_thread_io_blocking_call, the fd comes back in val from the blocking region, and then RUBY_VM_CHECK_INTS_BLOCKING(ec) runs:
https://github.com/ruby/ruby/blob/v4.0.7/thread.c#L1987
https://github.com/ruby/ruby/blob/v4.0.7/thread.c#L1999
If the "stream closed in another thread" interrupt (or a kill) is pending at that point, it raises. The tag jump unwinds past rsock_s_accept, and val, which by now is a perfectly good file descriptor, is simply dropped. Nothing closes it.
rsock_s_accept_nonblock calls accept and rsock_init_sock back to back, with no blocking region or interrupt check between them, which would explain why that path doesn't leak:
https://github.com/ruby/ruby/blob/v4.0.7/ext/socket/init.c#L675-L694
I haven't built master. Reading it, though, rsock_s_accept is unchanged there, and rb_thread_io_blocking_call still runs RUBY_VM_CHECK_INTS_BLOCKING(ec) after func returns, so I'd expect the same behavior.
I'm not sure what the cleanest fix is. Since accept returns a resource rather than just data, maybe the socket extension could handle it locally: notice that accept succeeded and close (or wrap) the fd before letting the pending interrupt propagate. I don't know the interrupt machinery well enough to say whether a general fix in rb_thread_io_blocking_call would be better.
Workarounds, for anyone who lands here:
- Wait with IO.select([server]), then call server.accept_nonblock(exception: false), treating :wait_readable as "try again". Closing the server from another thread then just makes the accept thread exit with IOError, and no descriptor is lost.
- Or, as in the original report, make sure the accept thread has stopped before you close the listener.
Script (usage is in the header comment; it needs lsof):
# ruby accept_leak.rb MODE [ITERS] [nonblock]
# MODE: close | kill | close_then_kill
# pass "nonblock" as the third arg to use IO.select + accept_nonblock
require 'socket'
mode = ARGV[0] || 'close'
iters = Integer(ARGV[1] || '300')
nonblock = ARGV[2] == 'nonblock'
ports = []
iters.times do
srv = TCPServer.new('127.0.0.1', 0)
port = srv.addr[1]
ports << port
acceptor = Thread.new do
begin
loop do
if nonblock
IO.select([srv])
c = srv.accept_nonblock(exception: false)
next if c == :wait_readable
else
c = srv.accept
end
c.close
end
rescue IOError, Errno::EBADF
nil
end
end
running = true
clients = Array.new(4) do
Thread.new do
while running
begin
Socket.tcp('127.0.0.1', port, connect_timeout: 0.2).close
rescue StandardError
nil
end
end
end
end
sleep(rand * 0.005)
case mode
when 'close'
srv.close
when 'kill'
acceptor.kill
when 'close_then_kill'
srv.close
acceptor.join(0.1)
acceptor.kill
end
acceptor.join(1)
running = false
clients.each(&:join)
srv.close unless srv.closed?
end
GC.start
sleep 0.2
# Count server-side TCP descriptors (local port is one of our listener ports)
# that are still open in this process.
lsof = IO.popen(['lsof', '-nP', '-a', '-p', Process.pid.to_s, '-iTCP'], &:read)
leaked = lsof.lines.select do |line|
ports.any? { |p| line.include?("127.0.0.1:#{p}->") }
end
open_objects = ObjectSpace.each_object(BasicSocket).count { |s| !s.closed? }
puts format('%-8s %-16s %-9s leaked=%d/%d open socket objects=%d',
RUBY_VERSION, mode, nonblock ? 'nonblock' : 'blocking',
leaked.size, iters, open_objects)
Updated by PvpDJgHP (William Thomas NELSON) 19 days ago
I've opened a pull request: https://github.com/ruby/ruby/pull/19518
Could you try it with your reproduction?