IO::Endpoint::Wrapper#accept appears to leak accepted peer sockets because it yields the accepted socket to the block but does not close it afterward.
In a long-running TCP server, each short-lived client connection leaves one additional file descriptor open until the process eventually hits EMFILE / Too many open files.
I hit this through rubydns / async-dns: after enough DNS-over-TCP connections, the server stopped being able to create new sockets for upstream lookups and started logging:
{"time":"2026-03-25T07:00:33+01:00","severity":"error","oid":59272,"pid":57,"subject":"Resolv::DNS::Message","message":"Too many open files - socket(2)"}
Affected code
lib/io/endpoint/wrapper.rb currently has this shape:
def accept(server, timeout: nil, linger: nil, **options, &block)
loop do
socket, address = socket_accept(server)
# ...
schedule do
# ...
yield socket, address
end
end
end
Unlike connect and bind, there is no ensure that closes the accepted peer socket after the block returns.
Expected behavior
Each accepted peer socket should be closed by accept after the block finishes, unless ownership is explicitly transferred elsewhere.
The lifecycle should be similar to connect / bind, which wrap yielded sockets in ensure ... socket.close.
Actual behavior
Accepted sockets remain open after the block completes, and file descriptors grow linearly with the number of TCP connections handled.
Minimal reproduction
require "bundler/setup"
require "io/endpoint"
require "socket"
begin
GC.disable
wrapper = IO::Endpoint::Wrapper.default
server = TCPServer.new("127.0.0.1", 0)
port = server.local_address.ip_port
accept_thread = Thread.new do
wrapper.accept(server) do |socket, _address|
socket.read(1) rescue nil
end
end
sleep 0.1
initial = Dir.children("/proc/self/fd").count
300.times do
client = TCPSocket.new("127.0.0.1", port)
client.write(".")
client.close
end
sleep 0.5
after = Dir.children("/proc/self/fd").count
puts "initial_fd_count=#{initial}"
puts "after_fd_count=#{after}"
puts "delta=#{after - initial}"
ensure
accept_thread&.kill
server&.close
end
Actual result
On my machine:
initial_fd_count=7
after_fd_count=307
delta=300
This indicates one leaked FD per accepted TCP connection.
Expected result
The FD count should remain approximately stable after handled connections complete.
Versions
- Ruby:
3.4.8
- io-endpoint:
0.15.2
- async-dns:
1.4.1
- rubydns:
2.1.1
- async:
2.24.0
- io-event:
1.10.0
- OS: Linux
6.19.10-arch1-1
I also tested io-endpoint 0.17.2, and the same reproduction still leaked one file descriptor per connection.
Real-world impact
This breaks long-running TCP servers that use IO::Endpoint::Wrapper#accept, including DNS servers using rubydns / async-dns.
In our DNS service:
- TCP DNS requests accumulated leaked descriptors over time.
- The process eventually hit the open-files limit.
- New upstream sockets could no longer be created.
- DNS resolution effectively stopped until the process restarted.
Local workaround
We worked around it by ensuring the accepted socket is closed after Async::DNS::StreamHandler#handle_connection returns:
module Async
module DNS
class StreamHandler
alias original_handle_connection handle_connection
def handle_connection(socket)
original_handle_connection(socket)
ensure
socket.close unless socket.closed?
end
end
end
end
Suggested fix
Wrap the yielded socket in IO::Endpoint::Wrapper#accept with an ensure that closes the peer socket after the block returns, similar to the existing connect and bind helpers.
IO::Endpoint::Wrapper#acceptappears to leak accepted peer sockets because it yields the accepted socket to the block but does not close it afterward.In a long-running TCP server, each short-lived client connection leaves one additional file descriptor open until the process eventually hits
EMFILE/Too many open files.I hit this through
rubydns/async-dns: after enough DNS-over-TCP connections, the server stopped being able to create new sockets for upstream lookups and started logging:Affected code
lib/io/endpoint/wrapper.rbcurrently has this shape:Unlike
connectandbind, there is noensurethat closes the accepted peer socket after the block returns.Expected behavior
Each accepted peer socket should be closed by
acceptafter the block finishes, unless ownership is explicitly transferred elsewhere.The lifecycle should be similar to
connect/bind, which wrap yielded sockets inensure ... socket.close.Actual behavior
Accepted sockets remain open after the block completes, and file descriptors grow linearly with the number of TCP connections handled.
Minimal reproduction
Actual result
On my machine:
This indicates one leaked FD per accepted TCP connection.
Expected result
The FD count should remain approximately stable after handled connections complete.
Versions
3.4.80.15.21.4.12.1.12.24.01.10.06.19.10-arch1-1I also tested
io-endpoint 0.17.2, and the same reproduction still leaked one file descriptor per connection.Real-world impact
This breaks long-running TCP servers that use
IO::Endpoint::Wrapper#accept, including DNS servers usingrubydns/async-dns.In our DNS service:
Local workaround
We worked around it by ensuring the accepted socket is closed after
Async::DNS::StreamHandler#handle_connectionreturns:Suggested fix
Wrap the yielded socket in
IO::Endpoint::Wrapper#acceptwith anensurethat closes the peer socket after the block returns, similar to the existingconnectandbindhelpers.