perf: wake frame loop on PTY output and cut render hot-path costs#337
Merged
Conversation
Issue: Typing in long agent conversations or with many terminals visible showed a noticeable keystroke-to-screen delay, clearly worse than Ghostty, which this project uses as its VT library. Solution: The frame loop only discovered PTY output by polling once per frame, so an echo that arrived right after the loop went to sleep sat unread for up to 50ms. A new watcher thread polls the PTY master fds and posts the existing SDL wake event, and active-frame pacing now waits in SDL instead of an uninterruptible sleep. The rest of the frame budget was reclaimed in the hot paths: ghostty-vt is fed whole slices instead of single bytes, the renderer resolves each row once instead of doing a page-list walk per cell, and glyph textures are cached without color and tinted at draw time. Persistence writes during window drags are debounced.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Pull request overview
This PR reduces end-to-end terminal latency under PTY output and lowers render hot-path costs by adding an event-driven wake mechanism for PTY readability and optimizing terminal parsing/rendering/caching paths.
Changes:
- Add a background PTY-readability watcher thread that posts the existing SDL wake event so PTY output interrupts SDL waits immediately.
- Reduce per-frame CPU/GPU work by feeding ghostty-vt full slices, walking terminal rows via pins (amortized O(1) per row), and tinting cached white glyph textures at draw time.
- Debounce persistence writes to avoid synchronous
persistence.tomlwrites on every window-move/resize tick.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/app/runtime.zig | Integrates PTY watcher wake events; makes active pacing interruptible via SDL wait; debounces persistence saves. |
| src/session/pty_watcher.zig | New polling thread that watches session PTY master fds and pushes runtime wake events on readiness/HUP/ERR. |
| src/session/state.zig | Increases PTY read buffer; caps per-call drain; switches output processing to slice-based VT parsing; adjusts exit polling logic. |
| src/vt_stream.zig | Contains OSC 8 hyperlink capacity errors while keeping slice-based parsing (nextSlice) fast. |
| src/render/renderer.zig | Replaces per-cell getCell() traversal with per-row pin stepping and direct row cell indexing; updates selection/link checks accordingly. |
| src/font.zig | Removes color from glyph cache key (white raster + SDL_SetTextureColorMod tinting); memoizes per-codepoint face availability; batches eviction. |
| src/c.zig | Re-exports SDL_SetTextureColorMod for tinting cached glyph textures. |
| docs/ARCHITECTURE.md | Updates architecture/threading and frame-loop documentation to reflect PTY watcher + wait model + persistence debounce. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Issue: Review feedback on #337: the drain loop checked the budget before each read but always read a full buffer, so one processOutput call could exceed the documented 1MiB cap by up to 64KiB. Solution: Cap each read slice at the remaining budget (cappedReadLen), so the final read of a call can't overshoot. Ghostty's stream parser is stateful, so a read boundary that splits an escape sequence is handled the same way any short read already is.
Issue: The root test block in main.zig only referenced three files, so most of the repo's tests (including several added on this branch) were never compiled or run. Zig collects tests only from files reachable through that block. Solution: Reference every test-bearing file from the root test block, raising the executed count from 30 to 265, and fix what that uncovered. Real bugs: kitty CSI-u encoding for alt-modified Tab/Enter/Backspace was unreachable behind legacy Alt mappings, Cmd+keypad-plus required Shift to change the font size, and the URL matcher trimmed the closing paren of balanced Wikipedia-style links. Broken tests: two const-qualifier compile errors, a case-sensitive path assertion, a scrollbar test that never simulated draws, and a vt_stream test that hung waiting for an ANSI DECRQM reply ghostty 1.3.1 never sends.
Issue: The expanded root test block referenced cwd.zig unconditionally, which trips its top-level @CompileError guard on Linux and broke the ubuntu CI job. Solution: Reference cwd.zig from the test block only on macOS, matching how the rest of the code imports it conditionally. Its tests still run on macOS; Linux never compiled this module in the first place.
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.
Problem
Typing into a long agent conversation, or having several terminals stream output at once, showed a visible delay between keypress and screen update. Ghostty on the same machine felt much snappier, even though Architect uses ghostty-vt for terminal emulation.
The main cause: the frame loop only discovered PTY output by polling once per frame. When idle it parked in
SDL_WaitEventTimeoutfor up to 50ms, and nothing woke it when the shell echoed a keystroke or an agent produced output. An echo that arrived 2ms after the loop went to sleep sat unread for the remaining 48ms. Agent TUIs almost always take longer than one frame to echo, so they hit this window on nearly every keystroke.On top of that, several hot paths made busy frames expensive:
nextSlicefast pathpages.getCell()per cell, and each call walks the page list from the top (O(rows²·cols) per repaint)Solution
A new
session/pty_watcher.zigthread polls the PTY master fds of all spawned sessions and posts the existing SDL wake event when output arrives, using the same pattern as the notify and control socket threads. A wake-pending flag stops it from spinning ahead of the main thread's drain. Active-frame pacing now waits in SDL instead of an uninterruptiblestd.Thread.sleep, so input during pacing starts the frame immediately.The hot paths were fixed independently:
processOutputfeeds ghostty-vt whole slices; hyperlink capacity errors are contained in the stream handler instead of forcing byte-at-a-time parsing. The read buffer grew to 64KiB and draining is capped at exactly 1MiB per session per frame (the read slice is capped at the remaining budget) so a flood cannot starve input.down(1), indexing the row's cell slice directly. Selection and hover-link checks reuse a free pin copy instead of a fresh page-list walk per cell. A test asserts the row walk yields identical cells to the old per-cell lookups.SDL_SetTextureColorMod, removing color from the cache key. A per-codepoint face-mask memo replaces the per-cellTTF_FontHasGlyphFFI calls, and cache eviction selects a 256-entry batch per pass instead of scanning all 4096 entries on every insert.Persistence writes are also debounced: window drags used to write
persistence.tomlon every move event, now at most once per 500ms dirty window.Test collection fix (added after review)
While addressing review feedback we found that the root test block in
src/main.zigonly referenced three files, so most of the repo's tests (including several added on this branch) were never compiled or run. The block now references every test-bearing file, raising the executed count from 30 to 265. That uncovered and fixed:zig build,zig build test(265/265),just lint, andzig fmt --checkall pass. Two behavior notes for reviewers: in non-vsync mode, event floods can start frames faster than 60fps (intended, that is the latency win), anddelegateVtinvt_stream.zigrelies on ghostty-vt's readonly handler error set staying within the declared nine errors. It holds for 1.3.1; a future upgrade that adds an error member would panic there in debug builds rather than fail compilation.Test plan
lscolors, syntax-highlighted agent output, 256-color/RGB sequences, emoji still full-color