Skip to content

perf: wake frame loop on PTY output and cut render hot-path costs#337

Merged
forketyfork merged 4 commits into
mainfrom
perf/input-latency
Jul 3, 2026
Merged

perf: wake frame loop on PTY output and cut render hot-path costs#337
forketyfork merged 4 commits into
mainfrom
perf/input-latency

Conversation

@forketyfork

@forketyfork forketyfork commented Jul 3, 2026

Copy link
Copy Markdown
Owner

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_WaitEventTimeout for 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:

  • ghostty-vt was fed one byte at a time, skipping its SIMD nextSlice fast path
  • the renderer called pages.getCell() per cell, and each call walks the page list from the top (O(rows²·cols) per repaint)
  • glyph textures were cached per (text run, color), so scrolling colored output missed the cache constantly, paying shaping, rasterization, and a GPU upload each time

Solution

A new session/pty_watcher.zig thread 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 uninterruptible std.Thread.sleep, so input during pacing starts the frame immediately.

The hot paths were fixed independently:

  • processOutput feeds 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.
  • The renderer resolves one pin per row and steps it with 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.
  • Glyph textures are rasterized white and tinted at draw time with SDL_SetTextureColorMod, removing color from the cache key. A per-codepoint face-mask memo replaces the per-cell TTF_FontHasGlyph FFI 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.toml on 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.zig only 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:

  • kitty CSI-u encoding for alt-modified Tab/Enter/Backspace was unreachable behind the legacy Alt word-navigation mappings, so kitty-protocol apps (Claude Code, Codex) never received those combinations
  • Cmd+keypad-plus required Shift to increase the font size
  • the URL matcher trimmed the closing paren of balanced Wikipedia-style links
  • two const-qualifier compile errors, a case-sensitive path assertion, a scrollbar test that never simulated draw calls, and a vt_stream test that hung waiting for an ANSI DECRQM reply ghostty 1.3.1 never sends

zig build, zig build test (265/265), just lint, and zig fmt --check all pass. Two behavior notes for reviewers: in non-vsync mode, event floods can start frames faster than 60fps (intended, that is the latency win), and delegateVt in vt_stream.zig relies 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

  • Run the app, type into a plain shell and into a Claude/Codex session: echo should feel immediate, with no intermittent ~50ms hitches
  • Grid view with several agents streaming at once: typing in the focused tile stays responsive, tiles update smoothly
  • Colored output looks right (glyph tinting change): ls colors, syntax-highlighted agent output, 256-color/RGB sequences, emoji still full-color
  • Text selection highlight and Cmd+hover link underline render at the correct cells, including in scrollback view (renderer pin change)
  • Alt+Enter inside Claude Code inserts a newline (kitty CSI-u fix); Cmd+keypad-plus bumps the font size without Shift
  • Window drag and resize feel smooth; position and size are restored after quit and relaunch (debounced persistence)
  • Quit with a running agent: teardown still captures the session ID and resumes it on next launch (touches the processOutput path)

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.
@forketyfork forketyfork requested a review from Copilot July 3, 2026 06:34
@forketyfork forketyfork marked this pull request as ready for review July 3, 2026 06:34
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.toml writes 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.

Comment thread src/session/state.zig
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.
@forketyfork forketyfork merged commit 757eb2b into main Jul 3, 2026
4 checks passed
@forketyfork forketyfork deleted the perf/input-latency branch July 3, 2026 09:38
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.

2 participants