diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000000..67681d9bc46 --- /dev/null +++ b/DESIGN.md @@ -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. diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 237dbafae23..8314f7271bf 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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. diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 3962b06f890..fbd3fa7511e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 => { + 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 => { 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') diff --git a/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts new file mode 100644 index 00000000000..021b7781e16 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts new file mode 100644 index 00000000000..4ba28068b24 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts @@ -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 +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts index 5c8026b74d0..b8102fa9492 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts index f4b434c948b..26357d14a9c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -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> parkingEnabled: boolean nowMs: number + restorePolicy?: TerminalParkRestorePolicy } & TerminalColdParkPolicyOverrides ): Set { 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> parkingEnabled: boolean nowMs: number + restorePolicy?: TerminalParkRestorePolicy } & TerminalColdParkPolicyOverrides ): Set { 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 diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index bd044d1578f..b67645133de 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -79,6 +79,7 @@ type MockStoreState = { } > runtimePaneTitlesByTabId: Record> + settings: { terminalSshViewParking?: boolean } | null clearTabLaunchAgent: ReturnType clearRuntimePaneTitle: ReturnType setTabLayout: ReturnType @@ -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 } diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 4328f5db980..bdde930c6b2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -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 void>() const paneIdByPtyId = new Map() 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 } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 28773698d9b..58f865097c5 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -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()) const terminalTabParkingTimersRef = useRef(new Map()) const parkVerdictRecordsRef = useRef(new Map()) @@ -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 diff --git a/src/shared/types.ts b/src/shared/types.ts index ec27f126711..4a47488cd15 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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. */