Skip to content

Keep a command's output head and tail, drop its middle at pipe speed - #88

Merged
pcarrier merged 3 commits into
yas-run:mainfrom
indent-com:keep-output
Sep 30, 2026
Merged

pcarrier merged 3 commits into
yas-run:mainfrom
indent-com:keep-output

Conversation

@pcarrier

@pcarrier pcarrier commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #86 (on #85, on #83): merge those first. This PR's own changes are the last two commits: e51e829, and the review fixes in f59495d.

A command that writes far more than its client keeps (Ultimator's bash keeps 150,000 UTF-16 units from each end) still has every byte sent today, one window per round trip. At 100 ms RTT, 20 MB takes 3.6 s with 1 MiB windows (88 s with the 24 KiB windows before #87). Everything between the first and last 150 KB is then thrown away on the client. With KEEP_OUTPUT, the server drops the middle as it reads it and says exactly what it dropped.

Protocol

  • SPAWN_KEEP_OUTPUT (32), opt-in. It needs REPORT_EXIT and SPAWN extension tag 4, [head_bytes: u64, tail_bytes: u64]. The tail is at most MAX_KEEP_OUTPUT_TAIL_BYTES, 1 MiB. Servers offer the flag in tag 19, which Report a spawned process's exit unasked: bash in one round trip #86 made a flag set, so older clients just don't see it.

  • Cuts between characters. The cuts fall where a WHATWG UTF-8 decoder with replacement (a JS TextDecoder, Rust's from_utf8_lossy) reading the whole stream is between characters:

    • the head is at least head_bytes. It ends between characters, or where the next byte can't continue the character it is in;
    • the tail is at most tail_bytes, starts between characters, and once anything was dropped never starts with a continuation byte.

    Decoding the head and the tail, apart or one after the other, gives exactly the characters they have within the whole stream.

  • The Transfer carries the head, then the tail, with contiguous offsets.

  • The EXIT event says what was dropped, in extension tag 1 (stdout) and tag 2 (stderr), present only when something was. Each is an OutputElision: offset (the head's length), then the dropped bytes, lines, code_points and utf16_units, counted as that decoder reads them within the whole stream. A client can then say what it didn't get, in its own units.

  • When the tail goes out. Normally at the stream's end. If the stream outlives the exit (a residue past its grace, TERMINATE, a lost owner, a forced cleanup), the tail goes out before the exit is reported, and whatever is written after that goes to nobody.

Server (output_keep.rs, process.rs)

  • KeptOutput handles a stream's cuts and counts:
    • it tracks the head's decoder state and keeps a ring of the last tail_bytes;
    • bytes pushed out of the ring are counted as they drop, with runs of ASCII counted in bulk.
  • The output reader waits for the owner only while the head goes out. After that it reads at the writer's speed and sends the tail frame by frame at the end.
  • flush_kept asks the readers for their tails and waits for them as drain_paced does. It runs before abandon_residue, before the forced cleanup aborts the pipes, in TERMINATE, and on owner loss. A stream stopped before its tail goes out counts what it kept as dropped (drop_rest).

yas-client

  • Command::keep_output(head, tail) sets the flag and extension only where the server offers KEEP_OUTPUT and REPORT_EXIT; elsewhere all the output comes.
  • Process::elided(stderr) returns the elision once the exit arrives, and Output.elided carries both streams' elisions (review, f59495d).

Tests

  • output_keep unit tests (6):
    • 4,000 random mixed-width streams, cut at random head and tail sizes and fed in random pieces. For each, the head and tail decode as within the whole, and the counts equal from_utf8_lossy/encode_utf16 of the dropped part;
    • emoji and CJK cuts move to character ends;
    • a regression for a head ending in a broken character next to a tail that could continue it;
    • output that fits drops nothing;
    • bulk ASCII counting;
    • drop_rest.
  • client_host kept_output_is_its_head_and_tail_and_the_exit_counts_the_rest:
    • 64 MiB of yes on stdout plus 1 MiB on stderr: the exact head and tail bytes come, with exact counts on both streams;
    • a mixed-width file whose cuts fall inside 🙂 and 中 comes with exact bytes, code points and UTF-16 units, on stdout and stderr;
    • output that fits comes whole, with no elision;
    • merged stderr;
    • a residue holding stdout past its grace: the tail kept so far comes before the exit, with the counts to that point. Negative control: without that flush, only the head comes.
  • yas-wire an_output_elision_is_its_five_counts_and_they_must_agree (fabc391): the encoding, every prefix rejected, each broken count rule rejected, and the widest counts accepted. protocol-fuzz had found OutputElision::decode computing 2 * code_points in plain u64 arithmetic, which overflows. The check now saturates.

Checks

Run locally at e51e829 (then at f59495d: client_host 38/38 with --include-ignored, clippy --workspace --all-targets, yas-client lib 33):

Fork CI results will be posted here.

@ultimator-agent

ultimator-agent Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed e51e829 in 11m 48s: 2 issues · Session

@pcarrier

Copy link
Copy Markdown
Collaborator Author

Fork CI: indent-com#42 (yas-run Actions are stuck org-wide).

@ultimator-agent ultimator-agent Bot 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.

Ultimator's review of e51e829

Adds SPAWN_KEEP_OUTPUT. For each output stream the server keeps a head that ends on a UTF-8 character boundary and a ring of the last bytes. It drops and counts the middle as fast as the pipe gives it, and flushes the kept tails before an exit that outlives the streams. The EXIT event reports what was dropped, and the client reads it through Command::keep_output and Process::elided. The cutting logic holds up: I re-ran output_keep's tests, plus 20,000 extra random cases with drop_rest part-way through a stream (and part-way through a tail) and 300 cases with reads up to 20 KB. All counts matched from_utf8_lossy. The flush and abort ordering in the server's cleanup paths looks consistent. I found one gap in the client API and one misplaced attribute.

2 issues: 1 bug, 1 nit

No issue is open since the review of f59495d.

Session · @ultimator-agent review reviews again · @ultimator-agent fix fixes the open issues

Comment thread crates/client/src/process.rs
Comment thread crates/server/src/lib.rs
pcarrier added a commit to indent-com/yas that referenced this pull request Sep 30, 2026
Review of yas-run#88: Output had no place for what KEEP_OUTPUT dropped, and
output() takes the process, so a kept command's head and tail came joined
with nothing to say a middle was missing. Output.elided now carries it
(the test's helper is output_limited again). And the new mod output_keep
line took mod process's #[cfg(any(unix, windows))].
@ultimator-agent

ultimator-agent Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed f59495d in 34s: no new issues, 2 resolved · Session

@ultimator-agent ultimator-agent Bot 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.

Ultimator's review of f59495d

f59495d adds elided to Output, filled in by output_limited after the wait, with the field docs updated. The test now collects kept output through output_limited. It also puts mod process back under #[cfg(any(unix, windows))]. process.rs is the only place in the workspace that builds Output as a struct literal, so the new field breaks nothing. Both open issues are fixed and I found nothing new.

No issues found · 2 resolved

Session · @ultimator-agent review reviews again · @ultimator-agent fix fixes the open issues

@ultimator-agent

ultimator-agent Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed fabc391 in 27s: no new issues · Session

@ultimator-agent ultimator-agent Bot 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.

Ultimator's review of fabc391

fabc391 switches the UTF-16 upper bound in OutputElision::decode to code_points.saturating_mul(2). Before, a server that sent over 2^63 code points overflowed the multiplication: debug builds panicked and release builds wrapped to a wrong result. The new test covers the exact 40-byte encoding, every truncated prefix, one input breaking each rule, and the two largest inputs, and each case behaves as it should under the new check. There was nothing left open and I found nothing new.

No issues found.

Session · @ultimator-agent review reviews again · @ultimator-agent fix fixes the open issues

pcarrier added a commit to indent-com/yas that referenced this pull request Sep 30, 2026
Review of yas-run#88: Output had no place for what KEEP_OUTPUT dropped, and
output() takes the process, so a kept command's head and tail came joined
with nothing to say a middle was missing. Output.elided now carries it
(the test's helper is output_limited again). And the new mod output_keep
line took mod process's #[cfg(any(unix, windows))].
@ultimator-agent

ultimator-agent Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed 763661d in 1m 42s: no new issues · Session

@ultimator-agent ultimator-agent Bot 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.

Ultimator's review of 763661d

Rebased onto c812b11, now that #83 and #85 have landed; the PR's code is unchanged. git range-diff shows each of the six remaining commits as an identical patch of the one it replaces (the three REPORT_EXIT/tag-19 commits, plus e51e829, f59495d and fabc391). The tree difference from fabc391 to 763661d is exactly what main gained besides the stack, #89 and #90: the old stack tip (1afbacf) and c812b11 differ in the same 19 files, by the same stat. At the new head, cargo run -p xtask -- protocol --check reports the schema and generated artifacts current. Nothing new, and nothing is open.

No issues found.

Session · @ultimator-agent review reviews again · @ultimator-agent fix fixes the open issues

SPAWN_KEEP_OUTPUT (32), with REPORT_EXIT and SPAWN extension tag 4
[head_bytes, tail_bytes] (the tail at most 1 MiB), sends only the head and
the tail of each output stream. What comes between is dropped as the
server reads it, never held for the client's credit, so a command writing
far more than its client keeps runs at the speed of its pipe instead of
one window per round trip.

The cuts fall between characters as a WHATWG UTF-8 decoder with
replacement reads the whole stream (a JavaScript TextDecoder, Rust's
from_utf8_lossy), so decoding the head and the tail gives exactly the
characters they have within it. The EXIT event says what was dropped of
each stream (extension tags 1 and 2, OutputElision: offset, bytes, lines,
code points, UTF-16 units), so a client can say how much it did not get in
the units it counts.

The output reader waits for the owner only while the head goes out, keeps
the last tail_bytes in a ring, and sends them at the stream's end, or
before the exit is reported when the stream outlives it (a residue past
its grace, TERMINATE, a lost owner, a forced cleanup): flush_kept.

yas-client: Command::keep_output(head, tail), set where the server offers
it; Process::elided(stderr) once the exit came.
Review of yas-run#88: Output had no place for what KEEP_OUTPUT dropped, and
output() takes the process, so a kept command's head and tail came joined
with nothing to say a middle was missing. Output.elided now carries it
(the test's helper is output_limited again). And the new mod output_keep
line took mod process's #[cfg(any(unix, windows))].
protocol-fuzz found OutputElision::decode multiplying a decoded code point
count by two, which panics past u64::MAX / 2. Saturate instead: a count that
wide bounds nothing it could hold. Test the five counts' rules, prefixes, and
the widest values.
@ultimator-agent

ultimator-agent Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed 3c38bb3 in 41s: no new issues · Session

@ultimator-agent ultimator-agent Bot 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.

Ultimator's review of 3c38bb3

Rebased onto 889fa47 now that #86 has landed, and the code is the same as the round-4 head. The tree of 3c38bb3 is byte-for-byte the tree of 763661d (bfc88ed). #86's squash has the same tree as the old stack tip (446b0b0). git range-diff shows each of the three remaining commits as an identical patch of the one it replaces. Nothing new, and nothing is open.

No issues found.

Session · @ultimator-agent review reviews again · @ultimator-agent fix fixes the open issues

@pcarrier
pcarrier merged commit 4852d3a into yas-run:main Sep 30, 2026
6 of 11 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.

1 participant