fix: don't hang terminal proxy when one forwarding pump exits first (#25479)

The proxy gathered _client_to_upstream and _upstream_to_client with
return_exceptions=True. When upstream sends a graceful CLOSE,
_upstream_to_client returns but gather keeps waiting on
_client_to_upstream, which is blocked in ws.receive() until the browser
disconnects. The handler stays pending and the finally: session.close()
cleanup is deferred, leaking a ClientSession and an open browser socket.

Use asyncio.wait(return_when=FIRST_COMPLETED) and cancel the pending
sibling, so the proxy unwinds as soon as either direction finishes. The
pumps' bare except Exception already lets CancelledError (a BaseException)
propagate, so cancellation is clean and they need no change.

Fixes #25464

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Classic298
2026-06-01 19:10:04 +02:00
committed by GitHub
parent 7594823edd
commit 02b65ea582
+14 -5
View File
@@ -331,11 +331,20 @@ async def ws_terminal(
except Exception:
pass
await asyncio.gather(
_client_to_upstream(),
_upstream_to_client(),
return_exceptions=True,
)
# End the proxy as soon as either direction finishes (e.g. a
# graceful upstream CLOSE) and cancel the sibling, which would
# otherwise hang on a blocked ws.receive() until the browser leaves.
tasks = [
asyncio.create_task(_client_to_upstream()),
asyncio.create_task(_upstream_to_client()),
]
_done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception as e:
log.exception('Terminal WebSocket proxy error: %s', e)
finally: