mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): release metadata for retired terminals (#35535)
* fix(mobile): prune metadata for retired terminal handles * test(mobile): give the terminal-inventory recording adapter the pruned caches --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -32,8 +32,8 @@ export function resolveRetainedTerminalHandles(context: {
|
||||
return new Set(context.liveHandles).add(activeHandle)
|
||||
}
|
||||
|
||||
// Sweep the whole metrics map: chat-retained handles may already have left the subscription map; preserve identity on no-op.
|
||||
export function pruneTerminalKeyboardMetrics<T>(
|
||||
// Cached handles may outlive subscriptions; preserve map identity when nothing is retired.
|
||||
export function pruneTerminalHandleMap<T>(
|
||||
previous: Map<string, T>,
|
||||
shouldPrune: (handle: string) => boolean
|
||||
): Map<string, T> {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { createElement, useState } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { stubRpcClient } from '../transport/rpc-client-test-stub'
|
||||
import type { TerminalModes } from '../terminal/terminal-webview-contract'
|
||||
import {
|
||||
runTerminalViewportFitPass,
|
||||
TerminalViewportResubscribeBudget
|
||||
} from './mobile-terminal-viewport-resubscribe'
|
||||
import type { Terminal } from './mobile-session-route-types'
|
||||
import { useMobileSessionTerminalList } from './use-mobile-session-terminal-list'
|
||||
|
||||
const modes: TerminalModes = {
|
||||
bracketedPasteMode: true,
|
||||
altScreen: false,
|
||||
mouseTrackingMode: 'none',
|
||||
sgrMouseMode: false,
|
||||
sgrMousePixelsMode: false,
|
||||
applicationCursorKeysMode: false,
|
||||
kittyKeyboardFlags: 0,
|
||||
modifyOtherKeys: 0
|
||||
}
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
async function mount(retired: number, covered: boolean) {
|
||||
const handles = [...Array.from({ length: retired }, (_, index) => `closed-${index}`), 'live']
|
||||
const ptyModesRef = { current: new Map(handles.map((handle) => [handle, modes])) }
|
||||
const terminalCwdRef = { current: new Map(handles.map((handle) => [handle, `/work/${handle}`])) }
|
||||
const subscribeSeqRef = { current: new Map(handles.map((handle) => [handle, 4])) }
|
||||
const initialModesSeenRef = { current: new Set(handles) }
|
||||
const webReadyHandlesRef = { current: new Set(handles) }
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
const showNativeChatRef = { current: covered }
|
||||
const activeHandleRef = { current: covered ? 'closed-0' : 'live' }
|
||||
const initialDisplayModes = new Map(handles.map((handle) => [handle, 'phone' as const]))
|
||||
const liveTerminal: Terminal = { handle: 'live', title: 'Live', isActive: true }
|
||||
const terminalsRef = { current: [liveTerminal] }
|
||||
let available = true
|
||||
const client = stubRpcClient({
|
||||
sendRequest: async (method) => {
|
||||
expect(method).toBe('terminal.list')
|
||||
return available
|
||||
? { id: 'inventory', ok: true, result: { terminals: [liveTerminal] } }
|
||||
: { id: 'inventory', ok: false, error: { code: 'unavailable', message: 'Retry' } }
|
||||
}
|
||||
})
|
||||
let current: {
|
||||
fetch: () => Promise<boolean>
|
||||
displayModes: typeof initialDisplayModes
|
||||
} | null = null
|
||||
function Harness() {
|
||||
const [, setTerminals] = useState<Terminal[]>([liveTerminal])
|
||||
const [displayModes, setTerminalModes] = useState(initialDisplayModes)
|
||||
const scope = {
|
||||
hostId: 'host',
|
||||
worktreeId: 'folder:terminal-cache',
|
||||
client,
|
||||
setTerminals,
|
||||
terminalsRef,
|
||||
sessionTabsRef: { current: [] },
|
||||
pruneTerminalHandlesFromLiveInput: vi.fn(),
|
||||
defaultTerminalHandlesToLiveInput: vi.fn(),
|
||||
clearTerminalLiveInputDefault: vi.fn(),
|
||||
setTerminalKeyboardMetrics: vi.fn(),
|
||||
setTerminalModes,
|
||||
ptyModesRef,
|
||||
terminalCwdRef,
|
||||
subscribeSeqRef,
|
||||
initialModesSeenRef,
|
||||
webReadyHandlesRef,
|
||||
terminalRefs: { current: new Map() },
|
||||
terminalUnsubsRef: { current: new Map() },
|
||||
viewportResubscribeBudgetRef: { current: budget },
|
||||
activeHandleRef,
|
||||
showNativeChatRef,
|
||||
unsubscribeTerminal: vi.fn(),
|
||||
nativeChatStream: { notifyListedHandles: vi.fn() },
|
||||
bufferedTerminalDraftState: { pruneDrafts: vi.fn() }
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: supplies all members read by this production inventory hook; unrelated route fields are unused.
|
||||
const list = useMobileSessionTerminalList(scope as never)
|
||||
current = { fetch: list.fetchTerminals, displayModes }
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
function read() {
|
||||
if (!current) {
|
||||
throw new Error('Inventory hook did not mount')
|
||||
}
|
||||
return current
|
||||
}
|
||||
return {
|
||||
budget,
|
||||
subscribeSeqRef,
|
||||
read,
|
||||
setAvailable: (next: boolean) => {
|
||||
available = next
|
||||
},
|
||||
leaveChat: () => {
|
||||
showNativeChatRef.current = false
|
||||
activeHandleRef.current = 'live'
|
||||
},
|
||||
caches: [
|
||||
ptyModesRef.current,
|
||||
terminalCwdRef.current,
|
||||
subscribeSeqRef.current,
|
||||
initialModesSeenRef.current,
|
||||
webReadyHandlesRef.current
|
||||
],
|
||||
async refresh(expected = true) {
|
||||
await act(async () => expect(await read().fetch()).toBe(expected))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it.each([10, 100])(
|
||||
'releases metadata for %i retired terminals after accepted inventory',
|
||||
async (count) => {
|
||||
const inventory = await mount(count, false)
|
||||
await inventory.refresh()
|
||||
expect([...inventory.caches, inventory.read().displayModes].map((cache) => cache.size)).toEqual(
|
||||
[1, 1, 1, 1, 1, 1]
|
||||
)
|
||||
for (const cache of [...inventory.caches, inventory.read().displayModes]) {
|
||||
expect([...cache.keys()]).toEqual(['live'])
|
||||
}
|
||||
const unchanged = inventory.read().displayModes
|
||||
await inventory.refresh()
|
||||
expect(inventory.read().displayModes).toBe(unchanged)
|
||||
}
|
||||
)
|
||||
|
||||
it('retains omitted chat input ownership and prunes it after coverage ends', async () => {
|
||||
const inventory = await mount(10, true)
|
||||
await inventory.refresh()
|
||||
for (const cache of [...inventory.caches, inventory.read().displayModes]) {
|
||||
expect([...cache.keys()]).toEqual(['closed-0', 'live'])
|
||||
}
|
||||
inventory.leaveChat()
|
||||
await inventory.refresh()
|
||||
for (const cache of [...inventory.caches, inventory.read().displayModes]) {
|
||||
expect([...cache.keys()]).toEqual(['live'])
|
||||
}
|
||||
})
|
||||
|
||||
it('retains cached state on a refused inventory and releases it after recovery', async () => {
|
||||
const inventory = await mount(10, false)
|
||||
const before = inventory.read().displayModes
|
||||
inventory.setAvailable(false)
|
||||
await inventory.refresh(false)
|
||||
expect(inventory.read().displayModes).toBe(before)
|
||||
expect([...inventory.caches, inventory.read().displayModes].map((cache) => cache.size)).toEqual([
|
||||
11, 11, 11, 11, 11, 11
|
||||
])
|
||||
inventory.setAvailable(true)
|
||||
await inventory.refresh()
|
||||
for (const cache of [...inventory.caches, inventory.read().displayModes]) {
|
||||
expect([...cache.keys()]).toEqual(['live'])
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidates a pending viewport fit before a retired handle reuses its sequence', async () => {
|
||||
const inventory = await mount(1, false)
|
||||
let releaseReady = () => {}
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
releaseReady = resolve
|
||||
})
|
||||
const fitDimensions = vi.fn(() => ({ cols: 40, rows: 30 }))
|
||||
const subscribeToTerminal = vi.fn()
|
||||
const unsubscribeTerminal = vi.fn()
|
||||
runTerminalViewportFitPass({
|
||||
handle: 'closed-0',
|
||||
seq: 4,
|
||||
hostCols: 80,
|
||||
hostRows: 24,
|
||||
sentViewport: null,
|
||||
budget: inventory.budget,
|
||||
diagnostics: {
|
||||
streamResubscribing: vi.fn(),
|
||||
streamResubscribeHeld: vi.fn(),
|
||||
streamResubscribeExhausted: vi.fn()
|
||||
},
|
||||
viewportRef: { current: null },
|
||||
viewportMeasuredRef: { current: false },
|
||||
subscribeSeqRef: inventory.subscribeSeqRef,
|
||||
terminalUnsubsRef: { current: new Map() },
|
||||
terminalFrameRef: { current: { width: 390, height: 600 } },
|
||||
getTerminalRef: () => ({ awaitReady: () => ready, fitDimensions }),
|
||||
unsubscribeTerminal,
|
||||
subscribeToTerminal,
|
||||
scheduleDelayedAction: vi.fn(),
|
||||
showToast: vi.fn()
|
||||
})
|
||||
const retiredGeneration = inventory.budget.retryGeneration('closed-0')
|
||||
const liveGeneration = inventory.budget.retryGeneration('live')
|
||||
await inventory.refresh()
|
||||
inventory.subscribeSeqRef.current.set('closed-0', 4)
|
||||
expect(inventory.budget.retryGeneration('live')).toBe(liveGeneration)
|
||||
releaseReady()
|
||||
await Promise.resolve()
|
||||
expect(inventory.budget.isRetryGenerationCurrent('closed-0', retiredGeneration)).toBe(false)
|
||||
expect(fitDimensions).not.toHaveBeenCalled()
|
||||
expect(unsubscribeTerminal).not.toHaveBeenCalled()
|
||||
expect(subscribeToTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import type { Terminal } from './mobile-session-route-types'
|
||||
import { mergeTerminalListWithKnownRecords, terminalRecordsEqual } from './mobile-terminal-records'
|
||||
import {
|
||||
createTerminalPrunePredicate,
|
||||
pruneTerminalKeyboardMetrics,
|
||||
pruneTerminalHandleMap,
|
||||
resolveRetainedTerminalHandles
|
||||
} from './mobile-terminal-prune-decision'
|
||||
import type { MobileSessionTerminalStreamDisplayModel } from './use-mobile-session-terminal-stream-display'
|
||||
@@ -23,6 +23,12 @@ export function useMobileSessionTerminalList(scope: MobileSessionTerminalStreamD
|
||||
defaultTerminalHandlesToLiveInput,
|
||||
clearTerminalLiveInputDefault,
|
||||
setTerminalKeyboardMetrics,
|
||||
setTerminalModes,
|
||||
ptyModesRef,
|
||||
terminalCwdRef,
|
||||
subscribeSeqRef,
|
||||
initialModesSeenRef,
|
||||
webReadyHandlesRef,
|
||||
terminalRefs,
|
||||
terminalUnsubsRef,
|
||||
viewportResubscribeBudgetRef,
|
||||
@@ -94,7 +100,26 @@ export function useMobileSessionTerminalList(scope: MobileSessionTerminalStreamD
|
||||
viewportResubscribeBudgetRef.current.forget(handle)
|
||||
clearTerminalLiveInputDefault(handle)
|
||||
}
|
||||
setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))
|
||||
for (const cache of [
|
||||
ptyModesRef.current,
|
||||
terminalCwdRef.current,
|
||||
initialModesSeenRef.current,
|
||||
webReadyHandlesRef.current
|
||||
]) {
|
||||
for (const handle of cache.keys()) {
|
||||
if (shouldPrune(handle)) {
|
||||
cache.delete(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const handle of subscribeSeqRef.current.keys()) {
|
||||
if (shouldPrune(handle)) {
|
||||
viewportResubscribeBudgetRef.current.forget(handle)
|
||||
subscribeSeqRef.current.delete(handle)
|
||||
}
|
||||
}
|
||||
setTerminalModes((prev) => pruneTerminalHandleMap(prev, shouldPrune))
|
||||
setTerminalKeyboardMetrics((prev) => pruneTerminalHandleMap(prev, shouldPrune))
|
||||
// Reappearing handles may have live PTYs and earn a fresh chat rearm budget.
|
||||
nativeChatStream.notifyListedHandles(liveHandles)
|
||||
// Refill viewport attempts only after real handle absence and return.
|
||||
|
||||
@@ -106,6 +106,12 @@ export function sessionScreenReadMountAdapters(
|
||||
effect('default-live-input', [...handles]),
|
||||
clearTerminalLiveInputDefault: (handle) => effect('clear-live-input', { handle }),
|
||||
setTerminalKeyboardMetrics: () => {},
|
||||
setTerminalModes: () => {},
|
||||
ptyModesRef: { current: new Map() },
|
||||
terminalCwdRef: { current: new Map() },
|
||||
subscribeSeqRef: { current: new Map() },
|
||||
initialModesSeenRef: { current: new Set() },
|
||||
webReadyHandlesRef: { current: new Set() },
|
||||
terminalRefs: { current: new Map() },
|
||||
terminalUnsubsRef: { current: unsubs },
|
||||
initializedHandlesRef: { current: new Set([HANDLE]) },
|
||||
|
||||
Reference in New Issue
Block a user