Skip to content

asyncio: a failure in setsockopt(TCP_NODELAY) during transport creation leaves a zombie transport, so the fd is poisoned and Server.wait_closed() hangs #161076

Open

Description

@OsmnvAslan

Bug report

Bug description:

_SelectorSocketTransport.__init__ (and _ProactorSocketTransport.__init__) call base_events._set_nodelay(sock) after the base __init__ has already registered the transport: server._attach(self), and for the selector loop loop._transports[fd] = self. If setsockopt() raises there, the accept path (since gh-155934) closes the socket and reports "Error on transport creation for incoming connection", but the half-built transport stays registered:

  • Server._clients keeps a strong reference to it, so Server.wait_closed() never returns;
  • it stays in loop._transports and is not closing, so every later connection that gets the same fd number fails with RuntimeError: File descriptor ... is used by transport .... In practice the server stops serving new clients.

Reproducer (simulates the failing setsockopt() for the first accepted connection):

import asyncio
from asyncio import base_events

# Simulate setsockopt(TCP_NODELAY) failing for the first accepted connection,
# as macOS does (EINVAL) for a socket the peer already reset before accept().
real_set_nodelay = base_events._set_nodelay
calls = 0
def failing_set_nodelay(sock):
    global calls
    calls += 1
    if calls == 1:
        raise OSError(22, "Invalid argument")
    real_set_nodelay(sock)
base_events._set_nodelay = failing_set_nodelay

async def main():
    loop = asyncio.get_running_loop()
    loop.set_exception_handler(lambda loop, ctx: print("handler:", repr(ctx["exception"])[:90]))

    async def handle(reader, writer):
        writer.write(b"ok")
        writer.close()

    server = await asyncio.start_server(handle, "127.0.0.1", 0)
    port = server.sockets[0].getsockname()[1]

    for i in range(3):
        reader, writer = await asyncio.open_connection("127.0.0.1", port)
        print(f"connection {i}:", await reader.read())
        writer.close()
        await asyncio.sleep(0.1)

    server.close()
    try:
        await asyncio.wait_for(server.wait_closed(), 2)
        print("wait_closed() returned")
    except TimeoutError:
        print("wait_closed() hangs; server still tracks", len(server._clients), "client(s)")

asyncio.run(main())

Output on main (Linux):

handler: OSError(22, 'Invalid argument')
connection 0: b''
handler: RuntimeError("File descriptor <socket.socket fd=8, family=2, type=1, proto=6, laddr=('127.
connection 1: b''
handler: RuntimeError("File descriptor <socket.socket fd=8, family=2, type=1, proto=6, laddr=('127.
connection 2: b''
wait_closed() hangs; server still tracks 1 client(s)

Without the simulated failure, all three connections get b'ok' and wait_closed() returns.

When can this happen?

On Linux, setsockopt(TCP_NODELAY) succeeds even on a socket that was reset before accept(), so I could not trigger this without the monkeypatch. On macOS, setsockopt(TCP_NODELAY) fails with EINVAL on such a dead accepted socket; Cap'n Proto hit exactly this in its accept loop (bitcoin-core/libmultiprocess#319). asyncio is not affected on macOS today only because it skips TCP_NODELAY for accepted sockets there (sock.proto is 0, gh-148783). Once gh-148784 lands, a single client that resets the connection before it is accepted would be enough to trigger this on macOS. Any other OSError raised at that point has the same effect.

Possible fix

TCP_NODELAY is only a latency optimization, so _set_nodelay() could ignore OSError. Alternatively, it could be called before the transport registers itself with the server and the loop. I'm happy to send a PR if either approach sounds right.

CPython versions tested on:

CPython main branch

Operating systems tested on:

Linux


AI disclosure: I found this while reviewing gh-156366 and investigated it with the help of an AI assistant (Claude). The reproducer and the control case (the same script without the simulated failure) were run on current main; I reviewed the analysis and the cited sources.

Activity

  1. added
    type-bugAn unexpected behavior, bug, or error
    stdlibStandard Library Python modules in the Lib/ directory
    on Nov 1, 2026
  2. hamarshehmhmd commented on Nov 1, 2026

    @hamarshehmhmd

    I reproduced this on current main (debug build at 0fd00ee). I patched base_events._set_nodelay to raise OSError(22) on the first call, then opened three connections to a start_server server. The first one fails, and the next two get RuntimeError: File descriptor ... is used by transport from the exception handler and an empty read. server.close(); await server.wait_closed() then hangs, because server._clients still holds the half-built transport.

    The cause is the order of operations in _SelectorSocketTransport.__init__ (Lib/asyncio/selector_events.py L954-964). super().__init__ registers the transport in loop._transports[fd] and calls server._attach(self) (L811). Only after that does L964 call _set_nodelay. If that call raises, nothing undoes the registration. _ProactorSocketTransport.__init__ (proactor_events.py L641) has the same problem with server._attach (L67).

    Fix: set TCP_NODELAY before the base __init__, so a failure happens before anything is registered. The proactor base needs a class-level _sock = None, the same as _SelectorTransport already has, so that __del__ doesn't raise AttributeError on the half-built object:

    --- a/Lib/asyncio/selector_events.py
    +++ b/Lib/asyncio/selector_events.py
    @@ -950,6 +950,12 @@ class _SelectorSocketTransport(_SelectorTransport):
     
         def __init__(self, loop, sock, protocol, waiter=None,
                      extra=None, server=None, context=None):
    +        # Disable the Nagle algorithm -- small writes will be
    +        # sent without waiting for the TCP ACK.  This generally
    +        # decreases the latency (in some cases significantly.)
    +        # Do it before the base __init__ registers the transport with the
    +        # loop and the server, so a failure leaves nothing behind.
    +        base_events._set_nodelay(sock)
             self._read_ready_cb = None
             super().__init__(loop, sock, protocol, extra, server, context)
             self._eof = False
    @@ -958,10 +964,6 @@ def __init__(self, loop, sock, protocol, waiter=None,
                 self._write_ready = self._write_sendmsg
             else:
                 self._write_ready = self._write_send
    -        # Disable the Nagle algorithm -- small writes will be
    -        # sent without waiting for the TCP ACK.  This generally
    -        # decreases the latency (in some cases significantly.)
    -        base_events._set_nodelay(self._sock)
     
             self._call_soon(self._protocol.connection_made, self)
    --- a/Lib/asyncio/proactor_events.py
    +++ b/Lib/asyncio/proactor_events.py
    @@ -47,6 +47,8 @@ class _ProactorBasePipeTransport(transports._FlowControlMixin,
                                      transports.BaseTransport):
         """Base class for pipe and socket transports."""
     
    +    _sock = None  # Initialized early so __del__ works if __init__ fails.
    +
         def __init__(self, loop, sock, protocol, waiter=None,
                      extra=None, server=None):
             super().__init__(extra, loop)
    @@ -637,8 +639,9 @@ class _ProactorSocketTransport(_ProactorReadPipeTransport,
     
         def __init__(self, loop, sock, protocol, waiter=None,
                      extra=None, server=None):
    -        super().__init__(loop, sock, protocol, waiter, extra, server)
    +        # Before the base __init__ attaches the transport to the server.
             base_events._set_nodelay(sock)
    +        super().__init__(loop, sock, protocol, waiter, extra, server)

    The accept path already closes the socket when the transport constructor raises, so no other cleanup is needed.

    Tested: with the fix, the same script logs the one OSError, the next two connections read b'ok', and wait_closed() returns. I added test_ctor_set_nodelay_error to SelectorSocketTransportTests and ProactorSocketTransportTests. Each one patches _set_nodelay to raise and asserts that server._attach isn't called (and, for the selector, that the fd isn't in loop._transports). Both fail without the change ("Expected '_attach' to not have been called. Called 1 times.") and pass with it. ./python -m test test_asyncio -j8 passes (3,033 tests, 25 skipped). I only ran this on Linux, so the proactor test ran against a mocked proactor, not on Windows.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    stdlibStandard Library Python modules in the Lib/ directorytopic-asynciotype-bugAn unexpected behavior, bug, or error

    Projects

    • Status
      Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions