Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<unknown> =>
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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -815,19 +817,38 @@ 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.
// Keep polling inside the bounded window and let it expire as unknown liveness, never as removal.
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'
}
Expand Down
Loading