Skip to content

perf: stop invisible windows from rendering, cap the escrow holder leak - #237

Merged
arzafran merged 5 commits into
mainfrom
perf/idle-cost-and-escrow-leak
Aug 3, 2026
Merged

perf: stop invisible windows from rendering, cap the escrow holder leak#237
arzafran merged 5 commits into
mainfrom
perf/idle-cost-and-escrow-leak

Conversation

@arzafran

@arzafran arzafran commented Aug 3, 2026

Copy link
Copy Markdown
Member

What this does

Two costs that never showed up in normal use, found by this morning's perf audit:

  • A minimized or fully covered Programa window kept rendering as if it were on screen. Nothing ever told ghostty a window went invisible, only pane switches inside the app. Now window occlusion and minimize feed straight into the renderer, so hidden windows stop doing per-frame work.
  • The hidden helper process that keeps your terminals alive across updates never exited, under any circumstance. Every abnormal quit left one behind forever, holding pty file descriptors and keeping dead shells alive. Three of them were found on a dev machine, the oldest three days old, propping up 12 zombie shells. The helper now expires sessions nobody reclaimed after an hour and exits once it holds nothing.

Also fixes CLAUDE.md pointing at a programad Zig build that has not existed for a while (the daemon is Go, at daemon/remote).

Review order

  1. Sources/SessionEscrow.swift across the three escrow commits, in order. The middle one closes two races the first one introduced (an fd close after a timed-out drain wait, and an exit/accept race). The safety rule throughout: never exit while a session is held, never close an fd a drain thread might still be reading.
  2. Sources/GhosttySurfaceScrollView.swift - occlusion is the AND of pane visibility and window visibility, tracked separately so neither can clobber the other.
  3. CLAUDE.md - doc fix only.

Behavior change to know about

Escrowed sessions nobody reclaims are now dropped after one hour, closing their pty (the shell gets SIGHUP; scrollback still restores from disk on next launch). Before, they were held forever. One hour is deliberately far above any real relaunch, but it is a judgment call - flag if you think it should differ. Explicit claim/renew on relaunch is the durable design and should get an issue.

Test plan

  • CI green
  • Minimize a window with a terminal running top, wait, restore - confirm it resumes cleanly and top kept running
  • Quit the app abnormally (kill -9), confirm the holder exits ~90s later on its own (watch ps | grep session-escrow-holder)
  • Update-relaunch still revives sessions (holder survives normal relaunch reconnect)

Verified so far

Occlusion wiring confirmed live in a tagged build (debug log shows effective=0 when covered, effective=1 on expose). Idle CPU with a visible window measured at ~6%; the occluded-window delta is not yet measured. One earlier claim corrected during verification: the "CVDisplayLink = 69% of activity" figure from the audit was a misread of sample output (it counts thread presence, not CPU) - the fix stands on the unwired-occlusion finding, not that number.

Previously ghostty only learned about intra-app pane visibility
(setVisibleInUI), never whole-window visibility, so a minimized or
fully-covered window kept its renderer and CVDisplayLink running as if
on screen. Adds window-level observers (didChangeOcclusionState,
didMiniaturize/didDeminiaturize) that track window visibility
separately from pane visibility and push their AND to
ghostty_surface_set_occlusion whenever either input changes, so neither
can clobber the other.
Escrow holder processes never exited: after the app died, a draining
session stayed in the registry forever waiting for a retrieval that
might never come (crash, force quit, deleted tagged build, an update
that reclaims nothing), and the holder's accept loop had no exit path
at all even once nothing was left to hold.

Adds a reaper thread, started before the accept loop so it can never
race the first connection: it sweeps every reaperInterval to close out
sessions that have been draining past unclaimedSessionTTL (mirroring
handleRetrieveRequest's drain/retrieve coordination so an expiry can
never race a drain thread's in-flight read), and exits the holder once
its registry has been empty with no live connections for
idleExitGrace. Never exits with a non-empty registry or a live
connection, matching the same safety precondition retrieval already
relies on.
Two defects found in the escrow reaper added by the previous commit:

- retireExpiredSessions() discarded the drain-stop wait result and
  closed the fd unconditionally, even on a timeout where the drain
  thread might still be mid-read on it. Now mirrors
  handleRetrieveRequest: on timeout, leave the fd untouched and let
  HeldSession.deinit's safety net close it once the drain thread (which
  holds its own strong reference for as long as it runs) actually
  returns.

- reaperTick() could decide to exit based on a stale snapshot of
  registry/activeConnectionCount taken before releasing the lock,
  leaving a window where the accept loop could register a new session
  right before exit() destroyed it. Now the exit decision re-checks the
  precondition and sets a shuttingDown flag in the same lock
  acquisition the accept loop checks before registering a connection,
  so the two can never race.
unclaimedSessionTTL: 600s -> 3600s. Elapsed time alone can't prove a
session is abandoned -- a machine that sleeps mid-relaunch, or a user
who quits and reopens much later, can legitimately exceed 10 minutes,
and a false expiry silently destroys a session someone wanted (SIGHUP
to the shell, only scrollback survives). One hour keeps the leak
bounded while making false retirement implausible for real relaunch
flows; explicit claim/renew reconciliation is the durable fix and is
tracked separately.

retrieveRecvTimeoutSeconds (5s) -> retrieveRecvTimeout (500ms).
SessionEscrowClient.retrieve runs synchronously on the main thread
during session restore, once per escrowed panel, serially -- a
present-but-unresponsive holder stalled launch by 5s per panel. The
holder is a local same-machine AF_UNIX socket that answers in
microseconds when healthy, so 500ms is already ~1000x a healthy round
trip, and the cost of a miss is just the scrollback-replay fallback,
not data loss. The timeval is now built by splitting whole seconds
from the fractional remainder instead of truncating to tv_sec, which
would have silently zeroed the budget.
The remote daemon is Go at daemon/remote, built via
scripts/build_remote_daemon_release_assets.sh. The documented
'cd programad && zig build' referenced a directory that does not
exist, failing every agent session that followed it.
@arzafran
arzafran merged commit e3cb247 into main Aug 3, 2026
10 checks passed
@arzafran
arzafran deleted the perf/idle-cost-and-escrow-leak branch August 3, 2026 15:41
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