diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-absence-evidence.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-absence-evidence.test.ts index 719aebfe795..813ffa7b5c1 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-absence-evidence.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-absence-evidence.test.ts @@ -1,4 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTerminalClientTab +} from '../../../../shared/runtime-types' import { createRemoteRuntimeTransportMocks, type MultiplexSubscriptionCallbacks @@ -54,11 +58,227 @@ function readySnapshot(snapshotVersion: number): unknown { } } +function splitLeaf( + leafId: string, + terminal: string | null, + isActive = false +): RuntimeMobileSessionTerminalClientTab { + const tab = { + type: 'terminal' as const, + id: `host-tab-1::${leafId}`, + parentTabId: 'host-tab-1', + leafId, + title: `Terminal ${leafId}`, + isActive + } + return terminal === null + ? { ...tab, status: 'pending-handle', terminal: null } + : { ...tab, status: 'ready', terminal } +} + +function hostTabs( + snapshotVersion: number, + tabs: RuntimeMobileSessionTerminalClientTab[], + retiredLeafIds: string[] = [] +): RuntimeMobileSessionTabsResult { + return { + worktree: 'id:wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion, + activeGroupId: 'group-1', + activeTabId: tabs.find((tab) => tab.isActive)?.id ?? null, + activeTabType: tabs.length > 0 ? 'terminal' : null, + ...(retiredLeafIds.length > 0 + ? { + retiredTerminalSurfaces: retiredLeafIds.map((leafId) => ({ + parentTabId: 'host-tab-1', + leafId, + ptyId: `pty-${leafId}`, + terminal: `terminal-${leafId}` + })) + } + : {}), + tabs + } +} + +/** Activation reports the pane's leaf pending; each listing answers in turn, the last one repeating. */ +function serveHostSession( + activated: RuntimeMobileSessionTabsResult, + listings: RuntimeMobileSessionTabsResult[] +): void { + let listCount = 0 + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method === 'session.tabs.activate') { + return { ok: true, result: activated } + } + if (args.method === 'session.tabs.list') { + const listing = listings[Math.min(listCount, listings.length - 1)] + listCount += 1 + return { ok: true, result: listing } + } + return { ok: true, result: { terminal: { handle: 'duplicate-terminal' } } } + }) +} + +function sessionTabsListCount(): number { + return runtimeCall.mock.calls.filter((call) => call[0].method === 'session.tabs.list').length +} + +const leaf2Pending = hostTabs(1, [ + splitLeaf('leaf-1', 'terminal-1'), + splitLeaf('leaf-2', null, true) +]) +const siblingOnly = hostTabs(2, [splitLeaf('leaf-1', 'terminal-1', true)]) + describe('remote runtime pty transport host absence evidence', () => { beforeEach(() => { resetRemoteRuntimeTransport() }) + afterEach(() => { + vi.useRealTimers() + }) + + it('retires a split leaf once two listings agree it is gone while a sibling remains', async () => { + vi.useFakeTimers() + serveHostSession(leaf2Pending, [siblingOnly]) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'leaf-2' + }) + + const connect = transport.connect({ url: '', callbacks: { onError } }) + await vi.advanceTimersByTimeAsync(450) + + await expect(connect).resolves.toBeUndefined() + expect(onError).toHaveBeenCalledWith('Remote terminal was closed.') + expect(sessionTabsListCount()).toBe(2) + await Promise.resolve() + await Promise.resolve() + expect( + runtimeCall.mock.calls.some((call) => call[0].method.startsWith('session.tabs.close')) + ).toBe(false) + }) + + it('does not retire a split leaf on one sibling-only listing the host has not caught up with', async () => { + vi.useFakeTimers() + // An orchestrator opening several panes at once: the host publishes leaf-2 a moment after the + // first listing, so that listing carries only the sibling (#20917). + serveHostSession(leaf2Pending, [ + siblingOnly, + hostTabs(3, [splitLeaf('leaf-1', 'terminal-1'), splitLeaf('leaf-2', 'terminal-2', true)]) + ]) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'leaf-2' + }) + + const connect = transport.connect({ url: '', callbacks: { onError } }) + await vi.advanceTimersByTimeAsync(450) + + await expect(connect).resolves.toEqual({ + id: 'remote:env-1@@terminal-2', + replay: '', + isReattach: true + }) + expect(onError).not.toHaveBeenCalled() + }) + + it('retires a split leaf on the host retirement proof without a second listing', async () => { + vi.useFakeTimers() + serveHostSession(leaf2Pending, [ + hostTabs(2, [splitLeaf('leaf-1', 'terminal-1', true)], ['leaf-2']) + ]) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'leaf-2' + }) + + const connect = transport.connect({ url: '', callbacks: { onError } }) + await vi.advanceTimersByTimeAsync(450) + + await expect(connect).resolves.toBeUndefined() + expect(onError).toHaveBeenCalledWith('Remote terminal was closed.') + expect(sessionTabsListCount()).toBe(1) + }) + + it('retires the last leaf of a tab on the host retirement proof', async () => { + vi.useFakeTimers() + // The retired leaf was the tab's only surface, so no sibling is left to read as absence. + serveHostSession(hostTabs(1, [splitLeaf('leaf-1', null, true)]), [hostTabs(2, [], ['leaf-1'])]) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'leaf-1' + }) + + const connect = transport.connect({ url: '', callbacks: { onError } }) + await vi.advanceTimersByTimeAsync(450) + + await expect(connect).resolves.toBeUndefined() + expect(onError).toHaveBeenCalledWith('Remote terminal was closed.') + expect(sessionTabsListCount()).toBe(1) + }) + + it('confirms absence from a listing that began after a sibling listing still in flight', async () => { + vi.useFakeTimers() + // Only the transport's own requests reach the RPC; the sibling listings below never do. + serveHostSession(leaf2Pending, [ + hostTabs(4, [splitLeaf('leaf-1', 'terminal-1'), splitLeaf('leaf-2', 'terminal-2', true)]) + ]) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { listRemoteRuntimeSessionTabsDeduped } = + await import('@/runtime/remote-runtime-session-tabs-inflight') + const registerSiblingListing = (settleAfterMs: number): Promise => + listRemoteRuntimeSessionTabsDeduped({ + environmentId: 'env-1', + worktreeId: 'wt-1', + load: () => + new Promise((resolve) => { + setTimeout(() => resolve({ snapshot: siblingOnly }), settleAfterMs) + }) + }) + + // A pane opened moments earlier owns the in-flight listing the transport first waits out. + const first = registerSiblingListing(200) + void first.then(() => { + // Another sibling takes the slot before the transport lists, so its first answer is still stale. + void registerSiblingListing(400) + }) + + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'leaf-2' + }) + + const connect = transport.connect({ url: '', callbacks: { onError } }) + await vi.advanceTimersByTimeAsync(1_500) + + await expect(connect).resolves.toEqual({ + id: 'remote:env-1@@terminal-2', + replay: '', + isReattach: true + }) + expect(onError).not.toHaveBeenCalled() + // Both sibling listings were served from the in-flight cache, so the one RPC is the + // confirming request the transport started only after the pending sibling settled. + expect(sessionTabsListCount()).toBe(1) + }) + it('does not report a live host terminal as closed when activation answers tab_not_found', async () => { runtimeCall.mockImplementation(async (args: { method: string }) => { if (args.method === 'session.tabs.activate') { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts index e27a981e23b..c302c4d7dad 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts @@ -398,98 +398,6 @@ describe('createRemoteRuntimePtyTransport', () => { expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-2' }) }) - it('stops polling when a requested split leaf disappears but siblings remain', async () => { - vi.useFakeTimers() - try { - runtimeCall.mockImplementation((args) => { - if (args.method === 'session.tabs.activate') { - return Promise.resolve({ - ok: true, - result: { - worktree: 'id:wt-1', - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: 'group-1', - activeTabId: 'host-tab-1::leaf-2', - activeTabType: 'terminal', - tabs: [ - { - type: 'terminal', - id: 'host-tab-1::leaf-1', - parentTabId: 'host-tab-1', - leafId: 'leaf-1', - title: 'Terminal 1', - isActive: false, - status: 'ready', - terminal: 'terminal-1' - }, - { - type: 'terminal', - id: 'host-tab-1::leaf-2', - parentTabId: 'host-tab-1', - leafId: 'leaf-2', - title: 'Terminal 2', - isActive: true, - status: 'pending-handle', - terminal: null - } - ] - } - }) - } - if (args.method === 'session.tabs.list') { - return Promise.resolve({ - ok: true, - result: { - worktree: 'id:wt-1', - publicationEpoch: 'epoch-1', - snapshotVersion: 2, - activeGroupId: 'group-1', - activeTabId: 'host-tab-1::leaf-1', - activeTabType: 'terminal', - tabs: [ - { - type: 'terminal', - id: 'host-tab-1::leaf-1', - parentTabId: 'host-tab-1', - leafId: 'leaf-1', - title: 'Terminal 1', - isActive: true, - status: 'ready', - terminal: 'terminal-1' - } - ] - } - }) - } - return Promise.resolve({ ok: true, result: { terminal: { handle: 'duplicate-terminal' } } }) - }) - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const onError = vi.fn() - const transport = createRemoteRuntimePtyTransport('env-1', { - worktreeId: 'wt-1', - tabId: 'web-terminal-host-tab-1', - leafId: 'leaf-2' - }) - - const connect = transport.connect({ url: '', callbacks: { onError } }) - await vi.advanceTimersByTimeAsync(150) - - await expect(connect).resolves.toBeUndefined() - expect(onError).toHaveBeenCalledWith('Remote terminal was closed.') - expect( - runtimeCall.mock.calls.filter((call) => call[0].method === 'session.tabs.list') - ).toHaveLength(1) - await Promise.resolve() - await Promise.resolve() - expect( - runtimeCall.mock.calls.some((call) => call[0].method.startsWith('session.tabs.close')) - ).toBe(false) - } finally { - vi.useRealTimers() - } - }) - it('leaves a timed-out pending split untouched without closing its parent', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index d4d899526e6..be22ebfe3da 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -87,6 +87,7 @@ import { toHostSessionTabId } from '../../../../shared/terminal-surface-id' import { listRemoteRuntimeSessionTabsAfterCurrentInFlight } from '@/runtime/remote-runtime-session-tabs-inflight' +import { hasRetirementProofForSurface } from '../../../../shared/terminal-retirement-proof-ledger' import { subscribeAcceptedWebSessionTerminalHandle } from '@/runtime/web-session-terminal-handle-events' import { hostSnapshotAffirmsWorktreeContents } from '@/runtime/host-session-snapshot-authority' import { runRemoteAgentSessionLaunch } from '@/runtime/remote-agent-session-launch' @@ -760,6 +761,7 @@ export function createRemoteRuntimePtyTransport( let pollMs = HOST_SESSION_ATTACH_POLL_MS let nextRequest: 'activate' | 'list' = 'list' let activationOutcomeUnknown = false + let siblingOnlyAbsenceSeen = false while (isCurrent()) { const remainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt) if (remainingMs <= 0) { @@ -815,12 +817,29 @@ export function createRemoteRuntimePtyTransport( continue } if (!hasHostSessionTerminalSurface(snapshot, hostTabId)) { + // Why: host-attested retirement outranks every inference below, including the empty-tab republish guard. + if ( + leafId && + hasRetirementProofForSurface(snapshot.retiredTerminalSurfaces, { + parentTabId: hostTabId, + leafId + }) + ) { + return false + } const siblingStillExists = getHostSessionTerminalSurfaces(snapshot, hostTabId, { matchRequestedLeaf: false }).length > 0 if (siblingStillExists) { - return false + // Why: a split still publishing, or a sibling's stale listing joined late, reads exactly like a + // retired leaf; one listing proves nothing, so confirm on a second (#20917). + if (siblingOnlyAbsenceSeen) { + return false + } + siblingOnlyAbsenceSeen = true + nextRequest = 'list' + continue } // Why: a populated surface list missing only this leaf is positive absence, but a list carrying no // surface at all for the tab is a client-side snapshot of a host that may still be republishing. @@ -828,6 +847,8 @@ export function createRemoteRuntimePtyTransport( nextRequest = 'list' continue } + // Why: a live sighting retracts the pending absence proof, as it does for orphan reconciliation. + siblingOnlyAbsenceSeen = false // Why: a host relaunch republishes the surface unmaterialized, and only activation can mint its PTY — list-only polling waits forever. nextRequest = activationOutcomeUnknown ? 'list' : 'activate' } diff --git a/src/shared/terminal-retirement-proof-ledger.ts b/src/shared/terminal-retirement-proof-ledger.ts index 4d570497f67..e9aa42db082 100644 --- a/src/shared/terminal-retirement-proof-ledger.ts +++ b/src/shared/terminal-retirement-proof-ledger.ts @@ -25,6 +25,14 @@ export function dropRetirementProofsForLiveSurfaces( return retired.filter((surface) => !live.has(surfaceKey(surface))) } +/** The host's own removal evidence for a pane, whatever handle it last held. */ +export function hasRetirementProofForSurface( + retired: readonly RuntimeMobileSessionRetiredTerminalSurface[] | undefined, + surface: { parentTabId: string; leafId: string } +): boolean { + return retired?.some((proof) => surfaceKey(proof) === surfaceKey(surface)) === true +} + /** Newest evidence wins per exact identity; the oldest identities fall off past the cap. */ export function appendRetiredTerminalSurfaceProofs( existing: readonly RuntimeMobileSessionRetiredTerminalSurface[] | undefined,