Skip to content

Watch the output during peek --wait, not only the screen (#206) - #208

Merged
myobie merged 2 commits into
mainfrom
agent/peek-wait-watches-the-stream
Nov 3, 2026
Merged

myobie merged 2 commits into
mainfrom
agent/peek-wait-watches-the-stream

Conversation

@myobie

@myobie myobie commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #206. pty peek --wait missed a marker that was printed and scrolled off the screen between two of its 200 ms screen reads. A finite wait then ended only at its bound, and a wait with no bound ended only when the session exited.

Cause. peek_wait (crates/pty-client/src/peek.rs), like Node's cmdPeekWait, only ever looked at snapshots of the visible screen. Output that came and went between two snapshots was never seen.

Fix.

  • The output stream. The wait holds one read-only peek connection for its whole length. The daemon sends it the screen and then every byte of output after it. The wait matches that screen, and the visible text of each output chunk as it arrives.

    • Escape sequences (CSI, OSC, DCS and the like) are dropped, also when a chunk boundary splits one.
    • Only a pattern's length of text is kept between chunks.
    • Text is joined only across SGR, a charset designation or an escape string. Every other CSI and the single-byte escapes such as ESC 8 can move the cursor, so they reset the carried text (after checking it for a match). END \e[2;1H004 doesn't match END 004. Text drawn that way is left to the screen read.
    • Once the next screen read is due, the watch reads at most one more chunk. Output that never pauses can't hold the wait past its screen read or its timeout.
  • The screen reads stay. The 200 ms screen read still runs, so text drawn by cursor movement is still found on screen. If the watch connection ends, the wait carries on with screen reads alone.

  • Connections. The watch's own screen is the first look, so a pattern already on screen opens no more connections than before. cli_peek_color_cleanup's fake daemon counts them and still passes.

  • Missed is told apart from never printed. On a timeout the wait reads the full scrollback once. If the pattern is there, it was printed before the wait began, and a second line says so. The first line and exit code 1 are unchanged.

    Timed out after 1s waiting for "END 004".
    It is in the scrollback from before the wait began, no longer on screen.
    

    A new PeekWaitError::Missed variant carries this. Nothing outside pty matches on the enum, and fractal doesn't use it.

  • Decision 0019 records the difference from Node, and docs/parity.md lists it beside decision 0006.

Measured. The repro is on Linux at main, using the installed 0.13.0-rust+79d526d, whose peek code is identical to main's. The session waits 1 s, prints END 004, and then prints 200 lines. The command is pty peek --plain --wait 'END 004' -t 6:

Scrolled marker Marker stays on screen
main exit 1 after 6.0 s, "Timed out…" exit 0 after 1.2 s
this branch exit 0 after 1.0 s (the printed screen starts at line 178) exit 0 after 1.0 s

Tests.

  • Real session: crates/pty/tests/cli_peek_wait.rs.
    • a_marker_scrolled_away_while_waiting_satisfies_the_wait: one write prints the marker and 200 lines, so no screen read can see it. The test also checks that the marker really is off screen and in the scrollback.
    • a_marker_from_before_the_wait_is_reported_as_missed: this case also checks that a pattern that never appeared keeps Node's single line.
  • Fake daemon: in crates/pty-client/tests/client_peek.rs.
    • peek_wait_sees_a_marker_that_only_the_output_carried: the marker is split across chunks and styles.
    • peek_wait_tells_a_missed_pattern_from_one_that_never_appeared.
  • Scanner: peek::visible_text_tests. It covers split escapes, an OSC title and a DCS that must not match, and the tail bound.
  • Review fixes, added at 7ee880e:
    • peek_wait_keeps_its_bound_under_output_that_never_pauses (fake daemon flooding the watch): a 1 s wait times out in 1.01 to 1.02 s (3 runs). At 55d0f09 it overran in 2 of 3 runs, one past 10 s and one at 3.9 s; one finished at 1.46 s.
    • peek::output_watch_tests::past_its_deadline_the_watch_reads_one_chunk_and_returns is the deterministic control. Past the deadline, the watch reads one chunk of a socket holding 93 KB and returns. At 55d0f09 it read all 93,465 bytes.
    • peek::visible_text_tests::text_either_side_of_a_cursor_move_is_not_joined matched END \e[2;1H004 at 55d0f09.
  • Control: with main's peek.rs and only the new behavior tests applied, all four fail:
    • the real scrolled case exits 1 after 10 s;
    • the fake stream case returns TimedOut after 5 s;
    • both missed cases print only the first line.
  • Existing peek --wait tests pass unchanged. These are the fake-daemon client_peek tests, cli_peek_color_cleanup, and the conformance peek_wait.rs port.
  • Workspace (cargo test --workspace --no-fail-fast, Linux, sandboxed root, at 55d0f09): 2155 passed, 1 failed, 9 ignored. The failure is interactive::typing_filters_the_list_and_escape_clears_it, which also fails on main in my checkouts: the checkout path fuzzy-matches the filter text. It passes in CI. At 7ee880e I ran pty-client (137 passed), cli_peek_wait and cli_peek_color_cleanup (3 passed), and the conformance peek_wait and scrollback_fidelity ports (17 and 8 passed).

Limits.

  • What the wait prints. A wait satisfied from the stream prints the screen at that moment, which may no longer show the marker.
  • One more client. While the wait runs, the session counts one more read-only client, as it does during peek -f.
  • Marker drawn in pieces. A marker drawn out of order with cursor movement is found only if it is on screen at a 200 ms read, as before.
  • The --wait help line is Node's fixture text ("Block until appears on screen") and is unchanged.
  • Tier-1 files. None touched; the wire format is unchanged.

peek --wait read the plain visible screen every 200 ms, as the Node tool
does. A marker printed and scrolled away between two reads was never
seen. The wait then ran on to its timeout, or, with no timeout, until
the session exited and lastLines happened to include the marker.

The wait now holds one read-only peek connection for its whole length.
The daemon sends that connection the screen and then every byte of
output after it. The wait matches the screen, and the visible text of
the output as it arrives:

- Escape sequences are dropped, also when a chunk boundary splits one.
- Only a pattern's length of text is kept across chunks.

The 200 ms screen read stays, so text drawn by cursor movement is still
found. The watch's own screen is the first look, so a pattern already on
screen costs no more connections than before.

On a timeout the wait reads the full scrollback once. A pattern found
there was printed before the wait began, and a second line says so. The
first line and the exit code are Node's. Decision 0019 records the
difference.
…s styles

Two review findings on the output watch:

- wait_until applied its deadline only to poll. Under output that never
  pauses, poll reports ready every time, so the read went on past the
  deadline. The wait then reached neither its screen read nor its
  timeout. It now returns once the deadline has passed, after at most
  one more chunk.
- The scanner dropped every CSI and joined the text either side, so
  "END \e[2;1H004" matched "END 004" although the terminal never showed
  it. Text now joins only across SGR, a charset designation or an
  escape string. Every other CSI and the single-byte escapes reset the
  carried text, after checking it for a match. Text drawn that way is
  left to the screen read.

Tests:
- A fake daemon that floods the watch: a 1 s wait times out in about 1 s.
- A unit test: past its deadline, the watch reads one chunk of a full
  socket and returns.
- Negative scanner cases for cursor moves.
@myobie

myobie commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Independent read GO at exact head 7ee880e59813d30689fccb84d34e4c38026f1722. All 9 checks are SUCCESS on this head and the PR is CLEAN. I read the first head (55d0f09f) in full and the follow-up commit as a delta.

Read:

  • peek --wait now also holds one read-only peek connection for the whole wait and matches the pattern against the screen it starts with and against the visible text of the output as it arrives. A marker printed and scrolled away between two 200 ms screen reads now ends the wait at once. The 200 ms screen read stays, so text drawn anywhere on the screen is still found.
  • On a timeout, one full-scrollback read tells a marker that was missed (a second stderr line, exit code still 1) from one that never appeared. The first line is unchanged.
  • The first head had one defect that I raised: the output read had no deadline check, so a session printing without pause could hold the wait past its time bound. The follow-up returns once the deadline passes even while output is pending, and adds a flood test plus a deterministic socket-pair test. The same commit makes the scanner stop joining text across cursor moves, so END \e[2;1H004 no longer forms a match; text is joined only across style changes, charset designations and escape strings.
  • A lost output connection falls back to the old screen polling. No wire-format change and no tier-1 file (events.jsonl, <id>.json, docs/disk-layout.md) is touched.

Risks (low):

  • While a wait runs, the session counts one more read-only client, as it does during peek -f.
  • The second stderr line on a missed marker differs from Node; it is recorded in decision 0019 and a parity row.
  • The screen printed after a match may no longer show the pattern.
  • The flood test is time-based (about 1 s with a 2.5 s limit) and could flake on a heavily loaded runner; the socket-pair test is Linux only.

@myobie
myobie merged commit d618d49 into main Nov 3, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Finite peek --wait marker returns only after the session exits

1 participant