Skip to content

Hibernate inactive terminal surfaces to bound long-session resource use #680

Description

@panosAthDBX

What happened?

Supacode becomes progressively memory-heavy and sluggish during multi-day use with several long-running agent TUIs across worktrees. The underlying shells remain healthy; restarting only Supacode immediately recovers most of the memory and CPU while the same zmx-backed OMP processes continue running.

I expected deselected worktrees to have bounded background cost. Instead, each touched worktree retains its WorktreeTerminalState, GhosttySurfaceView, renderer, graphics resources, and terminal state until the worktree is pruned. Occlusion stops display-link drawing, but Ghostty still runs updateFrame for terminal wakeups while the surface is hidden.

Live restart A/B

Measured on Supacode 0.10.6 after approximately 47 hours of app uptime:

Signal Before restart Fresh restart After reopening old sessions
Supacode physical footprint ~1.6 GB (1.8 GB peak) ~381–403 MB ~693–735 MB
Supacode CPU ~91–157% ~10–21% ~18–35%
Renderer activity Two renderer threads ~60% and ~56% active One moderately active; others idle Six renderers; two idle and four processing wakeups
Main thread ~96.5% idle Responsive Mostly idle

The important zmx/OMP processes survived the restart. One 21-hour OMP process retained the same PID and was still using approximately 1.1 GB RSS afterward. This isolates the pathological state to the old Supacode/Ghostty surfaces rather than the agent process alone.

Before restart, Supacode's default malloc zone held approximately 753.7 MB allocated. After restart and reopening the sessions it held approximately 140.4 MB. /usr/bin/leaks found only 160 bytes, so this looks like reachable per-surface/aged terminal-renderer accumulation rather than a conventional unreachable leak.

Current source behavior matches the observation:

  • WorktreeTerminalManager.state(for:) retains a state per touched worktree (WorktreeTerminalManager.swift:478-507).
  • Selecting another worktree only calls setAllSurfacesOccluded() on the previous state (WorktreeTerminalManager.swift:410-415).
  • States and surfaces are released only by prune (WorktreeTerminalManager.swift:622-659).
  • Ghostty stops the display link for invisible surfaces (renderer/generic.zig:1053-1063) but renderer wakeups still execute updateFrame before drawFrame is skipped (renderer/Thread.zig:513-532, 596-617).

The previously reported Ghostty non-standard PageList leak does not match this profile: the bundled source contains the fix at PageList.zig:3167-3170, and this process was malloc/graphics-heavy rather than dominated by unbounded VM_ALLOCATE regions.

Steps to reproduce

  1. Open several Supacode worktrees and start long-running TUI coding agents in them.
  2. Leave the sessions running and switch among the worktrees over one or more days.
  3. Observe Supacode's physical footprint and per-renderer CPU with vmmap -summary <pid>, top -pid <pid>, and /usr/bin/sample <pid> 3.
  4. Quit Supacode normally without terminating terminal sessions, then relaunch it.
  5. Confirm the zmx/agent PIDs survived while Supacode's memory and renderer CPU dropped substantially.
  6. Reopen the old worktrees. Resource use rises with reattached surfaces but remains well below the aged pre-restart process.

Proposed solution

Hibernate terminal surfaces belonging to inactive worktrees after an inactivity grace period.

  • Enable hibernation by default, with a setting allowing users to opt out.
  • Preserve the WorktreeTerminalState layout and surface UUIDs, but detach and destroy the inactive Ghostty surfaces/renderers without killing their zmx sessions.
  • Cancel pending hibernation when a worktree is selected again. Recreate its surfaces and reattach using the existing zmx session IDs.
  • Let zmx restore the terminal snapshot. zmx already retains terminal state and scrollback through libghostty-vt and serializes it during reattachment (ThirdParty/zmx/src/main.zig:934-968).
  • Keep explicit close, prune, and "terminate sessions" behavior unchanged; those paths must still kill sessions where they do today.
  • Handle selection/hibernate races on @MainActor so a newly selected surface cannot be torn down by an expired task.

Synchronize the history limit

Ghostty and zmx both currently default to 10,000,000 bytes, but the values are independent:

  • Ghostty: configurable scrollback-limit (Config.zig:1368-1387).
  • zmx: hardcoded DaemonConfig.max_scrollback = 10_000_000 (ThirdParty/zmx/src/main.zig:472-474).

Capture the effective Ghostty limit per session and use the same value for:

  1. The original Ghostty surface.
  2. The zmx daemon's max_scrollback.
  3. The recreated surface after wake.

This avoids truncating user configurations above 10 MB and avoids zmx retaining unnecessary history for configurations below 10 MB. It also preserves the existing rule that a running surface keeps the limit it was created with.

Acceptance criteria

  • Inactive worktree surfaces release their Ghostty renderers and graphics resources after the grace period.
  • Their shell/agent and zmx session remain alive.
  • Selecting the worktree restores the screen, alternate-screen state, cursor, and no more than the configured history limit.
  • Hibernation is enabled by default and can be disabled in Settings.
  • Repeated hibernate/wake cycles do not duplicate tabs, surfaces, callbacks, or zmx clients.
  • Closing/pruning a hibernated worktree still removes the zmx session.
  • Focused lifecycle, race, restoration, and lower/default/higher history-limit tests cover the behavior.

Alternatives considered

  • Restart Supacode periodically: effective but disruptive and hides the retained-state problem.
  • Only coalesce hidden Ghostty updateFrame calls: likely reduces CPU but does not release per-surface renderer, Metal, and terminal allocations.
  • Hard-cap retained worktree states: bounds memory but loses the explicit lifecycle and restoration semantics that zmx already supports.

Supacode version and build

0.10.6 (1783877721)

macOS version

macOS 26.5.1 (25F80)

System locale

en_GB

Mac hardware

Apple M5 Pro, 48 GB

Are you planning to fix this yourself?

  • I intend to open a pull request that fixes this bug.

Before submitting

  • I searched existing issues and this is not a duplicate.
  • I am on the latest available version of Supacode.
  • I agree to follow this project's Code of Conduct.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions