Skip to content

fix(terminals): stop the control-bridge forwarder cancelling mid-drain - #7901

Open
yaoharry wants to merge 2 commits into
mainfrom
test/deflake-terminals
Open

yaoharry wants to merge 2 commits into
mainfrom
test/deflake-terminals

Conversation

@yaoharry

Copy link
Copy Markdown
Collaborator

Related issue

Summary

tests/terminals/test_control_bridge.py::test_control_bridge_burst_then_exit_delivers_full_tail
failed 10 times across 10 distinct commits with 4 confirmed flakes (failed, then passed on
re-run of the same commit):

E  AssertionError: burst-then-exit dropped the tail: got 1996313 Y bytes of 2000000
   — forwarder was cancelled before draining the queued backlog

This is a real product bug, not test timing. The test already waited deterministically on
reader_done / forward_done events, and bytes were still short — which is the tell.

bridge_tmux_control_to_websocket bounded the post-%exit drain with a total-elapsed-time
timeout. asyncio.shield protected forward_task from that wait_for's own timeout, but once
the 5s elapsed the code fell through to an unconditional cancel loop that cancelled
forward_task for real — even while it was actively sending frames, just slower than 5s.
And because the done-callback fires on any completion including cancellation, forward_done
was set identically whether the forwarder finished cleanly or was killed mid-drain. The
"drain complete" signal was genuinely a lie, for the test and for any real caller.

Impact on users: a TUI that bursts output just before exiting loses the tail of that output in
the web terminal, under host contention or with a slow client.

Fix: bound the drain by idle time instead of total elapsed time. _forward_terminal_to_ws
gains an on_frame_sent hook; the bridge tracks last-send activity and only gives up once the
forwarder has gone a full window with zero frames sent. The backlog is finite (the reader
queues the EOF sentinel before exiting), so a forwarder still moving bytes must be allowed to
finish. A genuinely wedged forwarder still falls through to the same cancel path, but now logs
the queue depth and idle duration so a future stall is diagnosable rather than a bare timeout.

Two test-side races in the same area, both found by stress-running the directory:

  • test_seed_rejoins_soft_wrapped_lines and
    test_seed_alternate_screen_does_not_leak_primary_history each did one fixed
    sleep(0.5) then a single capture. tmux renders a pane program's writes into its grid
    asynchronously, so under load the capture can race the reflow. Replaced with a
    _capture_seed_until poll helper that returns the last capture either way, so a genuine
    rejoin bug is reported with the actual pane content instead of a bare timeout.
  • tests/terminals/test_registry_io.py's _MARKER_BUDGET_S is a hang guard whose duration is
    incidental — each poll round-trips a real send-keys/capture-pane subprocess spawn plus
    bash running the command. It now uses tests.budgets.budget(5.0), so it follows CI's scale
    knob (20s under ci.yml's OMNIGENT_TEST_TIMEOUT_SCALE=4, still 5s locally so a genuine
    hang fails fast) rather than a larger hardcoded constant.

Test Plan

  • Mechanism proved directly, not inferred. Temporarily shrinking the drain timeout to 0.05s
    and instrumenting the cancel site produced
    DEBUG-CANCEL task=tmux-control-forward qsize=264 — the forwarder was force-cancelled with
    264 chunks still queued, and the test failed with the same "dropped the tail" assertion as
    CI. In CI's unmodified 5s runs the residual was only 2-4 KB because contention pushed the
    total drain just past 5s; same mechanism, smaller remainder.
  • Re-ran that experiment against the fixed code with the idle window shrunk to 0.02s (tighter
    than the test's 5ms/frame send delay) → passes, confirming the new logic tolerates a
    slow-but-progressing drain the old code would have killed.
  • Reviewer-run independent stress on the shared box: 8/8 consecutive runs of
    pytest tests/terminals -n 8 --dist=loadfile -q -p no:randomly green. The worker's own
    15-iteration run before the seed fix had 1 failure, in the alt-screen seed test now fixed here.
  • Verified budget(5.0) resolves to 20.0 with OMNIGENT_TEST_TIMEOUT_SCALE=4 and 5.0
    unset, and that repl-sdk (the group owning tests/terminals) is in the ci.yml matrix whose
    shared Run pytest step sets that variable — so the scaling actually applies here.
  • pre-commit run --files <4 changed files>: all hooks pass except pyrefly, whose errors are
    all in unrelated sdks/python-client / omnigent/repl modules resolving against a different
    worktree's editable SDK path (a known shared-venv artifact); none reference the changed files.

Demo

  • Visual demo attached below
  • Non-visual evidence provided below or in Test Plan
  • Not applicable — no behavioral change

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

test_control_bridge_burst_then_exit_delivers_full_tail is the regression test for the product
fix and is unchanged — its assertion (full 2 MB payload delivered) was already correct; it was
the bridge that dropped bytes. Manual verification covers the mechanism itself: the
shrunk-timeout instrumentation above is what demonstrates cancellation-with-backlog, and it is
not committed because reproducing it requires deliberately mis-sizing a production constant.

Changelog

Terminal output from a program that prints a burst just before exiting is no longer truncated in the web terminal

This pull request and its description were written by Isaac.

harry-yao_data and others added 2 commits September 21, 2026 09:01
bridge_tmux_control_to_websocket bounded the post-%exit drain wait by
total elapsed time (5s). A forwarder that was still actively sending
frames but hadn't finished the whole backlog within that window got
force-cancelled anyway, silently dropping whatever was still queued —
the forward_done event fires on cancellation too, so the drop looked
like a clean completion. Under CI/host contention (or a genuinely slow
client) this drops the tail of a burst-then-exit program's output.

Bound the wait by IDLE time instead: only give up once the forwarder
goes _FORWARD_DRAIN_IDLE_TIMEOUT_S with no frame actually sent, since
the backlog is finite (the reader already queued the EOF sentinel) and
a forwarder that keeps moving bytes must be allowed to finish however
long that takes. Log the queued backlog size when a stall does trigger
the fallback cancel, so a future regression is diagnosable.

Also poll for tmux to finish reflowing a soft-wrapped line before
asserting on the seed capture, instead of a single fixed-delay
capture that can race the reflow under load, and give the registry-io
marker-poll tests more realistic headroom for contended hosts (each
poll round-trips a real tmux subprocess spawn).

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: harry-yao_data <harry.yao@databricks.com>
…een seed race

_MARKER_BUDGET_S was a flat 15s guess for host contention with no repro to
size it against. Use tests.budgets.budget() instead: it's a hang guard, not
a latency assertion, so its duration should follow CI's own
OMNIGENT_TEST_TIMEOUT_SCALE knob (4x in the repl-sdk job) rather than a
hand-picked constant — 20s under CI contention, 5s locally so a genuine
hang still fails fast.

test_seed_alternate_screen_does_not_leak_primary_history has the same
fixed-sleep-then-single-capture race as the soft-wrap seed test: it flaked
under load during verification of that fix. Reuse the same polling helper
so the capture waits for the alt-screen draw to actually finish instead of
racing tmux's reflow.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: harry-yao_data <harry.yao@databricks.com>
@github-actions github-actions Bot added the size/M Pull request size: M label Sep 21, 2026
@omnigent-ci

omnigent-ci Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Polly AI Review

Blocking issues

  • [P1] A quiet period before exit can cause immediate tail loss. In omnigent/terminals/control_bridge.py:808, the drain timeout uses last_forward_activity without resetting it when draining starts. If the pane has been quiet for over five seconds, then emits output and exits, that timestamp can already be expired. If the first WebSocket send is still pending, the activity hook has not refreshed it. The drain returns immediately, and cleanup cancels the forwarder with output still queued.

    An isolated asyncio reproduction using the patched forwarder confirmed the regression: after 5.2 seconds of silence, a 40,960-byte tail delivered zero bytes before cancellation. The previous timeout logic delivered all bytes in approximately 0.5 seconds under the same conditions. This reproduction models reader queueing and a slow WebSocket; it is not a full tmux integration test.

    Required fix: Give draining a fresh idle window on entry, then extend it after completed sends—for example, measure from max(drain_started_at, last_forward_activity).

Security vulnerabilities

No security vulnerabilities identified in the diff. No dependency pins or package extras changed.

Non-blocking notes

  • Add deterministic regression coverage. The committed tests do not distinguish the new idle timeout from the previous total timeout. Cover:
    • Silence longer than the idle window, followed by a final burst and exit.
    • A drain lasting longer than one window while sends keep completing.
    • A stalled send that is cancelled after the idle window.
  • Validation limits, not PR defects: Full integration tests were not run. Codex validation could not start because of a startup timeout; OpenCode, Cursor, Hermes, Pi, and Antigravity CLI binaries were unavailable.

Approach

The progress hook and idle-based drain are appropriate for preserving output from slow but progressing clients. Keeping the initial idle window local to the drain wait fixes the regression without changing the overall design. The test polling and timeout-budget changes fit the described terminal-test stabilization work.

Summary

Request changes. The PR addresses a real output-loss mechanism, but introduces another: inactivity before shutdown can consume the entire drain allowance before newly queued output gets a chance to send. Start the idle window at drain entry and add the three regression cases above. Verify that a pane which remains silent beyond the timeout, prints a final marker, and exits still delivers that marker through a slow client, while a genuinely stalled client terminates within the idle bound.


Automated review by Polly · workflow run

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

Labels

size/M Pull request size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant