Skip to content

fix(tui_gateway): keep a strong reference to the coalesced token flush task - #83973

Open
briandevans wants to merge 4 commits into
NousResearch:mainfrom
briandevans:fix/tui-gateway-ws-token-flush-anchor-69684
Open

briandevans wants to merge 4 commits into
NousResearch:mainfrom
briandevans:fix/tui-gateway-ws-token-flush-anchor-69684

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

This is a sibling follow-up to #69684 (commit 8e013099177)

  • What fix(tui_gateway): preserve websocket batch order #69684 covered: it introduced the whole token-coalescing subsystem in tui_gateway/ws.py_pending_tokens, _token_lock, _safe_send_many, _flush_tokens — and made a coalesced batch indivisible and correctly ordered, with the two tests that still live in tests/test_tui_gateway_ws.py.
  • What fix(tui_gateway): preserve websocket batch order #69684 did NOT touch: the survival of that batch. _flush_tokens() swaps the buffer out and hands the resulting list to a bare asyncio.create_task() whose return value is dropped. Ordering was completed; the reference was not.
  • What this adds: a strong reference to the in-flight flush, and the teardown bound that new reference implies.

What does this PR do?

_flush_tokens() is the streaming hot path. Every message.delta / reasoning.delta / thinking.delta frame bound for a TUI, desktop or web WebSocket client is buffered into _pending_tokens and flushed from this ~30 fps timer callback (_TOKEN_COALESCE_S = 0.033). On main it ends:

batch = self._pending_tokens
self._pending_tokens = []
self._loop.create_task(self._safe_send_many(batch))

asyncio.create_task() keeps only a weak reference, so the loop may garbage-collect a still-pending task mid-flight. The repo already states this invariant against itself, at gateway/run.py:10494-10497:

We still hold a strong reference in self._restart_task: a bare asyncio.create_task() keeps only a weak reference, so the event loop may garbage-collect a still-pending task mid-flight.

Why this instance is worse than the ordinary fire-and-forget case. The batch left _pending_tokens before the task existed, so the task holds the only surviving reference to those frames. A collected task takes them with it — nothing retries, nothing re-queues, and no error is logged anywhere. The user-visible symptom is the assistant's reply stopping part-way through in the TUI/desktop window while the turn is still running server-side.

Reachability (all on main): server.py:5767/:5772/:9976 _emit("message.delta", …)server.py:1573 _emitwrite_jsonserver.py:1558-1561 routes the session's frame to t.write(obj)ws.py:118 WSTransport.write()_is_streaming_frame true (ws.py:53-57) → buffered, _arm_token_flush arms call_later_flush_tokens() → the unanchored task. Every streamed token frame on every WS client goes through it; no unusual configuration is required.

And the invariant the remedy creates. Anchoring the task gives the transport a strong reference it did not have before, so close() — the sole teardown path, handle_ws's finally, on the loop thread — now owes that reference a bound. close() currently latches _closed and cancels the coalesce TimerHandle, and neither releases what the transport still holds:

  • Cancelling that timer removes the only caller that would ever have drained _pending_tokens, so whatever is buffered at that instant stays retained on a transport that can no longer send it.
  • _safe_send_many only re-checks _closed between frames. A send already suspended inside ws.send_text() on a wedged socket — the exact condition _WS_WRITE_TIMEOUT_S exists for — never observes the latch. With the anchor in place that task and the transport keep each other alive after handle_ws has returned, one pair per wedged connection.

close() is synchronous, so it cannot await the drain the way GatewayPlatform.cancel_background_tasks() does. That bound is deliberate and disclosed: it cancels without awaiting, which is the non-blocking half of the same contract. Nothing is lost by it — _closed already guarantees no further frame reaches the wire, and handle_ws closes the socket immediately afterwards. CancelledError derives from BaseException, so _safe_send_many's except Exception neither swallows it nor spuriously latches _closed.

The anchor uses the repo's established idiom rather than a bespoke one: a self._background_tasks: set[asyncio.Task] plus task.add_done_callback(self._background_tasks.discard), as in gateway/platforms/base.py:3058/:5773/:5781 and gateway/platforms/api_server.py:3952/:3956. The set is touched only on the loop thread (the timer callback and the done callback), so it needs no extra locking.

Related Issue

No filed issue. This is a sibling completion of merged PR #69684 (8e013099177 fix(tui_gateway): preserve websocket batch order), which introduced the coalescing subsystem this defect lives in.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

Three atomic commits:

  1. fix(tui_gateway): anchor the coalesced token flush tasktui_gateway/ws.py. Adds self._background_tasks: set[asyncio.Task] to WSTransport.__init__; _flush_tokens() now keeps the task it creates and releases it from the task's own done callback.
  2. fix(tui_gateway): release the coalesce buffer and in-flight sends on closetui_gateway/ws.py. close() clears _pending_tokens under _token_lock (matching what _flush_tokens already does on its own _closed branch) and cancels every task still in the anchor set.
  3. test(tui_gateway): cover coalesced flush anchoring and transport teardowntests/test_tui_gateway_ws.py, three cases, +109 lines.

Deliberately not changed

  • write()'s if on_loop: branch (ws.py:156) keeps its bare create_task. It looks like the same defect and is not shipped as one, because on current main that branch is unreachable in production: ws.py:392 dispatches every request via await asyncio.to_thread(server.dispatch, req, transport) (the file says so itself at :385-388"a separate thread, so transport.write is the safe path there"), inline responses are written by handle_ws with write_async (ws.py:314, :370, :401, :419) and never write(), and the other production caller server.py:1616 _broadcast_global_event is documented as running on background threads. So every real write() call takes the safe_schedule_threadsafe branch, which is already safe — the concurrent Future from run_coroutine_threadsafe keeps the loop-side task referenced. That branch is a latent hazard on a defensive path, not a live bug, and this PR does not claim otherwise.
  • _token_flush_armed is not reset in close(). Leaving it True actively suppresses re-arming from a racing writer thread on a closed transport, which is the safer state.

How to Test

pytest tests/test_tui_gateway_ws.py -v

Verified in both directions, and the two production commits were verified independently of each other:

tree under test …anchors_coalesced_token_flush …close_cancels_in_flight_batch_send …close_drops_the_coalesce_buffer
main (neither fix) FAIL FAIL FAIL
commit 1 only (anchor, no teardown) PASS FAIL FAIL
this branch PASS PASS PASS

Failure texts, read rather than merely observed:

  • On main, all three: AttributeError: 'WSTransport' object has no attribute '_background_tasks' — the transport keeps no reference to the in-flight send at all.
  • On commit 1 only: AssertionError: close() left an in-flight batch send pending / assert not {<Task pending name='Task-6' coro=<WSTransport._safe_send_many() running at tui_gateway/ws.py:252> …>}, and AssertionError: assert ['T1', 'T2'] == [].

The tests are deterministic — asyncio.Event gates, no sleep as synchronization and no forced garbage collection (whether a pending task is collected is timing-dependent, so a gc.collect() repro would be a flake and could pass on broken code). They assert the behavioural invariant instead: after _flush_tokens() returns the batch is gone from _pending_tokens and exactly one in-flight send is still reachable from the transport, it delivers both frames in order when released, and the done callback empties the set again so it cannot grow across a turn's hundreds of flushes.

They extend the harness #69684 itself added (test_ws_transport_serializes_concurrent_sends, test_ws_transport_preserves_cross_batch_order) rather than introducing a new one.

Also run green: tests/tui_gateway/test_inline_rpc_gil_starvation.py and tests/tui_gateway/test_cold_start_gil_stall.py, the other suites that exercise WSTransport's write path — 23 passed together with the file above.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — I ran the focused and adjacent suites listed under "How to Test" (23 passed), not the full suite locally
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS (Darwin 25.4.0), Python 3.11.15

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — the _flush_tokens docstring and the __init__/close comments now state the reference invariant
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — pure asyncio task bookkeeping, no platform-specific code or syscalls
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Related / Positioning

Overlap was measured rather than assumed, and both nets are reported separately.

By changed path (gh pr list --json files, newest 300 open PRs, spanning #83486–#83970 — this is a recency net covering roughly the last day, not a coverage net): one hit, #83524 (@jaxmatrix, "stop a resuming viewer stealing a running session's stream"). Its production file is tui_gateway/server.py, not ws.py; it touches tests/test_tui_gateway_ws.py only, appending at @@ -228,3 +228,47 @@. Different production surface, different defect — but the test-file overlap is real and is disclosed here.

By symbol, corpus-wide (gh search prs --state open, which reaches all open PRs but indexes title/body text only): _flush_tokens, _pending_tokens, _arm_token_flush and _token_flush_armed each return zero open PRs.

Every open PR that touches tui_gateway/ws.py, hunk-anchored against the regions edited here (_flush_tokens and close()):

PR author ws.py hunks overlap
#42983 @Ghostkyi written against a pre-coalescing ws.py — its base has singular _safe_send(line) and no _pending_tokens/_token_lock/_safe_send_many/_flush_tokens none; it moves the worker-thread fut.result() wait onto a module-level _WRITE_EXECUTOR and leaves the on-loop create_task bare
#39393 @rodboev @@ -28,6, @@ -40,6, @@ -75,6 +94,22 @@ def __init__ — header/__init__ only none in production. It also touches tests/test_tui_gateway_ws.py — disclosed
#52519 @Sahil-SS9 @@ -119,7, @@ -221,7 neither region
#55969 @Sahil-SS9 @@ -240,7 +240,14 @@ async def _safe_send_many inside the coroutine body, not its scheduling

#49605 (@Morad37) creates tests/tui_gateway/test_ws.py, a path that does not exist on main, and does not touch this file. #81395, #42249 and #50586 match WSTransport in title/body text only and none of the three touches tui_gateway/ws.py.

_flush_tokens() is the streaming hot path: every message.delta /
reasoning.delta / thinking.delta frame bound for a TUI, desktop or web
WebSocket client is buffered into _pending_tokens and flushed from this
~30fps timer callback.

The flush swaps the buffer out under _token_lock and only then creates the
send task:

    batch = self._pending_tokens
    self._pending_tokens = []
    self._loop.create_task(self._safe_send_many(batch))

asyncio.create_task() keeps only a weak reference, so the loop may collect a
still-pending task mid-flight. gateway/run.py:10494 already states this
invariant against itself. Here the consequence is worse than the usual
fire-and-forget case: the batch left the buffer before the task existed, so
the task holds the only surviving reference to those frames. A collected
task takes them with it — nothing retries and nothing re-queues, so the
tokens never reach the wire. The user sees the assistant's reply stop
part-way through while the turn is still running server-side, with no error
logged anywhere.

Keep a strong reference in a _background_tasks set and release it from the
task's own done callback, matching the established idiom in
gateway/platforms/base.py and gateway/platforms/api_server.py. The set is
touched only on the loop thread (the timer callback and the done callback),
so it needs no extra locking.

This completes NousResearch#69684, which made the coalesced batch indivisible and
correctly ordered but left its only reference on a task nothing holds.
…close

Anchoring the flush task gives the transport a strong reference it did not
have before, and teardown now owes that reference a bound. close() is the
sole teardown path (handle_ws's finally, on the loop thread) and until now
did two things: latch _closed and cancel the coalesce TimerHandle.

Neither releases what the transport is still holding.

- Cancelling the timer removes the only caller that would ever have drained
  _pending_tokens, so whatever is buffered at that moment stays retained on a
  transport that can no longer send it. Clear it under _token_lock, which is
  what _flush_tokens already does on its own _closed branch.
- _safe_send_many only re-checks _closed between frames. A send suspended
  inside ws.send_text() on a wedged socket — the exact condition
  _WS_WRITE_TIMEOUT_S exists for — never observes the latch, so it stays
  pending indefinitely while the anchor set holds it and it holds the
  transport. handle_ws returns and the pair keeps itself alive, one per
  wedged connection.

Cancel every task still in the set. close() is synchronous, so it cannot
await the drain the way GatewayPlatform.cancel_background_tasks does; that
bound is deliberate and the cancel is the non-blocking half of the same
contract. Nothing is lost by it: _closed already guarantees no further frame
reaches the wire, and handle_ws closes the socket immediately afterwards.
CancelledError derives from BaseException, so _safe_send_many's
`except Exception` does not swallow it or spuriously latch _closed, and each
task's done callback empties the set as it unwinds.
…down

Extends the harness NousResearch#69684 added for the coalescing subsystem rather than
inventing a new one; all three cases are deterministic and use asyncio.Event
gates, no sleeps and no forced garbage collection.

- test_ws_transport_anchors_coalesced_token_flush drives _flush_tokens()
  directly, asserts the batch has left _pending_tokens (so the send task
  holds the only reference to it), asserts exactly one in-flight send is
  still reachable from the transport, then releases it and checks both frames
  reach the socket in order and the done callback empties the set again.
- test_ws_transport_close_cancels_in_flight_batch_send wedges FakeWS.send_text
  on an Event that is never set — the condition _WS_WRITE_TIMEOUT_S exists
  for — and asserts close() leaves no pending send behind.
- test_ws_transport_close_drops_the_coalesce_buffer asserts close() releases
  frames the cancelled timer can no longer deliver.

Verified in both directions. Against the unpatched transport all three fail
because WSTransport keeps no reference to the send task at all. Against the
anchor alone, without the teardown change, the first passes and the other two
fail for their own stated reasons: "close() left an in-flight batch send
pending" with the live _safe_send_many task in the message, and the buffer
still holding ['T1', 'T2'].
Copilot AI lite review requested due to automatic review settings August 11, 2026 15:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a lifecycle bug in the TUI gateway WebSocket transport where coalesced token-flush tasks could be garbage-collected mid-flight (dropping streamed frames), by anchoring those tasks until completion and ensuring teardown cancels in-flight sends and releases buffered tokens.

Changes:

  • Anchor _flush_tokens()-spawned _safe_send_many() tasks via a per-transport _background_tasks set with done-callback cleanup.
  • Improve close() teardown by clearing the coalesce buffer and cancelling any anchored in-flight flush tasks.
  • Add deterministic tests covering task anchoring, cancellation on close, and buffer release on close.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
tui_gateway/ws.py Keeps strong references to coalesced flush tasks and adds teardown cancellation/buffer release to prevent dropped frames and leaks.
tests/test_tui_gateway_ws.py Adds regression tests validating anchoring behavior and correct teardown semantics.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tui_gateway/ws.py
Comment on lines +285 to +286
for task in [t for t in self._background_tasks if not t.done()]:
task.cancel()
@alt-glitch alt-glitch added type/bug Something isn't working comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 11, 2026
…allback at a time

close() cancels the anchored batch sends but then waits for each task's done
callback to discard it. Until the loop next runs, transport ->
_background_tasks -> task -> bound _safe_send_many -> transport is still a
cycle, so the pair is reclaimable only by the cycle collector instead of by
refcount — and when the send is wedged inside ws.send_text(), which is the
condition this teardown exists to bound, that wait has no upper bound at all.

Clear the set outright after the cancel loop, matching
GatewayPlatform.cancel_background_tasks, which clears after its own drain.
Clearing is terminal here: _flush_tokens returns early once _closed is
latched, so nothing repopulates the set, and set.discard on an
already-removed task is a no-op, so the done callbacks stay harmless.

test_ws_transport_close_cancels_in_flight_batch_send now asserts the set is
empty synchronously on return from close(), before the loop is given a chance
to run any callback.
@briandevans

Copy link
Copy Markdown
Contributor Author

@copilot Finding addressed in commit 212f49f1ed9 (current head 212f49f1ed9).

  • close() now drops the tracking outright instead of waiting for each task's done callback — self._background_tasks.clear() at tui_gateway/ws.py:295, immediately after the cancel loop. This matches GatewayPlatform.cancel_background_tasks, which clears after its own drain (gateway/platforms/base.py:6952).
  • Why it matters here specifically: until the loop next runs, transport -> _background_tasks -> task -> bound _safe_send_many -> transport is a reference cycle, so the pair is reclaimable only by the cycle collector rather than by refcount. On a send wedged inside ws.send_text() — the exact condition this teardown path exists to bound — that wait has no upper bound at all.
  • Why clearing is safe and terminal: _flush_tokens returns early once _closed is latched, so nothing repopulates the set after close(); and set.discard on an already-removed task is a no-op, so the still-registered done callbacks stay harmless.
  • Covered by test_ws_transport_close_cancels_in_flight_batch_send (tests/test_tui_gateway_ws.py:276), which now asserts the set is empty synchronously on return from close() at tests/test_tui_gateway_ws.py:311 — i.e. before the loop is given a chance to run any callback — in addition to the existing assertions that no in-flight send is left pending and that the task ends cancelled.

tests/test_tui_gateway_ws.py plus the two other suites that exercise WSTransport's write path (tests/tui_gateway/test_inline_rpc_gil_starvation.py, tests/tui_gateway/test_cold_start_gil_stall.py) are green: 23 passed.

@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

fix(tui_gateway): keep a strong reference to the coalesced token flush task

No blocking issues found. The analysis is correct (bare create_task is only weakly referenced; the batch is already swapped out of _pending_tokens before the task exists), the fix is minimal, and the three tests pin the behavior well. A few minor observations:

  1. Cancellation is fire-and-forget by necessity, but worth a comment at the call siteclose() cancels in-flight tasks without awaiting (it can't, being synchronous on the loop thread). On a genuinely wedged socket where await ws.send_text() never returns, task.cancel() won't interrupt the underlying await promptly; the safety net is that _background_tasks.clear() drops the strong reference, so the pair is at least collectable. That is documented in the comment already — consider also noting that a cancelled task's CancelledError propagates out of _safe_send_many (it subclasses BaseException, so the except Exception there won't swallow it and mark the transport closed) — which is the desired behavior here, but a future reader may not assume it.

  2. write_async vs the timer flush remain asymmetricwrite_async drains _pending_tokens and awaits _safe_send_many inline (not anchored), while only the timer path is anchored. That is fine today, but a comment noting the asymmetry would prevent a future refactor from "simplifying" the timer path into the anchored one or vice versa.

  3. Micro-nit: the [t for t in self._background_tasks if not t.done()] copy is correct (avoids mutating the set during iteration) — worth a one-line comment so it doesn't get "simplified" to a bare iteration later.

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

Labels

comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants