fix(terminal): park SSH worktrees like local ones (C1 retention, slice A)

SSH ptys were blanket-excluded from hidden-view parking, so a hidden SSH
worktree retained every pane forever (C1: renderer heap climbs to the V8
ceiling). SSH bytes transit local main — fact-mode watchers already cover
them, and main keeps a headless model served over pty:getMainBufferSnapshot
that the SSH reattach path never consulted.

- isParkRestorableTerminalPty: snapshot-backed OR (SSH + policy); threaded
  through both park verdicts, both selectors, watcher coverage, and the
  watcher start guard. Remote-runtime/fail-open/foreign/null unchanged.
- Parked-SSH reveal paints from main's headless model (dimension-matched,
  ~5k rows) and degrades to the relay 100KiB replay unless the snapshot is a
  non-empty source==='headless' payload — never a blank/stale paint.
- Kill switch: settings.terminalSshViewParking (default on).

DESIGN.md records the approved plan and the H1 magnitude non-claim.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-28 13:21:53 -07:00
co-authored by Orca
parent b41e813cb5
commit 6362a5c442
11 changed files with 628 additions and 12 deletions
+335
View File
@@ -0,0 +1,335 @@
# C1 fix design — memory-bounded terminal retention
Status: APPROVED at decision gate (A+B+C, D deferred) with binding conditions:
(1) parked-SSH reveal must verify per-pty-class that a main-side headless
emulator actually backs `pty:getMainBufferSnapshot` (accept only
`source === 'headless'`); degrade to relay replay otherwise — never a
blank/stale paint. (2) Separable commits A→B→C, each independently
revertable behind its own kill switch; A/B default ON. (3) Budget defaults
12-hidden + 45-min TTL as named constants with sizing comments grounded in
the measured 2.5–19 MB numbers. (4) Preserve the damping conventions in §1.2;
add a flip-loop regression test for eviction. (5) Tests must cover SSH
park+reveal round-trip fidelity, folder workspaces, remote-runtime eviction,
fail-open exemption, and C demotion+restore. (6) Explicit non-claim recorded
below.
**Non-claim and residual (gate condition 6):** this fix does NOT claim H1
alone reaches 3.5 GB. It bounds the retained-pane fleet, which also bounds
every per-pane accumulator. The named residual is the uncapped
`pendingSideEffects` queue (`terminal-pane/pty-transport.ts:157`, H2 in the
diagnosis) — it still grows without bound inside a *visible or warm* pane
under background timer throttling and needs its own cap + drain fix as a
follow-up. H3 (premature ack) is already fixed at HEAD (#10012) but absent
from the 1.4.15x field builds.
All cites at HEAD `9eff3728a3` in this worktree. Companion diagnosis:
`crash-c1-heap-leak-diagnosis/C1_DIAGNOSIS.md` (other worktree, read-only).
---
## 1. Confirmed mechanism at HEAD (Phase-1 re-verification)
The H1 mechanism holds at HEAD exactly as diagnosed at v1.4.155; the only
change to the two core files since v1.4.155 was #10179 and its revert.
- `Terminal.tsx:1018` — `mountedWorktreeIdsRef.current.add(...)` on every
activation, permanently; removal only when the worktree ceases to exist
(`:1031-1037`). Both render paths mount panes for every id in the set
(`:2071` split, `:2124` legacy).
- The mounted set also grows **without user action**: background mounts from
agent launches (`launch-agent-background-session.ts:303`), sleeping-agent
wakes (`wake-sleeping-agents-in-background.ts:83`), mobile tab subscriptions
(`useIpcEvents.ts:1694`), CLI-driven tab creation (`useIpcEvents.ts:1767`),
and browser-automation bootstrap leases which mount the **whole worktree**
with no tab restriction (`useIpcEvents.ts:222`). This is what survives
`renderer_recovery_reload` and re-populates the set overnight.
- Sole eviction lever is cold parking, and eligibility is all-or-nothing:
`canParkTerminalWorktreeRenderers` (`terminal-hidden-view-parking.ts:70-108`)
requires `isSnapshotBackedTerminalPty` (`:56-68`) for EVERY tab. HEAD adds a
further veto absent from the v155 diagnosis: a worktree with any
**watcher-uncoverable** tab never parks (`Terminal.tsx:875-880`).
- Un-parkable classes at HEAD (each ⇒ unlimited retention of the whole
worktree): SSH ptys, remote-runtime ptys, null ptyIds
(`ssh-target-cleanup.ts:127` mints exactly that), separator-less
daemon-fail-open ids, ptys minted under another worktreeId
(`terminal-hidden-view-parking.ts:56-68`), tabs with pending
startup/activation spawns (`:99-106`), and any watcher-uncoverable tab
(`terminal-parked-tab-watchers.ts:92-109`).
- Caps compose per-worktree: `TERMINAL_TAB_HOT_RETAIN_LIMIT = 12` is enforced
per overlay-layer instance, i.e. per worktree
(`use-terminal-tab-cold-parking.ts:136`), on top of the 8-worktree warm cap
— and neither cap sees un-parkable worktrees at all.
Corrections to the established framing (things the fix should NOT build for):
1. **The legacy non-split path is effectively dead.**
`anyMountedWorktreeHasLayout` synthesizes a layout from merely having one
group (`split-group-mount.ts:7-23,33-45`); groups are created on first
`addTab` (`tab-group-state.ts:57-80`) and hydration always writes both
(`tabs-hydration.ts:271-284`). The legacy branch is reachable only when
every mounted worktree has zero tabs and zero groups. Per-tab-parking
parity for it, or retiring it, is not a C1 lever (kept out of scope).
2. **No parking flip-loop ever existed.** The first parking attempt
(`1ab6b87c7f`, reverted `8eee59c084`) was pulled for going *silent* (no
byte watchers), not for oscillating. The two real React #185 loops
(`9038a78d37` overlay measure↔fit; `e8452c8c50` Activity portal
reconciliation) are adjacent, and their post-mortems define the damping
conventions any new lever must keep: verdict excluded from its own effect
deps (`Terminal.tsx:911-921`), set-equality bail-outs returning the
previous reference (`Terminal.tsx:153-163`), strictly-positive recheck
delays (`terminal-hidden-view-parking.ts:269-284`), and monotone
consumption edges. The one genuinely bidirectional verdict input is
Activity-portal readiness; the watcher-coverage veto reads module state
written at unmount (`terminal-parked-watcher-registry.ts:25-35`) and sits
in no dep array.
3. **SSH exclusion is about reveal fidelity, not observability.** Fact-mode
parked watchers subscribe main-computed side-effect facts and work for any
pty whose bytes transit local main — which includes SSH; only a non-null
`runtimeEnvironmentId` disqualifies
(`terminal-side-effect-facts-handler.ts:44-62`). And main already
maintains a headless emulator (~5000-row model) for every pty it ingests,
SSH included: `runtime.onPtyData` → `trackHeadlessTerminalData` →
`getOrCreateHeadlessTerminal` (`orca-runtime.ts:7484,7528,9026-9044,
9105-9113`), served over `pty:getMainBufferSnapshot`
(`src/main/ipc/pty.ts:4058-4116`). **The SSH reattach path simply never
consults it** — it paints the relay's rolling 100 KiB raw-byte buffer
(`src/relay/pty-handler.ts:175,472-480`) after an unconditional clear
(`pty-connection.ts:7378-7391`).
4. **Remote-runtime is the genuinely hard class.** No bytes transit main (no
emulator, no facts); the desktop subscribe snapshot is screen-only —
`scrollbackRows: 0` (`src/main/runtime/rpc/methods/terminal.ts:682-696`)
— even though the runtime host holds scrollback and serves it to mobile
(`:697-714`).
## 2. Quantification (what a retained hidden pane actually costs)
Measured empirically with the repo's own `@xterm/headless@6.1.0-beta.287`
(agent-CLI-like SGR output, 200 cols; steady state after scrollback wrap):
| scrollback | V8 heap (counts toward the 3586 MB ceiling) | ArrayBuffers (off-heap, invisible to `usedJSHeapSize`) |
|---|---|---|
| 5 000 rows (default) | ~2.5 MB | ~12 MB |
| 50 000 rows (max) | ~19 MB | ~115 MB |
Cost plateaus once scrollback wraps (120k lines into a 50k buffer = same as
60k). On top of the xterm graph, each mounted-but-hidden pane retains: all six
addons, ResizeObservers, React fiber tree, the PTY IPC subscription, a
background write queue capped at max(2 MiB, rows×120) chars — up to ~6 MB at
50k rows (`terminal-scrollback-policy.ts:37-46`,
`pane-terminal-output-scheduler.ts:102,124`) — and the **uncapped**
`pendingSideEffects` array (`terminal-pane/pty-transport.ts:157`, H2), which
is per-pane closure state and grows without bound under background timer
throttling. Only worktree-level hiding releases WebGL; a tab hidden behind a
sibling in the active worktree keeps its GL context
(`terminal-visibility-resume.ts:103-137`). Nothing trims scrollback or
addons on hide.
**Honest magnitude statement.** Static H1 retention alone reaches GB scale
only with ~10⁲ big-scrollback panes (e.g. 20 SSH worktrees × 5 agent tabs ≈
0.4–1.9 GB depending on scrollback setting, plus per-pane floors). The
observed multi-hour monotone climbs and the codeg-dev 1.7 GB-in-20-min boot
burst are best explained as **H1 × per-pane accumulators**: H1 supplies an
unbounded, streaming, never-evicted pane fleet; H2 (and, in the 1.4.15x field
builds, H3 premature-ack) supply per-pane growth that never plateaus.
Bounding the fleet bounds the class — every per-pane accumulator dies with
its pane. If the gate wants a pure-H1 story, this design does not claim one;
the instrumentation branch (`crash-c1-heap-leak-diagnosis` @ 57f39c369b:
precise memory + paneTerminals census + terminalOutputQueue contributor)
confirms attribution on the next field cycle and composes with this fix
unchanged.
Field fit: the zero-git OOM bundle (`1biDRoy…`, 6.6 MB/min for 10 h) is an
SSH workspace with 7 worktree activations — the exact "nothing can ever
park" profile.
## 3. Consumers/assumptions of "mounted forever" (eviction blast-radius map)
What unmounting a pane already handles correctly (verified disposal chain:
`use-terminal-pane-lifecycle.ts:1587-1680`, `pane-lifecycle.ts:213-300`,
`pty-connection.ts:8264-8377`): PTY survives (detach, never kill,
`pty-transport.ts:906-916`); store tab entry, `tab.ptyId`, layouts, and
`capturedPanesByTabId` persist; exit observers stay registered.
What breaks or degrades if a worktree is evicted without further work:
| surface | behavior after eviction | mitigation available |
|---|---|---|
| Bells/titles/completions/PR links | dark unless a watcher covers the tab; watcher predicate today requires snapshot-backed pty (`terminal-parked-tab-watchers.ts:92-109`) | fact-mode works for SSH/local/fail-open (bytes transit main); only remote-runtime lacks the channel |
| Agent status (sidebar) | main-side `agentDetector`/OSC processing runs at ingestion regardless of panes (`orca-runtime.ts:7500-7505`); remote-runtime status flows via runtime graph | verify per-surface in tests |
| Reveal content, local daemon-backed | full daemon snapshot reattach — today's parked path (`pty-connection.ts:7892-7933`) | none needed |
| Reveal content, SSH | relay replay: last 100 KiB raw bytes, no dimension match; empty after relay restart; `SSH_SESSION_EXPIRED` → blank fresh shell (`ssh-pty-session-reattach.ts:39-81`, `pty-connection.ts:7746-7752`) | main headless snapshot upgrade (Option A) |
| Reveal content, remote-runtime | current screen only, zero scrollback | RPC change to request scrollback rows (exists server-side for mobile) |
| Reveal content, daemon-fail-open (separator-less id) | generic path fresh-spawns, **orphaning the live pty** (`pty-connection.ts:7885,8106`) | exempt from eviction, or accept + 512 KB capture painted as cold-restore |
| OSC 133;D / Command Code facts | already dropped for parked tabs today (watcher omits `onCommandFinished`/`onCommandCode*`, cf. `pty-connection.ts:2212-2234`) — pre-existing gap, unchanged | out of scope |
| Snapshot restore fidelity, folder workspaces | folder-workspace ids are worktree-shaped; `isSnapshotBackedTerminalPty` passes identically (`pty-session-id.ts:21-25`) — no special-case needed | covered by tests |
| Input during eviction | impossible (hidden worktree has no focused pane); parked-tab input path already routes `window.api.pty.write` (`terminal-parked-tab-watchers.ts:143`) | n/a |
## 4. Options
### Option A — Make SSH worktrees parkable (fidelity upgrade + eligibility)
Convert the largest un-parkable class into ordinary parked citizens.
1. **Eligibility:** extend the parking predicate so an SSH pty
(`parseAppSshPtyId`) counts as restorable, gated on a per-pty "main model
available" signal rather than blanket exclusion. Keep remote-runtime,
separator-less, foreign-worktree, null exclusions unchanged.
2. **Watcher coverage:** relax `canWatcherCoverParkedTerminalTab` for SSH to
the fact-mode availability predicate (bytes transit main). No new watcher
machinery: fact consumers already work for SSH.
3. **Reveal fidelity:** on parked-SSH reveal, keep relay `pty.attach` as the
liveness authority (unchanged — respects the SSH reattach recovery chain),
but paint from `pty:getMainBufferSnapshot` (≈5000-row dimension-matched
serialized model, incl. `pendingEscapeTailAnsi`) when it returns non-null,
falling back to today's 100 KiB relay replay when it returns null (main
returns null after a delivery gap when only a tail is retained —
`src/main/ipc/pty.ts:4082-4090`). The fetch/apply/dedupe machinery exists
(`applyMainBufferSnapshot`, `pty-connection.ts:6561-6711`; seq-bounded via
`pendingDeliveryStartSeq`).
- **Data loss / UX:** strictly better than today's *unmount* outcome and
bounded vs today's *retention* outcome: a re-activated parked SSH worktree
shows ~5k rows of scrollback instead of (a) full renderer buffer at
unbounded memory cost, or (b) 100 KiB raw replay. Bells/titles/completions
stay live via facts. Scroll position restored as bottom-offset intent like
local parks.
- **Flip-loop surface:** none new — same verdict machinery, one more class
passes the existing (damped) filter. The eligibility signal must be stable
(derive from pty id shape + a monotone capability latch, never from
fetch-time success/failure).
- **Blast radius:** parking predicate, watcher predicate, SSH reveal paint
path. No main-process changes required (snapshot IPC exists).
- **Rollback:** the existing `terminalHiddenViewParking` kill switch disables
the whole lever; a class-scoped flag (`parkSshTerminals`) can gate just
this.
- **Limit:** does nothing for remote-runtime / fail-open / foreign-pty /
uncoverable classes; retention stays eligibility-bounded for them.
### Option B — Hard mounted-worktree budget applied BEFORE parkability (the actual memory bound)
The task's stated goal — memory-bounded rather than eligibility-bounded —
requires an eviction lever that does not consult eligibility.
1. In the existing worktree-parking effect (`Terminal.tsx:822-921`), after
the parked-set selection, rank **all** hidden mounted worktrees (parkable
or not) by `hiddenSinceMs`, with the same exemptions parking already
honors (visible, measurable, portal-holding, pending startup/activation
spawn, last-active). Beyond a budget `MAX_MOUNTED_HIDDEN_WORKTREES`
(proposed 12) **or** past a long TTL (proposed 45 min) for un-parkable
ones, **evict**: delete from `mountedWorktreeIdsRef`, drop restriction
maps and `hiddenSince` (the deletion path that already exists for removed
worktrees, `Terminal.tsx:1031-1037`), bump a revision state guarded by
set-equality.
2. Eviction = full unmount; re-activation is identical to first activation
after boot (reattach per class; Option A upgrades the SSH case). Start
fact-mode watchers for evicted tabs where the transport exists (SSH,
local, fail-open); remote-runtime tabs go dark for bells/titles (agent
status still flows via the runtime graph) — accepted, documented loss.
3. Class carve-outs: separator-less daemon-fail-open tabs are exempt from
eviction (remount would fresh-spawn and orphan the live pty,
`pty-connection.ts:8106`) — they fall to Option C instead.
4. Best-effort capture at eviction: run the existing 512 KB
`shutdownBufferCaptures` serialization (`TerminalPane.tsx:2347-2389`,
`terminal-shutdown-layout-capture.ts:49-152`) into
`layout.buffersByLeafId` before unmount, so even a snapshot-less remount
paints last-known content cold-restore-style rather than blank.
- **Data loss / UX:** an evicted worktree re-activates like after an app
restart: local = full snapshot; SSH = 5k rows (with A) or 100 KiB replay;
remote-runtime = current screen + captured tail. Worst case is strictly
the app-restart experience users already have.
- **Flip-loop surface:** the new verdict follows every existing damping rule:
computed in the same effect, excluded from its own deps, set-equality
guard, positive-delay recheck timers, and eviction is monotone (a worktree
leaves the set; only real activation/background-mount events re-add it).
The one cyclical risk is a periodic background-mount re-mounting an evicted
worktree (browser-automation lease, mobile subscribe); cadence is bounded
below by the 30 s hysteresis + TTL, and the eviction ranking must treat a
re-mounted worktree as freshly hidden (it does — `hiddenSince` restarts).
- **Blast radius:** Terminal.tsx effect, watcher predicate (shared with A),
capture call, new policy function + constants in
`terminal-hidden-view-parking.ts`. No main changes.
- **Rollback:** new setting `terminalMountedWorktreeBudget` (0/absent =
disabled) independent of the parking kill switch; ship default-on in rc,
flip default via settings if the field disagrees.
### Option C — Scrollback demotion for eviction-exempt hidden panes
For panes that must stay mounted (fail-open class; any future exempt class):
after the hot-retain TTL hidden, set `terminal.options.scrollback` to 1 000
and back on reveal. Reclaims ~80 % of the xterm graph (19 MB → ~2.5 MB at
50k settings) with zero mount-topology change, zero coverage change, zero
flip-loop surface (no React state involved; a pane-manager pass over hidden
panes). Cost: trimmed history for long-hidden panes of that class; does not
bound pane count, fibers, or the H2 queue.
### Option D — Byte-weighted hot-retain budget (refinement, not proposed now)
Replace count caps with a byte budget using `terminal.buffer.active.length`
per mounted pane. With A+B in place the count caps stop being the binding
constraint; defer unless field data shows the warm set itself is the residual
problem. (Also candidates for follow-up hygiene, not this fix: making the
12-tab cap global rather than per-worktree; capping `pendingSideEffects`,
which is H2's own fix.)
## 5. Recommendation (ranked)
**Ship A + B together, C as the carve-out companion, D deferred.**
- A alone fixes the dominant observed class (SSH; the zero-git OOM bundle)
with the smallest risk, but leaves retention eligibility-bounded — any new
pty class silently re-opens unlimited retention.
- B alone bounds everything but delivers a worse SSH reveal (100 KiB replay)
than A makes possible, and SSH is the class users will actually hit.
- A+B: retention is memory-bounded for every class, and the highest-traffic
class re-activates at near-local fidelity. C covers the one class B must
exempt. All three are independently kill-switchable.
Suggested landing order (single branch, separable commits): policy module +
tests → A (eligibility + watcher predicate + SSH paint) → B (budget +
eviction + capture) → C (demotion). If the gate wants a smaller first slice:
A alone is shippable and reversible; B follows in the same release train.
## 6. Test plan
Unit (vitest):
- `terminal-hidden-view-parking.test.ts` extensions: SSH ids eligible under
the new predicate; remote/fail-open/foreign still excluded; eviction
ranking (budget, TTL, exemptions, last-active, determinism); set-equality
no-op on unchanged recompute (currently untested, per the flip-loop
report); recheck-delay positivity for the new deadlines.
- Watcher predicate: fact-mode coverage accepted for SSH pty ids; remote
runtime still uncoverable; capture-staleness on pty re-mint unchanged.
- Eviction reducer/helper: restriction maps and hiddenSince cleaned;
fail-open exemption; capture invoked before unmount.
- SSH reveal paint: snapshot-preferred, relay-replay fallback on null, seq
dedupe bound honored (mock transport).
E2E (Playwright, existing harness `tests/e2e/terminal-hidden-view-parking.spec.ts`
patterns + `terminal-parked-memory.spec.ts`):
- SSH-workspace park/reveal fidelity (extend the existing SSH e2e rig);
bell/title while an SSH worktree is parked.
- Eviction: activate N>budget worktrees with streaming tabs, assert panes
beyond budget unmount (`terminalElements` census), reveal restores, and
memory falls (parked-memory spec pattern).
- Folder-workspace park/evict/reveal parity (project rule).
- Flip-loop guard: drive 25 evict/re-activate cycles (mirror of the existing
25-cycle park spec `:467`), assert no React #185 and byte-for-byte restore
where snapshot-backed.
- Legacy path: not covered (dead code; assert only that the worktree-level
verdict still compiles it out — no new machinery).
Gates: `oxfmt --check -c .oxfmtrc.json` on changed files, oxlint, typecheck,
targeted vitest suites; no max-lines disables.
## 7. Rollout / rollback
- Three independent switches: existing `terminalHiddenViewParking` (master),
new `parkSshTerminals` (A), new `terminalMountedWorktreeBudget` (B; 0
disables, also disables C's TTL demotion). Defaults on in rc.
- Composes with the instrumentation branch: its `paneTerminals` census and
precise-memory highwater directly measure this fix's effect in the field
(expected signature: `paneTerminals.live` plateaus at the budget; heap
staircase flattens after the warm window).
- Revert story: each option is a separable commit; B's eviction is additive
to the parking effect and reverts cleanly; A's paint change falls back to
the relay-replay branch which remains intact.
+3
View File
@@ -272,6 +272,7 @@ function Terminal(): React.JSX.Element | null {
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
const terminalSshParkingEnabled = useAppStore((s) => s.settings?.terminalSshViewParking !== false)
const terminalTitleSnapshotAuthorityEnabled = useAppStore((s) =>
isMainTerminalSideEffectAuthorityForPty({
settings: s.settings,
@@ -883,6 +884,7 @@ function Terminal(): React.JSX.Element | null {
pendingStartupByTabId,
parkingEnabled: terminalParkingEnabled,
nowMs,
restorePolicy: { sshParkingEnabled: terminalSshParkingEnabled },
...overrides
})
// Why: a worktree with any watcher-uncoverable tab must never park, or it goes silent for bells/titles/completions (sank the first parking attempt).
@@ -931,6 +933,7 @@ function Terminal(): React.JSX.Element | null {
tabsByWorktree,
terminalParkingEnabled,
terminalParkingRevision,
terminalSshParkingEnabled,
workspaceSurfaces
])
// Why: gate on workspaceSessionReady so TerminalPane doesn't mount and spawn a duplicate PTY before reconnectPersistedTerminals() finishes.
@@ -243,6 +243,10 @@ import {
scheduleHiddenOutputRestore
} from './hidden-output-restore-scheduler'
import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback'
import {
decideSshReattachPaintSource,
shouldFetchSshReattachModelSnapshot
} from './ssh-reattach-model-restore'
import {
getExecutionHostIdForWorktree,
getSettingsForWorktreeRuntimeOwner
@@ -7614,6 +7618,37 @@ export function connectPanePty(
registerPaneSerializerFor(ptyId)
let reattachPayloadApplied = !hasStructuralReplay
// Why (C1 SSH parking): main's headless model holds ~5k rows for SSH ptys
// while the relay replay is a 100KiB raw-byte tail; prefer the model on
// reveal. Only a non-empty 'headless'-sourced snapshot qualifies — the
// renderer-serializer fallback has no mounted xterm after a park.
const tryApplySshMainModelReattachSnapshot = async (): Promise<boolean> => {
const sshParkingEnabled = useAppStore.getState().settings?.terminalSshViewParking !== false
if (!shouldFetchSshReattachModelSnapshot({ ptyId, sshParkingEnabled })) {
return false
}
const snapshot = await window.api.pty
.getMainBufferSnapshot(ptyId, {
scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback)
})
.catch(() => null)
if (!isCurrentReattachPayload()) {
// Why: a superseded attempt must not paint, but reporting true would skip the replay fallback of the live attempt.
return true
}
if (
!snapshot ||
decideSshReattachPaintSource({ ptyId, sshParkingEnabled, snapshot }) !==
'main-model-snapshot'
) {
return false
}
rememberReattachPayloadAgentSignal(snapshot.data, { fullScreenReplay: true })
kittyKeyboardModes.scanReplay(snapshot.data)
await applyMainBufferSnapshot(snapshot)
sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration)
return true
}
const applyReattachPayload = async (): Promise<void> => {
if (!isCurrentReattachPayload()) {
return
@@ -7659,6 +7694,15 @@ export function connectPanePty(
}
}
} else if (connectResult?.replay) {
if (await tryApplySshMainModelReattachSnapshot()) {
if (connectResult.coldRestore && !isRemoteRuntimePtyId(ptyId)) {
window.api.pty.ackColdRestore(ptyId)
}
return
}
if (!isCurrentReattachPayload()) {
return
}
rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true })
// Relay replay may overlap xterm's pre-disconnect content; clear first to avoid duplication.
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import {
decideSshReattachPaintSource,
shouldFetchSshReattachModelSnapshot
} from './ssh-reattach-model-restore'
const SSH_PTY_ID = 'ssh:conn-1@@relay-pty-1'
const LOCAL_PTY_ID = 'repo::/worktree@@session-1'
describe('shouldFetchSshReattachModelSnapshot', () => {
it('fetches only for SSH ptys with SSH parking enabled', () => {
expect(
shouldFetchSshReattachModelSnapshot({ ptyId: SSH_PTY_ID, sshParkingEnabled: true })
).toBe(true)
expect(
shouldFetchSshReattachModelSnapshot({ ptyId: SSH_PTY_ID, sshParkingEnabled: false })
).toBe(false)
expect(
shouldFetchSshReattachModelSnapshot({ ptyId: LOCAL_PTY_ID, sshParkingEnabled: true })
).toBe(false)
})
})
describe('decideSshReattachPaintSource', () => {
const headless = { data: 'screen', source: 'headless' as const }
it('paints from the main model only for a non-empty headless snapshot', () => {
expect(
decideSshReattachPaintSource({
ptyId: SSH_PTY_ID,
sshParkingEnabled: true,
snapshot: headless
})
).toBe('main-model-snapshot')
})
it('degrades to relay replay on null, renderer-sourced, sourceless, or empty snapshots', () => {
for (const snapshot of [
null,
{ data: 'screen', source: 'renderer' as const },
{ data: 'screen' },
{ data: '', source: 'headless' as const }
]) {
expect(
decideSshReattachPaintSource({ ptyId: SSH_PTY_ID, sshParkingEnabled: true, snapshot })
).toBe('relay-replay')
}
})
it('never upgrades when the kill switch is off or the pty is not SSH', () => {
expect(
decideSshReattachPaintSource({
ptyId: SSH_PTY_ID,
sshParkingEnabled: false,
snapshot: headless
})
).toBe('relay-replay')
expect(
decideSshReattachPaintSource({
ptyId: LOCAL_PTY_ID,
sshParkingEnabled: true,
snapshot: headless
})
).toBe('relay-replay')
})
})
@@ -0,0 +1,36 @@
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
export type SshReattachPaintSource = 'main-model-snapshot' | 'relay-replay'
export type SshReattachModelSnapshot = {
data: string
source?: 'headless' | 'renderer'
}
/**
* Which payload paints an SSH reattach (C1 SSH-parking design gate). Only
* main's headless model is trusted: a 'renderer'-sourced snapshot serializes a
* mounted xterm, which no longer exists once the pane parked — anything but a
* non-empty headless snapshot degrades to the relay replay, never a blank paint.
*/
export function decideSshReattachPaintSource(args: {
ptyId: string
sshParkingEnabled: boolean
snapshot: SshReattachModelSnapshot | null
}): SshReattachPaintSource {
if (!args.sshParkingEnabled || parseAppSshPtyId(args.ptyId) === null) {
return 'relay-replay'
}
if (!args.snapshot || args.snapshot.source !== 'headless' || args.snapshot.data.length === 0) {
return 'relay-replay'
}
return 'main-model-snapshot'
}
/** Skip the snapshot fetch entirely when the paint could never use it. */
export function shouldFetchSshReattachModelSnapshot(args: {
ptyId: string
sshParkingEnabled: boolean
}): boolean {
return args.sshParkingEnabled && parseAppSshPtyId(args.ptyId) !== null
}
@@ -11,6 +11,7 @@ import {
canParkTerminalWorktreeRenderers,
getTerminalTabColdParkRecheckDelayMs,
getTerminalWorktreeColdParkRecheckDelayMs,
isParkRestorableTerminalPty,
isSnapshotBackedTerminalPty,
selectColdParkedTerminalTabs,
selectColdParkedTerminalWorktrees
@@ -46,6 +47,32 @@ describe('isSnapshotBackedTerminalPty', () => {
})
})
describe('isParkRestorableTerminalPty', () => {
const worktreeId = 'repo::/worktree'
const sshPolicy = { sshParkingEnabled: true }
it('accepts every snapshot-backed pty regardless of policy', () => {
expect(isParkRestorableTerminalPty(`${worktreeId}@@session-1`, worktreeId)).toBe(true)
expect(isParkRestorableTerminalPty(`${worktreeId}@@session-1`, worktreeId, sshPolicy)).toBe(
true
)
})
it('accepts SSH ptys only when the SSH-parking policy is enabled', () => {
expect(isParkRestorableTerminalPty('ssh:ssh-1@@pty-1', worktreeId, sshPolicy)).toBe(true)
expect(isParkRestorableTerminalPty('ssh:ssh-1@@pty-1', worktreeId)).toBe(false)
expect(
isParkRestorableTerminalPty('ssh:ssh-1@@pty-1', worktreeId, { sshParkingEnabled: false })
).toBe(false)
})
it('rejects remote-runtime, fail-open, foreign, and null ptys under every policy', () => {
for (const ptyId of ['remote:env-1@@terminal-1', 'pty-local-detached', 'other@@s-1', null]) {
expect(isParkRestorableTerminalPty(ptyId, worktreeId, sshPolicy)).toBe(false)
}
})
})
describe('canParkTerminalWorktreeRenderers', () => {
const hiddenSinceMs = 1_000
const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
@@ -65,6 +92,24 @@ describe('canParkTerminalWorktreeRenderers', () => {
expect(canParkTerminalWorktreeRenderers(base)).toBe(true)
})
it('parks a hidden SSH worktree only under the SSH restore policy', () => {
const sshArgs = {
...base,
terminalTabs: [{ id: 'tab-1', ptyId: 'ssh:conn-1@@pty-1' }]
}
expect(canParkTerminalWorktreeRenderers(sshArgs)).toBe(false)
expect(
canParkTerminalWorktreeRenderers({ ...sshArgs, restorePolicy: { sshParkingEnabled: true } })
).toBe(true)
expect(
canParkTerminalWorktreeRenderers({
...sshArgs,
terminalTabs: [...sshArgs.terminalTabs, { id: 'tab-2', ptyId: 'remote:env-1@@t-1' }],
restorePolicy: { sshParkingEnabled: true }
})
).toBe(false)
})
it('keeps a previously mounted v19 terminal eligible for ordinary parking', () => {
const legacyPtyId = 'repo::/worktree@@session-1'
clearTerminalProviderSnapshotCapabilities()
@@ -67,6 +67,26 @@ export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: st
return separatorIdx !== -1 && ptyId.slice(0, separatorIdx) === worktreeId
}
export type TerminalParkRestorePolicy = {
/** settings.terminalSshViewParking !== false — the C1 SSH-parking kill switch. */
sshParkingEnabled?: boolean
}
// Why: SSH bytes transit local main, so main's headless model (served over
// pty:getMainBufferSnapshot) can re-hydrate a parked SSH reveal, with the
// relay's replay buffer as fallback — fact-mode watchers cover side effects
// either way. Remote-runtime ptys never transit main; they stay un-parkable.
export function isParkRestorableTerminalPty(
ptyId: string | null,
worktreeId: string,
policy?: TerminalParkRestorePolicy
): boolean {
if (isSnapshotBackedTerminalPty(ptyId, worktreeId)) {
return true
}
return policy?.sshParkingEnabled === true && ptyId !== null && parseAppSshPtyId(ptyId) !== null
}
export function canParkTerminalWorktreeRenderers(args: {
worktreeId: string
terminalTabs: readonly ColdParkableTerminalTab[]
@@ -80,6 +100,7 @@ export function canParkTerminalWorktreeRenderers(args: {
hiddenSinceMs: number | null
nowMs: number
coldParkDelayMs?: number
restorePolicy?: TerminalParkRestorePolicy
}): boolean {
if (
!args.parkingEnabled ||
@@ -103,7 +124,7 @@ export function canParkTerminalWorktreeRenderers(args: {
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
return false
}
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
return isParkRestorableTerminalPty(tab.ptyId, args.worktreeId, args.restorePolicy)
})
}
@@ -114,6 +135,7 @@ export function canParkTerminalTabRenderer(args: {
parkingEnabled: boolean
nowMs: number
coldParkDelayMs?: number
restorePolicy?: TerminalParkRestorePolicy
}): boolean {
const tab = args.terminalTab
if (
@@ -133,7 +155,7 @@ export function canParkTerminalTabRenderer(args: {
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
return false
}
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
return isParkRestorableTerminalPty(tab.ptyId, args.worktreeId, args.restorePolicy)
}
type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number }
@@ -197,6 +219,7 @@ export function selectColdParkedTerminalWorktrees(
pendingStartupByTabId: Readonly<Record<string, unknown>>
parkingEnabled: boolean
nowMs: number
restorePolicy?: TerminalParkRestorePolicy
} & TerminalColdParkPolicyOverrides
): Set<string> {
if (!args.parkingEnabled) {
@@ -212,7 +235,8 @@ export function selectColdParkedTerminalWorktrees(
pendingStartupByTabId: args.pendingStartupByTabId,
parkingEnabled: args.parkingEnabled,
nowMs: args.nowMs,
coldParkDelayMs
coldParkDelayMs,
...(args.restorePolicy ? { restorePolicy: args.restorePolicy } : {})
})
) {
continue
@@ -233,6 +257,7 @@ export function selectColdParkedTerminalTabs(
pendingStartupByTabId: Readonly<Record<string, unknown>>
parkingEnabled: boolean
nowMs: number
restorePolicy?: TerminalParkRestorePolicy
} & TerminalColdParkPolicyOverrides
): Set<string> {
if (!args.parkingEnabled) {
@@ -249,7 +274,8 @@ export function selectColdParkedTerminalTabs(
pendingStartupByTabId: args.pendingStartupByTabId,
parkingEnabled: args.parkingEnabled,
nowMs: args.nowMs,
coldParkDelayMs
coldParkDelayMs,
...(args.restorePolicy ? { restorePolicy: args.restorePolicy } : {})
})
) {
continue
@@ -79,6 +79,7 @@ type MockStoreState = {
}
>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
settings: { terminalSshViewParking?: boolean } | null
clearTabLaunchAgent: ReturnType<typeof vi.fn>
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
setTabLayout: ReturnType<typeof vi.fn>
@@ -135,6 +136,7 @@ describe('terminal-parked-tab-watchers', () => {
tabsByWorktree: {},
terminalLayoutsByTabId: {},
runtimePaneTitlesByTabId: {},
settings: null,
clearTabLaunchAgent: vi.fn(),
clearRuntimePaneTitle: vi.fn(),
setTabLayout: vi.fn(),
@@ -204,10 +206,9 @@ describe('terminal-parked-tab-watchers', () => {
expect(startedWatchers[0].options).toMatchObject({ ptyId: SECOND_PTY_ID })
})
it('never starts watchers for remote-runtime or SSH PTYs', () => {
it('never starts watchers for remote-runtime PTYs', () => {
capturePanes([
{ ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
{ ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }
])
syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] })
@@ -217,6 +218,22 @@ describe('terminal-parked-tab-watchers', () => {
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
})
it('starts watchers for SSH PTYs (C1 SSH parking, default on)', () => {
capturePanes([{ ptyId: 'ssh:conn-1@@pty-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked({ tabs: [{ id: TAB_ID, ptyId: 'ssh:conn-1@@pty-1' }] })
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
expect(startedWatchers[0].options).toMatchObject({ ptyId: 'ssh:conn-1@@pty-1' })
})
it('never starts watchers for SSH PTYs when terminalSshViewParking is off', () => {
mockStoreState.settings = { terminalSshViewParking: false }
capturePanes([{ ptyId: 'ssh:conn-1@@pty-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] })
expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled()
})
it('keeps existing watchers across repeated syncs of the same parked state', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
@@ -702,6 +719,28 @@ describe('terminal-parked-tab-watchers', () => {
})
it('rejects a capture containing a PTY without snapshot backing', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
// Why foreign-worktree id: minted under another worktree, never restorable.
{ ptyId: 'other::wt@@session-9', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
false
)
})
it('accepts an SSH PTY under the default C1 SSH-parking policy', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
true
)
})
it('rejects an SSH PTY when terminalSshViewParking is off', () => {
mockStoreState.settings = { terminalSshViewParking: false }
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
@@ -17,7 +17,10 @@ import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
import { subscribeToPtyExit } from './pty-dispatcher'
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking'
import {
isParkRestorableTerminalPty,
type TerminalParkRestorePolicy
} from './terminal-hidden-view-parking'
import {
resolveTabTitleAfterPaneClose,
shouldClearLaunchAgentForClosedPane
@@ -94,9 +97,18 @@ function resolveParkedTerminalPaneCandidates(
return capturedIsCurrent ? captured.panes : fallbackParkedPaneCandidates(tab, state)
}
// Why: fact-mode watchers work for any pty whose bytes transit local main —
// SSH included — so watcher coverage follows the park-restore policy, not the
// stricter daemon-snapshot predicate.
function parkRestorePolicyFromState(state: {
settings: { terminalSshViewParking?: boolean } | null
}): TerminalParkRestorePolicy {
return { sshParkingEnabled: state.settings?.terminalSshViewParking !== false }
}
/**
* Whether parked byte watchers can fully cover this tab's PTYs (every candidate
* has a snapshot-backed PTY on a valid leaf). Hosts must refuse to park a tab
* has a park-restorable PTY on a valid leaf). Hosts must refuse to park a tab
* that fails this check, or bell/title/completion side effects silently drop.
*/
export function canWatcherCoverParkedTerminalTab(
@@ -105,14 +117,16 @@ export function canWatcherCoverParkedTerminalTab(
// Why: cold activation needs stronger snapshot support (view never mounted); ordinary parking can reattach a mounted view.
isPtyEligible: ParkedTerminalPtyEligibility = allowSnapshotBackedPty
): boolean {
const panes = resolveParkedTerminalPaneCandidates(tab, useAppStore.getState())
const state = useAppStore.getState()
const panes = resolveParkedTerminalPaneCandidates(tab, state)
const restorePolicy = parkRestorePolicyFromState(state)
return (
panes.length > 0 &&
panes.every(
(pane) =>
pane.ptyId !== null &&
isTerminalLeafId(pane.leafId) &&
isSnapshotBackedTerminalPty(pane.ptyId, worktreeId) &&
isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy) &&
isPtyEligible(pane.ptyId)
)
)
@@ -125,6 +139,7 @@ function startParkedTabWatchers(
): void {
const state = useAppStore.getState()
const panes = resolveParkedTerminalPaneCandidates(tab, state)
const restorePolicy = parkRestorePolicyFromState(state)
const disposersByPtyId = new Map<string, () => void>()
const paneIdByPtyId = new Map<string, number>()
for (const pane of panes) {
@@ -134,7 +149,7 @@ function startParkedTabWatchers(
!ptyId ||
disposersByPtyId.has(ptyId) ||
!isTerminalLeafId(pane.leafId) ||
!isSnapshotBackedTerminalPty(ptyId, worktreeId)
!isParkRestorableTerminalPty(ptyId, worktreeId, restorePolicy)
) {
continue
}
@@ -78,6 +78,9 @@ export function useTerminalTabColdParking(args: {
const terminalParkingEnabled = useAppStore(
(state) => state.settings?.terminalHiddenViewParking !== false
)
const terminalSshParkingEnabled = useAppStore(
(state) => state.settings?.terminalSshViewParking !== false
)
const terminalTabHiddenSinceRef = useRef(new Map<string, number>())
const terminalTabParkingTimersRef = useRef(new Map<string, number>())
const parkVerdictRecordsRef = useRef(new Map<string, ParkVerdictFlipRecord>())
@@ -147,6 +150,7 @@ export function useTerminalTabColdParking(args: {
pendingStartupByTabId,
parkingEnabled: terminalParkingEnabled,
nowMs,
restorePolicy: { sshParkingEnabled: terminalSshParkingEnabled },
...overrides
})
// Why: a tab the byte watchers cannot cover (no capture, no layout
@@ -196,6 +200,7 @@ export function useTerminalTabColdParking(args: {
pendingStartupByTabId,
shouldMeasureHiddenWorktree,
terminalParkingEnabled,
terminalSshParkingEnabled,
terminalTabParkingRevision,
terminalTabs,
worktreeId
+2
View File
@@ -2848,6 +2848,8 @@ export type GlobalSettings = {
terminalScopeHistoryByWorktree: boolean
/** Kill switch for hidden terminal view parking: unmount long-hidden panes while a pane-less watcher keeps PTY side effects alive. */
terminalHiddenViewParking?: boolean
/** Kill switch for SSH terminal parking (C1): SSH panes park like local ones; reveal restores from main's headless model, falling back to relay replay. */
terminalSshViewParking?: boolean
/** Kill switch for main-process PTY side-effect authority; on (default) = title/bell/agent facts via pty:sideEffect channel, not renderer byte parsing. */
terminalMainSideEffectAuthority?: boolean
/** Kill switch for main's hidden-delivery gate (Phase 4): drops PTY bytes to hidden views after model ingestion; requires terminalMainSideEffectAuthority. */