Filed by Claude Code on evnchn's behalf.
TL;DR:
AsyncClient can be left permanently connected == True on a dead transport, and there is no supported way to recover it — disconnect(), shutdown() and a fresh connect() all fail.
The cause is an ordering gap in connect():
connect() assigns self.connected = True as its last statement, after awaiting the namespace handshake.
_handle_eio_disconnect() guards its namespace teardown and self.connected = False behind if self.connected:.
- A transport death in between therefore cleans up nothing — the flag is still
False — and then connect() resumes and sets it anyway, on a socket that is gone.
Reproduced deterministically on 5.16.3 / 4.13.3 (current latest): 25/25 macOS, 20/20 Linux, control 0/25. MRE below — no monkeypatching, no writes to any client attribute.
Two candidate fixes, and I think the second matters more than the first:
- Don't report success onto a dropped transport — re-check before the final assignment, or gate it on
self.eio.state == 'connected'.
- Let
disconnect() reset self.connected even when the transport is already gone, so a client is always recoverable without touching internals.
Reproduction — 74 lines, socketio + aiohttp only
The server is a raw aiohttp Engine.IO/Socket.IO endpoint so the timing is controllable. It does nothing illegal: it accepts the namespace, then closes. --control adds a single 0.1 s sleep before closing and the bug disappears, which is what isolates the race.
"""
MRE: AsyncClient is left permanently `connected == True` on a dead Engine.IO transport.
python-socketio 5.16.3 / python-engineio 4.13.3 / Python 3.12 / aiohttp 3.14.3
Run: python mre.py -> BUG (server closes right after the namespace CONNECT)
python mre.py --control -> OK (same server, one-line delay before closing)
"""
import asyncio
import sys
import socketio
from aiohttp import web
CONTROL = '--control' in sys.argv
OPEN = '0{"sid":"S","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}'
async def server(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
await ws.send_str(OPEN) # engine.io OPEN
async for msg in ws:
if str(msg.data).startswith('40'): # socket.io CONNECT for '/'
await ws.send_str('40{"sid":"NS"}') # accept the namespace
if CONTROL:
await asyncio.sleep(0.1) # <-- the only difference
await ws.send_str('1') # engine.io CLOSE
await ws.close()
break
return ws
def state(sio):
ws = sio.eio.ws
return (f'connected={sio.connected!r:6} eio.state={sio.eio.state!r:16} '
f'ws_closed={(ws.closed if ws else None)!r} namespaces={sio.namespaces}')
async def main():
app = web.Application()
app.router.add_route('*', '/socket.io/', server)
runner = web.AppRunner(app)
await runner.setup()
await web.TCPSite(runner, '127.0.0.1', 0).start()
url = f'http://127.0.0.1:{runner.addresses[0][1]}'
sio = socketio.AsyncClient(reconnection=False)
await sio.connect(url, transports=['websocket'], wait_timeout=3)
await asyncio.sleep(0.2)
print('after connect() ...', state(sio))
await sio.disconnect()
print('after disconnect()..', state(sio))
await sio.shutdown()
print('after shutdown() ...', state(sio))
try:
await sio.connect(url, transports=['websocket'], wait_timeout=3)
print('reconnect ......... OK')
except socketio.exceptions.ConnectionError as e:
print(f'reconnect ......... ConnectionError({e!r}) -- client is wedged')
wedged = sio.connected and sio.eio.state != 'connected'
print('\nRESULT:', 'BUG - connected==True on a dead transport' if wedged else 'OK')
await runner.cleanup()
sys.exit(1 if wedged else 0)
asyncio.run(main())
Output:
$ python mre.py
after connect() ... connected=True eio.state='disconnected' ws_closed=True namespaces={'/': 'NS'}
after disconnect().. connected=True eio.state='disconnected' ws_closed=True namespaces={'/': 'NS'}
after shutdown() ... connected=True eio.state='disconnected' ws_closed=True namespaces={'/': 'NS'}
reconnect ......... ConnectionError(ConnectionError('Already connected')) -- client is wedged
RESULT: BUG - connected==True on a dead transport
$ python mre.py --control
after connect() ... connected=False eio.state='disconnected' ws_closed=True namespaces={}
after disconnect().. connected=False eio.state='disconnected' ws_closed=True namespaces={}
after shutdown() ... connected=False eio.state='disconnected' ws_closed=True namespaces={}
reconnect ......... OK
RESULT: OK
It exits 1 on bug and 0 on OK, so it is machine-checkable.
What this proves: the wedged state is reachable and unrecoverable. What it does not prove: that a cooperative server produces it unaided — see the refuted section.
Why it cannot be recovered
disconnect() sends namespace DISCONNECT packets, then calls eio.disconnect().
engineio's _send_packet() returns immediately unless state == 'connected'.
engineio's disconnect() has its entire body guarded by if self.state == 'connected', so on a dead transport it only calls _reset() and never emits the disconnect event — the sole thing that resets AsyncClient.connected.
shutdown() delegates to disconnect() whenever connected is set, inheriting the dead end.
The only escapes today are writing client.connected = False by hand, or discarding the object. (AsyncSimpleClient.disconnect() effectively does the latter — it drops and replaces the underlying AsyncClient rather than recovering it.)
Precision note: _handle_eio_disconnect() does clear self.callbacks, self._binary_packet and self.sid unconditionally. Only the namespace teardown and self.connected = False sit inside the guard — which is enough.
What I could NOT reproduce (refuted)
A plain cooperative AsyncServer calling sio.disconnect(sid) from its own connect handler does not reproduce, at any delay from 0 to 5 ms (0/45 attempts). With no proxy and no client-side await, the client always wins the race — the window is narrower than a network round-trip.
So the MRE's trigger is fair to call staged. The counter is that transport death during connect is what proxies, load balancers and restarting pods actually cause, and that the unrecoverability half needs no race at all to confirm.
A second variant uses a genuine socketio.AsyncServer with an ordinary async connect handler, severing the link in the network with a small TCP proxy. It reproduces identically, with a real server-issued sid in namespaces (e.g. {'/': 'OHo8U94zAQThoD2OAAAB'}). Happy to attach it.
Two source details explain why the window is reachable rather than sub-microsecond:
engineio's _receive_packet dispatches with run_async=True, so the Socket.IO message handler runs in a separate task, decoupled from the read loop.
_handle_connect populates self.namespaces[ns] and then awaits the user's connect handler before _connect_event.set(). Any await in that handler widens the window to the handler's duration.
Environment
- python-socketio 5.16.3, python-engineio 4.13.3 — both current latest on PyPI, verified with a fresh unpinned install, so this is not a stale pin.
- aiohttp 3.14.3, websocket transport.
- Verified on macOS 15 (arm64, Python 3.12.11) and Linux (
python:3.12-slim, aarch64, Python 3.12.13).
- Untested: Windows, and the polling transport (both repros pin
transports=['websocket']).
Found while investigating a downstream report where devices became permanently unreachable after a network change, with connected == True and no TCP connection in the process's socket list: https://github.com/zauberzeug/nicegui/issues/6212
Changelog
- 2026-07-28 — restructured into TL;DR + folded evidence; added the refuted-reproduction section; no findings changed.
Filed by Claude Code on evnchn's behalf.
TL;DR:
AsyncClientcan be left permanentlyconnected == Trueon a dead transport, and there is no supported way to recover it —disconnect(),shutdown()and a freshconnect()all fail.The cause is an ordering gap in
connect():connect()assignsself.connected = Trueas its last statement, after awaiting the namespace handshake._handle_eio_disconnect()guards its namespace teardown andself.connected = Falsebehindif self.connected:.False— and thenconnect()resumes and sets it anyway, on a socket that is gone.Reproduced deterministically on 5.16.3 / 4.13.3 (current latest): 25/25 macOS, 20/20 Linux, control 0/25. MRE below — no monkeypatching, no writes to any client attribute.
Two candidate fixes, and I think the second matters more than the first:
self.eio.state == 'connected'.disconnect()resetself.connectedeven when the transport is already gone, so a client is always recoverable without touching internals.Reproduction — 74 lines, socketio + aiohttp only
The server is a raw aiohttp Engine.IO/Socket.IO endpoint so the timing is controllable. It does nothing illegal: it accepts the namespace, then closes.
--controladds a single 0.1 s sleep before closing and the bug disappears, which is what isolates the race.Output:
It exits
1on bug and0on OK, so it is machine-checkable.What this proves: the wedged state is reachable and unrecoverable. What it does not prove: that a cooperative server produces it unaided — see the refuted section.
Why it cannot be recovered
disconnect()sends namespace DISCONNECT packets, then callseio.disconnect().engineio's_send_packet()returns immediately unlessstate == 'connected'.engineio'sdisconnect()has its entire body guarded byif self.state == 'connected', so on a dead transport it only calls_reset()and never emits thedisconnectevent — the sole thing that resetsAsyncClient.connected.shutdown()delegates todisconnect()wheneverconnectedis set, inheriting the dead end.The only escapes today are writing
client.connected = Falseby hand, or discarding the object. (AsyncSimpleClient.disconnect()effectively does the latter — it drops and replaces the underlyingAsyncClientrather than recovering it.)Precision note:
_handle_eio_disconnect()does clearself.callbacks,self._binary_packetandself.sidunconditionally. Only the namespace teardown andself.connected = Falsesit inside the guard — which is enough.What I could NOT reproduce (refuted)
A plain cooperative
AsyncServercallingsio.disconnect(sid)from its ownconnecthandler does not reproduce, at any delay from 0 to 5 ms (0/45 attempts). With no proxy and no client-sideawait, the client always wins the race — the window is narrower than a network round-trip.So the MRE's trigger is fair to call staged. The counter is that transport death during connect is what proxies, load balancers and restarting pods actually cause, and that the unrecoverability half needs no race at all to confirm.
A second variant uses a genuine
socketio.AsyncServerwith an ordinary asyncconnecthandler, severing the link in the network with a small TCP proxy. It reproduces identically, with a real server-issued sid innamespaces(e.g.{'/': 'OHo8U94zAQThoD2OAAAB'}). Happy to attach it.Two source details explain why the window is reachable rather than sub-microsecond:
engineio's_receive_packetdispatches withrun_async=True, so the Socket.IO message handler runs in a separate task, decoupled from the read loop._handle_connectpopulatesself.namespaces[ns]and then awaits the user'sconnecthandler before_connect_event.set(). Anyawaitin that handler widens the window to the handler's duration.Environment
python:3.12-slim, aarch64, Python 3.12.13).transports=['websocket']).Found while investigating a downstream report where devices became permanently unreachable after a network change, with
connected == Trueand no TCP connection in the process's socket list:https://github.com/zauberzeug/nicegui/issues/6212Changelog