diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 929a68a7146..1cfef6da8fe 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-30", + "updatedAt": "2026-08-31", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -5735,11 +5735,13 @@ "providers": ["local", "local-daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "remote-runtime"], - "coverageNotes": "Deterministic renderer contracts hold CWD resolution behind an explicit promise, require the new pane to be created synchronously, and prove that close cancels the pending connection. Detach contracts reject cwd-pending and cwd-resolved deferred splits before PTY bind without mutation, carry resolved cwd for other unbound panes, and preserve persisted or live PTY handoff. Local transport contracts exercise the real bounded pre-connect buffer and one live input FIFO across ordinary, acknowledged, and immediate writes, concurrent flushes, in-flight teardown, late spawn success or failure, attach failure, failed spawn, same-id reuse, stale-spawn retirement ownership, destroy, and mutable recovery metadata. A mocked direct-SSH authority-rotation contract proves a rejected stale spawn releases its deferred-CWD fence. The committed headful Electron benchmark drives the real platform shortcut in a visible BrowserWindow and document for 3 warmups followed by 20 measured cold-CWD cycles, requiring a distinct child PTY, first echo, and observed child pty:exit before the next cycle. Remote-runtime coverage proves delegation remains host-owned; physical local-daemon, SSH, WSL, Linux, Windows, and folder-workspace latency journeys remain gaps.", + "coverageNotes": "Deterministic renderer contracts hold CWD resolution behind an explicit promise, require the new pane to be created synchronously, and prove that close cancels the pending connection. A module-level stable-pane-key handoff preserves the exact CWD promise and bounded pre-connect input across whole-tab remounts, including a tab rehome between worktree buckets; stale owners are fenced, concrete PTY bind and definitive spawn failure clear the record, and explicit pane close discards it. Detach contracts reject cwd-pending and cwd-resolved deferred splits before PTY bind without mutation, carry resolved cwd for other unbound panes, and preserve persisted or live PTY handoff. Local IPC transport contracts exercise the real bounded pre-connect buffer and one live input FIFO across seeded and newly typed ordinary, acknowledged, and immediate writes, concurrent flushes, in-flight teardown, late spawn success or failure, attach failure, failed spawn, same-id reuse, stale-spawn retirement ownership, destroy, and mutable recovery metadata. The handoff registry is capped at 64 records for 15 seconds and shares the existing 1,024-entry/conservative UTF-16 input ceilings. A mocked direct-SSH authority-rotation contract proves a rejected stale spawn releases its deferred-CWD fence. The committed headful Electron benchmark drives the real platform shortcut in a visible BrowserWindow and document for 3 warmups followed by 20 measured cold-CWD cycles, requiring a distinct child PTY, first echo, and observed child pty:exit before the next cycle. Remote-runtime coverage proves delegation remains host-owned; its host-delegated split path does not consume the local pre-connect input options, so remote-runtime input-remount replay and physical local-daemon, SSH, WSL, Linux, Windows, and folder-workspace latency journeys remain gaps.", "motivatingLinks": ["https://github.com/stablyai/orca/commit/572ed1a8882"], - "invariant": "A terminal split creates and activates its renderer pane before an inherited-CWD lookup settles, starts its PTY only after the resolved directory is available, and cannot be externally detached while that deferred spawn remains unbound. Other unbound panes preserve resolved cwd as startupCwd when detached. Bounded pre-connect input and later live local input share byte order, and teardown settles acknowledged writes without creating or rebinding a stale PTY. A disconnected or detached pending connect cannot bind its late fresh spawn, report its late failure through current callbacks, or ID-retire a newer same-ID owner; rejecting a stale direct-SSH spawn also releases the matching deferred-CWD fence. Natural exit cannot deliver queued work into a reused PTY id. Bound and remote-runtime splits remain owned by their execution host.", - "oracle": "Hold CWD resolution behind a controllable promise, invoke the production split path, and require manager.splitPane plus split telemetry before resolving it. Before PTY bind, require both cwd-pending and cwd-resolved deferred detach attempts to return null without layout, pane, tab, ownership, or focus mutation; separately require resolved cwd on an allowed unbound detach and unchanged persisted/live PTY adoption. Rotate a mocked direct-SSH authority while its delayed spawn is in flight, reject and disconnect the stale PTY claim, then require exactly one deferred-CWD cleanup when the delayed connect settles. At the PTY boundary, require zero connect calls while pending, the resolved CWD in spawn and local recovery metadata, one shared FIFO across pre-connect and live ordinary/acknowledged/immediate input, prompt acknowledged-promise settlement on teardown, retirement of an unowned late fresh spawn, preservation of a newer same-ID owner, and zero stale delivery after failure, close, destroy, detach, natural exit, or same-id reuse. In visible Electron, press the real split shortcut for 3 warmup cycles, then after a cold inherited-CWD interval for each of 20 measured cycles, and require complete focus, distinct-PTY bind, immediate-input echo, pane-count, and child-exit observations for every cycle; a timed-out, missing-event, or cleanup-aborted run must publish no headline latency and fail.", + "invariant": "A terminal split creates and activates its renderer pane before an inherited-CWD lookup settles, starts its PTY only after the resolved directory is available, and cannot be externally detached while that deferred spawn remains unbound. A whole-tab remount or worktree rehome preserves the same stable pane's CWD promise and admitted local pre-connect bytes in order until a successor binds or the intent is definitively abandoned; stale owners cannot append or clear the successor's record. Other unbound panes preserve resolved cwd as startupCwd when detached. Bounded pre-connect input and later live local input share byte order, and teardown settles acknowledged writes without creating or rebinding a stale PTY. A disconnected or detached pending connect cannot bind its late fresh spawn, report its late failure through current callbacks, or ID-retire a newer same-ID owner; rejecting a stale direct-SSH spawn also releases the matching deferred-CWD fence. Natural exit cannot deliver queued work into a reused PTY id. Bound and remote-runtime splits remain owned by their execution host.", + "oracle": "Hold CWD resolution behind a controllable promise, invoke the production split path, and require manager.splitPane plus split telemetry before resolving it. Before PTY bind, require both cwd-pending and cwd-resolved deferred detach attempts to return null without layout, pane, tab, ownership, or focus mutation; separately require resolved cwd on an allowed unbound detach and unchanged persisted/live PTY adoption. Exercise the stable-pane handoff registry through repeated remounts and a worktree rehome, requiring the identical CWD promise, ordered ordinary/acknowledged/immediate seed replay, stale-owner fencing, 64-record/15-second bounds, and discard on bind, failure, or explicit close. Rotate a mocked direct-SSH authority while its delayed spawn is in flight, reject and disconnect the stale PTY claim, then require exactly one deferred-CWD cleanup when the delayed connect settles. At the local IPC PTY boundary, require zero connect calls while pending, the resolved CWD in spawn and local recovery metadata, one shared FIFO across seeded/new pre-connect and live ordinary/acknowledged/immediate input, prompt predecessor acknowledged-promise settlement on teardown, retirement of an unowned late fresh spawn, preservation of a newer same-ID owner, and zero stale delivery after failure, close, destroy, detach, natural exit, or same-id reuse. Capture callback exceptions must not change admission results. In visible Electron, press the real split shortcut for 3 warmup cycles, then after a cold inherited-CWD interval for each of 20 measured cycles, and require complete focus, distinct-PTY bind, immediate-input echo, pane-count, and child-exit observations for every cycle; a timed-out, missing-event, or cleanup-aborted run must publish no headline latency and fail.", "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts --reporter=dot", + // Historical evidence record retained so its seven-file command remains auditable. "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts --reporter=dot", // Historical evidence records only; these labels do not verify the checkout or harness. "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", @@ -5753,6 +5755,7 @@ "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts", "src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts", "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts", + "src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts", "tests/e2e/terminal-split-activation-latency.spec.ts" ], "assertionRefs": [ @@ -5820,6 +5823,17 @@ "a stale fresh-spawn completion cannot retire a newer same-ID owner" ] }, + { + "file": "src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts", + "assertions": [ + "hands the same cwd promise and buffered input to a remounted leaf in order", + "keeps input across repeated remounts and fences stale owners", + "releases an unmounted owner without dropping its pending handoff", + "retains input within the shared preconnect entry and code-unit caps", + "evicts the oldest handoff when the record cap is reached", + "expires an abandoned handoff after the bounded remount window" + ] + }, { "file": "tests/e2e/terminal-split-activation-latency.spec.ts", "assertions": [ @@ -5841,6 +5855,15 @@ "durationSeconds": 56.59, "summary": "Seven focused files and 78 tests passed. Vitest reported 49.92 seconds and the measured wall time was 56.59 seconds; coverage includes split creation and focus ordering, nested CWD lineage, promise-identity and SSH authority-rotation cleanup fencing, full pre-bind detach fencing, resolved-CWD detach handoff, close-cancellation, single-FIFO ordering, late-spawn retirement and error suppression, preservation of a newer same-ID owner, generation fencing, in-flight settlement across explicit teardown and natural exit, attach cleanup, bounded retention, and existing input-write contracts." }, + { + "date": "2026-08-31", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts --reporter=dot", + "durationSeconds": 44.43, + "summary": "Eight focused files and 93 tests passed. Vitest reported 37.78 seconds and measured wall time was 44.43 seconds; the run adds stable-pane CWD/input handoff, repeated-remount stale-owner fencing, bounded 64-record/15-second retention, seeded-input caps, ordered ordinary/acknowledged/immediate replay, predecessor acknowledged-promise settlement, and capture-callback failure containment to the existing split, detach, CWD, and local transport contracts." + }, { "date": "2026-08-30", "runner": "local", @@ -5863,22 +5886,22 @@ "evidenceProcedure": "Historical artifact procedure: run the benchmark spec in a visible macOS Electron project from the primary worktree, complete 3 warmups followed by 20 measured cold-CWD cycles, save the JSON report, and record its SHA-256. The recorded command strings are audit records, not rerunnable gate commands: their operator-supplied labels name the claimed product SHA but did not verify the checkout, and the uncommitted harness revision was not captured. Rerun both product revisions with one committed harness before promotion.", "runtimeBudget": { "p95Seconds": 240, - "scope": "the full listed gate command set: seven deterministic renderer/local-transport unit files plus two opt-in 3-warmup/20-measured visible Electron benchmark invocations" + "scope": "the full listed gate command set: eight deterministic renderer/local-transport unit files plus two opt-in 3-warmup/20-measured visible Electron benchmark invocations" }, "flakeHistory": { "status": "not-started", - "evidence": "The focused promise-barrier suite and one visible baseline/candidate benchmark pair pass locally; routed CI and soak history have not started. The focused unit command runs the checkout from which it is invoked. Historical benchmark commands are audit records whose labels do not verify the product checkout or harness revision, and the stored pair predates the final harness hardening." + "evidence": "The focused promise-barrier and remount-handoff suite now passes locally in 93 tests; one visible baseline/candidate benchmark pair also passes locally. Routed CI and soak history have not started. The focused unit command runs the checkout from which it is invoked. Historical benchmark commands are audit records whose labels do not verify the product checkout or harness revision, and the stored pair predates the final harness hardening." }, "redGreenEvidence": { "status": "partial", - "evidence": "Before the production seam landed, the split assertion failed with zero manager calls while CWD was pending, and the transport assertion rejected the first pre-connect input. Before the detach fence, a deferred split could be removed after CWD resolved but before PTY bind, dropping its pre-connect input. Before the single-flight hardening, the concurrent-flush oracle delivered ordinary input before the earlier acknowledged write and clear left the in-flight promise pending. Before stale-spawn ownership fencing, the combined deferred-connect and newer same-ID attach fixture called kill on the current PTY. An intentional one-line revert of deferred-CWD cleanup in the stale direct-SSH claim branch failed its focused callback assertion with zero calls instead of one; restoring it passed all three focused tests and all 78 tests in the seven-file gate. Close, metadata, attach-failure, and remaining failure assertions are green on the candidate but have not each been recorded against an isolated intentional revert." + "evidence": "Before the production seam landed, the split assertion failed with zero manager calls while CWD was pending, and the transport assertion rejected the first pre-connect input. Before the detach fence, a deferred split could be removed after CWD resolved but before PTY bind, dropping its pre-connect input. Before the single-flight hardening, the concurrent-flush oracle delivered ordinary input before the earlier acknowledged write and clear left the in-flight promise pending. Before stale-spawn ownership fencing, the combined deferred-connect and newer same-ID attach fixture called kill on the current PTY. An intentional one-line revert of deferred-CWD cleanup in the stale direct-SSH claim branch failed its focused callback assertion with zero calls instead of one; restoring it passed the prior focused tests. The new remount-handoff and transport regressions are green in the 93-test eight-file run, but isolated intentional-revert evidence for each cleanup branch remains outstanding." }, "performanceBudget": { "required": true, - "evidence": "Pane creation and focus add no timer, polling, provider inventory, or subprocess work. CWD resolution remains one existing bounded request off the visible activation path, and detach admission adds only bounded map and record lookups. Pre-connect input, including an in-flight acknowledged write, is capped at 1,024 entries and a conservative UTF-16 ceiling derived from the existing terminal-input byte limit, drains through one worker in order, and clears on teardown or failed connect. In same-mode visible BrowserWindow runs with 3 warmups and n=20 measured cold-CWD cycles, baseline df14d1a2983d8339e788d0e521f1c4affd9c6d5f versus candidate d453ffcdb704764daced1b2917fddee7224389f0 changed shortcut-to-focus p50/p95/max from 65.7/88.7/102.6 ms to 12.8/14.4/16.5 ms. PTY bind changed from 122.8/154.0/159.7 ms to 177.0/316.1/333.3 ms, and first echo changed from 203.8/264.2/332.7 ms to 268.6/627.4/710.7 ms, so shell readiness regressed in this pair and remains diagnostic rather than part of the activation claim. The n=20 empirical p95 values are descriptive, are not a distribution guarantee, and are not CI-enforced." + "evidence": "Pane creation and focus add no timer, polling, provider inventory, or subprocess work. CWD resolution remains one existing bounded request off the visible activation path, and detach admission adds only bounded map and record lookups. The remount handoff adds one module-level map lookup per pane lifecycle, a 64-record cap, and a 15-second expiry; it retains no unbounded payload. Pre-connect input, including an in-flight acknowledged write and remount seed replay, is capped at 1,024 entries and a conservative UTF-16 ceiling derived from the existing terminal-input byte limit, drains through one worker in order, and clears on teardown or failed connect. In same-mode visible BrowserWindow runs with 3 warmups and n=20 measured cold-CWD cycles, baseline df14d1a2983d8339e788d0e521f1c4affd9c6d5f versus candidate d453ffcdb704764daced1b2917fddee7224389f0 changed shortcut-to-focus p50/p95/max from 65.7/88.7/102.6 ms to 12.8/14.4/16.5 ms. PTY bind changed from 122.8/154.0/159.7 ms to 177.0/316.1/333.3 ms, and first echo changed from 203.8/264.2/332.7 ms to 268.6/627.4/710.7 ms, so shell readiness regressed in this pair and remains diagnostic rather than part of the activation claim. The n=20 empirical p95 values are descriptive, are not a distribution guarantee, and are not CI-enforced." }, "promotionCriteria": [ - "Record complete red/green evidence for close, mixed-input ordering, metadata, and failure cleanup.", + "Record complete red/green evidence for close, remount/rehome handoff, mixed-input ordering, metadata, and failure cleanup.", "Collect 100 consecutive focused CI passes or 14 days without an unexplained flake.", "Run the committed real-shortcut Electron benchmark in routed CI or soak before enforcing a latency budget.", "Collect physical local-daemon, SSH or WSL plus Linux, Windows, and folder-workspace evidence before claiming provider-complete coverage." @@ -5886,11 +5909,12 @@ "knownGaps": [ "The visible benchmark pair ran on one Apple-silicon macOS host with a synthetic POSIX echo shell and a git-backed workspace; its n=20 empirical p95 is descriptive and not CI-enforced.", "No physical local-daemon, SSH, WSL, Linux, Windows, or folder-workspace latency journey has run; the synthetic fixture is currently skipped on Windows because it requires a POSIX shell.", + "Remote-runtime split creation remains host-delegated and its transport does not consume the local pre-connect seed/capture options; CWD handoff is covered, but remote-runtime pre-connect input replay has no implementation or evidence.", "The stored artifact pair predates the final harness cleanup/reporting and exit-generation hardening; immutable hashes preserve artifact identity, while operator labels record only the claimed product revision. Both product revisions should be rerun with one committed harness before promotion.", "No forced-failure visible benchmark artifact or focused report contract has been recorded; invalid-report construction is implemented but unverified by this gate.", "PTY-bind and first-echo readiness regressed in the recorded candidate pair and remain diagnostic rather than blocking the activation-ordering claim." ], - "demotionRule": "Keep experimental or demote if pane activation waits on CWD, a deferred split can detach before PTY bind, detached cwd is lost, input reorders or remains pending after cleanup, a closed pane can spawn, stale retirement kills a newer same-ID owner, remote-runtime delegation creates a competing local pane, or the focused suite flakes without an identified product or harness cause." + "demotionRule": "Keep experimental or demote if pane activation waits on CWD, a deferred split can detach before PTY bind, a remount or rehome loses its stable CWD/input handoff, stale owners mutate a successor record, detached cwd is lost, input reorders or remains pending after cleanup, a closed pane can spawn, stale retirement kills a newer same-ID owner, remote-runtime delegation creates a competing local pane, or the focused suite flakes without an identified product or harness cause." }, { "id": "terminal-session.kill-all-surface-cleanup", diff --git a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts new file mode 100644 index 00000000000..d746b46282c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { + PTY_PRECONNECT_INPUT_MAX_CODE_UNITS, + PTY_PRECONNECT_INPUT_MAX_ENTRIES +} from './pty-preconnect-input-buffer' +import { + appendDeferredSplitPaneInput, + beginDeferredSplitPaneHandoff, + claimDeferredSplitPaneHandoff, + clearDeferredSplitPaneHandoff, + DEFERRED_SPLIT_PANE_HANDOFF_MAX_RECORDS, + DEFERRED_SPLIT_PANE_HANDOFF_TTL_MS, + discardDeferredSplitPaneHandoff, + discardDeferredSplitPaneHandoffForKey, + discardDeferredSplitPaneHandoffsForTab, + getDeferredSplitPaneHandoffCountForTests, + releaseDeferredSplitPaneHandoff, + resetDeferredSplitPaneHandoffsForTests +} from './deferred-split-pane-handoff' + +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' + +describe('deferred split pane handoff', () => { + beforeEach(resetDeferredSplitPaneHandoffsForTests) + afterEach(resetDeferredSplitPaneHandoffsForTests) + + it('hands the same cwd promise and buffered input to a remounted leaf in order', () => { + const key = makePaneKey('tab-1', LEAF_1) + const cwdPromise = Promise.resolve('/source/cwd') + const initial = beginDeferredSplitPaneHandoff(key, cwdPromise) + + appendDeferredSplitPaneInput(initial, { data: 'typed', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { data: '\x1b[0n', kind: 'immediate' }) + appendDeferredSplitPaneInput(initial, { data: '\x03', kind: 'accepted' }) + + const remounted = claimDeferredSplitPaneHandoff(key) + + expect(remounted?.cwdPromise).toBe(cwdPromise) + expect(remounted?.preconnectInput).toEqual([ + { data: 'typed', kind: 'ordinary' }, + { data: '\x1b[0n', kind: 'immediate' }, + { data: '\x03', kind: 'accepted' } + ]) + }) + + it('keeps input across repeated remounts and fences stale owners', () => { + const key = makePaneKey('tab-1', LEAF_1) + const initial = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) + appendDeferredSplitPaneInput(initial, { data: 'before-first-remount', kind: 'ordinary' }) + const firstRemount = claimDeferredSplitPaneHandoff(key) + expect(firstRemount).not.toBeNull() + + appendDeferredSplitPaneInput(initial, { data: 'stale-input', kind: 'ordinary' }) + clearDeferredSplitPaneHandoff(initial) + discardDeferredSplitPaneHandoff(initial) + appendDeferredSplitPaneInput(firstRemount!.handle, { + data: 'before-second-remount', + kind: 'ordinary' + }) + + const secondRemount = claimDeferredSplitPaneHandoff(key) + expect(secondRemount?.preconnectInput).toEqual([ + { data: 'before-first-remount', kind: 'ordinary' }, + { data: 'before-second-remount', kind: 'ordinary' } + ]) + + clearDeferredSplitPaneHandoff(firstRemount!.handle) + expect(getDeferredSplitPaneHandoffCountForTests()).toBe(1) + clearDeferredSplitPaneHandoff(secondRemount!.handle) + expect(getDeferredSplitPaneHandoffCountForTests()).toBe(0) + }) + + it('releases an unmounted owner without dropping its pending handoff', () => { + const key = makePaneKey('tab-1', LEAF_1) + const initial = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) + appendDeferredSplitPaneInput(initial, { data: 'before-unmount', kind: 'ordinary' }) + + releaseDeferredSplitPaneHandoff(initial) + appendDeferredSplitPaneInput(initial, { data: 'late-stale', kind: 'ordinary' }) + clearDeferredSplitPaneHandoff(initial) + + expect(claimDeferredSplitPaneHandoff(key)?.preconnectInput).toEqual([ + { data: 'before-unmount', kind: 'ordinary' } + ]) + }) + + it('clears or discards only the current owner', () => { + const clearedKey = makePaneKey('tab-clear', LEAF_1) + const cleared = beginDeferredSplitPaneHandoff(clearedKey, Promise.resolve('/clear')) + clearDeferredSplitPaneHandoff(cleared) + expect(claimDeferredSplitPaneHandoff(clearedKey)).toBeNull() + + const discardedKey = makePaneKey('tab-discard', LEAF_1) + const discarded = beginDeferredSplitPaneHandoff(discardedKey, Promise.resolve('/discard')) + discardDeferredSplitPaneHandoff(discarded) + expect(claimDeferredSplitPaneHandoff(discardedKey)).toBeNull() + }) + + it('drops a stale record when an authoritative restored PTY wins the key', () => { + const key = makePaneKey('tab-authoritative', LEAF_1) + const stale = beginDeferredSplitPaneHandoff(key, Promise.resolve('/stale')) + appendDeferredSplitPaneInput(stale, { data: 'must-not-replay', kind: 'ordinary' }) + + discardDeferredSplitPaneHandoffForKey(key) + + expect(claimDeferredSplitPaneHandoff(key)).toBeNull() + expect(getDeferredSplitPaneHandoffCountForTests()).toBe(0) + }) + + it('replaces an older handoff for the same stable pane key', () => { + const key = makePaneKey('tab-1', LEAF_1) + const stale = beginDeferredSplitPaneHandoff(key, Promise.resolve('/stale')) + appendDeferredSplitPaneInput(stale, { data: 'stale', kind: 'ordinary' }) + const currentPromise = Promise.resolve('/current') + const current = beginDeferredSplitPaneHandoff(key, currentPromise) + + appendDeferredSplitPaneInput(stale, { data: 'late-stale', kind: 'ordinary' }) + clearDeferredSplitPaneHandoff(stale) + appendDeferredSplitPaneInput(current, { data: 'current', kind: 'ordinary' }) + + const claimed = claimDeferredSplitPaneHandoff(key) + expect(claimed?.cwdPromise).toBe(currentPromise) + expect(claimed?.preconnectInput).toEqual([{ data: 'current', kind: 'ordinary' }]) + }) + + it('retains input within the shared preconnect entry and code-unit caps', () => { + const entryKey = makePaneKey('tab-entries', LEAF_1) + const entryHandle = beginDeferredSplitPaneHandoff(entryKey, Promise.resolve('/entries')) + for (let index = 0; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) { + appendDeferredSplitPaneInput(entryHandle, { data: '', kind: 'ordinary' }) + } + appendDeferredSplitPaneInput(entryHandle, { data: 'overflow', kind: 'ordinary' }) + expect(claimDeferredSplitPaneHandoff(entryKey)?.preconnectInput).toHaveLength( + PTY_PRECONNECT_INPUT_MAX_ENTRIES + ) + + const codeUnitKey = makePaneKey('tab-code-units', LEAF_1) + const codeUnitHandle = beginDeferredSplitPaneHandoff( + codeUnitKey, + Promise.resolve('/code-units') + ) + appendDeferredSplitPaneInput(codeUnitHandle, { + data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), + kind: 'ordinary' + }) + appendDeferredSplitPaneInput(codeUnitHandle, { data: 'overflow', kind: 'ordinary' }) + expect(claimDeferredSplitPaneHandoff(codeUnitKey)?.preconnectInput).toEqual([ + { data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), kind: 'ordinary' } + ]) + }) + + it('discards every handoff for one tab without touching another tab', () => { + const firstKey = makePaneKey('tab-1', LEAF_1) + const secondKey = makePaneKey('tab-1', LEAF_2) + const otherKey = makePaneKey('tab-2', LEAF_1) + beginDeferredSplitPaneHandoff(firstKey, Promise.resolve('/first')) + beginDeferredSplitPaneHandoff(secondKey, Promise.resolve('/second')) + beginDeferredSplitPaneHandoff(otherKey, Promise.resolve('/other')) + + discardDeferredSplitPaneHandoffsForTab('tab-1') + + expect(claimDeferredSplitPaneHandoff(firstKey)).toBeNull() + expect(claimDeferredSplitPaneHandoff(secondKey)).toBeNull() + expect(claimDeferredSplitPaneHandoff(otherKey)).not.toBeNull() + }) + + it('evicts the oldest handoff when the record cap is reached', () => { + const keys = Array.from({ length: DEFERRED_SPLIT_PANE_HANDOFF_MAX_RECORDS + 1 }, (_, index) => + makePaneKey(`tab-${index}`, LEAF_1) + ) + for (const key of keys) { + beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) + } + + expect(getDeferredSplitPaneHandoffCountForTests()).toBe(DEFERRED_SPLIT_PANE_HANDOFF_MAX_RECORDS) + expect(claimDeferredSplitPaneHandoff(keys[0])).toBeNull() + expect(claimDeferredSplitPaneHandoff(keys.at(-1)!)).not.toBeNull() + }) + + it('expires an abandoned handoff after the bounded remount window', () => { + vi.useFakeTimers() + try { + const key = makePaneKey('tab-1', LEAF_1) + beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) + expect(getDeferredSplitPaneHandoffCountForTests()).toBe(1) + + vi.advanceTimersByTime(DEFERRED_SPLIT_PANE_HANDOFF_TTL_MS) + + expect(getDeferredSplitPaneHandoffCountForTests()).toBe(0) + expect(claimDeferredSplitPaneHandoff(key)).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('does not expose the registry input array by reference', () => { + const key = makePaneKey('tab-1', LEAF_1) + const handle = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) + appendDeferredSplitPaneInput(handle, { data: 'kept', kind: 'ordinary' }) + const firstClaim = claimDeferredSplitPaneHandoff(key) + firstClaim?.preconnectInput.splice(0) + + expect(claimDeferredSplitPaneHandoff(key)?.preconnectInput).toEqual([ + { data: 'kept', kind: 'ordinary' } + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts new file mode 100644 index 00000000000..9c17161cfa5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts @@ -0,0 +1,183 @@ +import type { PaneKey } from '../../../../shared/stable-pane-id' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { + PTY_PRECONNECT_INPUT_MAX_CODE_UNITS, + PTY_PRECONNECT_INPUT_MAX_ENTRIES +} from './pty-preconnect-input-buffer' +import type { PtyPreconnectInputEntry, PtyPreconnectInputKind } from './pty-preconnect-input-buffer' + +export type DeferredSplitPaneInputKind = PtyPreconnectInputKind +export type DeferredSplitPaneInput = PtyPreconnectInputEntry + +declare const deferredSplitPaneHandoffHandleBrand: unique symbol + +export type DeferredSplitPaneHandoffHandle = { + readonly [deferredSplitPaneHandoffHandleBrand]: true +} + +export type ClaimedDeferredSplitPaneHandoff = { + handle: DeferredSplitPaneHandoffHandle + cwdPromise: Promise + preconnectInput: DeferredSplitPaneInput[] +} + +export const DEFERRED_SPLIT_PANE_HANDOFF_TTL_MS = 15_000 +export const DEFERRED_SPLIT_PANE_HANDOFF_MAX_RECORDS = 64 + +type DeferredSplitPaneHandoffRecord = { + cwdPromise: Promise + expiresAtMs: number + expiryTimer: ReturnType + inputCodeUnits: number + owner: DeferredSplitPaneHandoffHandle + preconnectInput: DeferredSplitPaneInput[] +} + +const handoffs = new Map() +let keyByHandle = new WeakMap() + +function createHandle(key: PaneKey): DeferredSplitPaneHandoffHandle { + const handle = {} as DeferredSplitPaneHandoffHandle + keyByHandle.set(handle, key) + return handle +} + +function getOwnedRecord( + handle: DeferredSplitPaneHandoffHandle +): { key: PaneKey; record: DeferredSplitPaneHandoffRecord } | null { + const key = keyByHandle.get(handle) + const record = key ? handoffs.get(key) : undefined + if (!key || !record || record.owner !== handle) { + return null + } + if (record.expiresAtMs <= Date.now()) { + deleteHandoff(key, record) + return null + } + return { key, record } +} + +function deleteHandoff(key: PaneKey, expected?: DeferredSplitPaneHandoffRecord): void { + const record = handoffs.get(key) + if (!record || (expected && record !== expected)) { + return + } + clearTimeout(record.expiryTimer) + handoffs.delete(key) +} + +function pruneExpiredHandoffs(nowMs: number): void { + for (const [key, record] of handoffs) { + if (record.expiresAtMs <= nowMs) { + deleteHandoff(key, record) + } + } +} + +export function beginDeferredSplitPaneHandoff( + key: PaneKey, + cwdPromise: Promise +): DeferredSplitPaneHandoffHandle { + const nowMs = Date.now() + pruneExpiredHandoffs(nowMs) + deleteHandoff(key) + if (handoffs.size >= DEFERRED_SPLIT_PANE_HANDOFF_MAX_RECORDS) { + const oldestKey = handoffs.keys().next().value + if (oldestKey) { + deleteHandoff(oldestKey) + } + } + const owner = createHandle(key) + const record: DeferredSplitPaneHandoffRecord = { + cwdPromise, + expiresAtMs: nowMs + DEFERRED_SPLIT_PANE_HANDOFF_TTL_MS, + expiryTimer: setTimeout(() => { + deleteHandoff(key, record) + }, DEFERRED_SPLIT_PANE_HANDOFF_TTL_MS), + inputCodeUnits: 0, + owner, + preconnectInput: [] + } + record.expiryTimer.unref?.() + handoffs.set(key, record) + return owner +} + +export function claimDeferredSplitPaneHandoff( + key: PaneKey +): ClaimedDeferredSplitPaneHandoff | null { + const record = handoffs.get(key) + if (!record) { + return null + } + if (record.expiresAtMs <= Date.now()) { + deleteHandoff(key, record) + return null + } + const owner = createHandle(key) + record.owner = owner + return { + handle: owner, + cwdPromise: record.cwdPromise, + preconnectInput: record.preconnectInput.map((input) => ({ ...input })) + } +} + +export function appendDeferredSplitPaneInput( + handle: DeferredSplitPaneHandoffHandle, + input: DeferredSplitPaneInput +): void { + const owned = getOwnedRecord(handle) + if ( + !owned || + owned.record.preconnectInput.length >= PTY_PRECONNECT_INPUT_MAX_ENTRIES || + input.data.length > PTY_PRECONNECT_INPUT_MAX_CODE_UNITS - owned.record.inputCodeUnits + ) { + return + } + owned.record.preconnectInput.push({ data: input.data, kind: input.kind }) + owned.record.inputCodeUnits += input.data.length +} + +export function releaseDeferredSplitPaneHandoff(handle: DeferredSplitPaneHandoffHandle): void { + const owned = getOwnedRecord(handle) + if (owned) { + owned.record.owner = createHandle(owned.key) + } +} + +export function clearDeferredSplitPaneHandoff(handle: DeferredSplitPaneHandoffHandle): void { + const owned = getOwnedRecord(handle) + if (owned) { + deleteHandoff(owned.key, owned.record) + } +} + +export function discardDeferredSplitPaneHandoff(handle: DeferredSplitPaneHandoffHandle): void { + clearDeferredSplitPaneHandoff(handle) +} + +/** Drops a stale record when a restored pane already has an authoritative PTY. */ +export function discardDeferredSplitPaneHandoffForKey(key: PaneKey): void { + deleteHandoff(key) +} + +export function discardDeferredSplitPaneHandoffsForTab(tabId: string): void { + for (const [key, record] of handoffs) { + if (parsePaneKey(key)?.tabId === tabId) { + deleteHandoff(key, record) + } + } +} + +export function resetDeferredSplitPaneHandoffsForTests(): void { + for (const [key, record] of handoffs) { + deleteHandoff(key, record) + } + keyByHandle = new WeakMap() +} + +export function getDeferredSplitPaneHandoffCountForTests(): number { + pruneExpiredHandoffs(Date.now()) + return handoffs.size +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index d52edae3503..05ef62637c7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -16,6 +16,7 @@ import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/termin import type { PtyTransportRecoveryState } from './pty-transport-types' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import type { DirectSshPaneRetryAttemptId } from '@/store/slices/direct-ssh-terminal-recovery' +import type { PtyPreconnectInputEntry } from './pty-preconnect-input-buffer' export type PtyPaneStartup = { command: string @@ -57,6 +58,10 @@ export type PtyConnectionDeps = { cwd?: string /** Delays a fresh split's spawn without delaying its renderer pane. */ cwdPromise?: Promise + /** Input handed off from a predecessor mount of the same deferred split. */ + preconnectInput?: readonly PtyPreconnectInputEntry[] + /** Captures newly retained input for a remount-safe deferred split handoff. */ + onPreconnectInput?: (input: PtyPreconnectInputEntry) => void /** Releases a deferred split's detach fence when its initial spawn yields no PTY. */ onDeferredCwdSpawnFailed?: () => void startup?: PtyPaneStartup diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index 8cca6ec713a..28fe066edc6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -36,7 +36,15 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { session.agentLaunchPreferences = toAgentLaunchPreferences(session.paneStartup?.sessionOptions) session.transportOptions = { cwd: session.deps.cwd, - ...(session.deps.cwdPromise ? { bufferInputUntilConnect: true } : {}), + ...(session.deps.cwdPromise || session.deps.preconnectInput?.length + ? { bufferInputUntilConnect: true } + : {}), + ...(session.deps.preconnectInput?.length + ? { preconnectInput: session.deps.preconnectInput } + : {}), + ...(session.deps.onPreconnectInput + ? { onPreconnectInput: session.deps.onPreconnectInput } + : {}), // Why: only fresh local IPC spawns may recover from a saved startup cwd // whose directory was deleted (#7239); remote-runtime and SSH spawns // resolve cwd on another host and must keep exact cwd semantics. diff --git a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts index ac129ce3283..39137f146c2 100644 --- a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts @@ -136,4 +136,42 @@ describe('createPtyPreconnectInputBuffer', () => { await expect(entryAccepted).resolves.toBe(false) await entryFlush }) + + it('applies entry and code-unit caps to seeded input', async () => { + const entryBuffer = createPtyPreconnectInputBuffer( + Array.from({ length: PTY_PRECONNECT_INPUT_MAX_ENTRIES + 1 }, () => ({ + data: '', + kind: 'ordinary' as const + })) + ) + let entryWrites = 0 + + expect(entryBuffer.enqueue('', 'ordinary')).toBe(false) + await entryBuffer.flush({ + isCurrent: () => true, + sendInput: () => { + entryWrites += 1 + return true + }, + sendInputImmediate: () => true + }) + expect(entryWrites).toBe(PTY_PRECONNECT_INPUT_MAX_ENTRIES) + + const codeUnitBuffer = createPtyPreconnectInputBuffer([ + { data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), kind: 'ordinary' }, + { data: 'overflow', kind: 'ordinary' } + ]) + const codeUnitWrites: number[] = [] + + expect(codeUnitBuffer.enqueue('new', 'ordinary')).toBe(false) + await codeUnitBuffer.flush({ + isCurrent: () => true, + sendInput: (data) => { + codeUnitWrites.push(data.length) + return true + }, + sendInputImmediate: () => true + }) + expect(codeUnitWrites).toEqual([PTY_PRECONNECT_INPUT_MAX_CODE_UNITS]) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts index 194a0322101..4ba6def71ac 100644 --- a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts +++ b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts @@ -4,9 +4,15 @@ export const PTY_PRECONNECT_INPUT_MAX_ENTRIES = 1024 // One UTF-16 code unit encodes to at most three UTF-8 bytes. export const PTY_PRECONNECT_INPUT_MAX_CODE_UNITS = Math.floor(TERMINAL_INPUT_MAX_BYTES / 3) -type BufferedInput = { +export type PtyPreconnectInputKind = 'ordinary' | 'immediate' | 'accepted' + +/** Input retained while a pane waits for its first PTY connection. */ +export type PtyPreconnectInputEntry = { data: string - kind: 'ordinary' | 'immediate' | 'accepted' + kind: PtyPreconnectInputKind +} + +type BufferedInput = PtyPreconnectInputEntry & { resolve?: (accepted: boolean) => void } @@ -19,13 +25,22 @@ type PreconnectInputWriter = { export type PtyPreconnectInputBuffer = { isBuffering: () => boolean - enqueue: (data: string, kind: 'ordinary' | 'immediate') => boolean - enqueueAccepted: (data: string) => Promise + enqueue: ( + data: string, + kind: 'ordinary' | 'immediate', + onRetained?: (entry: PtyPreconnectInputEntry) => void + ) => boolean + enqueueAccepted: ( + data: string, + onRetained?: (entry: PtyPreconnectInputEntry) => void + ) => Promise flush: (writer: PreconnectInputWriter) => Promise clear: () => void } -export function createPtyPreconnectInputBuffer(): PtyPreconnectInputBuffer { +export function createPtyPreconnectInputBuffer( + initialEntries: readonly PtyPreconnectInputEntry[] = [] +): PtyPreconnectInputBuffer { let pending: BufferedInput[] = [] let pendingCodeUnits = 0 let buffering = true @@ -52,9 +67,25 @@ export function createPtyPreconnectInputBuffer(): PtyPreconnectInputBuffer { } const createInput = ( data: string, - kind: BufferedInput['kind'], + kind: PtyPreconnectInputKind, resolve?: BufferedInput['resolve'] ): BufferedInput => ({ data, kind, ...(resolve ? { resolve } : {}) }) + const notifyRetained = ( + onRetained: ((entry: PtyPreconnectInputEntry) => void) | undefined, + input: BufferedInput + ): void => { + try { + onRetained?.({ data: input.data, kind: input.kind }) + } catch { + // Handoff capture is advisory; a callback failure must not reject input admission. + } + } + + // Seeded entries came from a predecessor transport and must not be reported + // back to that predecessor's handoff owner as newly typed input. + for (const entry of initialEntries) { + retain(createInput(entry.data, entry.kind)) + } const clear = (): void => { const dropped = pending pending = [] @@ -153,16 +184,22 @@ export function createPtyPreconnectInputBuffer(): PtyPreconnectInputBuffer { return { isBuffering: () => buffering, - enqueue(data, kind) { + enqueue(data, kind, onRetained) { const input = createInput(data, kind) - return retain(input) + const retained = retain(input) + if (retained) { + notifyRetained(onRetained, input) + } + return retained }, - enqueueAccepted(data) { + enqueueAccepted(data, onRetained) { return new Promise((resolve) => { const input = createInput(data, 'accepted', resolve) if (!retain(input)) { resolve(false) + return } + notifyRetained(onRetained, input) }) }, flush, diff --git a/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts index db3afae23c3..1060d617dd2 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts @@ -8,7 +8,8 @@ import { PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES } from './pty-input-write-que import { createDeferred, flushAsyncTicks } from './pty-connection-test-async' import { PTY_PRECONNECT_INPUT_MAX_CODE_UNITS, - PTY_PRECONNECT_INPUT_MAX_ENTRIES + PTY_PRECONNECT_INPUT_MAX_ENTRIES, + type PtyPreconnectInputEntry } from './pty-preconnect-input-buffer' import { installIpcPtyWindow, @@ -116,6 +117,107 @@ describe('createIpcPtyTransport', () => { expect(delivered).toEqual(['first-', 'second-', 'third']) }) + it('flushes remount-handoff input before newly typed input without recapturing the seed', async () => { + const spawn = createDeferred<{ id: string }>() + vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never) + const delivered: string[] = [] + vi.mocked(window.api.pty.write).mockImplementation((_id, data) => { + delivered.push(data) + }) + vi.mocked(window.api.pty.writeAccepted).mockImplementation(async (_id, data) => { + delivered.push(data) + return true + }) + const onPreconnectInput = vi.fn() + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createIpcPtyTransport({ + bufferInputUntilConnect: true, + preconnectInput: [ + { data: 'before-remount-', kind: 'ordinary' }, + { data: '\x1b[0n', kind: 'immediate' }, + { data: '\x03', kind: 'accepted' } + ], + onPreconnectInput + }) + + const connecting = transport.connect({ url: '', callbacks: {} }) + expect(transport.sendInput('new-ordinary-')).toBe(true) + expect(transport.sendInputImmediate('new-immediate-')).toBe(true) + const accepted = transport.sendInputAccepted?.('new-accepted') + expect(onPreconnectInput.mock.calls).toEqual([ + [{ data: 'new-ordinary-', kind: 'ordinary' }], + [{ data: 'new-immediate-', kind: 'immediate' }], + [{ data: 'new-accepted', kind: 'accepted' }] + ]) + + spawn.resolve({ id: 'pty-1' }) + await connecting + await expect(accepted).resolves.toBe(true) + await flushAsyncTicks() + + expect(delivered).toEqual([ + 'before-remount-', + '\x1b[0n', + '\x03', + 'new-ordinary-', + 'new-immediate-', + 'new-accepted' + ]) + }) + + it('settles a predecessor accepted write while its successor replays the captured bytes', async () => { + const spawn = createDeferred<{ id: string }>() + vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise as never) + const delivered: string[] = [] + vi.mocked(window.api.pty.writeAccepted).mockImplementation(async (_id, data) => { + delivered.push(data) + return true + }) + const captured: PtyPreconnectInputEntry[] = [] + const { createIpcPtyTransport } = await import('./pty-transport') + const predecessor = createIpcPtyTransport({ + bufferInputUntilConnect: true, + onPreconnectInput: (input) => captured.push(input) + }) + + const predecessorAccepted = predecessor.sendInputAccepted?.('\x03') + expect(captured).toEqual([{ data: '\x03', kind: 'accepted' }]) + + const successor = createIpcPtyTransport({ preconnectInput: captured }) + const connecting = successor.connect({ url: '', callbacks: {} }) + await predecessor.destroy?.() + + await expect(predecessorAccepted).resolves.toBe(false) + spawn.resolve({ id: 'pty-1' }) + await connecting + await flushAsyncTicks() + + expect(delivered).toEqual(['\x03']) + }) + + it('contains capture callback failures without rejecting retained input', async () => { + const onPreconnectInput = vi.fn(() => { + throw new Error('capture failed') + }) + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createIpcPtyTransport({ + bufferInputUntilConnect: true, + onPreconnectInput + }) + + expect(transport.sendInput('ordinary')).toBe(true) + expect(transport.sendInputImmediate('immediate')).toBe(true) + const accepted = transport.sendInputAccepted?.('accepted') + expect(onPreconnectInput).toHaveBeenCalledTimes(3) + + await transport.connect({ url: '', callbacks: {} }) + + await expect(accepted).resolves.toBe(true) + expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'ordinary') + expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'immediate') + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'accepted') + }) + it('keeps live acknowledged input ahead of later ordinary and immediate writes', async () => { vi.useFakeTimers() const acceptedWrite = createDeferred() diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index f8671772e73..b67085b1b07 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -15,6 +15,7 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { PtyDataMeta } from './pty-dispatcher' import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime-terminal-multiplexer' +import type { PtyPreconnectInputEntry } from './pty-preconnect-input-buffer' export type PtyBufferSnapshot = { data: string @@ -229,6 +230,10 @@ export type IpcPtyTransportOptions = { cwd?: string /** Retain bounded user input while a visible split waits to start its PTY. */ bufferInputUntilConnect?: boolean + /** Seed a fresh transport with input handed off from a remounted deferred split. */ + preconnectInput?: readonly PtyPreconnectInputEntry[] + /** Records newly retained input against a remount-safe deferred split handoff. */ + onPreconnectInput?: (input: PtyPreconnectInputEntry) => void cwdFallback?: 'worktree' env?: Record envToDelete?: string[] diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index bdbcc770f62..73615050956 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -49,9 +49,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra let lastExitGeneration: number | null = null let suppressAttentionEvents = false let storedCallbacks: Parameters[0]['callbacks'] = {} - const preconnectInputBuffer = opts.bufferInputUntilConnect - ? createPtyPreconnectInputBuffer() - : null + const preconnectInputBuffer = + opts.bufferInputUntilConnect || opts.preconnectInput?.length + ? createPtyPreconnectInputBuffer(opts.preconnectInput) + : null const inputWriteQueue = createPtyInputWriteQueue({ isWritable: (id) => !destroyed && connected && ptyId === id, @@ -207,14 +208,14 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra sendInput(data) { if (!destroyed && preconnectInputBuffer?.isBuffering()) { - return preconnectInputBuffer.enqueue(data, 'ordinary') + return preconnectInputBuffer.enqueue(data, 'ordinary', opts.onPreconnectInput) } return !destroyed && connected && ptyId ? inputWriteQueue.enqueue(ptyId, data) : false }, sendInputImmediate(data) { if (!destroyed && preconnectInputBuffer?.isBuffering()) { - return preconnectInputBuffer.enqueue(data, 'immediate') + return preconnectInputBuffer.enqueue(data, 'immediate', opts.onPreconnectInput) } return !destroyed && connected && ptyId ? inputWriteQueue.enqueueQueryReply(ptyId, data) @@ -226,7 +227,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra : { async sendInputAccepted(data: string): Promise { if (!destroyed && preconnectInputBuffer?.isBuffering()) { - return preconnectInputBuffer.enqueueAccepted(data) + return preconnectInputBuffer.enqueueAccepted(data, opts.onPreconnectInput) } if (destroyed || !connected || !ptyId) { return false diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 70101cd3721..b7541a6e3db 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -124,6 +124,7 @@ import { mergePaneCwdFromOsc7, type PaneCwdMap } from './resolve-split-cwd' +import type { PtyPreconnectInputEntry } from './pty-preconnect-input-buffer' import { installMouseHideWhileTyping } from './mouse-hide-while-typing' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { connectPanePty } from './pty-connection' @@ -136,6 +137,7 @@ import { import { getConnectionId } from '@/lib/connection-context' import { resolvePaneWslDistro } from './terminal-pane-wsl-distro' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { canReleaseReplayedScrollbackFromStore } from './replayed-scrollback-store-release' import { fitAndFocusPanes, fitPanes } from './pane-helpers' @@ -161,6 +163,17 @@ import { import { acquireWebviewsDragPassthrough } from '../browser-pane/host-guest/webview-registry' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' import { closeTerminalTab } from '../terminal/terminal-tab-actions' +import { + appendDeferredSplitPaneInput, + beginDeferredSplitPaneHandoff, + claimDeferredSplitPaneHandoff, + clearDeferredSplitPaneHandoff, + discardDeferredSplitPaneHandoff, + discardDeferredSplitPaneHandoffForKey, + discardDeferredSplitPaneHandoffsForTab, + releaseDeferredSplitPaneHandoff, + type DeferredSplitPaneHandoffHandle +} from './deferred-split-pane-handoff' import { seedStartupSessionRestoredBanner, type SessionRestoredBannerReason @@ -813,6 +826,9 @@ export function useTerminalPaneLifecycle({ const mouseHideDisposables = mouseHideDisposablesRef.current const imeCompositionDisposables = imeCompositionDisposablesRef.current const imeNativeTextForwarderDisposables = imeNativeTextForwarderDisposablesRef.current + // Numeric pane ids are mount-local; the handle itself is keyed by the + // durable tab/leaf identity so a whole-tab remount can reclaim it. + const deferredSplitHandoffs = new Map() const worktreePath = useAppStore .getState() @@ -984,6 +1000,13 @@ export function useTerminalPaneLifecycle({ syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => { const paneCwd = paneCwdRef.current.get(paneId) if (ptyId) { + const deferredSplitHandoff = deferredSplitHandoffs.get(paneId) + if (deferredSplitHandoff) { + // A concrete PTY owns the input queue now; do not replay it into a + // later layout mount. + clearDeferredSplitPaneHandoff(deferredSplitHandoff) + deferredSplitHandoffs.delete(paneId) + } const settledPaneCwd = clearPaneCwdDeferredSpawn(paneCwd) if (settledPaneCwd && settledPaneCwd !== paneCwd) { // A concrete PTY settles the split admission fence; the lookup is @@ -1038,6 +1061,33 @@ export function useTerminalPaneLifecycle({ const manager = new PaneManager(container, { // Split spawn hints let the renderer pane appear before a slow inherited-cwd lookup finishes. onPaneCreated: (pane, spawnHints) => { + const paneKey = makePaneKey(tabId, pane.leafId) + const restoredPtyId = ptyDeps.restoredPtyIdByLeafId?.[pane.leafId] + const hasAuthoritativeSpawnHint = Boolean( + spawnHints?.cwd || spawnHints?.ptyId || restoredPtyId + ) + let effectiveSpawnHints = spawnHints + let claimedDeferredSplitHandoff: ReturnType = null + let deferredSplitHandoff: DeferredSplitPaneHandoffHandle | undefined + if (spawnHints?.cwdPromise && !hasAuthoritativeSpawnHint) { + deferredSplitHandoff = beginDeferredSplitPaneHandoff(paneKey, spawnHints.cwdPromise) + deferredSplitHandoffs.set(pane.id, deferredSplitHandoff) + } else if (!hasAuthoritativeSpawnHint) { + claimedDeferredSplitHandoff = claimDeferredSplitPaneHandoff(paneKey) + if (claimedDeferredSplitHandoff) { + deferredSplitHandoff = claimedDeferredSplitHandoff.handle + deferredSplitHandoffs.set(pane.id, deferredSplitHandoff) + effectiveSpawnHints = { + ...spawnHints, + cwdPromise: claimedDeferredSplitHandoff.cwdPromise + } + } + } else { + // A restored PTY or explicit spawn hint is authoritative; an older + // deferred record must not be claimed by a later remount. + discardDeferredSplitPaneHandoffForKey(paneKey) + } + const handoffForInput = deferredSplitHandoff // OSC 52 — TUI-initiated clipboard writes (Zellij/tmux/nvim/fzf/ssh). // Why: read settingsRef at fire time so mid-session gate toggles apply; return true in both paths so xterm doesn't fall through. const osc52Disposable = pane.terminal.parser.registerOscHandler( @@ -1060,21 +1110,21 @@ export function useTerminalPaneLifecycle({ const existingPaneCwd = paneCwdRef.current.get(pane.id) if (!existingPaneCwd) { paneCwdRef.current.set(pane.id, { - cwd: resolvePaneSeedCwd(spawnHints?.cwd, ptyDeps.cwd), + cwd: resolvePaneSeedCwd(effectiveSpawnHints?.cwd, ptyDeps.cwd), confirmed: false, - ...(spawnHints?.cwdPromise - ? { deferredSplitSpawn: true, pendingCwd: spawnHints.cwdPromise } + ...(effectiveSpawnHints?.cwdPromise + ? { deferredSplitSpawn: true, pendingCwd: effectiveSpawnHints.cwdPromise } : {}) }) - } else if (spawnHints?.cwdPromise && !existingPaneCwd.confirmed) { + } else if (effectiveSpawnHints?.cwdPromise && !existingPaneCwd.confirmed) { paneCwdRef.current.set(pane.id, { ...existingPaneCwd, deferredSplitSpawn: true, - pendingCwd: spawnHints.cwdPromise + pendingCwd: effectiveSpawnHints.cwdPromise }) } - if (spawnHints?.cwdPromise) { - const cwdPromise = spawnHints.cwdPromise + if (effectiveSpawnHints?.cwdPromise) { + const cwdPromise = effectiveSpawnHints.cwdPromise void cwdPromise.then( (cwd) => { const current = paneCwdRef.current.get(pane.id) @@ -1451,24 +1501,39 @@ export function useTerminalPaneLifecycle({ const panePtyBinding = connectPanePty(pane, manager, { ...ptyDeps, ...(onQueuedStartupSpawned ? { onQueuedStartupSpawned } : {}), - ...(spawnHints?.cwdPromise + ...(effectiveSpawnHints?.cwdPromise ? { onDeferredCwdSpawnFailed: () => { const current = paneCwdRef.current.get(pane.id) - const settled = clearPaneCwdDeferredSpawn(current, spawnHints.cwdPromise) + const settled = clearPaneCwdDeferredSpawn(current, effectiveSpawnHints.cwdPromise) if (settled && settled !== current) { paneCwdRef.current.set(pane.id, settled) } + if (handoffForInput) { + clearDeferredSplitPaneHandoff(handoffForInput) + deferredSplitHandoffs.delete(pane.id) + } } } : {}), + ...(handoffForInput + ? { + onPreconnectInput: (input: PtyPreconnectInputEntry) => + appendDeferredSplitPaneInput(handoffForInput, input) + } + : {}), + ...(claimedDeferredSplitHandoff?.preconnectInput.length + ? { preconnectInput: claimedDeferredSplitHandoff.preconnectInput } + : {}), // Why: spread order matters — spawnHints.cwd (source pane) must override ptyDeps.cwd (worktree root) so splits boot in the live cwd. - ...(spawnHints?.cwd ? { cwd: spawnHints.cwd } : {}), - ...(spawnHints?.cwdPromise ? { cwdPromise: spawnHints.cwdPromise } : {}), - restoredPtyIdByLeafId: spawnHints?.ptyId + ...(effectiveSpawnHints?.cwd ? { cwd: effectiveSpawnHints.cwd } : {}), + ...(effectiveSpawnHints?.cwdPromise + ? { cwdPromise: effectiveSpawnHints.cwdPromise } + : {}), + restoredPtyIdByLeafId: effectiveSpawnHints?.ptyId ? { ...ptyDeps.restoredPtyIdByLeafId, - [pane.leafId]: spawnHints.ptyId + [pane.leafId]: effectiveSpawnHints.ptyId } : ptyDeps.restoredPtyIdByLeafId, restoredLeafId: pane.leafId @@ -1578,6 +1643,13 @@ export function useTerminalPaneLifecycle({ panePtyBindings.delete(paneId) } const leafId = closedPane?.leafId + const deferredSplitHandoff = deferredSplitHandoffs.get(paneId) + if (deferredSplitHandoff) { + // Explicit pane removal is terminal for the split intent; only a + // whole-tab remount is allowed to retain this record. + discardDeferredSplitPaneHandoff(deferredSplitHandoff) + deferredSplitHandoffs.delete(paneId) + } if (leafId && isRetiredSurface) { retireMountedTerminalPaneSurface({ paneKey: makePaneKey(tabId, leafId), @@ -2000,10 +2072,13 @@ export function useTerminalPaneLifecycle({ return () => { window.removeEventListener(SPLIT_TERMINAL_PANE_EVENT, onCliSplitPane) window.removeEventListener(CLOSE_TERMINAL_PANE_EVENT, onCliClosePane) - const currentWorktreeTabs = useAppStore.getState().tabsByWorktree[worktreeId] - const tabStillExists = Boolean( - currentWorktreeTabs?.some((candidate) => candidate.id === tabId) - ) + const currentStore = useAppStore.getState() + const currentWorktreeTabs = currentStore.tabsByWorktree[worktreeId] + // A tab move removes the old worktree bucket before the replacement + // surface mounts. Use the shared global terminal-tab ownership check so + // an ID-less deferred split survives that rehome (including duplicate + // host/worktree rows, which intentionally share the durable tab id). + const tabStillExists = isTerminalTabPresent(currentStore, tabId) unregisterRuntimeTab() if (resizeRaf !== null) { cancelAnimationFrame(resizeRaf) @@ -2073,8 +2148,19 @@ export function useTerminalPaneLifecycle({ } }) ) - for (const transport of paneTransports.values()) { + for (const [paneId, transport] of paneTransports) { const ptyId = transport.getPtyId() + const deferredSplitHandoff = deferredSplitHandoffs.get(paneId) + if (deferredSplitHandoff) { + if (tabStillExists && !ptyId) { + // Keep only the transient launch record; the old transport and + // xterm are still disposable during a whole-tab remount. + releaseDeferredSplitPaneHandoff(deferredSplitHandoff) + } else { + clearDeferredSplitPaneHandoff(deferredSplitHandoff) + } + deferredSplitHandoffs.delete(paneId) + } if ( shouldDetachPaneTransportOnUnmount({ tabStillExists, @@ -2093,6 +2179,11 @@ export function useTerminalPaneLifecycle({ transport.destroy?.() } } + if (!tabStillExists) { + // Covers a pane whose transport was removed before this cleanup (for + // example, a close raced the effect teardown). + discardDeferredSplitPaneHandoffsForTab(tabId) + } for (const panePtyBinding of panePtyBindings.values()) { panePtyBinding.dispose() } diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.test.ts b/src/renderer/src/store/slices/terminal-tab-retirement.test.ts index 7365707be97..410422b1162 100644 --- a/src/renderer/src/store/slices/terminal-tab-retirement.test.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement.test.ts @@ -116,6 +116,17 @@ describe('terminal tab retirement planning', () => { expect(isTerminalTabPresent(state, 'tab-1')).toBe(true) }) + it('recognizes a tab after it is rehomed into a new worktree bucket', () => { + const state = makeState({ + tabsByWorktree: { + 'wt-old': [], + 'wt-new': [makeTab('tab-rehomed', 'wt-new', null)] + } + }) + + expect(isTerminalTabPresent(state, 'tab-rehomed')).toBe(true) + }) + it('does not retire a PTY still referenced by another live surface', () => { const shared = 'pty-in-transfer' const state = makeState({