fix(terminal): make snapshot capability lookup async (#11881)
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
parent
6e7ceafd07
commit
c8a22ad0a6
|
|
@ -4621,6 +4621,102 @@
|
|||
],
|
||||
"demotionRule": "Keep experimental or demote if the gate flakes without a product or harness bug, if any local shutdown reaches fallback before startup settles, or if shutdown adds inventory scans or retry loops."
|
||||
},
|
||||
{
|
||||
"id": "terminal-provider.snapshot-capability-renderer-responsiveness",
|
||||
"title": "PTY snapshot capability discovery never blocks renderer JavaScript",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-provider",
|
||||
"layer": "renderer-ipc",
|
||||
"surfaces": [
|
||||
"renderer startup",
|
||||
"cold terminal restoration",
|
||||
"hidden terminal parking",
|
||||
"SSH terminal restoration",
|
||||
"paired web terminal restoration"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos", "linux"],
|
||||
"coveredProviders": ["daemon", "ssh", "remote-runtime"],
|
||||
"coverageNotes": "A preload contract and Electron main-stall oracle prove capability lookup is asynchronous. Startup prefetch covers restored primary and split-pane PTY identities before cold activation, while unknown providers remain eager. Docker SSH journeys prove remote terminals still remount eagerly and reclaim their authenticated PTY owner after restart.",
|
||||
"motivatingLinks": ["https://stablygroup.slack.com/archives/C0BD60A5J85/p1785524559818629"],
|
||||
"invariant": "PTY snapshot capability discovery must never synchronously block renderer JavaScript. Restored daemon capability must be known before workspace readiness enables cold activation; unknown or legacy capability must remain eager. A healthy SSH provider must return definitive false without polling. One unresponsive capability batch must fail open within one second regardless of PTY count, and stale async responses must not update current bindings.",
|
||||
"oracle": "The preload test rejects sendSync and requires ipcRenderer.invoke. Unit contracts assert 512-ID batching, one-second fail-open, unknown retry, definitive SSH false, and generation-fenced stale responses. During an injected 1.5-second Electron main-thread stall, a renderer-owned 50ms interval must keep a maximum gap below 500ms and each API call must return within 100ms. The production cold-activation journey must still mount at most three of eight daemon tabs after reload, while Docker SSH restoration remains eager.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/preload/pty-snapshot-capability-ipc.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/app-startup-routing.test.ts --reporter=dot",
|
||||
"pnpm exec electron-vite build --mode e2e",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/pty-snapshot-capability-main-stall.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/preload/pty-snapshot-capability-ipc.test.ts",
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/main/providers/ssh-pty-provider.test.ts",
|
||||
"src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts",
|
||||
"src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx",
|
||||
"src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts",
|
||||
"src/renderer/src/app-startup-routing.test.ts",
|
||||
"tests/e2e/pty-snapshot-capability-main-stall.spec.ts",
|
||||
"tests/e2e/terminal-cold-activation-deferral.spec.ts",
|
||||
"tests/e2e/ssh-cold-activation-restore.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/pty-snapshot-capability-main-stall.spec.ts",
|
||||
"assertions": [
|
||||
"a 1.5-second main stall leaves renderer interval gaps below 500ms",
|
||||
"capability calls return to renderer JavaScript within 100ms"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-cold-activation-deferral.spec.ts",
|
||||
"assertions": [
|
||||
"cold reload mounts at most three of eight daemon tabs and parked watchers cover the rest"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-31",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/pty-snapshot-capability-main-stall.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3",
|
||||
"result": "passed",
|
||||
"durationSeconds": 16.8,
|
||||
"summary": "Three 1.5-second main stalls produced 32 renderer calls each; maximum interval gaps were 70.3ms, 70.1ms, and 71.6ms, and maximum call-return durations were 0.1ms, 0.1ms, and 0.2ms. The synchronous baseline produced a 1465.1ms interval gap and 1464.2ms call-return duration. Cold daemon restore and both Docker SSH restore journeys also passed."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "focused Electron responsiveness and cold-restore journeys"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The deterministic Electron oracle passed three consecutive local runs; CI soak history is not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The byte-identical Electron oracle failed with synchronous sendSync at a 1465.1ms renderer interval gap and 1464.2ms call-return duration, then passed three times with async invoke at no more than 71.6ms and 0.2ms respectively. The preload test also fails on the synchronous baseline."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Capability requests are deduplicated by PTY ID, sent in bounded 512-ID batches, cached after a definitive result, and retried only for unknown results. SSH now returns definitive false. One unresponsive batch fails open after one second without scanning further batches. No subprocesses or provider inventories are added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the Electron main-stall oracle for at least 100 consecutive passes or 14 days across required CI platforms.",
|
||||
"Collect Windows and Linux desktop cold-restore coverage.",
|
||||
"Add a headed paired-desktop cold-restore journey if provider capability semantics move into the remote runtime."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The main-stall and local daemon cold-restore Electron journeys currently run on macOS only.",
|
||||
"Docker SSH proves the Linux relay/provider path but not a Linux desktop renderer.",
|
||||
"This gate proves renderer responsiveness to main stalls; it does not identify the cause of the reported production hard freeze."
|
||||
],
|
||||
"demotionRule": "Demote if capability discovery reintroduces synchronous renderer IPC, cold daemon tabs mount eagerly despite authoritative snapshots, SSH capability polls after a definitive response, or the renderer gap budget flakes without a product or harness bug."
|
||||
},
|
||||
{
|
||||
"id": "terminal-provider.ssh-remote-reattach-contract",
|
||||
"title": "SSH restore and remote-runtime recovery preserve live terminal ownership",
|
||||
|
|
|
|||
|
|
@ -6652,7 +6652,7 @@ describe('registerPtyHandlers', () => {
|
|||
await pendingInventory
|
||||
})
|
||||
|
||||
it('reports authoritative snapshot capability with the owning provider context', () => {
|
||||
it('reports authoritative snapshot capability with the owning provider context', async () => {
|
||||
const capabilityProvider = {
|
||||
authoritativeIds: new Set(['current-pty']),
|
||||
canProvideAuthoritativeBufferSnapshot(id: string) {
|
||||
|
|
@ -6661,19 +6661,29 @@ describe('registerPtyHandlers', () => {
|
|||
}
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
setLocalPtyProvider(capabilityProvider as never)
|
||||
const listener = onMock.mock.calls.find(
|
||||
([channel]) => channel === 'pty:getAuthoritativeBufferSnapshotCapabilitiesSync'
|
||||
)?.[1] as ((event: { returnValue?: unknown }, args: { ids: unknown[] }) => void) | undefined
|
||||
const event: { returnValue?: unknown } = {}
|
||||
const result = await handlers.get('pty:getAuthoritativeBufferSnapshotCapabilities')?.(null, {
|
||||
ids: ['current-pty', 'legacy-pty', 'current-pty', 42]
|
||||
})
|
||||
|
||||
listener?.(event, { ids: ['current-pty', 'legacy-pty', 'current-pty', 42] })
|
||||
|
||||
expect(event.returnValue).toEqual([
|
||||
expect(result).toEqual([
|
||||
{ id: 'current-pty', authoritative: true },
|
||||
{ id: 'legacy-pty', authoritative: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('answers false, not null, for a resolved provider with no snapshot capability', async () => {
|
||||
// Why: null is never cached, so a provider that merely omits the optional
|
||||
// method would keep the renderer's retry timer armed for the whole session.
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
setLocalPtyProvider({ spawn: vi.fn(), write: vi.fn() } as never)
|
||||
|
||||
const result = await handlers.get('pty:getAuthoritativeBufferSnapshotCapabilities')?.(null, {
|
||||
ids: ['local-pty']
|
||||
})
|
||||
|
||||
expect(result).toEqual([{ id: 'local-pty', authoritative: false }])
|
||||
})
|
||||
|
||||
it('checks single-PTY liveness without listing every session', async () => {
|
||||
const hasPty = vi.fn((id: string) => id === 'live-pty')
|
||||
const listProcesses = vi.fn(async () => {
|
||||
|
|
|
|||
|
|
@ -1911,7 +1911,7 @@ export function registerPtyHandlers(
|
|||
ipcMain.removeHandler('pty:confirmForegroundProcess')
|
||||
ipcMain.removeHandler('pty:getCwd')
|
||||
ipcMain.removeHandler('pty:getSize')
|
||||
ipcMain.removeAllListeners('pty:getAuthoritativeBufferSnapshotCapabilitiesSync')
|
||||
ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities')
|
||||
ipcMain.removeHandler('pty:declarePendingPaneSerializer')
|
||||
ipcMain.removeHandler('pty:settlePaneSerializer')
|
||||
ipcMain.removeHandler('pty:clearPendingPaneSerializer')
|
||||
|
|
@ -6324,9 +6324,9 @@ export function registerPtyHandlers(
|
|||
return Array.from(deduped.values())
|
||||
})
|
||||
|
||||
ipcMain.on(
|
||||
'pty:getAuthoritativeBufferSnapshotCapabilitiesSync',
|
||||
(event, args: { ids?: unknown }) => {
|
||||
ipcMain.handle(
|
||||
'pty:getAuthoritativeBufferSnapshotCapabilities',
|
||||
(_event, args: { ids?: unknown }) => {
|
||||
const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : []
|
||||
const capabilities: { id: string; authoritative: boolean | null }[] = []
|
||||
const seen = new Set<string>()
|
||||
|
|
@ -6341,18 +6341,22 @@ export function registerPtyHandlers(
|
|||
}
|
||||
seen.add(value)
|
||||
const provider = tryGetProviderForPty(value)
|
||||
// Why: degraded routing mixes preserved daemons with an in-process fallback; keep all panes mounted rather than guess ownership.
|
||||
// Why null is reserved for an unresolved route: the renderer never caches
|
||||
// it and keeps re-arming, so a provider that merely omits the optional
|
||||
// method would poll forever. Omitting it means "no authoritative
|
||||
// snapshot" — the same mount-eagerly outcome null already produced, but
|
||||
// cacheable. Degraded routing keeps its explicit false for the same reason.
|
||||
capabilities.push({
|
||||
id: value,
|
||||
authoritative: provider?.canProvideAuthoritativeBufferSnapshot
|
||||
? provider.canProvideAuthoritativeBufferSnapshot(value)
|
||||
: provider && routesFreshSpawnsToLocalProvider(provider)
|
||||
? false
|
||||
: null
|
||||
authoritative:
|
||||
provider === undefined || provider === null
|
||||
? null
|
||||
: provider.canProvideAuthoritativeBufferSnapshot
|
||||
? provider.canProvideAuthoritativeBufferSnapshot(value)
|
||||
: false
|
||||
})
|
||||
}
|
||||
// Why: cold deferral runs during render before hidden panes mount; this in-memory route lookup lets legacy PTYs mount in that pass.
|
||||
event.returnValue = capabilities
|
||||
return capabilities
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ describe('SshPtyProvider', () => {
|
|||
expect(provider.getConnectionId()).toBe('conn-1')
|
||||
})
|
||||
|
||||
it('reports that SSH panes cannot restore from authoritative provider snapshots', () => {
|
||||
expect(provider.canProvideAuthoritativeBufferSnapshot(scopedPty1)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a shared claim probe alive when one waiter disconnects', async () => {
|
||||
let finishProbe!: (result: { agentSessionClaimVersion: number }) => void
|
||||
mux.request.mockReturnValueOnce(
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ export class SshPtyProvider implements IPtyProvider {
|
|||
|
||||
getConnectionId = (): string => this.connectionId
|
||||
|
||||
canProvideAuthoritativeBufferSnapshot = (_id: string): boolean => false
|
||||
|
||||
private toRelayPtyId = (id: string): string => toRelaySshPtyId(this.connectionId, id)
|
||||
|
||||
private toAppPtyId = (id: string): string => toAppSshPtyId(this.connectionId, id)
|
||||
|
|
|
|||
|
|
@ -1560,7 +1560,7 @@ export type PreloadApi = {
|
|||
listSessions: () => Promise<PtyListedSession[]>
|
||||
getAuthoritativeBufferSnapshotCapabilities?: (
|
||||
ids: string[]
|
||||
) => { id: string; authoritative: boolean | null }[]
|
||||
) => Promise<{ id: string; authoritative: boolean | null }[]>
|
||||
hasPty: (id: string) => Promise<boolean | null>
|
||||
getMainBufferSnapshot: (
|
||||
id: string,
|
||||
|
|
|
|||
|
|
@ -1031,8 +1031,8 @@ const api = {
|
|||
listSessions: (): Promise<PtyListedSession[]> => ipcRenderer.invoke('pty:listSessions'),
|
||||
getAuthoritativeBufferSnapshotCapabilities: (
|
||||
ids: string[]
|
||||
): { id: string; authoritative: boolean | null }[] =>
|
||||
ipcRenderer.sendSync('pty:getAuthoritativeBufferSnapshotCapabilitiesSync', { ids }),
|
||||
): Promise<{ id: string; authoritative: boolean | null }[]> =>
|
||||
ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }),
|
||||
hasPty: (id: string): Promise<boolean | null> => ipcRenderer.invoke('pty:hasPty', { id }),
|
||||
|
||||
getMainBufferSnapshot: (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PreloadApi } from './api-types'
|
||||
|
||||
const { exposeInMainWorld, invoke, on, removeListener, send, sendSync } = vi.hoisted(() => ({
|
||||
exposeInMainWorld: vi.fn(),
|
||||
invoke: vi.fn(),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
send: vi.fn(),
|
||||
sendSync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
contextBridge: { exposeInMainWorld },
|
||||
ipcRenderer: { invoke, on, removeListener, send, sendSync },
|
||||
webFrame: {
|
||||
getZoomFactor: vi.fn(() => 1),
|
||||
setZoomFactor: vi.fn(),
|
||||
setVisualZoomLevelLimits: vi.fn()
|
||||
},
|
||||
webUtils: { getPathForFile: vi.fn(() => '') }
|
||||
}))
|
||||
|
||||
vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} }))
|
||||
|
||||
describe('PTY snapshot capability preload IPC', () => {
|
||||
const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
exposeInMainWorld.mockReset()
|
||||
invoke.mockReset()
|
||||
on.mockReset()
|
||||
removeListener.mockReset()
|
||||
send.mockReset()
|
||||
sendSync.mockReset()
|
||||
Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true })
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
vi.stubGlobal('document', { addEventListener: vi.fn() })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
if (originalContextIsolated) {
|
||||
Object.defineProperty(process, 'contextIsolated', originalContextIsolated)
|
||||
} else {
|
||||
Reflect.deleteProperty(process, 'contextIsolated')
|
||||
}
|
||||
})
|
||||
|
||||
it('queries capabilities asynchronously without parking the renderer', async () => {
|
||||
const capabilities = [{ id: 'ssh-pty', authoritative: false }]
|
||||
invoke.mockResolvedValueOnce(capabilities)
|
||||
await import('./index')
|
||||
const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi
|
||||
|
||||
await expect(
|
||||
api.pty.getAuthoritativeBufferSnapshotCapabilities?.(['ssh-pty'])
|
||||
).resolves.toEqual(capabilities)
|
||||
expect(invoke).toHaveBeenCalledWith('pty:getAuthoritativeBufferSnapshotCapabilities', {
|
||||
ids: ['ssh-pty']
|
||||
})
|
||||
expect(sendSync).not.toHaveBeenCalledWith(
|
||||
'pty:getAuthoritativeBufferSnapshotCapabilitiesSync',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -211,6 +211,10 @@ import {
|
|||
hasRequestedBackgroundTerminalWorktreeMount,
|
||||
subscribeBackgroundTerminalWorktreeMountRequests
|
||||
} from './components/terminal/background-terminal-worktree-mount'
|
||||
import {
|
||||
collectTerminalProviderSnapshotPtyIds,
|
||||
synchronizeTerminalProviderSnapshotCapabilities
|
||||
} from './components/terminal/terminal-provider-snapshot-capability'
|
||||
import { useRemoteRuntimeRecoveryTriggers } from './runtime/use-remote-runtime-recovery-triggers'
|
||||
|
||||
// Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible.
|
||||
|
|
@ -1074,6 +1078,11 @@ function App(): React.JSX.Element {
|
|||
await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () =>
|
||||
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
|
||||
)
|
||||
await timeRendererStartupStep('terminal-provider-snapshot-capabilities', () => {
|
||||
return synchronizeTerminalProviderSnapshotCapabilities(
|
||||
collectTerminalProviderSnapshotPtyIds(useAppStore.getState())
|
||||
)
|
||||
})
|
||||
reconnectStarted = true
|
||||
await timeRendererStartupStep('reconnect-terminals', () =>
|
||||
actions.reconnectPersistedTerminals(abortController.signal)
|
||||
|
|
|
|||
|
|
@ -257,6 +257,17 @@ describe('renderer startup runtime routing', () => {
|
|||
expect(source).toContain('<WorkspacePortScanner enabled={workspaceSessionReady} />')
|
||||
})
|
||||
|
||||
it('prefetches terminal snapshot capabilities before reconnect unlocks cold activation', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
const capabilityIndex = source.indexOf(
|
||||
"timeRendererStartupStep('terminal-provider-snapshot-capabilities'"
|
||||
)
|
||||
const reconnectIndex = source.indexOf("timeRendererStartupStep('reconnect-terminals'")
|
||||
|
||||
expect(capabilityIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(reconnectIndex).toBeGreaterThan(capabilityIndex)
|
||||
})
|
||||
|
||||
it('does not load the terminal workbench on the no-workspace landing path', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
|
||||
|
|
|
|||
|
|
@ -151,10 +151,10 @@ describe('canParkTerminalWorktreeRenderers', () => {
|
|||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a previously mounted v19 terminal eligible for ordinary parking', () => {
|
||||
it('keeps a previously mounted v19 terminal eligible for ordinary parking', async () => {
|
||||
const legacyPtyId = 'repo::/worktree@@session-1'
|
||||
clearTerminalProviderSnapshotCapabilities()
|
||||
synchronizeTerminalProviderSnapshotCapabilities([legacyPtyId], (ids) =>
|
||||
await synchronizeTerminalProviderSnapshotCapabilities([legacyPtyId], async (ids) =>
|
||||
ids.map((id) => ({ id, authoritative: false }))
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ describe('cold activation tab deferral', () => {
|
|||
expect(restrictions.get('wt-1')).toEqual(new Set(['tab-1', 'tab-2', 'tab-5', 'tab-9']))
|
||||
})
|
||||
|
||||
it('mounts legacy PTYs eagerly while deferring snapshot-capable siblings', () => {
|
||||
it('mounts legacy PTYs eagerly while deferring snapshot-capable siblings', async () => {
|
||||
const worktreeId = 'wt-1'
|
||||
const allTabIds = tabIds(7)
|
||||
const ptyIdByTabId = new Map(
|
||||
|
|
@ -285,7 +285,7 @@ describe('cold activation tab deferral', () => {
|
|||
)
|
||||
const legacyPtyId = ptyIdByTabId.get('tab-2')!
|
||||
clearTerminalProviderSnapshotCapabilities()
|
||||
synchronizeTerminalProviderSnapshotCapabilities([...ptyIdByTabId.values()], (ids) =>
|
||||
await synchronizeTerminalProviderSnapshotCapabilities([...ptyIdByTabId.values()], async (ids) =>
|
||||
ids.map((id) => ({ id, authoritative: id !== legacyPtyId }))
|
||||
)
|
||||
const restrictions = new Map<string, ReadonlySet<string>>()
|
||||
|
|
@ -315,17 +315,17 @@ describe('cold activation tab deferral', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('preserves cold-activation deferral for an all-current daemon worktree', () => {
|
||||
it('preserves cold-activation deferral for an all-current daemon worktree', async () => {
|
||||
const worktreeId = 'wt-current'
|
||||
const allTabIds = tabIds(7)
|
||||
const ptyIdByTabId = new Map(
|
||||
allTabIds.map((tabId) => [tabId, `${worktreeId}@@${tabId}-session`])
|
||||
)
|
||||
const resolve = vi.fn((ids: string[]) =>
|
||||
const resolve = vi.fn(async (ids: string[]) =>
|
||||
ids.map((id) => ({ id, authoritative: true as boolean | null }))
|
||||
)
|
||||
clearTerminalProviderSnapshotCapabilities()
|
||||
synchronizeTerminalProviderSnapshotCapabilities([...ptyIdByTabId.values()], resolve)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities([...ptyIdByTabId.values()], resolve)
|
||||
const restrictions = new Map<string, ReadonlySet<string>>()
|
||||
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,56 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearTerminalProviderSnapshotCapabilities,
|
||||
collectTerminalProviderSnapshotPtyIds,
|
||||
synchronizeTerminalProviderSnapshotCapabilities,
|
||||
terminalProviderHasAuthoritativeSnapshot
|
||||
} from './terminal-provider-snapshot-capability'
|
||||
|
||||
describe('terminal provider snapshot capabilities', () => {
|
||||
beforeEach(() => clearTerminalProviderSnapshotCapabilities())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('records current and legacy daemon capabilities from one batch', () => {
|
||||
const resolve = vi.fn(() => [
|
||||
it('collects every restored split-pane binding once', () => {
|
||||
expect(
|
||||
collectTerminalProviderSnapshotPtyIds({
|
||||
tabsByWorktree: {
|
||||
worktree: [
|
||||
{ id: 'tab-1', ptyId: 'primary' },
|
||||
{ id: 'tab-2', ptyId: null }
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['primary', 'split'],
|
||||
'tab-2': ['folder-pane']
|
||||
},
|
||||
pendingReconnectPtyIdByTabId: { 'tab-2': 'restored-primary' },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-2': { ptyIdsByLeafId: { leaf: 'restored-split' } }
|
||||
}
|
||||
})
|
||||
).toEqual(['primary', 'split', 'folder-pane', 'restored-primary', 'restored-split'])
|
||||
})
|
||||
|
||||
it('records current and legacy daemon capabilities from one batch', async () => {
|
||||
const resolve = vi.fn(async () => [
|
||||
{ id: 'current', authoritative: true },
|
||||
{ id: 'legacy', authoritative: false }
|
||||
])
|
||||
|
||||
synchronizeTerminalProviderSnapshotCapabilities(['current', 'legacy'], resolve)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(['current', 'legacy'], resolve)
|
||||
|
||||
expect(resolve).toHaveBeenCalledWith(['current', 'legacy'])
|
||||
expect(terminalProviderHasAuthoritativeSnapshot('current')).toBe(true)
|
||||
expect(terminalProviderHasAuthoritativeSnapshot('legacy')).toBe(false)
|
||||
})
|
||||
|
||||
it('caches resolved PTYs and prunes closed ones', () => {
|
||||
const resolve = vi.fn((ids: string[]) =>
|
||||
it('caches resolved PTYs and prunes closed ones', async () => {
|
||||
const resolve = vi.fn(async (ids: string[]) =>
|
||||
ids.map((id) => ({ id, authoritative: true as boolean | null }))
|
||||
)
|
||||
|
||||
synchronizeTerminalProviderSnapshotCapabilities(['pty-1', 'pty-2'], resolve)
|
||||
synchronizeTerminalProviderSnapshotCapabilities(['pty-2', 'pty-3'], resolve)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(['pty-1', 'pty-2'], resolve)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(['pty-2', 'pty-3'], resolve)
|
||||
|
||||
expect(resolve).toHaveBeenNthCalledWith(1, ['pty-1', 'pty-2'])
|
||||
expect(resolve).toHaveBeenNthCalledWith(2, ['pty-3'])
|
||||
|
|
@ -36,7 +59,7 @@ describe('terminal provider snapshot capabilities', () => {
|
|||
expect(terminalProviderHasAuthoritativeSnapshot('pty-3')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not rescan an unchanged fully resolved PTY collection on later renders', () => {
|
||||
it('does not rescan an unchanged fully resolved PTY collection on later renders', async () => {
|
||||
let indexedReads = 0
|
||||
const ids = new Proxy(['pty-1', 'pty-2'], {
|
||||
get(target, property, receiver) {
|
||||
|
|
@ -46,42 +69,132 @@ describe('terminal provider snapshot capabilities', () => {
|
|||
return Reflect.get(target, property, receiver)
|
||||
}
|
||||
})
|
||||
const resolve = vi.fn((batch: string[]) =>
|
||||
const resolve = vi.fn(async (batch: string[]) =>
|
||||
batch.map((id) => ({ id, authoritative: true as boolean | null }))
|
||||
)
|
||||
|
||||
synchronizeTerminalProviderSnapshotCapabilities(ids, resolve)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(ids, resolve)
|
||||
indexedReads = 0
|
||||
synchronizeTerminalProviderSnapshotCapabilities(ids, resolve)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(ids, resolve)
|
||||
|
||||
expect(indexedReads).toBe(0)
|
||||
expect(resolve).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('bounds initial capability IPC to batches of 512 PTYs', () => {
|
||||
it('bounds initial capability IPC to batches of 512 PTYs', async () => {
|
||||
const ids = Array.from({ length: 1_025 }, (_, index) => `pty-${index}`)
|
||||
const resolve = vi.fn((batch: string[]) =>
|
||||
const resolve = vi.fn(async (batch: string[]) =>
|
||||
batch.map((id) => ({ id, authoritative: true as boolean | null }))
|
||||
)
|
||||
|
||||
synchronizeTerminalProviderSnapshotCapabilities(ids, resolve)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(ids, resolve)
|
||||
|
||||
expect(resolve.mock.calls.map(([batch]) => batch.length)).toEqual([512, 512, 1])
|
||||
})
|
||||
|
||||
it('retries capabilities that are still unknown during daemon startup', () => {
|
||||
it('retries capabilities that are still unknown during daemon startup', async () => {
|
||||
const resolve = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce([{ id: 'pty-1', authoritative: null }])
|
||||
.mockReturnValueOnce([{ id: 'pty-1', authoritative: true }])
|
||||
.mockResolvedValueOnce([{ id: 'pty-1', authoritative: null }])
|
||||
.mockResolvedValueOnce([{ id: 'pty-1', authoritative: true }])
|
||||
|
||||
synchronizeTerminalProviderSnapshotCapabilities(['pty-1'], resolve, 1_000)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(['pty-1'], resolve, 1_000)
|
||||
expect(terminalProviderHasAuthoritativeSnapshot('pty-1')).toBe(false)
|
||||
synchronizeTerminalProviderSnapshotCapabilities(['pty-1'], resolve, 1_999)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(['pty-1'], resolve, 1_999)
|
||||
expect(resolve).toHaveBeenCalledOnce()
|
||||
synchronizeTerminalProviderSnapshotCapabilities(['pty-1'], resolve, 2_000)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(['pty-1'], resolve, 2_000)
|
||||
|
||||
expect(resolve).toHaveBeenCalledTimes(2)
|
||||
expect(terminalProviderHasAuthoritativeSnapshot('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds an unresponsive capability resolver and keeps the result unknown', async () => {
|
||||
vi.useFakeTimers()
|
||||
const synchronization = synchronizeTerminalProviderSnapshotCapabilities(
|
||||
['pty-1'],
|
||||
() => new Promise(() => {}),
|
||||
1_000
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
await expect(synchronization).resolves.toBe(1_000)
|
||||
expect(terminalProviderHasAuthoritativeSnapshot('pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a stale capability response after the live PTY set changes', async () => {
|
||||
let resolveStale!: (value: { id: string; authoritative: boolean | null }[]) => void
|
||||
const stale = new Promise<{ id: string; authoritative: boolean | null }[]>((resolve) => {
|
||||
resolveStale = resolve
|
||||
})
|
||||
const first = synchronizeTerminalProviderSnapshotCapabilities(['old-pty'], () => stale)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(['current-pty'], async () => [
|
||||
{ id: 'current-pty', authoritative: true }
|
||||
])
|
||||
|
||||
resolveStale([{ id: 'old-pty', authoritative: true }])
|
||||
await first
|
||||
|
||||
expect(terminalProviderHasAuthoritativeSnapshot('old-pty')).toBe(false)
|
||||
expect(terminalProviderHasAuthoritativeSnapshot('current-pty')).toBe(true)
|
||||
})
|
||||
|
||||
it('stops polling for a PTY whose route never resolves', async () => {
|
||||
// The retry re-arms this module's own timer, so a fixed delay is a 1 Hz IPC
|
||||
// plus a full all-PTY scan for the life of the app.
|
||||
const resolve = vi.fn(async () => [{ id: 'gone-pty', authoritative: null }])
|
||||
const backoffSchedule = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000]
|
||||
|
||||
let nowMs = 1_000
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(['gone-pty'], resolve, nowMs)
|
||||
for (const delayMs of backoffSchedule) {
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(
|
||||
['gone-pty'],
|
||||
resolve,
|
||||
nowMs + delayMs - 1
|
||||
)
|
||||
nowMs += delayMs
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(['gone-pty'], resolve, nowMs)
|
||||
}
|
||||
const settledCallCount = resolve.mock.calls.length
|
||||
|
||||
for (let index = 1; index <= 1_000; index += 1) {
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(
|
||||
['gone-pty'],
|
||||
resolve,
|
||||
nowMs + index * 60_000
|
||||
)
|
||||
}
|
||||
|
||||
expect(settledCallCount).toBe(backoffSchedule.length + 1)
|
||||
expect(resolve).toHaveBeenCalledTimes(settledCallCount)
|
||||
expect(terminalProviderHasAuthoritativeSnapshot('gone-pty')).toBe(false)
|
||||
})
|
||||
|
||||
it('stops rescheduling once an unresolvable PTY has settled', async () => {
|
||||
const resolve = vi.fn(async () => [{ id: 'gone-pty', authoritative: null }])
|
||||
|
||||
let nowMs = 1_000
|
||||
let retryDelayMs: number | null = null
|
||||
for (const delayMs of [0, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000]) {
|
||||
nowMs += delayMs
|
||||
retryDelayMs = await synchronizeTerminalProviderSnapshotCapabilities(
|
||||
['gone-pty'],
|
||||
resolve,
|
||||
nowMs
|
||||
)
|
||||
}
|
||||
|
||||
expect(retryDelayMs).toBeNull()
|
||||
})
|
||||
|
||||
it('re-probes an unknown PTY from scratch after it closes and a new one appears', async () => {
|
||||
const resolve = vi.fn(async () => [{ id: 'gone-pty', authoritative: null }])
|
||||
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(['gone-pty'], resolve, 1_000)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities([], resolve, 2_000)
|
||||
await synchronizeTerminalProviderSnapshotCapabilities(['gone-pty'], resolve, 2_001)
|
||||
|
||||
expect(resolve).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,51 @@
|
|||
type SnapshotCapability = { id: string; authoritative: boolean | null }
|
||||
type SnapshotCapabilityResolver = (ids: string[]) => Promise<SnapshotCapability[]>
|
||||
type SnapshotCapabilityTab = { id: string; ptyId?: string | null }
|
||||
type SnapshotCapabilityBindingState = {
|
||||
tabsByWorktree: Readonly<Record<string, readonly SnapshotCapabilityTab[]>>
|
||||
ptyIdsByTabId: Readonly<Record<string, readonly string[]>>
|
||||
pendingReconnectPtyIdByTabId?: Readonly<Record<string, string>>
|
||||
terminalLayoutsByTabId?: Readonly<
|
||||
Record<string, { ptyIdsByLeafId?: Readonly<Record<string, string>> }>
|
||||
>
|
||||
}
|
||||
|
||||
const authoritativeSnapshotByPtyId = new Map<string, boolean>()
|
||||
const unknownCapabilityRetryAtByPtyId = new Map<string, number>()
|
||||
const unknownCapabilityAttemptsByPtyId = new Map<string, number>()
|
||||
const UNKNOWN_CAPABILITY_RETRY_MS = 1_000
|
||||
const UNKNOWN_CAPABILITY_MAX_RETRY_MS = 30_000
|
||||
/** 1/2/4/8/16/30/30 s — ~91 s of daemon-startup grace, then settle conservatively. */
|
||||
const UNKNOWN_CAPABILITY_MAX_ATTEMPTS = 8
|
||||
const CAPABILITY_RESOLUTION_TIMEOUT_MS = 1_000
|
||||
let lastSynchronizedLivePtyIds: readonly string[] | null = null
|
||||
let earliestUnknownCapabilityRetryAtMs = Number.POSITIVE_INFINITY
|
||||
let synchronizationGeneration = 0
|
||||
|
||||
export function collectTerminalProviderSnapshotPtyIds(
|
||||
state: SnapshotCapabilityBindingState
|
||||
): string[] {
|
||||
const ids = new Set<string>()
|
||||
for (const worktreeTabs of Object.values(state.tabsByWorktree)) {
|
||||
for (const tab of worktreeTabs) {
|
||||
if (tab.ptyId) {
|
||||
ids.add(tab.ptyId)
|
||||
}
|
||||
for (const ptyId of state.ptyIdsByTabId[tab.id] ?? []) {
|
||||
ids.add(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ptyId of Object.values(state.pendingReconnectPtyIdByTabId ?? {})) {
|
||||
ids.add(ptyId)
|
||||
}
|
||||
for (const layout of Object.values(state.terminalLayoutsByTabId ?? {})) {
|
||||
for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) {
|
||||
ids.add(ptyId)
|
||||
}
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
function refreshEarliestUnknownCapabilityRetry(): void {
|
||||
earliestUnknownCapabilityRetryAtMs = Number.POSITIVE_INFINITY
|
||||
|
|
@ -13,23 +54,67 @@ function refreshEarliestUnknownCapabilityRetry(): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function synchronizeTerminalProviderSnapshotCapabilities(
|
||||
/**
|
||||
* Why bounded: an id that stays unknown re-arms this resolver's own retry timer,
|
||||
* so a fixed delay is a 1 Hz main-process IPC plus a full all-PTY scan for the
|
||||
* life of the app. Settling on `false` is the same mount-eagerly behaviour an
|
||||
* unresolved id already gets, and it lets the loop converge.
|
||||
*/
|
||||
function backOffUnknownCapability(ptyId: string, nowMs: number): void {
|
||||
const attempts = (unknownCapabilityAttemptsByPtyId.get(ptyId) ?? 0) + 1
|
||||
if (attempts >= UNKNOWN_CAPABILITY_MAX_ATTEMPTS) {
|
||||
authoritativeSnapshotByPtyId.set(ptyId, false)
|
||||
unknownCapabilityAttemptsByPtyId.delete(ptyId)
|
||||
unknownCapabilityRetryAtByPtyId.delete(ptyId)
|
||||
return
|
||||
}
|
||||
unknownCapabilityAttemptsByPtyId.set(ptyId, attempts)
|
||||
unknownCapabilityRetryAtByPtyId.set(
|
||||
ptyId,
|
||||
nowMs +
|
||||
Math.min(UNKNOWN_CAPABILITY_RETRY_MS * 2 ** (attempts - 1), UNKNOWN_CAPABILITY_MAX_RETRY_MS)
|
||||
)
|
||||
}
|
||||
|
||||
function unknownCapabilityRetryDelayMs(nowMs: number): number | null {
|
||||
return earliestUnknownCapabilityRetryAtMs === Number.POSITIVE_INFINITY
|
||||
? null
|
||||
: Math.max(0, earliestUnknownCapabilityRetryAtMs - nowMs)
|
||||
}
|
||||
|
||||
async function resolveSnapshotCapabilityBatch(
|
||||
resolve: SnapshotCapabilityResolver,
|
||||
batch: string[]
|
||||
): Promise<SnapshotCapability[] | null> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
resolve(batch),
|
||||
new Promise<null>((resolveTimeout) => {
|
||||
timeout = setTimeout(() => resolveTimeout(null), CAPABILITY_RESOLUTION_TIMEOUT_MS)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export async function synchronizeTerminalProviderSnapshotCapabilities(
|
||||
livePtyIds: readonly string[],
|
||||
resolveCapabilities?: (ids: string[]) => SnapshotCapability[],
|
||||
resolveCapabilities?: SnapshotCapabilityResolver,
|
||||
observedAtMs?: number
|
||||
): void {
|
||||
// Why: Terminal can re-render for unrelated UI state. A stable binding list
|
||||
// must add no repeated all-PTY scan or IPC work to that render path.
|
||||
): Promise<number | null> {
|
||||
if (
|
||||
livePtyIds === lastSynchronizedLivePtyIds &&
|
||||
earliestUnknownCapabilityRetryAtMs === Number.POSITIVE_INFINITY
|
||||
) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
const nowMs = observedAtMs ?? Date.now()
|
||||
if (livePtyIds === lastSynchronizedLivePtyIds && nowMs < earliestUnknownCapabilityRetryAtMs) {
|
||||
return
|
||||
return unknownCapabilityRetryDelayMs(nowMs)
|
||||
}
|
||||
const generation = ++synchronizationGeneration
|
||||
lastSynchronizedLivePtyIds = livePtyIds
|
||||
const live = new Set(livePtyIds.filter((id) => id.length > 0))
|
||||
for (const cachedId of authoritativeSnapshotByPtyId.keys()) {
|
||||
|
|
@ -40,6 +125,7 @@ export function synchronizeTerminalProviderSnapshotCapabilities(
|
|||
for (const pendingId of unknownCapabilityRetryAtByPtyId.keys()) {
|
||||
if (!live.has(pendingId)) {
|
||||
unknownCapabilityRetryAtByPtyId.delete(pendingId)
|
||||
unknownCapabilityAttemptsByPtyId.delete(pendingId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -51,36 +137,68 @@ export function synchronizeTerminalProviderSnapshotCapabilities(
|
|||
const resolve = resolveCapabilities ?? window.api.pty.getAuthoritativeBufferSnapshotCapabilities
|
||||
if (!resolve) {
|
||||
for (const id of missing) {
|
||||
unknownCapabilityRetryAtByPtyId.set(id, nowMs + UNKNOWN_CAPABILITY_RETRY_MS)
|
||||
backOffUnknownCapability(id, nowMs)
|
||||
}
|
||||
refreshEarliestUnknownCapabilityRetry()
|
||||
return
|
||||
return unknownCapabilityRetryDelayMs(nowMs)
|
||||
}
|
||||
for (let offset = 0; offset < missing.length; offset += 512) {
|
||||
const batch = missing.slice(offset, offset + 512)
|
||||
let resolved: SnapshotCapability[]
|
||||
let resolved: SnapshotCapability[] | null
|
||||
try {
|
||||
resolved = resolve(batch)
|
||||
resolved = await resolveSnapshotCapabilityBatch(resolve, batch)
|
||||
} catch {
|
||||
if (generation !== synchronizationGeneration) {
|
||||
return null
|
||||
}
|
||||
// Why: unknown capability must keep the pane mounted. Do not cache the
|
||||
// failure as supported; back off before retrying daemon startup.
|
||||
for (const id of batch) {
|
||||
unknownCapabilityRetryAtByPtyId.set(id, nowMs + UNKNOWN_CAPABILITY_RETRY_MS)
|
||||
backOffUnknownCapability(id, nowMs)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (generation !== synchronizationGeneration) {
|
||||
return null
|
||||
}
|
||||
if (!resolved) {
|
||||
for (const id of missing.slice(offset)) {
|
||||
backOffUnknownCapability(id, nowMs)
|
||||
}
|
||||
break
|
||||
}
|
||||
const resolvedById = new Map(resolved.map((entry) => [entry.id, entry.authoritative]))
|
||||
for (const id of batch) {
|
||||
const authoritative = resolvedById.get(id)
|
||||
if (typeof authoritative === 'boolean') {
|
||||
authoritativeSnapshotByPtyId.set(id, authoritative)
|
||||
unknownCapabilityRetryAtByPtyId.delete(id)
|
||||
unknownCapabilityAttemptsByPtyId.delete(id)
|
||||
} else {
|
||||
unknownCapabilityRetryAtByPtyId.set(id, nowMs + UNKNOWN_CAPABILITY_RETRY_MS)
|
||||
backOffUnknownCapability(id, nowMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshEarliestUnknownCapabilityRetry()
|
||||
return unknownCapabilityRetryDelayMs(nowMs)
|
||||
}
|
||||
|
||||
export function startTerminalProviderSnapshotCapabilitySynchronization(
|
||||
livePtyIds: readonly string[]
|
||||
): () => void {
|
||||
let disposed = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const synchronize = async (): Promise<void> => {
|
||||
const retryDelayMs = await synchronizeTerminalProviderSnapshotCapabilities(livePtyIds)
|
||||
if (!disposed && retryDelayMs !== null) {
|
||||
retryTimer = setTimeout(() => void synchronize(), Math.max(1, retryDelayMs))
|
||||
}
|
||||
}
|
||||
void synchronize()
|
||||
return () => {
|
||||
disposed = true
|
||||
clearTimeout(retryTimer)
|
||||
}
|
||||
}
|
||||
|
||||
export function terminalProviderHasAuthoritativeSnapshot(ptyId: string): boolean {
|
||||
|
|
@ -90,6 +208,8 @@ export function terminalProviderHasAuthoritativeSnapshot(ptyId: string): boolean
|
|||
export function clearTerminalProviderSnapshotCapabilities(): void {
|
||||
authoritativeSnapshotByPtyId.clear()
|
||||
unknownCapabilityRetryAtByPtyId.clear()
|
||||
unknownCapabilityAttemptsByPtyId.clear()
|
||||
lastSynchronizedLivePtyIds = null
|
||||
earliestUnknownCapabilityRetryAtMs = Number.POSITIVE_INFINITY
|
||||
synchronizationGeneration += 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { clearTerminalProviderSnapshotCapabilities } from './terminal-provider-snapshot-capability'
|
||||
|
||||
const storeState = {
|
||||
tabsByWorktree: {
|
||||
'repo::worktree': [{ id: 'tab-1', ptyId: 'ssh:target@@pty-1' }]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['ssh:target@@pty-1'] }
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState)
|
||||
}))
|
||||
|
||||
import { useTerminalProviderSnapshotCapability } from './use-terminal-provider-snapshot-capability'
|
||||
|
||||
describe('useTerminalProviderSnapshotCapability', () => {
|
||||
const resolveCapabilities = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
clearTerminalProviderSnapshotCapabilities()
|
||||
resolveCapabilities.mockReset()
|
||||
;(window as unknown as { api: unknown }).api = {
|
||||
pty: { getAuthoritativeBufferSnapshotCapabilities: resolveCapabilities }
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
delete (window as unknown as { api?: unknown }).api
|
||||
})
|
||||
|
||||
it('prefetches restored PTYs after render before activation is enabled', async () => {
|
||||
resolveCapabilities.mockResolvedValue([{ id: 'ssh:target@@pty-1', authoritative: false }])
|
||||
|
||||
renderHook(() => {
|
||||
useTerminalProviderSnapshotCapability(false)
|
||||
expect(resolveCapabilities).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(resolveCapabilities).toHaveBeenCalledOnce())
|
||||
expect(resolveCapabilities).toHaveBeenCalledWith(['ssh:target@@pty-1'])
|
||||
})
|
||||
|
||||
it('does not poll again after a provider returns a definitive result', async () => {
|
||||
vi.useFakeTimers()
|
||||
resolveCapabilities.mockResolvedValue([{ id: 'ssh:target@@pty-1', authoritative: false }])
|
||||
const hook = renderHook(() => useTerminalProviderSnapshotCapability(true))
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(resolveCapabilities).toHaveBeenCalledOnce()
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('cancels an unknown-capability retry when the hook unmounts', async () => {
|
||||
vi.useFakeTimers()
|
||||
resolveCapabilities.mockResolvedValue([{ id: 'ssh:target@@pty-1', authoritative: null }])
|
||||
const hook = renderHook(() => useTerminalProviderSnapshotCapability(true))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(resolveCapabilities).toHaveBeenCalledOnce()
|
||||
|
||||
hook.unmount()
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
|
||||
expect(resolveCapabilities).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,28 +1,23 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { synchronizeTerminalProviderSnapshotCapabilities } from './terminal-provider-snapshot-capability'
|
||||
import {
|
||||
collectTerminalProviderSnapshotPtyIds,
|
||||
startTerminalProviderSnapshotCapabilitySynchronization
|
||||
} from './terminal-provider-snapshot-capability'
|
||||
|
||||
export function useTerminalProviderSnapshotCapability(enabled: boolean): void {
|
||||
const tabsByWorktree = useAppStore((state) => state.tabsByWorktree)
|
||||
const ptyIdsByTabId = useAppStore((state) => state.ptyIdsByTabId)
|
||||
const boundPtyIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const worktreeTabs of Object.values(tabsByWorktree)) {
|
||||
for (const tab of worktreeTabs) {
|
||||
if (tab.ptyId) {
|
||||
ids.add(tab.ptyId)
|
||||
}
|
||||
for (const ptyId of ptyIdsByTabId[tab.id] ?? []) {
|
||||
ids.add(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...ids]
|
||||
}, [ptyIdsByTabId, tabsByWorktree])
|
||||
const boundPtyIds = useMemo(
|
||||
() => collectTerminalProviderSnapshotPtyIds({ tabsByWorktree, ptyIdsByTabId }),
|
||||
[ptyIdsByTabId, tabsByWorktree]
|
||||
)
|
||||
|
||||
if (enabled) {
|
||||
// Why: deferral is decided in this render. A cached in-memory IPC batch
|
||||
// prevents legacy panes from unmounting before an async effect runs.
|
||||
synchronizeTerminalProviderSnapshotCapabilities(boundPtyIds)
|
||||
}
|
||||
useEffect(() => {
|
||||
// Why: hydration exposes restored PTY ids before activation unlocks; prefetching here preserves cold deferral without blocking render.
|
||||
if (!enabled && boundPtyIds.length === 0) {
|
||||
return
|
||||
}
|
||||
return startTerminalProviderSnapshotCapabilitySynchronization(boundPtyIds)
|
||||
}, [boundPtyIds, enabled])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3150,7 +3150,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
|
|||
getSize: () => Promise.resolve(null),
|
||||
listSessions: () => Promise.resolve([]),
|
||||
getAuthoritativeBufferSnapshotCapabilities: (ids) =>
|
||||
ids.map((id) => ({ id, authoritative: false })),
|
||||
Promise.resolve(ids.map((id) => ({ id, authoritative: false }))),
|
||||
hasPty: () => Promise.resolve(null),
|
||||
getMainBufferSnapshot: () => Promise.resolve(null),
|
||||
// Why: remote-runtime PTYs skip local main (no side-effect source); renderer byte parsing stays authoritative.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { expect, test } from './helpers/orca-app'
|
||||
|
||||
type CapabilityProbe = {
|
||||
calls: number
|
||||
gapsMs: number[]
|
||||
returnDurationsMs: number[]
|
||||
timer: number
|
||||
}
|
||||
|
||||
test('PTY capability lookup keeps renderer JavaScript responsive while main is stalled', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}) => {
|
||||
await orcaPage.evaluate(() => {
|
||||
const probe: CapabilityProbe = {
|
||||
calls: 0,
|
||||
gapsMs: [],
|
||||
returnDurationsMs: [],
|
||||
timer: 0
|
||||
}
|
||||
let previousTickAt = performance.now()
|
||||
probe.timer = window.setInterval(() => {
|
||||
const tickAt = performance.now()
|
||||
probe.gapsMs.push(tickAt - previousTickAt)
|
||||
previousTickAt = tickAt
|
||||
const callStartedAt = performance.now()
|
||||
void window.api.pty.getAuthoritativeBufferSnapshotCapabilities?.(['ssh:e2e@@pty-1'])
|
||||
probe.returnDurationsMs.push(performance.now() - callStartedAt)
|
||||
probe.calls += 1
|
||||
}, 50)
|
||||
;(window as typeof window & { __capabilityProbe?: CapabilityProbe }).__capabilityProbe = probe
|
||||
})
|
||||
await expect
|
||||
.poll(() =>
|
||||
orcaPage.evaluate(
|
||||
() =>
|
||||
(window as typeof window & { __capabilityProbe?: CapabilityProbe }).__capabilityProbe
|
||||
?.calls ?? 0
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const mainBlockedMs = await electronApp.evaluate(() => {
|
||||
const startedAt = Date.now()
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1_500)
|
||||
return Date.now() - startedAt
|
||||
})
|
||||
const metrics = await orcaPage.evaluate(() => {
|
||||
const probe = (window as typeof window & { __capabilityProbe?: CapabilityProbe })
|
||||
.__capabilityProbe
|
||||
if (!probe) {
|
||||
throw new Error('Capability probe missing')
|
||||
}
|
||||
clearInterval(probe.timer)
|
||||
return {
|
||||
calls: probe.calls,
|
||||
maxGapMs: Math.max(...probe.gapsMs),
|
||||
maxReturnDurationMs: Math.max(...probe.returnDurationsMs)
|
||||
}
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[pty-capability-main-stall] mainBlockedMs=${mainBlockedMs} calls=${metrics.calls} maxGapMs=${metrics.maxGapMs.toFixed(1)} maxReturnDurationMs=${metrics.maxReturnDurationMs.toFixed(1)}`
|
||||
)
|
||||
|
||||
expect(mainBlockedMs).toBeGreaterThanOrEqual(1_400)
|
||||
expect(metrics.calls).toBeGreaterThanOrEqual(10)
|
||||
expect(metrics.maxGapMs).toBeLessThan(500)
|
||||
expect(metrics.maxReturnDurationMs).toBeLessThan(100)
|
||||
})
|
||||
Loading…
Reference in New Issue