Skip to content

fix(cli): mime serve's view orders every print with its draws and starts a live display of its own each time it comes back - #139

Merged
NicholasEhsanRoy merged 2 commits into
v0.3from
v0.3-terminal-race
Nov 4, 2026
Merged

NicholasEhsanRoy merged 2 commits into
v0.3from
v0.3-terminal-race

Conversation

@NicholasEhsanRoy

@NicholasEhsanRoy NicholasEhsanRoy commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

test_a_scripted_session_on_a_terminal_shows_each_event_once_above_a_strip_that_follows_the_status failed with "the live region is gone" after #138 (run 41839710392, [60]; earlier run 41762639938, [100]). #138 made the reader wait for the terminal's end; it could not reproduce the failure naturally. This PR captures the failure, reproduces it, and fixes the cause in ServeDisplay.

Capture

On a failure of its end checks, the session test now prints the screen after the stop (lines numbered), what the terminal received where the strip was first drawn and at its end (control sequences written out, each part at most 4000 characters), and whether the reader saw the terminal's end. A planted failure shows the message.

Reproduction and cause

Conditions: the session test at its three widths beside tests/cli/test_serve_view.py, three pytest runs at once on two xdist workers each, all under taskset on four cores with two busy loops of our own (and on two cores with three, for one arm), on a machine other sessions were also loading. Unchanged code: 2 failures in 225 executions, both at [60], both "the live region is gone", both with the transcript below.

The captured transcript (60 columns) shows the cause. After the load report, the view's draw (its own thread) wrote \r\x1b[2K\x1b[1A\x1b[2K and a 7-line READY region; then serve's ready line (the serve loop's thread) wrote the same 2-line reset, its line and the region. Five lines of the first frame (READY · step 0, Ctrl+C to stop, the table's header) stayed in the scrollback. rich's live display finds the top of its region from the height it was last drawn at under its own lock, but draws the region again only after letting that lock go, so a draw between the two leaves the print moving up by a stale height. The end of the transcript was complete and the reader saw the terminal's end: the clear was not lost. An in-process probe with nothing injected (ServeDisplay on a pseudo-terminal going through serve's load report and ready line, on one contended core) leaves the same ghost in 4 of 5100 trials, the raw bytes showing the same two writes with the same stale reset.

The same transcript shows a second defect of the same kind, on every run: the view kept one live display across the pause around the load report, still holding the spinner's shape, so the first line once the view was back erased the report's last line (derived graph: ...) wherever the building phase takes two lines (60 and 80 columns).

Fix

  • Every print through the view's console holds the lock its draws hold (_ordered_console): serve's own lines, the events, the draws, and writes to the redirected standard streams from any thread come one after the other. A line of serve's own can now wait for a draw under way, which stays bounded by the writer's flush wait.
  • The view starts a live display of its own each time it comes back, so no reset refers to a region no longer on the screen.

Tests

In tests/cli/test_serve_view.py, each with a control that fails its check on every run:

  • A draw made on another thread while the ready line is being printed (between its reset and its region, the window a loaded machine hit) leaves no frame of the strip. Control: a console whose prints are not ordered with the draws. The draw is forced deterministically: the line waits for it to have run or to be held back.
  • The first line once the view is back erases nothing printed while it was away (the phase checked to take more than one line). Control: one live display kept across the pause.

Both fail on 60 of 60 runs on the code before this change (10 runs, 3 widths each).

After the fix, the same loop: 0 failures in 180 executions, and the probe 0 ghosts in 3000 trials. At a base rate near 1% neither comparison discriminates on its own (with the unchanged code's rate, no failure in 180 has probability about 0.2, no ghost in 3000 about 0.1). What shows the fix is the pair of tests above, whose interleaving is forced: 60 of 60 failures before, none after, and their controls fail on every run. Stress: 20 of 20 runs passed on tests/cli/test_serve_terminal.py and tests/cli/test_serve_view.py.

Not changed here: RunDisplay (mime run) restarts one live display across its pause too, and prints Ctrl+C's acknowledgement beside rich's own refresh thread; the same class, not this test's failure.

🤖 Generated with Claude Code

…rts a live display of its own each time it comes back

test_a_scripted_session_on_a_terminal_... failed with "the live region is gone" on CI after #138 (PR
#126's run 41839710392, [60]; PR #136's run 41762639938, [100]). The failure message showed no frames,
so the test now prints, on a failure of its end checks, the screen after the stop and what the
terminal received where the strip was first drawn and at its end, each bounded, and whether the
reader saw the terminal's end.

Reproduced naturally with that capture: the session test at its three widths beside
test_serve_view.py, three pytest runs at once on two xdist workers each, all under taskset on four
cores with two busy loops of our own. The transcript shows the cause. rich's live display finds the
top of its region from the height it was last drawn at, under its own lock, and draws the region
again only after letting that lock go. Serve's ready line (the serve loop's thread) and the view's
draw (its own thread) each wrote the same stale reset, the second erasing only part of the region
the first had drawn: five lines of a READY frame, "Ctrl+C to stop" among them, stayed in the
scrollback. The end of the transcript was complete and the reader saw the terminal's end, so the
clear was not lost.

Every print through the view's console now holds the lock its draws hold (_ordered_console): serve's
own lines, the events, the draws and writes to the redirected standard streams come one after the
other. The same transcript showed a second defect of the same kind: the view kept one live display
across the pause around the load report, whose shape was the spinner's, so the first line once the
view was back erased the report's last line wherever the building phase takes two lines (60 and 80
columns, every run). The view now starts a live display of its own each time it comes back.

Tests (tests/cli/test_serve_view.py), each with a control that fails its check on every run: a draw
made on another thread while the ready line is being printed, between its reset and its region,
leaves no frame of the strip (the control: a console whose prints are not ordered with the draws);
and the first line once the view is back erases nothing printed while it was away (the control: one
live display kept across the pause). Both fail on every run on the code before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NicholasEhsanRoy NicholasEhsanRoy added ready The pull request's head is final: CI runs the full matrix on it stress Run the pull request's changed tests many times with fresh Hypothesis seeds (stress.yml) labels Nov 4, 2026
…d moved (#140), so this PR's entry goes to docs/development/changes_0.3.0.dev1.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGvZFhw6o3WBo28MJ9998e
@NicholasEhsanRoy
NicholasEhsanRoy merged commit 55d24fb into v0.3 Nov 4, 2026
86 checks passed
@NicholasEhsanRoy
NicholasEhsanRoy deleted the v0.3-terminal-race branch November 4, 2026 21:24
NicholasEhsanRoy added a commit that referenced this pull request Nov 4, 2026
…tay in the development record's Fixed section

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGvZFhw6o3WBo28MJ9998e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready The pull request's head is final: CI runs the full matrix on it stress Run the pull request's changed tests many times with fresh Hypothesis seeds (stress.yml)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant