Repository navigation
Conversation
The reader posted every 4 KiB read as its own UserEvent::PtyBytes(Vec) through winit's unbounded proxy channel. Under a flood it ran ahead of the parser by the whole output (peak RSS 400-650 MB) and the main thread handled ~16k events per 64 MB, each with a fresh Vec. Now the reader appends to a buffer shared with the main thread's PtyReader and posts a payload-less PtyReady only when none is in flight. Past BUFFER_CAP (256 KiB) it stops reading, so the kernel PTY buffer fills and the child blocks. The main thread swaps the whole buffer out (no copy, no steady-state allocation) and hands it to the parser via peek/consume. The close is a flag in the same state, so it is reported only once every byte read before it has been consumed; Event::PtyBytes/PtyClosed become Event::PtyReady. A batch that ends mid synchronized update now always asks for a wake at the BSU watchdog deadline (previously only when the BSU began in that batch; otherwise it requested a redraw render_term would skip).
Parsing everything the reader had buffered before each frame drew nothing until a flood ended: winit hands out every queued user event before RedrawRequested, and the handler never ran out of bytes. handle_pty_ready now parses in 16 KiB slices. Output that drains before a frame interval passes is drawn at once as before. When output keeps coming it stops once a frame is due (16.7 ms after the last frame started, and after parsing at least as long as that frame took) and answers the new ControlSignal::Yield. The window runner redraws, drops reader wakes until about_to_wait (which runs after the iteration's redraws on X11, Wayland and macOS) and there delivers PtyReady again: one parse slice plus one frame per loop iteration. A check at the top of the handler covers output arriving about as fast as it parses. Mode 2026: never yields while rendering is paused; once a frame is due mid update, parses only up to the next ESU so the frame lands between updates even when ESU and the next BSU are back to back. The pacing policy lives in toastty::ingest with unit tests; the design is recorded in docs/decisions/pty-event-loop.md.
handle_pty_bytes ran tcgetpgrp + readlink /proc/<pid>/cwd (and built a String) for every PTY batch, ~8% of flood CPU. The CWD's only reader is RGP's r;path= registration, which is an APC, so probe only when the slice contains ESC _ (tracking an ESC that ends the previous slice). The probe still runs before the slice is parsed, so a relative path is resolved against the CWD of the process that is emitting it.
With the parse floor equal to the last frame's render time, a flood of frames that each cost ~10 ms (CJK text, whose glyphs are new to the atlas) spent half the main thread rendering: unicode throughput fell to ~0.45x of no-frames. Parse at least twice the render time (capped at 33 ms), so rendering takes at most a third; frames up to ~5 ms keep the 60 Hz cadence.
If a frame was already due when the loop first checked (the thread was descheduled for >= min_parse after the pass started), it stopped without peeking, and the 'nothing parsed' check answered Continue before the yield could. Nothing had taken the reader's buffer, so its wake stayed armed and it never posted PtyReady again: output froze for good (reviewer repro: one 40 ms stall froze `seq 1 3000000`). The loop's decisions move into ingest::Ingest::step and ingest::outcome, pure and unit-tested: step never stops before a slice is parsed (that slice also takes the buffer and re-arms the wake), and outcome answers any stop with Yield whatever was parsed. Also time the slice after peek() so a buffer swap isn't counted as parse time.
While mode 2026 holds frames back the parse loop never yields (the frame would be skipped), so a flood inside one long BSU kept input, resize and close waiting for up to the 1 s watchdog. After 33 ms of parsing in that state, stop with the new ControlSignal::Defer: like Yield (the runner drops reader wakes and resumes from about_to_wait) but without requesting a redraw.
…share test glue - App::event documents ControlSignal::Yield and Defer. - Runner::about_to_wait notes that Wayland pre_present_notify() would defer RedrawRequested past the resume and make it spin under Poll. - PtyReader::has_unconsumed is test-only now. - ChannelSink/recv_batch move to a hidden toastty_io::test_support module shared by the toastty-io and end_to_end tests. - pty-event-loop.md: Defer and the never-stop-before-a-slice invariant.
# Conflicts: # crates/toastty/src/main.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack 2/5. Based on #61. It fixes how PTY output reaches the main thread, so toastty keeps drawing while a program floods it and its memory stays flat.
Problem
The mio reader turned every read (usually a few hundred bytes) into its own
Vecand its own winit user event, over an unbounded channel. Undercat bigfile:RedrawRequested.tcgetpgrpioctl, areadlink /proc/<pid>/cwd, selection revalidation and so on.Change
toastty-io):PtyReaderon the main thread. Past the cap it stops reading, so the kernel PTY buffer fills and the producer blocks (back-pressure).PtyReadyonly when no wake is already queued, so there is never more than one in flight.crates/toastty/src/ingest.rs,handle_pty_ready):ControlSignal::Yield. The runner redraws, then resumes parsing fromabout_to_wait.ControlSignal::Defer, which requests no redraw) so input stays live. The 1 s watchdog is unchanged.Ingest::redraw_delay). It's zero after a quiet spell, so echoes still draw at once. Without this, floods drew up to 360 FPS once perf 3/5: parser + grid hot path — 2–7× parse, lazy scrollback (idle RSS 101→60 MB) #63's faster parser outran the reader. With perf 3/5: parser + grid hot path — 2–7× parse, lazy scrollback (idle RSS 101→60 MB) #63 on top, the cap cut flood CPU by 15–26% at the same throughput (ASCII 1.14 → 0.97 CPU s,seq1.60 → 1.18), with key latency unchanged.r;path=registration is the only reader of that CWD.docs/decisions/pty-event-loop.md.Results (bench/run.py, Xvfb, median of 3; the machine was shared with other jobs)
cat64 MB ASCII: FPS while streamingcat64 MB ASCII: peak RSSseq3M lines: MB/s / CPU s / peak RSScat32 MB SGR: FPS / peak RSSThroughput now pays for about 60 real frames a second. In interleaved runs ASCII and SGR break even within noise. Unicode drops to about 0.75×, because each CJK/emoji frame costs about 9 ms to shape and render and the flood now draws about 50 of them a second. Stream B (#63) speeds up the parser and stream C (#64) the per-frame cost, which wins this back.
Visual evidence
Two screenshots 0.5 s apart while
seq 1 3000000streams (bottom 200 px of a 1280×800 window).Before: the screen stays blank for the whole flood.

After: live output, then the end of the run.

I also checked by hand: Ctrl-C during a big
catgets the prompt back within 0.3 s,\e[6ngets its reply, OSC 2 sets the title, andexitcloses the window.Review
An independent reviewer found a permanent stall: a pass that was already over its frame budget when it started returned
Continuewithout taking any bytes, so the reader's wake stayed armed and output froze. They reproduced it with an injected 40 ms deschedule.00a6a35. The decision logic is now the pure functionsIngest::step/outcome, a pass never stops before its first slice, and every stop schedules a resume. Regression tests cover it.Deferinside a synchronized update. That's fixed ind3d9d8b, with a test.Risks
about_to_waitrunning after the iteration's redraws. I confirmed that in the winit 0.30.13 source for X11, Wayland and macOS, but only X11 has been run. A comment warns that adding Wayland'spre_present_notify()would break this ordering.Tests
New io tests use a socketpair and a counting sink. They cover: one wake per take, the writer blocking at the cap, close only after the last byte, and a dropped reader releasing a blocked thread. The ingest decision functions and the window scheduling for
Yield/Deferhave unit tests, and the integration and end-to-end tests were ported.cargo test --workspacepasses, and clippy adds no warnings beyond the base.