Repository navigation
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
Description
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._clientskeeps a strong reference to it, soServer.wait_closed()never returns;- it stays in
loop._transportsand is not closing, so every later connection that gets the same fd number fails withRuntimeError: 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.
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsTodo
Activity
hamarshehmhmd commentedon Nov 1, 2026
I reproduced this on current main (debug build at 0fd00ee). I patched
base_events._set_nodelayto raiseOSError(22)on the first call, then opened three connections to astart_serverserver. The first one fails, and the next two getRuntimeError: File descriptor ... is used by transportfrom the exception handler and an empty read.server.close(); await server.wait_closed()then hangs, becauseserver._clientsstill holds the half-built transport.The cause is the order of operations in
_SelectorSocketTransport.__init__(Lib/asyncio/selector_events.pyL954-964).super().__init__registers the transport inloop._transports[fd]and callsserver._attach(self)(L811). Only after that does L964 call_set_nodelay. If that call raises, nothing undoes the registration._ProactorSocketTransport.__init__(proactor_events.pyL641) has the same problem withserver._attach(L67).Fix: set
TCP_NODELAYbefore the base__init__, so a failure happens before anything is registered. The proactor base needs a class-level_sock = None, the same as_SelectorTransportalready has, so that__del__doesn't raiseAttributeErroron the half-built object: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 readb'ok', andwait_closed()returns. I addedtest_ctor_set_nodelay_errortoSelectorSocketTransportTestsandProactorSocketTransportTests. Each one patches_set_nodelayto raise and asserts thatserver._attachisn't called (and, for the selector, that the fd isn't inloop._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 -j8passes (3,033 tests, 25 skipped). I only ran this on Linux, so the proactor test ran against a mocked proactor, not on Windows.