perf(resource-usage): track closed PTY inventory incrementally

This commit is contained in:
Neil
2026-08-23 16:53:33 -07:00
parent ab3b1d07cd
commit 32e9028f29
15 changed files with 869 additions and 228 deletions
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { onMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority'
import type { PtySessionInventorySnapshot } from '../../shared/pty-listed-session'
import {
registerPtyHandlers,
registerSshPtyProvider,
@@ -169,6 +170,44 @@ describe('registerPtyHandlers', () => {
keepHistory: false
})
})
it('keeps a connection-lost SSH ownership scope unavailable after provider unregister', async () => {
registerPtyHandlers(mainWindow as never)
registerSshPtyProvider('ssh-available', {
listProcesses: vi.fn(async () => [
{ id: 'ssh:ssh-available@@pty-1', cwd: '/repo', title: 'available' }
])
} as never)
// Connection loss unregisters the provider but intentionally retains PTY
// routing ownership for daemon/relay grace and later reattach.
setPtyOwnership('ssh:ssh-disconnected@@retained', 'ssh-disconnected')
try {
const inventory = (await handlers.get('pty:listSessionInventory')!(
null,
undefined
)) as PtySessionInventorySnapshot
expect(inventory.sessions).toEqual([
{
id: 'ssh:ssh-available@@pty-1',
cwd: '/repo',
title: 'available',
agentOwnership: 'unknown'
}
])
expect(inventory.hostIdBySessionId).toEqual({
'ssh:ssh-available@@pty-1': 'ssh:ssh-available'
})
expect(new Set(inventory.queriedHostIds)).toEqual(new Set(['local', 'ssh:ssh-available']))
expect(new Set(inventory.respondingHostIds)).toEqual(new Set(['local', 'ssh:ssh-available']))
expect(inventory.unavailableHostIds).toEqual(['ssh:ssh-disconnected'])
expect(inventory.retainedSessionIdsByHost).toEqual({
'ssh:ssh-disconnected': ['ssh:ssh-disconnected@@retained']
})
expect(inventory.complete).toBe(false)
} finally {
unregisterSshPtyProvider('ssh-available')
}
})
it('reports agent ownership through pty:listSessions so the renderer cannot guess it', async () => {
registerPtyHandlers(mainWindow as never)
// Why: the renderer's binding map is empty during restore, so agent ownership is the only
@@ -118,7 +118,9 @@ describe('registerPtyHandlers', () => {
await spawnAndGetEnv()
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:spawned', {
id: expect.any(String)
id: expect.any(String),
hostId: 'local',
isReattach: false
})
})
it('marks local Claude launches live until the PTY is killed', async () => {
@@ -453,6 +453,11 @@ describe('registerPtyHandlers', () => {
expect(result).toMatchObject({ id: 'ssh-reattach', isReattach: true })
expect(result.launchConfig).toBeUndefined()
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:spawned', {
id: 'ssh-reattach',
hostId: 'ssh:ssh-reattach-1',
isReattach: true
})
})
it('reuses the runtime background handle in local PTY spawn env', async () => {
type RuntimeSpawnController = {
@@ -497,7 +502,9 @@ describe('registerPtyHandlers', () => {
'term_expected'
)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:spawned', {
id: spawned.id
id: spawned.id,
hostId: 'local',
isReattach: false
})
})
})
+101 -10
View File
@@ -240,7 +240,11 @@ import {
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtyListedSession } from '../../shared/pty-listed-session'
import {
PTY_SESSION_INVENTORY_RETAINED_ID_LIMIT,
type PtyListedSession,
type PtySessionInventorySnapshot
} from '../../shared/pty-listed-session'
// ─── Provider Registry ──────────────────────────────────────────────
// Routes PTY operations by connectionId (null = local provider).
@@ -2475,6 +2479,7 @@ export function registerPtyHandlers(
ipc.removeHandler('pty:spawn')
ipc.removeHandler('pty:kill')
ipc.removeHandler('pty:listSessions')
ipc.removeHandler('pty:listSessionInventory')
ipc.removeHandler('pty:hasPty')
ipc.removeHandler('pty:hasChildProcesses')
ipc.removeHandler('pty:getForegroundProcess')
@@ -3796,9 +3801,19 @@ export function registerPtyHandlers(
}
}
function sendPtySpawnedToRenderer(id: string): void {
function sendPtySpawnedToRenderer(
id: string,
hostId: ExecutionHostId,
isReattach: boolean,
exitedBeforeSpawnReply: boolean
): void {
if (!isRendererGone(mainWindow)) {
sendToRenderer(mainWindow, 'pty:spawned', { id })
sendToRenderer(mainWindow, 'pty:spawned', {
id,
hostId,
isReattach,
...(exitedBeforeSpawnReply ? { exitedBeforeSpawnReply: true } : {})
})
}
}
@@ -5466,7 +5481,12 @@ export function registerPtyHandlers(
})
}
// Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal.
sendPtySpawnedToRenderer(result.id)
sendPtySpawnedToRenderer(
result.id,
args.connectionId ? toSshExecutionHostId(args.connectionId) : LOCAL_EXECUTION_HOST_ID,
result.isReattach === true,
result.exitedBeforeSpawnReply === true
)
if (!args.connectionId) {
options?.onCodexHomePtySpawned?.({
id: result.id,
@@ -7189,7 +7209,12 @@ export function registerPtyHandlers(
: {})
}
// Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory.
sendPtySpawnedToRenderer(result.id)
sendPtySpawnedToRenderer(
result.id,
args.connectionId ? toSshExecutionHostId(args.connectionId) : LOCAL_EXECUTION_HOST_ID,
result.isReattach === true,
result.exitedBeforeSpawnReply === true
)
if (!args.connectionId) {
options?.onCodexHomePtySpawned?.({
id: result.id,
@@ -7773,18 +7798,39 @@ export function registerPtyHandlers(
}
})
ipc.handle('pty:listSessions', async (): Promise<PtyListedSession[]> => {
const listPtySessionInventory = async (): Promise<PtySessionInventorySnapshot> => {
const providers = registeredPtyProviders()
const deduped = new Map<string, PtyListedSession>()
const hostIdBySessionId = new Map<string, ExecutionHostId>()
const admission = new PtyProcessListAdmission()
const queriedHostIds = new Set<ExecutionHostId>(
providers.map(({ connectionId }) =>
connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
)
)
const respondingHostIds = new Set<ExecutionHostId>()
await visitPtyProcessListingsInBatches(
registeredPtyProviders(),
({ provider, connectionId }) =>
connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []),
providers,
async ({ provider, connectionId }) => {
const hostId = connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
try {
const sessions = await provider.listProcesses()
respondingHostIds.add(hostId)
return sessions
} catch (error) {
if (!connectionId) {
throw error
}
return []
}
},
({ provider, connectionId }, sessions) => {
const hostId = connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
for (const rawSession of sessions) {
const session = admission.admit(rawSession)
// Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider.
ptyOwnership.set(session.id, connectionId)
hostIdBySessionId.set(session.id, hostId)
deduped.set(session.id, {
id: session.id,
cwd: session.cwd,
@@ -7802,9 +7848,54 @@ export function registerPtyHandlers(
}
}
)
return Array.from(deduped.values())
// A connection-lost SSH provider is unregistered before its durable PTYs
// disappear. Retained routing ownership keeps that host in the expected set.
const expectedHostIds = new Set<ExecutionHostId>(queriedHostIds)
for (const connectionId of ptyOwnership.values()) {
expectedHostIds.add(
connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
)
}
const unavailableHostIds = Array.from(expectedHostIds).filter(
(hostId) => !respondingHostIds.has(hostId)
)
const unavailableHostIdSet = new Set(unavailableHostIds)
const retainedSessionIdsByHost = new Map<ExecutionHostId, string[]>()
let retainedSessionIdCount = 0
for (const [sessionId, connectionId] of ptyOwnership) {
if (retainedSessionIdCount >= PTY_SESSION_INVENTORY_RETAINED_ID_LIMIT) {
break
}
if (connectionId === null || deduped.has(sessionId)) {
continue
}
const hostId = toSshExecutionHostId(connectionId)
if (!unavailableHostIdSet.has(hostId)) {
continue
}
const retainedSessionIds = retainedSessionIdsByHost.get(hostId) ?? []
retainedSessionIds.push(sessionId)
retainedSessionIdsByHost.set(hostId, retainedSessionIds)
retainedSessionIdCount += 1
}
return {
sessions: Array.from(deduped.values()),
hostIdBySessionId: Object.fromEntries(hostIdBySessionId),
queriedHostIds: Array.from(queriedHostIds),
retainedSessionIdsByHost: Object.fromEntries(retainedSessionIdsByHost),
respondingHostIds: Array.from(respondingHostIds),
unavailableHostIds,
complete: unavailableHostIds.length === 0
}
}
ipc.handle('pty:listSessions', async (): Promise<PtyListedSession[]> => {
const inventory = await listPtySessionInventory()
return inventory.sessions
})
ipc.handle('pty:listSessionInventory', listPtySessionInventory)
ipc.handle(
'pty:getAuthoritativeBufferSnapshotCapabilities',
async (_event, args: { ids?: unknown }) => {
+18 -2
View File
@@ -3,8 +3,9 @@ import type {
SleepingAgentLaunchConfig
} from '../../shared/agent-session-resume'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import type { PtyListedSession } from '../../shared/pty-listed-session'
import type { PtyListedSession, PtySessionInventorySnapshot } from '../../shared/pty-listed-session'
import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics'
import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker'
import type {
@@ -17,6 +18,16 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut
import type { TuiAgent } from '../../shared/tui-agent'
import type { PtyManagementApi } from './pty-management-api'
export type PtySpawnedEvent = {
id: string
/** Provider scope for authoritative per-host pruning. Missing is fail-safe unknown. */
hostId?: ExecutionHostId
/** False proves a new provider session; true is a reattach. Missing means a mixed-version sender is ambiguous. */
isReattach?: boolean
/** The provider reported that this spawn was already dead before its reply, so consumers must reconcile. */
exitedBeforeSpawnReply?: true
}
export type PtyApi = {
spawn: (opts: {
cols: number
@@ -115,6 +126,11 @@ export type PtyApi = {
getCwd: (id: string) => Promise<string>
getSize: (id: string) => Promise<{ cols: number; rows: number } | null>
listSessions: () => Promise<PtyListedSession[]>
/**
* Detailed additive inventory. Missing on an older main process means
* completeness is unknown and consumers must fail safe.
*/
listSessionInventory?: () => Promise<PtySessionInventorySnapshot>
getAuthoritativeBufferSnapshotCapabilities?: (
ids: string[]
) => Promise<{ id: string; authoritative: boolean | null }[]>
@@ -196,7 +212,7 @@ export type PtyApi = {
onExit: (
callback: (data: { id: string; code: number; preserveRendererBinding?: boolean }) => void
) => () => void
onSpawned: (callback: (data: { id: string }) => void) => () => void
onSpawned: (callback: (data: PtySpawnedEvent) => void) => () => void
onSerializeBufferRequest: (
callback: (data: {
requestId: string
+6 -3
View File
@@ -116,7 +116,7 @@ import type {
} from '../shared/worktree/launch-types'
import type { GitPushTarget, WorktreeHeadIdentity } from '../shared/worktree/types'
import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker'
import type { PtyListedSession } from '../shared/pty-listed-session'
import type { PtyListedSession, PtySessionInventorySnapshot } from '../shared/pty-listed-session'
import type {
PtyRendererDeliveryHealthReply,
PtyRendererDeliveryStateReport
@@ -259,6 +259,7 @@ import type {
PluginHostLogLine,
PreloadApi
} from './api-types'
import type { PtySpawnedEvent } from './api/pty-api'
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
import {
KEYBOARD_LAYOUT_CHANGED_CHANNEL,
@@ -1139,6 +1140,8 @@ const api = {
ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }),
listSessions: (): Promise<PtyListedSession[]> => ipcRenderer.invoke('pty:listSessions'),
listSessionInventory: (): Promise<PtySessionInventorySnapshot> =>
ipcRenderer.invoke('pty:listSessionInventory'),
getAuthoritativeBufferSnapshotCapabilities: (
ids: string[]
): Promise<{ id: string; authoritative: boolean | null }[]> =>
@@ -1283,8 +1286,8 @@ const api = {
return () => ipcRenderer.removeListener('pty:exit', listener)
},
onSpawned: (callback: (data: { id: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { id: string }) => callback(data)
onSpawned: (callback: (data: PtySpawnedEvent) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: PtySpawnedEvent) => callback(data)
ipcRenderer.on('pty:spawned', listener)
return () => ipcRenderer.removeListener('pty:spawned', listener)
},
@@ -4,26 +4,24 @@ import { describe, expect, it } from 'vitest'
const SOURCE_PATH = resolve(__dirname, 'ResourceUsageStatusSegment.tsx')
const INVENTORY_HOOK_PATH = resolve(__dirname, 'use-resource-session-inventory.ts')
const INVENTORY_READER_PATH = resolve(__dirname, 'resource-session-inventory-reader.ts')
describe('ResourceUsageStatusSegment session inventory', () => {
it('does not poll global terminal sessions while the popover is closed', () => {
const source = readFileSync(SOURCE_PATH, 'utf8')
const inventoryHookSource = readFileSync(INVENTORY_HOOK_PATH, 'utf8')
const inventoryReaderSource = readFileSync(INVENTORY_READER_PATH, 'utf8')
expect(source).not.toContain('installWindowVisibilityInterval')
expect(source).not.toContain('SESSIONS_POLL_MS')
expect(inventoryHookSource).not.toContain('setInterval')
// Why: every seed/action/lifecycle refresh shares one guarded inventory
// read, and the closed path never installs a polling interval.
expect(inventoryHookSource.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1)
const openEffectIndex = source.indexOf('if (!open)')
const refreshIndex = source.indexOf('void refreshSessions()', openEffectIndex)
// Why: pty.listSessions() is a global daemon inventory and can pause input
// with large preserved-session sets. Keep continuous use off the closed path.
expect(openEffectIndex).toBeGreaterThanOrEqual(0)
expect(refreshIndex).toBeGreaterThan(openEffectIndex)
// The hook's 100-spawn behavioral regression proves zero closed fanout.
// This source guard keeps every full read behind the one detailed reader,
// whose legacy call is reachable only after method absence/rejection.
expect(inventoryReaderSource).toContain('listSessionInventory')
expect(inventoryReaderSource).toContain('isUnsupportedSessionInventoryMethod')
expect(inventoryReaderSource.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1)
expect(source).toContain('useResourceSessionInventory(workspaceSessionReady, open)')
})
it('seeds the closed badge from daemon inventory instead of wake-hint bound PTYs', () => {
@@ -752,7 +752,7 @@ export function ResourceUsageStatusSegment({
clearSessionsError,
removeSession,
removeSessions
} = useResourceSessionInventory(workspaceSessionReady)
} = useResourceSessionInventory(workspaceSessionReady, open)
const sessions = sessionInventory.sessions
const [killConfirm, setKillConfirm] = useState<UnifiedSessionRow | null>(null)
const [killing, setKilling] = useState(false)
@@ -855,23 +855,22 @@ export function ResourceUsageStatusSegment({
}
}, [workspaceSessionReady, fetchSnapshot])
// Poll memory only while the popover is open. Session inventory is still
// explicit-on-open/action/seed (not a closed interval) because full
// listSessions can pause input with large preserved-session sets.
// Poll memory only while the popover is open. The inventory hook performs
// one authoritative session reconciliation on open, then uses lifecycle
// events between explicit action/management refreshes.
useEffect(() => {
if (!open) {
return
}
void fetchSnapshot()
void refreshSessions()
// Why: only memory polls on an interval; session inventory is explicit on open/action since it's expensive with many terminals.
// Why: session inventory is event-driven; only memory needs an open interval.
const memTimer = window.setInterval(() => {
void fetchSnapshot()
}, POLL_MS)
return () => {
window.clearInterval(memTimer)
}
}, [open, fetchSnapshot, refreshSessions])
}, [open, fetchSnapshot])
useEffect(() => {
if (!open) {
@@ -0,0 +1,38 @@
import type { PtySessionInventorySnapshot } from '../../../../shared/pty-listed-session'
function isUnsupportedSessionInventoryMethod(error: unknown): boolean {
return (
error instanceof Error &&
error.message.includes("No handler registered for 'pty:listSessionInventory'")
)
}
async function readLegacyPartialInventory(): Promise<PtySessionInventorySnapshot> {
return {
sessions: await window.api.pty.listSessions(),
hostIdBySessionId: {},
retainedSessionIdsByHost: {},
queriedHostIds: [],
respondingHostIds: [],
unavailableHostIds: [],
// Why: the legacy array cannot prove which providers answered.
complete: false
}
}
export async function readResourceSessionInventory(): Promise<PtySessionInventorySnapshot> {
const listSessionInventory = window.api.pty.listSessionInventory
if (!listSessionInventory) {
return readLegacyPartialInventory()
}
try {
return await listSessionInventory()
} catch (error) {
if (!isUnsupportedSessionInventoryMethod(error)) {
throw error
}
// Why: during mixed-version restart a new preload can outlive an old main
// that has no handler for the additive detailed method.
return readLegacyPartialInventory()
}
}
@@ -0,0 +1,138 @@
import type { ExecutionHostId } from '../../../../shared/execution-host'
import {
PTY_SESSION_INVENTORY_RETAINED_ID_LIMIT,
type PtyListedSession,
type PtySessionInventorySnapshot
} from '../../../../shared/pty-listed-session'
type ReconciliationInput = {
snapshot: PtySessionInventorySnapshot
lifecycleRevision: number
removedAtRevision: Map<string, number>
spawnedAtRevision: Map<string, number>
currentKnownSessionIds: ReadonlySet<string>
currentHostIdBySessionId: ReadonlyMap<string, ExecutionHostId>
}
export type ResourceSessionInventoryReconciliation = {
liveSessions: PtyListedSession[]
listedSessionIds: Set<string>
knownSessionIds: Set<string>
hostIdBySessionId: Map<string, ExecutionHostId>
complete: boolean
}
export function reconcileResourceSessionInventory({
snapshot,
lifecycleRevision,
removedAtRevision,
spawnedAtRevision,
currentKnownSessionIds,
currentHostIdBySessionId
}: ReconciliationInput): ResourceSessionInventoryReconciliation {
const listedSessions = snapshot.sessions.filter(
({ id }) => (removedAtRevision.get(id) ?? 0) <= lifecycleRevision
)
const liveSessions = listedSessions.slice()
const listedSessionIds = new Set(listedSessions.map(({ id }) => id))
const knownSessionIds = new Set(listedSessionIds)
const hasHostScopeMetadata =
Array.isArray(snapshot.queriedHostIds) &&
Array.isArray(snapshot.respondingHostIds) &&
Array.isArray(snapshot.unavailableHostIds) &&
snapshot.hostIdBySessionId !== null &&
typeof snapshot.hostIdBySessionId === 'object'
const queriedHostIds = new Set(
hasHostScopeMetadata ? snapshot.queriedHostIds : ([] as ExecutionHostId[])
)
const respondingHostIds = new Set(
hasHostScopeMetadata ? snapshot.respondingHostIds : ([] as ExecutionHostId[])
)
const unavailableHostIds = hasHostScopeMetadata ? snapshot.unavailableHostIds : []
const retainedHostIdBySessionId = new Map<string, ExecutionHostId>()
let retainedSessionIdCount = 0
for (const [rawHostId, retainedSessionIds] of Object.entries(
snapshot.retainedSessionIdsByHost ?? {}
)) {
const hostId = unavailableHostIds.find((candidate) => candidate === rawHostId)
if (!hostId || !Array.isArray(retainedSessionIds)) {
continue
}
for (const sessionId of retainedSessionIds) {
if (retainedSessionIdCount >= PTY_SESSION_INVENTORY_RETAINED_ID_LIMIT) {
break
}
if (
typeof sessionId !== 'string' ||
listedSessionIds.has(sessionId) ||
(removedAtRevision.get(sessionId) ?? 0) > lifecycleRevision
) {
continue
}
retainedSessionIdCount += 1
knownSessionIds.add(sessionId)
retainedHostIdBySessionId.set(sessionId, hostId)
liveSessions.push({
id: sessionId,
cwd: '',
title: sessionId,
agentOwnership: 'unknown'
})
}
}
const complete =
hasHostScopeMetadata &&
snapshot.complete &&
unavailableHostIds.length === 0 &&
Array.from(queriedHostIds).every((hostId) => respondingHostIds.has(hostId))
const hostIdBySessionId = new Map<string, ExecutionHostId>()
for (const { id } of liveSessions) {
const listedHostId = Object.hasOwn(snapshot.hostIdBySessionId, id)
? snapshot.hostIdBySessionId[id]
: undefined
const hostId =
listedHostId ?? retainedHostIdBySessionId.get(id) ?? currentHostIdBySessionId.get(id)
if (hostId) {
hostIdBySessionId.set(id, hostId)
}
}
// Each responding host is authoritative only for its own absent IDs.
// Unknown or unavailable scopes stay known until a later scoped snapshot.
for (const id of currentKnownSessionIds) {
const hostId = currentHostIdBySessionId.get(id)
const canPrune = hostId ? respondingHostIds.has(hostId) : complete
if (!canPrune) {
knownSessionIds.add(id)
if (hostId) {
hostIdBySessionId.set(id, hostId)
}
}
}
for (const [id, spawnedRevision] of spawnedAtRevision) {
if (spawnedRevision > lifecycleRevision && (removedAtRevision.get(id) ?? 0) < spawnedRevision) {
knownSessionIds.add(id)
const hostId = currentHostIdBySessionId.get(id)
if (hostId) {
hostIdBySessionId.set(id, hostId)
}
}
}
// Markers at or before this request are now settled by its scoped authority.
for (const [id, removedRevision] of removedAtRevision) {
if (removedRevision <= lifecycleRevision) {
removedAtRevision.delete(id)
}
}
for (const [id, spawnedRevision] of spawnedAtRevision) {
if (spawnedRevision <= lifecycleRevision) {
spawnedAtRevision.delete(id)
}
}
return {
liveSessions,
listedSessionIds,
knownSessionIds,
hostIdBySessionId,
complete
}
}
@@ -1,9 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
EMPTY_DAEMON_SESSION_INVENTORY,
inventoryFromSessions,
removeSessionFromInventory,
removeSessionsFromInventory
EMPTY_DAEMON_SESSION_ROWS,
ResourceSessionInventoryRows
} from './resource-session-inventory'
import type { DaemonSession } from './resource-usage-merge-types'
@@ -11,35 +10,37 @@ function session(id: string): DaemonSession {
return { id, cwd: '/workspace', title: id, agentOwnership: 'absent' as const }
}
describe('resource session inventory', () => {
it('builds count from daemon listSessions payloads', () => {
const inventory = inventoryFromSessions([session('a'), session('b')])
expect(inventory.count).toBe(2)
expect(inventory.sessions.map((entry) => entry.id)).toEqual(['a', 'b'])
})
it('returns a detached sessions array so callers can mutate safely', () => {
const source = [session('a')]
const inventory = inventoryFromSessions(source)
describe('resource session inventory rows', () => {
it('replaces rows with a detached session-id index', () => {
const source = [session('a'), session('b')]
const rows = new ResourceSessionInventoryRows()
rows.replace(source)
source.pop()
expect(inventory.sessions).toEqual([session('a')])
expect(inventory.count).toBe(1)
expect(rows.toArray()).toEqual([session('a'), session('b')])
})
it('removes killed or exited sessions without inventing wake-hint ids', () => {
const start = inventoryFromSessions([session('live'), session('orphan'), session('other')])
const afterOne = removeSessionFromInventory(start, 'orphan')
expect(afterOne.count).toBe(2)
expect(afterOne.sessions.map((entry) => entry.id)).toEqual(['live', 'other'])
it('removes one or many rows by session id', () => {
const rows = new ResourceSessionInventoryRows()
rows.replace([session('live'), session('orphan'), session('other')])
const afterMany = removeSessionsFromInventory(start, new Set(['live', 'missing', 'other']))
expect(afterMany).toEqual(inventoryFromSessions([session('orphan')]))
expect(rows.remove('orphan')).toBe(true)
expect(rows.toArray().map(({ id }) => id)).toEqual(['live', 'other'])
expect(rows.removeMany(new Set(['live', 'missing', 'other']))).toBe(2)
expect(rows.toArray()).toEqual([])
})
it('is a no-op when removed ids are absent', () => {
const start = inventoryFromSessions([session('live')])
expect(removeSessionFromInventory(start, 'gone')).toBe(start)
expect(removeSessionsFromInventory(start, new Set())).toBe(start)
it('does not report row changes for absent ids', () => {
const rows = new ResourceSessionInventoryRows()
rows.replace([session('live')])
expect(rows.remove('gone')).toBe(false)
expect(rows.removeMany(new Set())).toBe(0)
expect(rows.toArray()).toEqual([session('live')])
})
it('shares the allocation-free closed inventory rows', () => {
expect(EMPTY_DAEMON_SESSION_INVENTORY.sessions).toBe(EMPTY_DAEMON_SESSION_ROWS)
expect(EMPTY_DAEMON_SESSION_INVENTORY.count).toBe(0)
})
})
@@ -1,43 +1,45 @@
import type { DaemonSession } from './resource-usage-merge-types'
/** Last-known daemon terminal inventory for the Resource Manager badge. */
/** Last-known daemon terminal inventory exposed to Resource Usage consumers. */
export type DaemonSessionInventory = {
sessions: DaemonSession[]
count: number
}
export const EMPTY_DAEMON_SESSION_ROWS: DaemonSession[] = []
export const EMPTY_DAEMON_SESSION_INVENTORY: DaemonSessionInventory = {
sessions: [],
sessions: EMPTY_DAEMON_SESSION_ROWS,
count: 0
}
export function inventoryFromSessions(sessions: readonly DaemonSession[]): DaemonSessionInventory {
return {
sessions: sessions.slice(),
count: sessions.length
}
}
/** Mutable row index owned by one inventory hook instance. Lifecycle removals are O(1). */
export class ResourceSessionInventoryRows {
private rowsById = new Map<string, DaemonSession>()
export function removeSessionsFromInventory(
inventory: DaemonSessionInventory,
sessionIds: ReadonlySet<string>
): DaemonSessionInventory {
if (sessionIds.size === 0 || inventory.sessions.length === 0) {
return inventory
replace(sessions: readonly DaemonSession[]): void {
this.rowsById = new Map(sessions.map((session) => [session.id, session]))
}
const sessions = inventory.sessions.filter((session) => !sessionIds.has(session.id))
if (sessions.length === inventory.sessions.length) {
return inventory
}
return {
sessions,
count: sessions.length
}
}
export function removeSessionFromInventory(
inventory: DaemonSessionInventory,
sessionId: string
): DaemonSessionInventory {
return removeSessionsFromInventory(inventory, new Set([sessionId]))
remove(sessionId: string): boolean {
return this.rowsById.delete(sessionId)
}
removeMany(sessionIds: ReadonlySet<string>): number {
let removed = 0
for (const sessionId of sessionIds) {
if (this.rowsById.delete(sessionId)) {
removed += 1
}
}
return removed
}
clear(): void {
this.rowsById.clear()
}
toArray(): DaemonSession[] {
return Array.from(this.rowsById.values())
}
}
@@ -1,6 +1,11 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import {
EMPTY_DAEMON_SESSION_ROWS,
ResourceSessionInventoryRows
} from './resource-session-inventory'
import type { DaemonSession } from './resource-usage-merge-types'
import { notifyDaemonSessionInventoryInvalidated } from './daemon-session-inventory-invalidation'
import { useResourceSessionInventory } from './use-resource-session-inventory'
@@ -21,19 +26,57 @@ describe('useResourceSessionInventory', () => {
const listSessions = vi.fn<() => Promise<DaemonSession[]>>()
const unsubscribeSpawned = vi.fn()
const unsubscribeExit = vi.fn()
let spawnedCallback: ((data: { id: string }) => void) | null = null
let spawnedCallback:
| ((data: {
id: string
hostId?: ExecutionHostId
isReattach?: boolean
exitedBeforeSpawnReply?: true
}) => void)
| null = null
let exitCallback: ((data: { id: string; code: number }) => void) | null = null
let inventoryComplete = true
let queriedHostIds: ExecutionHostId[] = ['local']
let respondingHostIds: ExecutionHostId[] = ['local']
let unavailableHostIds: ExecutionHostId[] = []
const inventoryHostIdBySessionId = new Map<string, ExecutionHostId>()
beforeEach(() => {
spawnedCallback = null
exitCallback = null
inventoryComplete = true
queriedHostIds = ['local']
respondingHostIds = ['local']
unavailableHostIds = []
inventoryHostIdBySessionId.clear()
listSessions.mockReset()
unsubscribeSpawned.mockReset()
unsubscribeExit.mockReset()
;(window as unknown as { api: unknown }).api = {
pty: {
listSessions,
onSpawned: (callback: (data: { id: string }) => void) => {
listSessionInventory: async () => {
const sessions = await listSessions()
return {
sessions,
hostIdBySessionId: Object.fromEntries(
sessions.map(({ id }) => [id, inventoryHostIdBySessionId.get(id) ?? 'local'])
),
retainedSessionIdsByHost: {},
queriedHostIds,
respondingHostIds,
unavailableHostIds,
complete: inventoryComplete
}
},
onSpawned: (
callback: (data: {
id: string
hostId?: ExecutionHostId
isReattach?: boolean
exitedBeforeSpawnReply?: true
}) => void
) => {
spawnedCallback = callback
return unsubscribeSpawned
},
@@ -53,9 +96,12 @@ describe('useResourceSessionInventory', () => {
it('seeds from the daemon inventory and resets when session restore is not ready', async () => {
listSessions.mockResolvedValue([session('one'), session('two')])
const { result, rerender } = renderHook(({ ready }) => useResourceSessionInventory(ready), {
initialProps: { ready: false }
})
const { result, rerender } = renderHook(
({ ready }) => useResourceSessionInventory(ready, false),
{
initialProps: { ready: false }
}
)
expect(result.current.sessionInventory.count).toBe(0)
expect(listSessions).not.toHaveBeenCalled()
@@ -73,7 +119,7 @@ describe('useResourceSessionInventory', () => {
listSessions
.mockRejectedValueOnce(new Error('daemon unavailable'))
.mockResolvedValueOnce([session('recovered')])
const { result } = renderHook(() => useResourceSessionInventory(true))
const { result } = renderHook(() => useResourceSessionInventory(true, true))
await waitFor(() => expect(result.current.sessionsError).toBe(true))
await act(async () => {
@@ -84,129 +130,267 @@ describe('useResourceSessionInventory', () => {
expect(result.current.sessionsError).toBe(false)
})
it('refreshes for background spawns without depending on mounted pane state', async () => {
listSessions
.mockResolvedValueOnce([session('one')])
.mockResolvedValue([session('one'), session('background')])
const { result } = renderHook(() => useResourceSessionInventory(true))
it('counts 100 authoritative novel spawns while closed without provider-wide lists', async () => {
listSessions.mockResolvedValue([session('seed')])
const { result } = renderHook(() => useResourceSessionInventory(true, false))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
await act(async () => {
spawnedCallback?.({ id: 'one' })
spawnedCallback?.({ id: 'background' })
spawnedCallback?.({ id: 'background-2' })
await new Promise((resolve) => window.setTimeout(resolve, 0))
act(() => {
for (let index = 0; index < 100; index += 1) {
spawnedCallback?.({ id: `background-${index}`, isReattach: false })
}
})
expect(result.current.sessionInventory.count).toBe(101)
expect(result.current.sessionInventory.sessions).toBe(EMPTY_DAEMON_SESSION_ROWS)
expect(listSessions).toHaveBeenCalledTimes(1)
})
it('removes 1000 closed sessions without fleet row materialization or inventory scans', async () => {
const sessions = Array.from({ length: 1000 }, (_, index) => session(`scale-${index}`))
const rowSnapshots = vi.spyOn(ResourceSessionInventoryRows.prototype, 'toArray')
listSessions.mockResolvedValue(sessions)
const { result } = renderHook(() => useResourceSessionInventory(true, false))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1000))
const snapshotsAfterSeed = rowSnapshots.mock.calls.length
const closedRows = result.current.sessionInventory.sessions
act(() => {
for (const { id } of sessions) {
exitCallback?.({ id, code: 0 })
}
})
expect(result.current.sessionInventory.count).toBe(0)
expect(result.current.sessionInventory.sessions).toBe(closedRows)
expect(result.current.sessionInventory.sessions).toBe(EMPTY_DAEMON_SESSION_ROWS)
expect(rowSnapshots).toHaveBeenCalledTimes(snapshotsAfterSeed)
expect(listSessions).toHaveBeenCalledTimes(1)
rowSnapshots.mockRestore()
})
it('ignores a same-ID reattach without changing the count or listing again', async () => {
listSessions.mockResolvedValue([session('one')])
const { result } = renderHook(() => useResourceSessionInventory(true, false))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
act(() => {
spawnedCallback?.({ id: 'one', isReattach: true })
})
expect(result.current.sessionInventory.count).toBe(1)
expect(listSessions).toHaveBeenCalledTimes(1)
})
it('decrements exits immediately and admits reuse of the same ID as a novel spawn', async () => {
listSessions.mockResolvedValue([session('reused')])
const { result } = renderHook(() => useResourceSessionInventory(true, false))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
act(() => {
exitCallback?.({ id: 'reused', code: 0 })
})
expect(result.current.sessionInventory.count).toBe(0)
act(() => {
spawnedCallback?.({ id: 'reused', isReattach: false })
})
expect(result.current.sessionInventory.count).toBe(1)
expect(result.current.sessionInventory.sessions).toEqual([])
expect(listSessions).toHaveBeenCalledTimes(1)
})
it('performs one authoritative reconciliation when the popover opens', async () => {
listSessions
.mockResolvedValueOnce([session('one')])
.mockResolvedValueOnce([session('one'), session('background')])
const { result, rerender } = renderHook(({ open }) => useResourceSessionInventory(true, open), {
initialProps: { open: false }
})
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
act(() => {
spawnedCallback?.({ id: 'background', isReattach: false })
})
expect(result.current.sessionInventory.count).toBe(2)
expect(listSessions).toHaveBeenCalledTimes(1)
rerender({ open: true })
await waitFor(() =>
expect(result.current.sessionInventory.sessions.map(({ id }) => id)).toEqual([
'one',
'background'
])
)
expect(listSessions).toHaveBeenCalledTimes(2)
})
it('reconciles an unknown reattach or mixed-version spawn instead of guessing', async () => {
listSessions
.mockResolvedValueOnce([session('one')])
.mockResolvedValueOnce([session('one'), session('legacy')])
const { result } = renderHook(() => useResourceSessionInventory(true, false))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
act(() => {
spawnedCallback?.({ id: 'legacy' })
spawnedCallback?.({
id: 'already-exited',
isReattach: false,
exitedBeforeSpawnReply: true
})
})
await waitFor(() => expect(result.current.sessionInventory.count).toBe(2))
expect(listSessions).toHaveBeenCalledTimes(2)
})
it('does not inventory again when an existing session reattaches', async () => {
listSessions.mockResolvedValue([session('one')])
const { result } = renderHook(() => useResourceSessionInventory(true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
it('prunes responding local, WSL, and SSH scopes while retaining an unavailable SSH host', async () => {
listSessions.mockResolvedValue([])
const { result, rerender } = renderHook(({ open }) => useResourceSessionInventory(true, open), {
initialProps: { open: false }
})
await waitFor(() => expect(listSessions).toHaveBeenCalledTimes(1))
act(() => {
spawnedCallback?.({ id: 'one' })
spawnedCallback?.({ id: 'local-stale', hostId: 'local', isReattach: false })
spawnedCallback?.({ id: 'wsl-stale', hostId: 'local', isReattach: false })
spawnedCallback?.({
id: 'ssh:ssh-available@@stale',
hostId: 'ssh:ssh-available',
isReattach: false
})
spawnedCallback?.({
id: 'ssh:ssh-unavailable@@retained',
hostId: 'ssh:ssh-unavailable',
isReattach: false
})
})
expect(result.current.sessionInventory.count).toBe(4)
expect(listSessions).toHaveBeenCalledTimes(1)
})
it('does not overlap provider-wide inventory reads for spawns during a slow refresh', async () => {
listSessions.mockResolvedValueOnce([session('one')])
const { result } = renderHook(() => useResourceSessionInventory(true))
inventoryComplete = false
queriedHostIds = ['local', 'ssh:ssh-available']
respondingHostIds = ['local', 'ssh:ssh-available']
unavailableHostIds = ['ssh:ssh-unavailable']
rerender({ open: true })
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
const inFlight = deferred<DaemonSession[]>()
listSessions.mockReturnValueOnce(inFlight.promise)
await act(async () => {
spawnedCallback?.({ id: 'background-one' })
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
expect(result.current.sessionInventory.sessions).toEqual([])
expect(result.current.sessionsError).toBe(true)
expect(listSessions).toHaveBeenCalledTimes(2)
inventoryComplete = true
queriedHostIds = ['local', 'ssh:ssh-available', 'ssh:ssh-unavailable']
respondingHostIds = ['local', 'ssh:ssh-available', 'ssh:ssh-unavailable']
unavailableHostIds = []
await act(async () => {
spawnedCallback?.({ id: 'background-two' })
await new Promise((resolve) => window.setTimeout(resolve, 0))
await result.current.refreshSessions()
})
expect(listSessions).toHaveBeenCalledTimes(2)
await act(async () => {
inFlight.resolve([session('one'), session('background-one'), session('background-two')])
await inFlight.promise
})
await waitFor(() => expect(result.current.sessionInventory.count).toBe(3))
expect(listSessions).toHaveBeenCalledTimes(2)
})
it('reconciles once when an in-flight inventory misses a later spawn', async () => {
listSessions.mockResolvedValueOnce([session('one')])
const { result } = renderHook(() => useResourceSessionInventory(true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
const inFlight = deferred<DaemonSession[]>()
listSessions
.mockReturnValueOnce(inFlight.promise)
.mockResolvedValueOnce([session('one'), session('background-one'), session('background-two')])
await act(async () => {
spawnedCallback?.({ id: 'background-one' })
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
act(() => {
spawnedCallback?.({ id: 'background-two' })
})
await act(async () => {
inFlight.resolve([session('one'), session('background-one')])
await inFlight.promise
})
await waitFor(() => expect(result.current.sessionInventory.count).toBe(3))
expect(result.current.sessionInventory.count).toBe(0)
expect(result.current.sessionsError).toBe(false)
expect(listSessions).toHaveBeenCalledTimes(3)
})
it('cancels a queued inventory read when the unknown session exits first', async () => {
listSessions.mockResolvedValueOnce([session('one')])
const { result } = renderHook(() => useResourceSessionInventory(true))
it('seeds a cold renderer from bounded unavailable-host ownership until reconnect', async () => {
const retainedId = 'ssh:ssh-disconnected@@retained'
const listSessionInventory = vi
.fn()
.mockResolvedValueOnce({
sessions: [],
hostIdBySessionId: {},
retainedSessionIdsByHost: { 'ssh:ssh-disconnected': [retainedId] },
queriedHostIds: ['local'],
respondingHostIds: ['local'],
unavailableHostIds: ['ssh:ssh-disconnected'],
complete: false
})
.mockResolvedValueOnce({
sessions: [],
hostIdBySessionId: {},
retainedSessionIdsByHost: {},
queriedHostIds: ['local', 'ssh:ssh-disconnected'],
respondingHostIds: ['local', 'ssh:ssh-disconnected'],
unavailableHostIds: [],
complete: true
})
window.api.pty.listSessionInventory = listSessionInventory
const { result } = renderHook(() => useResourceSessionInventory(true, true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
expect(result.current.sessionInventory.sessions).toEqual([
{ id: retainedId, cwd: '', title: retainedId, agentOwnership: 'unknown' }
])
expect(result.current.sessionsError).toBe(true)
expect(listSessions).not.toHaveBeenCalled()
act(() => {
spawnedCallback?.({ id: 'short-lived' })
exitCallback?.({ id: 'short-lived', code: 0 })
await act(async () => {
await result.current.refreshSessions()
})
await new Promise((resolve) => window.setTimeout(resolve, 0))
expect(listSessions).toHaveBeenCalledTimes(1)
expect(result.current.sessionInventory.count).toBe(1)
expect(result.current.sessionInventory.count).toBe(0)
expect(result.current.sessionInventory.sessions).toEqual([])
expect(result.current.sessionsError).toBe(false)
expect(listSessionInventory).toHaveBeenCalledTimes(2)
})
it('does not schedule follow-up inventory after unmount', async () => {
listSessions.mockResolvedValueOnce([session('one')])
const { result, unmount } = renderHook(() => useResourceSessionInventory(true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
const inFlight = deferred<DaemonSession[]>()
listSessions.mockReturnValueOnce(inFlight.promise)
await act(async () => {
spawnedCallback?.({ id: 'background-one' })
await new Promise((resolve) => window.setTimeout(resolve, 0))
it('falls back conservatively when a new preload invokes an old main', async () => {
const unsupportedInventory = vi.fn(async () => {
throw new Error(
"Error invoking remote method 'pty:listSessionInventory': Error: No handler registered for 'pty:listSessionInventory'"
)
})
window.api.pty.listSessionInventory = unsupportedInventory
const refreshedLocalSession = { ...session('local-session'), title: 'refreshed' }
listSessions
.mockResolvedValueOnce([session('local-session')])
.mockResolvedValueOnce([refreshedLocalSession])
const { result, rerender } = renderHook(({ open }) => useResourceSessionInventory(true, open), {
initialProps: { open: false }
})
await waitFor(() => expect(result.current.sessionsError).toBe(true))
expect(listSessions).toHaveBeenCalledTimes(1)
act(() => {
spawnedCallback?.({ id: 'background-two' })
spawnedCallback?.({
id: 'ssh:ssh-old@@remote-session',
hostId: 'ssh:ssh-old',
isReattach: false
})
})
unmount()
inFlight.resolve([session('one'), session('background-one')])
await inFlight.promise
await new Promise((resolve) => window.setTimeout(resolve, 0))
expect(result.current.sessionInventory.count).toBe(2)
rerender({ open: true })
await waitFor(() =>
expect(result.current.sessionInventory.sessions).toEqual([refreshedLocalSession])
)
expect(result.current.sessionInventory.count).toBe(2)
expect(result.current.sessionsError).toBe(true)
expect(listSessions).toHaveBeenCalledTimes(2)
expect(unsupportedInventory).toHaveBeenCalledTimes(2)
})
it('retains a novel spawn that races the initial authoritative seed', async () => {
const seed = deferred<DaemonSession[]>()
listSessions.mockReturnValueOnce(seed.promise)
const { result } = renderHook(() => useResourceSessionInventory(true, false))
await waitFor(() => expect(listSessions).toHaveBeenCalledTimes(1))
act(() => {
spawnedCallback?.({ id: 'raced', isReattach: false })
})
expect(result.current.sessionInventory.count).toBe(1)
await act(async () => {
seed.resolve([])
await seed.promise
})
expect(result.current.sessionInventory.count).toBe(1)
expect(listSessions).toHaveBeenCalledTimes(1)
})
it('filters an exit from an in-flight list without losing other new sessions', async () => {
listSessions.mockResolvedValueOnce([session('one'), session('exited')])
const { result } = renderHook(() => useResourceSessionInventory(true))
const { result } = renderHook(() => useResourceSessionInventory(true, true))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(2))
const stale = deferred<DaemonSession[]>()
@@ -232,7 +416,7 @@ describe('useResourceSessionInventory', () => {
it('keeps the newest result when refreshes resolve out of order', async () => {
listSessions.mockResolvedValueOnce([session('one')])
const { result } = renderHook(() => useResourceSessionInventory(true))
const { result } = renderHook(() => useResourceSessionInventory(true, false))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
const older = deferred<DaemonSession[]>()
@@ -261,7 +445,7 @@ describe('useResourceSessionInventory', () => {
listSessions
.mockResolvedValueOnce([session('one'), session('two')])
.mockResolvedValue([session('one')])
const { result } = renderHook(() => useResourceSessionInventory(true))
const { result } = renderHook(() => useResourceSessionInventory(true, false))
await waitFor(() => expect(result.current.sessionInventory.count).toBe(2))
await act(async () => {
@@ -272,9 +456,30 @@ describe('useResourceSessionInventory', () => {
expect(listSessions).toHaveBeenCalledTimes(2)
})
it('seeds exactly once after a daemon or runtime readiness restart', async () => {
listSessions
.mockResolvedValueOnce([session('before-restart')])
.mockResolvedValueOnce([session('after-restart')])
const { result, rerender } = renderHook(
({ ready }) => useResourceSessionInventory(ready, true),
{ initialProps: { ready: true } }
)
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
expect(listSessions).toHaveBeenCalledTimes(1)
rerender({ ready: false })
expect(result.current.sessionInventory.count).toBe(0)
rerender({ ready: true })
await waitFor(() =>
expect(result.current.sessionInventory.sessions).toEqual([session('after-restart')])
)
expect(listSessions).toHaveBeenCalledTimes(2)
})
it('ignores inventory invalidation before session restore is ready and after unmount', async () => {
listSessions.mockResolvedValue([session('one')])
const { unmount } = renderHook(({ ready }) => useResourceSessionInventory(ready), {
const { unmount } = renderHook(({ ready }) => useResourceSessionInventory(ready, false), {
initialProps: { ready: false }
})
@@ -292,7 +497,7 @@ describe('useResourceSessionInventory', () => {
it('unsubscribes from lifecycle events on unmount', () => {
listSessions.mockResolvedValue([])
const { unmount } = renderHook(() => useResourceSessionInventory(true))
const { unmount } = renderHook(() => useResourceSessionInventory(true, false))
unmount()
@@ -1,13 +1,14 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useMountedRef } from '@/hooks/useMountedRef'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { subscribeDaemonSessionInventoryInvalidated } from './daemon-session-inventory-invalidation'
import {
EMPTY_DAEMON_SESSION_INVENTORY,
inventoryFromSessions,
removeSessionFromInventory,
removeSessionsFromInventory,
EMPTY_DAEMON_SESSION_ROWS,
ResourceSessionInventoryRows,
type DaemonSessionInventory
} from './resource-session-inventory'
import { readResourceSessionInventory } from './resource-session-inventory-reader'
import { reconcileResourceSessionInventory } from './resource-session-inventory-reconciliation'
type ResourceSessionInventory = {
sessionInventory: DaemonSessionInventory
@@ -20,19 +21,29 @@ type ResourceSessionInventory = {
type ResourceSessionInventoryState = {
ready: boolean
sessionInventory: DaemonSessionInventory
sessionCount: number
sessionRowsRevision: number
sessionsError: boolean
}
export function useResourceSessionInventory(ready: boolean): ResourceSessionInventory {
export function useResourceSessionInventory(
ready: boolean,
authoritativeInventoryRequested: boolean
): ResourceSessionInventory {
const mountedRef = useMountedRef()
const refreshGenerationRef = useRef(0)
const lifecycleRevisionRef = useRef(0)
const removedAtRevisionRef = useRef(new Map<string, number>())
const spawnedAtRevisionRef = useRef(new Map<string, number>())
const knownSessionIdsRef = useRef(new Set<string>())
const knownHostIdBySessionIdRef = useRef(new Map<string, ExecutionHostId>())
const listedSessionIdsRef = useRef(new Set<string>())
const sessionRowsRef = useRef(new ResourceSessionInventoryRows())
const previousAuthoritativeInventoryRequestedRef = useRef(authoritativeInventoryRequested)
const [storedState, setStoredState] = useState<ResourceSessionInventoryState>(() => ({
ready,
sessionInventory: EMPTY_DAEMON_SESSION_INVENTORY,
sessionCount: 0,
sessionRowsRevision: 0,
sessionsError: false
}))
const state =
@@ -40,7 +51,8 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve
? storedState
: {
ready,
sessionInventory: EMPTY_DAEMON_SESSION_INVENTORY,
sessionCount: 0,
sessionRowsRevision: storedState.sessionRowsRevision + 1,
sessionsError: false
}
if (state !== storedState) {
@@ -56,29 +68,38 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve
const generation = ++refreshGenerationRef.current
const lifecycleRevision = lifecycleRevisionRef.current
try {
const sessions = await window.api.pty.listSessions()
// Why: an exit or newer refresh can land while the global provider list
// is in flight; stale results must not resurrect dead sessions.
const inventory = await readResourceSessionInventory()
// Why: lifecycle events can land while the global provider list is in flight.
// Exits must not resurrect dead sessions, while authoritative novel spawns
// must remain counted even if this particular list started too early.
if (!mountedRef.current || generation !== refreshGenerationRef.current) {
return
}
const currentRemovedAtRevision = removedAtRevisionRef.current
const liveSessions = sessions.filter(
({ id }) => (currentRemovedAtRevision.get(id) ?? 0) <= lifecycleRevision
)
// Tombstones at or before this request cannot suppress later ID reuse;
// only exits that raced this request must survive to the next refresh.
for (const [id, removedAtRevision] of currentRemovedAtRevision) {
if (removedAtRevision <= lifecycleRevision) {
currentRemovedAtRevision.delete(id)
}
}
knownSessionIdsRef.current = new Set(liveSessions.map(({ id }) => id))
setStoredState({
ready: true,
sessionInventory: inventoryFromSessions(liveSessions),
sessionsError: false
const reconciliation = reconcileResourceSessionInventory({
snapshot: inventory,
lifecycleRevision,
removedAtRevision: removedAtRevisionRef.current,
spawnedAtRevision: spawnedAtRevisionRef.current,
currentKnownSessionIds: knownSessionIdsRef.current,
currentHostIdBySessionId: knownHostIdBySessionIdRef.current
})
const { liveSessions, listedSessionIds, knownSessionIds, hostIdBySessionId, complete } =
reconciliation
knownSessionIdsRef.current = knownSessionIds
knownHostIdBySessionIdRef.current = hostIdBySessionId
listedSessionIdsRef.current = listedSessionIds
const retainedSessions = sessionRowsRef.current
.toArray()
.filter(({ id }) => knownSessionIds.has(id) && !listedSessionIds.has(id))
const sessions =
retainedSessions.length === 0 ? liveSessions : [...liveSessions, ...retainedSessions]
sessionRowsRef.current.replace(sessions)
setStoredState((current) => ({
ready: true,
sessionCount: knownSessionIds.size,
sessionRowsRevision: current.sessionRowsRevision + 1,
sessionsError: !complete
}))
} catch {
if (mountedRef.current && generation === refreshGenerationRef.current) {
setStoredState((current) => ({ ...current, sessionsError: true }))
@@ -95,10 +116,15 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve
// only that id preserves unrelated sessions discovered by the same list.
const lifecycleRevision = ++lifecycleRevisionRef.current
removedAtRevisionRef.current.set(sessionId, lifecycleRevision)
spawnedAtRevisionRef.current.delete(sessionId)
knownSessionIdsRef.current.delete(sessionId)
knownHostIdBySessionIdRef.current.delete(sessionId)
listedSessionIdsRef.current.delete(sessionId)
const removedRow = sessionRowsRef.current.remove(sessionId)
setStoredState((current) => ({
...current,
sessionInventory: removeSessionFromInventory(current.sessionInventory, sessionId)
sessionCount: knownSessionIdsRef.current.size,
sessionRowsRevision: current.sessionRowsRevision + (removedRow ? 1 : 0)
}))
}, [])
@@ -106,11 +132,16 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve
const lifecycleRevision = ++lifecycleRevisionRef.current
for (const sessionId of sessionIds) {
removedAtRevisionRef.current.set(sessionId, lifecycleRevision)
spawnedAtRevisionRef.current.delete(sessionId)
knownSessionIdsRef.current.delete(sessionId)
knownHostIdBySessionIdRef.current.delete(sessionId)
listedSessionIdsRef.current.delete(sessionId)
}
const removedRows = sessionRowsRef.current.removeMany(sessionIds)
setStoredState((current) => ({
...current,
sessionInventory: removeSessionsFromInventory(current.sessionInventory, sessionIds)
sessionCount: knownSessionIdsRef.current.size,
sessionRowsRevision: current.sessionRowsRevision + removedRows
}))
}, [])
@@ -118,12 +149,24 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve
refreshGenerationRef.current += 1
if (!ready) {
removedAtRevisionRef.current.clear()
spawnedAtRevisionRef.current.clear()
knownSessionIdsRef.current.clear()
knownHostIdBySessionIdRef.current.clear()
listedSessionIdsRef.current.clear()
sessionRowsRef.current.clear()
return
}
void refreshSessions()
}, [ready, refreshSessions])
useEffect(() => {
const wasRequested = previousAuthoritativeInventoryRequestedRef.current
previousAuthoritativeInventoryRequestedRef.current = authoritativeInventoryRequested
if (ready && authoritativeInventoryRequested && !wasRequested) {
void refreshSessions()
}
}, [authoritativeInventoryRequested, ready, refreshSessions])
useEffect(() => {
if (!ready) {
return
@@ -161,7 +204,7 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve
}
lifecycleRefresh = null
for (const id of pendingSpawnIds) {
if (knownSessionIdsRef.current.has(id)) {
if (listedSessionIdsRef.current.has(id)) {
pendingSpawnIds.delete(id)
}
}
@@ -169,15 +212,44 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve
})
}, 0)
}
const unsubscribeSpawned = window.api.pty.onSpawned(({ id }) => {
// Why: reattach emits the same lifecycle signal; known IDs must not turn remounts into global inventory scans.
if (knownSessionIdsRef.current.has(id)) {
return
const unsubscribeSpawned = window.api.pty.onSpawned(
({ id, hostId, isReattach, exitedBeforeSpawnReply }) => {
// Why: a spawn already dead before its reply is ambiguous even when
// its reusable ID is still known; only a full inventory can settle it.
if (exitedBeforeSpawnReply) {
pendingSpawnIds.add(id)
scheduleLifecycleRefresh()
return
}
if (hostId) {
knownHostIdBySessionIdRef.current.set(id, hostId)
}
// Reattach emits the same lifecycle signal. A known ID is already
// counted; unknown or mixed-version classification must reconcile.
if (knownSessionIdsRef.current.has(id)) {
return
}
if (isReattach !== false) {
pendingSpawnIds.add(id)
scheduleLifecycleRefresh()
return
}
const lifecycleRevision = ++lifecycleRevisionRef.current
spawnedAtRevisionRef.current.set(id, lifecycleRevision)
knownSessionIdsRef.current.add(id)
setStoredState((current) => ({
...current,
sessionCount: knownSessionIdsRef.current.size
}))
if (authoritativeInventoryRequested) {
// The open popover needs full rows, not only the authoritative badge count.
pendingSpawnIds.add(id)
scheduleLifecycleRefresh()
}
}
pendingSpawnIds.add(id)
// Why: serialize slow provider-wide lists; retry once only when a result missed a later spawn.
scheduleLifecycleRefresh()
})
)
const unsubscribeExit = window.api.pty.onExit(({ id }) => {
pendingSpawnIds.delete(id)
if (pendingSpawnIds.size === 0 && refreshTimer !== null) {
@@ -195,10 +267,21 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve
unsubscribeSpawned()
unsubscribeExit()
}
}, [ready, refreshSessions, removeSession])
}, [authoritativeInventoryRequested, ready, refreshSessions, removeSession])
const sessions = useMemo(
() =>
ready && authoritativeInventoryRequested
? sessionRowsRef.current.toArray()
: EMPTY_DAEMON_SESSION_ROWS,
[authoritativeInventoryRequested, ready, state.sessionRowsRevision]
)
const sessionInventory = useMemo(
() => ({ sessions, count: state.sessionCount }),
[sessions, state.sessionCount]
)
return {
sessionInventory: state.sessionInventory,
sessionInventory,
sessionsError: state.sessionsError,
refreshSessions,
clearSessionsError,
+19
View File
@@ -1,3 +1,6 @@
import type { ExecutionHostId } from './execution-host'
export const PTY_SESSION_INVENTORY_RETAINED_ID_LIMIT = 4096
/**
* Whether an agent holds a listed session.
*
@@ -24,6 +27,22 @@ export type PtyListedSession = {
agentOwnership: AgentOwnershipEvidence
}
/**
* Additive detailed inventory contract. Only `respondingHostIds` are
* authoritative for pruning. `unavailableHostIds` includes retained ownership
* scopes whose provider is currently unregistered after connection loss.
*/
export type PtySessionInventorySnapshot = {
sessions: PtyListedSession[]
hostIdBySessionId: Record<string, ExecutionHostId>
queriedHostIds: ExecutionHostId[]
/** Bounded last-known IDs for unavailable hosts, used by a cold renderer. */
retainedSessionIdsByHost: Partial<Record<ExecutionHostId, string[]>>
respondingHostIds: ExecutionHostId[]
unavailableHostIds: ExecutionHostId[]
complete: boolean
}
/** Only proven absence authorizes destroying a session without asking. */
export function mayDestroyWithoutOwnerEvidence(session: {
agentOwnership: AgentOwnershipEvidence