fix(terminal): release stale split cwd fence

This commit is contained in:
Neil
2026-08-30 21:27:46 -07:00
parent cff71ac75a
commit d453ffcdb7
3 changed files with 104 additions and 10 deletions
+9 -10
View File
@@ -5735,10 +5735,10 @@
"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. Remote-runtime coverage proves delegation remains host-owned; physical daemon, SSH, WSL, Linux, and Windows 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. 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. Remote-runtime coverage proves delegation remains host-owned; physical daemon, SSH, WSL, Linux, and Windows 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; 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. 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.",
"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.",
"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 --reporter=dot"
],
@@ -5763,9 +5763,7 @@
},
{
"file": "src/renderer/src/lib/pane-manager/pane-split-close.test.ts",
"assertions": [
"focuses the new pane before publishing an unresolved CWD spawn hint"
]
"assertions": ["focuses the new pane before publishing an unresolved CWD spawn hint"]
},
{
"file": "src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts",
@@ -5780,7 +5778,8 @@
"assertions": [
"starts no PTY connection before inherited CWD resolves",
"applies the resolved directory to transport options",
"disposing the split before resolution cancels the pending spawn"
"disposing the split before resolution cancels the pending spawn",
"invokes deferred-CWD cleanup exactly once after an authority-rotated direct-SSH spawn is disconnected and its delayed connect settles"
]
},
{
@@ -5825,8 +5824,8 @@
"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 --reporter=dot",
"durationSeconds": 31.47,
"summary": "Seven focused files and 77 tests passed. Vitest reported 26.56 seconds and the measured wall time was 31.47 seconds; coverage includes split creation and focus ordering, nested CWD lineage, promise-identity 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."
"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."
}
],
"runtimeBudget": {
@@ -5839,7 +5838,7 @@
},
"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. 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 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."
},
"performanceBudget": {
"required": true,
@@ -1,5 +1,6 @@
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { connectPanePty } from './pty-connection'
import { createDeferred, flushAsyncTicks } from './pty-connection-test-async'
import {
@@ -132,4 +133,97 @@ describe('connectPanePty split cwd resolution', () => {
expect(transport.connect).not.toHaveBeenCalled()
})
it('releases the deferred cwd fence when direct SSH authority changes during spawn', async () => {
const cwd = createDeferred<string>()
const spawn = createDeferred<string>()
const transport = createMockTransport()
const stalePtyId = toAppSshPtyId('target-a', 'pty-stale-split')
const livePtyId = toAppSshPtyId('target-a', 'pty-live')
let transportPtyId: string | null = null
transport.connect.mockReturnValueOnce(spawn.promise)
transport.getPtyId.mockImplementation(() => transportPtyId)
transport.disconnect.mockImplementation(() => {
transportPtyId = null
})
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: livePtyId, generation: 7 }] },
ptyIdsByTabId: { 'tab-1': [livePtyId] },
repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }],
sshConnectionStates: new Map([
[
'target-a',
{
targetId: 'target-a',
status: 'connected',
providerEpoch: 'epoch-old',
connectionGeneration: 3
}
]
]),
directSshPaneRetryByTabId: {},
directSshLivePtyBindingByTabId: {
'tab-1': {
attemptId: 'attempt-live-split',
authority: {
targetId: 'target-a',
providerEpoch: 'epoch-old',
connectionGeneration: 3
},
tabGeneration: 7,
ptyId: livePtyId
}
},
settleDirectSshPaneRetry: vi.fn()
}
const onDeferredCwdSpawnFailed = vi.fn()
const paneTransportsRef = {
current: new Map([[1, createMockTransport(livePtyId)]])
}
const binding = connectPanePty(
createPane(2) as never,
createManager(2) as never,
buildPaneConnectionDeps(() => mockStoreState, {
cwdPromise: cwd.promise,
onDeferredCwdSpawnFailed,
paneTransportsRef
}) as never
)
await flushAsyncTicks(20)
expect(transport.connect).not.toHaveBeenCalled()
cwd.resolve('/resolved/source-cwd')
await flushAsyncTicks(20)
expect(transport.connect).toHaveBeenCalledOnce()
mockStoreState.sshConnectionStates = new Map([
[
'target-a',
{
targetId: 'target-a',
status: 'connected',
providerEpoch: 'epoch-new',
connectionGeneration: 4
}
]
])
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
| ((ptyId: string) => void)
| undefined
expect(onPtySpawn).toBeTypeOf('function')
transportPtyId = stalePtyId
onPtySpawn?.(stalePtyId)
await flushAsyncTicks()
expect(transport.disconnect).toHaveBeenCalledOnce()
expect(onDeferredCwdSpawnFailed).not.toHaveBeenCalled()
spawn.resolve(stalePtyId)
await flushAsyncTicks(20)
expect(onDeferredCwdSpawnFailed).toHaveBeenCalledOnce()
binding.dispose()
})
})
@@ -174,6 +174,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
? spawnedPtyId
: session.transport.getPtyId()
if (resolvedPtyId && !session.claimCapturedDirectSshRetryPty(resolvedPtyId)) {
releaseDeferredCwdFence()
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
// Why: an outstanding declare keeps main's cooperation gate suppressing
// this paneKey's daemon-snapshot seed until something releases it.