Repository navigation
fix(tui): decouple WS read/write with dedicated writer task - #54612
yingliang-zhang wants to merge 1 commit into
Conversation
80b7262 to
860628c
Compare
The TUI gateway's handle_ws loop was a single serial coroutine: receive_text → dispatch → write_async. When streaming events from agent worker threads flooded the event loop with send_text coroutines, the write path stalled, blocking the read loop. Inbound RPCs — prompt.submit, session.resume, session.interrupt — sat unread in the WS receive buffer until the desktop client's 30s RPC timeout fired, surfacing as 'request timed out: prompt.submit' and bounced prompts. Root cause: WSTransport.write() from worker threads scheduled a send_text coroutine on the event loop and then blocked the caller with fut.result(timeout=10s). Under heavy tool output (kubectl, tar, long terminal commands) the loop couldn't drain the scheduled coroutines fast enough, creating a positive-feedback stall: each timed-out write left a frame 'in flight', the loop fell further behind, and the read loop never got CPU time to process new RPC requests. Fix: replace the synchronous schedule-and-block write path with a thread-safe queue + dedicated writer task. - WSTransport now owns a queue.Queue (maxsize=4096) and a single _writer_loop async task that drains it one frame at a time. - write() from any thread: non-blocking put_nowait, returns instantly. Never blocks the caller, never touches the event loop. - write_async() from the event loop: same non-blocking semantics. - Only the writer task calls ws.send_text, so there are never concurrent sends on the same socket (a fragile Starlette path). - handle_ws checks transport._closed at the top of each read iteration so a dead writer (send_text failure) tears down promptly. - On disconnect, the writer is given a 2s drain window before cancel. The read loop is now fully decoupled from write backpressure: a slow client can stall the writer, but RPC reads and dispatch never block. Addresses: NousResearch#53420, NousResearch#53773, NousResearch#48445, NousResearch#51665
860628c to
35d110b
Compare
|
Closing in favor of #55545, which merged the GIL-pressure WS-disconnect fix to This was part of a 4-PR cluster all targeting the same root cause (#48445/#50005). The dedicated-writer-task approach here is a substantial read/write-loop rewrite (+233/-117); we landed the lighter-footprint combination instead: loopback ws-ping tuning + token-frame coalescing (@peetwan, #51841) plus routing frontend-polled RPCs off the read loop into the thread pool (@Zyxxx-xxxyZ, #54287). Together those resolve the read-loop starvation (the Appreciate the clear root-cause writeup on the read/write coupling — it matched our diagnosis exactly. Thanks! |
Problem
The TUI gateway's
handle_wsloop was a single serial coroutine:When streaming events from agent worker threads flooded the event loop with
send_textcoroutines, the write path stalled, blocking the read loop. Inbound RPCs —prompt.submit,session.resume,session.interrupt— sat unread in the WS receive buffer until the desktop client's 30s RPC timeout fired, surfacing as:request timed out: prompt.submit(prompt bounced back to composer)request timed out: session.resume(session switch fails)Root cause
WSTransport.write()from worker threads scheduled asend_textcoroutine on the event loop and then blocked the caller withfut.result(timeout=10s). Under heavy tool output (kubectl, tar, long terminal commands) the loop couldn't drain the scheduled coroutines fast enough, creating a positive-feedback stall:Why this is hard for users to work around
The "heaviness" of a session is not determined by session count or duration — a single
kubectl describeortar -tvfcan produce thousands of output lines that flood the WS write path. Users cannot predict which command will trigger the stall.Fix
Replace the synchronous schedule-and-block write path with a thread-safe queue + dedicated writer task.
Changes (
tui_gateway/ws.py)write()schedules coroutine on loop + blocks withfut.result(timeout=10s)write()doesqueue.put_nowait()— returns instantly, never touches event loop_safe_sendcoroutines compete forsend_texton same socket_writer_looptask drains queue → onesend_textat a timeawait write_async(resp)if WS send is slowwrite_asyncjust enqueues_WS_WRITE_TIMEOUT_S = 10.0timeout + "frame left in flight" warningsDesign
WSTransport.write():queue.put_nowait(), thread-safe, returnsTrueunless transport closed. On queue overflow (client completely stuck), drops streaming frames and logs.WSTransport.write_async(): same semantics for event-loop callers. On overflow, returnsFalsesohandle_wstreats the RPC response as undeliverable and disconnects (4096 pending frames = dead client)._writer_loop(): single async task, drains queue viarun_in_executor(None, q.get), callssend_textone frame at a time. Only coroutine that touches the socket.handle_ws: starts writer after transport creation, checks_closedat top of each read iteration (writer failure → prompt teardown), drains writer with 2s timeout on disconnect.What this does NOT change
Transportprotocol interface unchanged (write(obj) -> bool,close() -> None)StdioTransportunaffected (separate implementation)server.pydispatch logic unchangedTests
Updated
tests/test_tui_gateway_ws.py:test_ws_disconnect_reaps_flagged_session_and_closes_workertest_ws_disconnect_preserves_and_repoints_reconnectable_sessiontest_ws_write_does_not_block_on_slow_event_looptest_ws_write_returns_false_when_closedwrite()returnsFalseafterclose()— the "peer is gone" signaltest_ws_writer_serializes_sendsAll 5 tests pass. Full
tests/tui_gateway/suite: 248 passed, 1 pre-existing failure unrelated to this change (test_goal_command— stale turn-budget literal).Related issues