Skip to content

IO::Endpoint::Wrapper#accept leaks accepted sockets after block returns #26

Description

@ineb

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:

  1. TCP DNS requests accumulated leaked descriptors over time.
  2. The process eventually hit the open-files limit.
  3. New upstream sockets could no longer be created.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions