mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
fix(terminal): bound stale handle reconnect polling
This commit is contained in:
@@ -490,39 +490,51 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
const draft = 'QA regular reconnect draft - keep this unsent'
|
||||
emitOutput(initialStreamId, draft)
|
||||
|
||||
runtimeCall.mockImplementation(async (args: { method: string }) =>
|
||||
args.method === 'session.tabs.list'
|
||||
? {
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: 'wt-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: null,
|
||||
activeTabId: 'tab-1::pane:1',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::pane:1',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'pane:1',
|
||||
title: 'Claude Code',
|
||||
isActive: true,
|
||||
status: 'ready',
|
||||
terminal: 'terminal-reconnected'
|
||||
}
|
||||
]
|
||||
}
|
||||
let hostListCalls = 0
|
||||
runtimeCall.mockImplementation(async (args: { method: string }) => {
|
||||
if (args.method === 'session.tabs.list') {
|
||||
hostListCalls += 1
|
||||
const terminal =
|
||||
hostListCalls === 1
|
||||
? 'terminal-stale'
|
||||
: hostListCalls === 2
|
||||
? null
|
||||
: 'terminal-reconnected'
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: 'wt-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: hostListCalls + 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: 'tab-1::pane:1',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::pane:1',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'pane:1',
|
||||
title: 'Claude Code',
|
||||
isActive: true,
|
||||
status: terminal ? 'ready' : 'pending-handle',
|
||||
terminal
|
||||
}
|
||||
]
|
||||
}
|
||||
: { ok: true, result: {} }
|
||||
)
|
||||
}
|
||||
}
|
||||
return { ok: true, result: {} }
|
||||
})
|
||||
|
||||
subscriptionCallbacks?.onResponse({
|
||||
ok: true,
|
||||
result: { type: 'error', streamId: initialStreamId, message: 'terminal_handle_stale' }
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(hostListCalls).toBeGreaterThanOrEqual(1))
|
||||
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-stale' })
|
||||
expect(onPtyExit).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() =>
|
||||
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-reconnected' })
|
||||
)
|
||||
@@ -536,6 +548,17 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-reconnected')
|
||||
expect(transport.isConnected()).toBe(true)
|
||||
expect(renderedScreen.at(-1)).toBe(draft)
|
||||
expect(hostListCalls).toBe(3)
|
||||
const subscribedTerminals = subscriptionSendBinary.mock.calls
|
||||
.map((call) => decodeTerminalStreamFrame(call[0]))
|
||||
.flatMap((frame) => {
|
||||
if (frame?.opcode !== TerminalStreamOpcode.Subscribe) {
|
||||
return []
|
||||
}
|
||||
const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload)
|
||||
return payload ? [payload.terminal] : []
|
||||
})
|
||||
expect(subscribedTerminals).toEqual(['terminal-stale', 'terminal-reconnected'])
|
||||
})
|
||||
|
||||
it('still retires the regular TUI surface after an explicit terminal exit', async () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import { isWebTerminalSurfaceTabId, toHostSessionTabId } from '@/runtime/web-ter
|
||||
const REMOTE_TERMINAL_INPUT_FLUSH_MS = 8
|
||||
const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33
|
||||
const HOST_SESSION_ATTACH_POLL_MS = 150
|
||||
const HOST_SESSION_REPLACEMENT_POLL_MAX_MS = 1_000
|
||||
const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000
|
||||
|
||||
function isRemoteTerminalStaleMessage(message: string): boolean {
|
||||
@@ -94,6 +95,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
let storedCallbacks: Parameters<PtyTransport['connect']>[0]['callbacks'] = {}
|
||||
let resubscribing = false
|
||||
let resubscribeRequested = false
|
||||
let resubscribeRequiresReplacement = false
|
||||
let subscriptionGeneration = 0
|
||||
let pendingViewportClaim = false
|
||||
let pendingClaimInput = ''
|
||||
@@ -212,6 +214,39 @@ export function createRemoteRuntimePtyTransport(
|
||||
return findReadyHostSessionHandle(listed, hostTabId)
|
||||
}
|
||||
|
||||
async function waitForReplacementHostSessionHandle(
|
||||
hostTabId: string,
|
||||
previousHandle: string
|
||||
): Promise<string | null> {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
const worktree = toRuntimeWorktreeSelector(worktreeId)
|
||||
const startedAt = Date.now()
|
||||
let pollMs = HOST_SESSION_ATTACH_POLL_MS
|
||||
while (!destroyed && connected && handle === previousHandle) {
|
||||
const listed = await callRuntime<RuntimeMobileSessionTabsResult>('session.tabs.list', {
|
||||
worktree
|
||||
})
|
||||
const nextHandle = findReadyHostSessionHandle(listed, hostTabId)
|
||||
if (nextHandle && nextHandle !== previousHandle) {
|
||||
return nextHandle
|
||||
}
|
||||
if (!hasHostSessionTerminalSurface(listed, hostTabId)) {
|
||||
return null
|
||||
}
|
||||
const remainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt)
|
||||
if (remainingMs <= 0) {
|
||||
return null
|
||||
}
|
||||
// Why: a stale response can precede publication of its replacement.
|
||||
// Bounded backoff avoids retrying the known-stale handle in a hot loop.
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remainingMs)))
|
||||
pollMs = Math.min(pollMs * 2, HOST_SESSION_REPLACEMENT_POLL_MAX_MS)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function attachHostSessionMirror(
|
||||
options: Parameters<PtyTransport['connect']>[0]
|
||||
): Promise<PtyConnectResult | undefined> {
|
||||
@@ -429,7 +464,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
// Why: reconnect can re-mint a mirrored pane's handle while its host tab
|
||||
// remains alive. Keep xterm/composer state mounted while we re-resolve it.
|
||||
closeMultiplexedStream()
|
||||
scheduleResubscribeAfterTransportClose()
|
||||
scheduleResubscribeAfterTransportClose(true)
|
||||
} else {
|
||||
retireRemoteTerminalId()
|
||||
}
|
||||
@@ -448,9 +483,15 @@ export function createRemoteRuntimePtyTransport(
|
||||
// handle (reconnect, epoch or PTY change). Re-derive it from the current
|
||||
// session snapshot instead of resubscribing the stale closure value, which
|
||||
// would mirror (and type into) whatever PTY now sits behind it (#7718).
|
||||
async function resubscribeAfterTransportClose(previousHandle: string): Promise<void> {
|
||||
async function resubscribeAfterTransportClose(
|
||||
previousHandle: string,
|
||||
requireReplacement: boolean
|
||||
): Promise<void> {
|
||||
if (tabId && isWebTerminalSurfaceTabId(tabId)) {
|
||||
const nextHandle = await listHostSessionHandle(toHostSessionTabId(tabId))
|
||||
const hostTabId = toHostSessionTabId(tabId)
|
||||
const nextHandle = requireReplacement
|
||||
? await waitForReplacementHostSessionHandle(hostTabId, previousHandle)
|
||||
: await listHostSessionHandle(hostTabId)
|
||||
if (destroyed || !connected || handle !== previousHandle) {
|
||||
return
|
||||
}
|
||||
@@ -469,17 +510,18 @@ export function createRemoteRuntimePtyTransport(
|
||||
await subscribeToHandle()
|
||||
}
|
||||
|
||||
function scheduleResubscribeAfterTransportClose(): void {
|
||||
function scheduleResubscribeAfterTransportClose(requireReplacement = false): void {
|
||||
if (destroyed || !connected || !handle) {
|
||||
return
|
||||
}
|
||||
if (resubscribing) {
|
||||
resubscribeRequested = true
|
||||
resubscribeRequiresReplacement ||= requireReplacement
|
||||
return
|
||||
}
|
||||
resubscribing = true
|
||||
const resubscribeHandle = handle
|
||||
void resubscribeAfterTransportClose(resubscribeHandle)
|
||||
void resubscribeAfterTransportClose(resubscribeHandle, requireReplacement)
|
||||
.catch((error) => {
|
||||
if (!destroyed && connected && handle) {
|
||||
clearPendingViewportClaim()
|
||||
@@ -490,7 +532,9 @@ export function createRemoteRuntimePtyTransport(
|
||||
resubscribing = false
|
||||
if (resubscribeRequested) {
|
||||
resubscribeRequested = false
|
||||
scheduleResubscribeAfterTransportClose()
|
||||
const pendingRequiresReplacement = resubscribeRequiresReplacement
|
||||
resubscribeRequiresReplacement = false
|
||||
scheduleResubscribeAfterTransportClose(pendingRequiresReplacement)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user