Files
orca/src/relay/pty-handler-spawn-admission.test.ts
Neil 39330c5aca fix(relay): retire PTYs the host proves are gone, and stop two per-poll scan storms (#17832)
* fix(relay): stop three CPU growth terms in a long-running remote session

pty.resize gated only on `managed.disposed`, which is bookkeeping rather than
liveness. A shell that exits without node-pty's `onExit` leaves an undisposed
entry holding a closed master fd, and UnixTerminal.resize has no fd guard, so
the ioctl threw `ioctl(2) failed, EBADF` into the dispatcher's generic
parse-error catch. Nothing retired the entry, so it stayed advertised and kept
activePtyCount above zero -- which is what stops a relay with an unlimited
grace from reaching its idle-no-ptys exit (#12423). Probe liveness with the
same helper attach/listProcesses use, retire a provably dead pid, and contain
an ioctl failure over a live-or-unverifiable process.

processHasChildren forked `pgrep -P` per pane per inspection poll, uncached.
procps-ng opens six procfs files per process to resolve one ppid, so each call
cost O(host process count). Answer from the TTL-cached `ps` table the same RPC
already captured for the foreground lookup (#13537).

The remote AI Vault scanner had no parse cache at all, so every forced rescan
re-read and re-parsed the whole transcript corpus, including files untouched
for a month. Give it the mtime+size keyed memo the local scanner has (#13753).

* fix(pty): invalidate the descriptor when node-pty gives up the handle (#17930)

Carried forward from PR #17930, which merged into this branch. Rebased onto
current main; main's newer node-pty-fd-leak test is kept as-is.

* fix(ai-vault): refresh codex titles on the remote parse-cache reuse path

The remote cache keys on the transcript's (mtime, size, host), but codex
titles live in $CODEX_HOME/session_index.jsonl and are written after the
rollout — so a cache hit froze the fallback title forever. Mirrors the
local scanner's existing reuse-path refresh via a shared core.

* fix(relay): publish the exit a reap performs, and rescan for close decisions

Two review findings on the CPU work.

reapExitedPty told only the relay-internal exit listener, so a retirement left
the client's pane mounted against a session the relay had already forgotten --
the next attach answered `PTY "<id>" not found` with nothing before it to
explain why. Pre-existing on three probe paths; resize made it user-triggered.
Publish the same pending-exit the natural onExit path publishes, carrying -1
("gone, status unrecoverable"), and skip it when onExit already reported the
real code.

processHasChildren now answers from a 500ms TTL-cached table. That is right for
pty.inspectProcess, which every tracked pane polls, but pty.hasChildProcesses
gates the window-close confirmation and workspace cleanup's idle evidence --
one destructive decision per answer, where a child started inside the window
would be killed unasked. Give that RPC a fresh scan; pgrep used to.

* fix(relay): publish a reap's exit only on proven-exited evidence

The publication is a verdict the client acts on by retiring the pane, so it
must not be reachable from the disposed-record sweep, which retires off our own
bookkeeping rather than the host's process table. Only ESRCH earns it.

* fix(i18n): restore the activity-options key the rebase dropped

* fix(i18n): union en.json with main so the rebase cannot drop keys
2026-09-02 15:14:44 -07:00

627 lines
23 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import * as ptyShellUtils from './pty-shell-utils'
const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
mockCreateShellPromptReadinessProbe: vi.fn(),
mockPtyInstance: {
// Why: attach now proves the backing pid is alive before replaying, so the
// default managed PTY must report a live pid. Reuse the test runner's own
// pid — always alive — so unrelated attach tests are not seen as dead.
pid: process.pid,
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
clear: vi.fn(),
pause: vi.fn(),
resume: vi.fn()
}
}))
vi.mock('node-pty', () => ({
spawn: mockPtySpawn
}))
vi.mock('../main/pty/posix-pty-process-groups', () => ({
forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback())
}))
vi.mock('../main/shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe
}))
import { MAX_RELAY_PTY_SESSIONS, PtyHandler } from './pty-handler'
import type { RelayDispatcher } from './dispatcher'
import {
beginPtyHandlerTest,
createMockDispatcher,
createPtyRequestHelpers,
createTestPtyHandler,
testPtyId,
endPtyHandlerTest
} from './pty-handler-test-harness'
import type { MockDispatcher } from './pty-handler-test-harness'
describe('PtyHandler', () => {
let dispatcher: MockDispatcher
let handler: PtyHandler
let originalPlatform: PropertyDescriptor | undefined
const { spawnPty, attachPty } = createPtyRequestHelpers(() => dispatcher)
beforeEach(() => {
;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({
mockPtySpawn,
mockPtyInstance,
mockCreateShellPromptReadinessProbe
}))
})
afterEach(async () => {
await endPtyHandlerTest(handler, originalPlatform)
})
it('registers all expected handlers', () => {
const methods = Array.from(dispatcher._requestHandlers.keys())
expect(methods).toContain('pty.spawn')
expect(methods).toContain('pty.attach')
expect(methods).toContain('pty.shutdown')
expect(methods).toContain('pty.sendSignal')
expect(methods).toContain('pty.getCwd')
expect(methods).toContain('pty.getInitialCwd')
expect(methods).toContain('pty.clearBuffer')
expect(methods).toContain('pty.hasChildProcesses')
expect(methods).toContain('pty.getForegroundProcess')
expect(methods).toContain('pty.inspectProcess')
expect(methods).toContain('pty.listProcesses')
expect(methods).toContain('pty.getDefaultShell')
const notifMethods = Array.from(dispatcher._notificationHandlers.keys())
expect(notifMethods).toContain('pty.data')
expect(notifMethods).toContain('pty.resize')
// Why not `toContain('pty.ackData')`: PtyHandler must NOT own that method. It used to
// register a no-op here, which survived only because the consumer session adapter was
// constructed later and overwrote it (STA-4571).
expect(notifMethods).not.toContain('pty.ackData')
})
it('rescans the process table for a close decision but not for a poll', async () => {
const hasChildren = vi.mocked(ptyShellUtils.processHasChildren)
const { id } = (await spawnPty({ cols: 80, rows: 24 })) as { id: string }
hasChildren.mockClear()
await dispatcher.callRequest('pty.inspectProcess', { id })
// The poll shares the TTL-cached table the foreground lookup already took.
expect(hasChildren).toHaveBeenLastCalledWith(mockPtyInstance.pid)
await dispatcher.callRequest('pty.hasChildProcesses', { id })
// This RPC only ever gates a destructive decision (window close, workspace
// cleanup), so it has to see a child started inside the 500ms window.
expect(hasChildren).toHaveBeenLastCalledWith(mockPtyInstance.pid, { fresh: true })
})
it('rejects strict process inspection for a missing relay PTY', async () => {
await expect(dispatcher.callRequest('pty.inspectProcess', { id: 'missing' })).rejects.toThrow(
'terminal_gone'
)
})
it('spawns a PTY and returns an id', async () => {
const result = await spawnPty({ cols: 80, rows: 24 })
expect(result).toEqual({ id: testPtyId(1), incarnationId: expect.any(String) })
expect(mockPtySpawn).toHaveBeenCalled()
expect(handler.activePtyCount).toBe(1)
})
it('replays an operation-owned spawn after its first response becomes stale', async () => {
const operationId = 'a'.repeat(43)
await dispatcher.callRequest(
'pty.spawn',
{ cols: 80, rows: 24, agentSessionCreateOperationId: operationId },
{ isStale: () => mockPtySpawn.mock.calls.length > 0 }
)
const replayed = await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
agentSessionCreateOperationId: operationId
})
expect(replayed).toEqual({ id: testPtyId(1), incarnationId: expect.any(String) })
expect(mockPtySpawn).toHaveBeenCalledOnce()
expect(mockPtyInstance.kill).not.toHaveBeenCalled()
expect(handler.activePtyCount).toBe(1)
})
it('retains an operation fence when publication fails after native spawn', async () => {
const operationId = 'f'.repeat(43)
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(() => {
throw new Error('listener publication failed')
})
})
const request = {
cols: 80,
rows: 24,
agentSessionCreateOperationId: operationId
}
await expect(dispatcher.callRequest('pty.spawn', request)).rejects.toThrow(
'listener publication failed'
)
await expect(dispatcher.callRequest('pty.spawn', request)).rejects.toThrow(
'listener publication failed'
)
expect(mockPtySpawn).toHaveBeenCalledOnce()
expect(handler.activePtyCount).toBe(1)
})
it('releases a canceled operation before native spawn after module preflight', async () => {
let finishModuleLoad!: (value: { spawn: typeof mockPtySpawn }) => void
const moduleLoad = new Promise<{ spawn: typeof mockPtySpawn }>((resolve) => {
finishModuleLoad = resolve
})
const internals = handler as unknown as {
loadPty(): Promise<{ spawn: typeof mockPtySpawn } | null>
}
const loadPty = vi.spyOn(internals, 'loadPty').mockReturnValueOnce(moduleLoad)
const abort = new AbortController()
const operationId = 'c'.repeat(43)
const request = { cols: 80, rows: 24, agentSessionCreateOperationId: operationId }
const spawning = dispatcher.callRequest('pty.spawn', request, {
isStale: () => abort.signal.aborted,
signal: abort.signal
})
abort.abort()
finishModuleLoad({ spawn: mockPtySpawn })
await expect(spawning).rejects.toThrow('client_disconnected')
expect(mockPtySpawn).not.toHaveBeenCalled()
loadPty.mockResolvedValue({ spawn: mockPtySpawn })
await expect(dispatcher.callRequest('pty.spawn', request)).resolves.toMatchObject({
id: testPtyId(2)
})
expect(mockPtySpawn).toHaveBeenCalledOnce()
})
it('rejects malformed create operation ids before spawning', async () => {
await expect(
dispatcher.callRequest('pty.spawn', { agentSessionCreateOperationId: 'not-valid' })
).rejects.toThrow('agent_session_operation_invalid')
expect(mockPtySpawn).not.toHaveBeenCalled()
})
it('adopts only the exact claimed owner generation on relay retry', async () => {
const agentSessionEnsure = {
claim: {
digestVersion: 1,
keyId: 'claim-key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex'
},
surface: {
worktreeId: 'repo::/tmp/worktree',
tabId: '11111111-1111-4111-8111-111111111111',
leafId: '22222222-2222-4222-8222-222222222222',
terminalHandle: 'term_claimed'
}
}
const first = (await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
agentSessionEnsure
})) as Record<string, unknown>
const second = (await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
agentSessionEnsure
})) as Record<string, unknown>
expect(first).toMatchObject({
id: testPtyId(1),
agentSessionEnsure: { disposition: 'created' }
})
expect(second).toMatchObject({
id: testPtyId(1),
agentSessionEnsure: { disposition: 'adopted' }
})
expect(second.agentSessionEnsure).toMatchObject({
owner: (first.agentSessionEnsure as { owner: unknown }).owner
})
expect(mockPtySpawn).toHaveBeenCalledOnce()
})
it('normalizes a missing native binding as degraded node-pty availability', async () => {
mockPtySpawn.mockImplementationOnce(() => {
throw new Error(
'Failed to load native module: conpty.node, checked: build/Release, prebuilds/win32-x64'
)
})
await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow(
'Remote terminals are unavailable'
)
expect(handler.activePtyCount).toBe(0)
})
it('keeps the load error it was handed instead of replacing it with guesses', async () => {
// #17830: the user got three remedies for four possible faults and could verify none.
// The relay must carry what it was actually told, and must not prescribe a toolchain
// install it never probed for.
const thrown =
'Failed to load native module: conpty.node, checked: build/Release, prebuilds/win32-x64'
mockPtySpawn.mockImplementationOnce(() => {
throw new Error(thrown)
})
const message = await dispatcher.callRequest('pty.spawn', {}).then(
() => '',
(error: Error) => error.message
)
expect(message).toContain(thrown)
expect(message).not.toContain('install make, a C++ compiler, and python3')
// Nothing here established a cause — the relay's node-pty directory is not on disk in
// this harness — so per docs/reference/ssh-execution-boundary.md it must say so rather
// than pick a diagnosis. Every message still names the host, for the bug report.
expect(message).toContain('could not establish why')
expect(message).toMatch(/Host: linux\/\w+, .*Node v[\d.]+ \(ABI \d+\)/)
})
it('preserves unrelated node-pty spawn failures', async () => {
mockPtySpawn.mockImplementationOnce(() => {
throw new Error('File not found: missing-shell.exe')
})
await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow(
'File not found: missing-shell.exe'
)
})
it('atomically caps concurrent PTY spawn admission', async () => {
const results = await Promise.allSettled(
Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, () =>
dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
)
)
expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(
MAX_RELAY_PTY_SESSIONS
)
expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1)
expect(results.find(({ status }) => status === 'rejected')).toMatchObject({
reason: expect.objectContaining({ message: 'Maximum number of PTY sessions reached (50)' })
})
expect(mockPtySpawn).toHaveBeenCalledTimes(MAX_RELAY_PTY_SESSIONS)
expect(handler.activePtyCount).toBe(MAX_RELAY_PTY_SESSIONS)
})
it('spawns a PTY without post-Node-18 array copy methods', async () => {
const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'toReversed')
Reflect.deleteProperty(Array.prototype, 'toReversed')
try {
await expect(dispatcher.callRequest('pty.spawn', {})).resolves.toMatchObject({
id: testPtyId(1)
})
expect(handler.activePtyCount).toBe(1)
} finally {
if (descriptor) {
Object.defineProperty(Array.prototype, 'toReversed', descriptor)
}
}
})
it('increments PTY ids on each spawn', async () => {
const r1 = await dispatcher.callRequest('pty.spawn', {})
const r2 = await dispatcher.callRequest('pty.spawn', {})
expect((r1 as { id: string }).id).toBe(testPtyId(1))
expect((r2 as { id: string }).id).toBe(testPtyId(2))
})
it('does not remint a PTY id across handler lifetimes', async () => {
const first = (await dispatcher.callRequest('pty.spawn', {})) as { id: string }
await handler.dispose({ waitForPhysicalExit: false })
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
const second = (await dispatcher.callRequest('pty.spawn', {})) as { id: string }
expect(first.id).toMatch(/^pty2:[^:]+:1$/)
expect(second.id).toMatch(/^pty2:[^:]+:1$/)
expect(second.id).not.toBe(first.id)
})
it('escapes the mint epoch so it cannot forge the id separators', async () => {
await handler.dispose({ waitForPhysicalExit: false })
dispatcher = createMockDispatcher()
// Why: the epoch is constructor-supplied. Encoding is what keeps a minted id
// exactly three fields, so it can never be read as another epoch/sequence and
// can never smuggle the `@@` that app-side SSH id routing splits on.
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, 'a:b@@c:9')
const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string }
const [prefix, epoch, sequence, ...extra] = spawned.id.split(':')
expect(extra).toEqual([])
expect(prefix).toBe('pty2')
expect(decodeURIComponent(epoch)).toBe('a:b@@c:9')
expect(sequence).toBe('1')
expect(spawned.id).not.toContain('@@')
})
it('revives a legacy id and advances the legacy sequence', async () => {
const state = JSON.stringify([
{ id: 'pty-7', pid: process.pid, cols: 80, rows: 24, cwd: process.cwd() }
])
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
const serialized = (await dispatcher.callRequest('pty.serialize', {
ids: ['pty-7']
})) as string
const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string }
expect(JSON.parse(serialized)).toMatchObject([{ id: 'pty-7' }])
expect(spawned.id).toBe(testPtyId(8))
})
it('does not advance its sequence from a revived foreign mint epoch', async () => {
const foreignId = 'pty2:previous-mint-epoch:40'
const state = JSON.stringify([
{ id: foreignId, pid: process.pid, cols: 80, rows: 24, cwd: process.cwd() }
])
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
const serialized = (await dispatcher.callRequest('pty.serialize', {
ids: [foreignId]
})) as string
const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string }
expect(JSON.parse(serialized)).toMatchObject([{ id: foreignId }])
expect(spawned.id).toBe(testPtyId(1))
})
it('admits default-cwd spawns through the worktree removal coordinator', async () => {
const finishCreation = vi.fn()
const beginWorktreePtySpawn = vi.fn((_operationPath: string) => finishCreation)
handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn })
await dispatcher.callRequest('pty.spawn', {})
expect(beginWorktreePtySpawn).toHaveBeenCalledWith(expect.any(String))
expect(beginWorktreePtySpawn.mock.calls[0][0]).not.toBe('')
expect(finishCreation).toHaveBeenCalledTimes(1)
})
it('fences both sibling worktree identity and removing cwd with rollback', async () => {
const finishSiblingAdmission = vi.fn()
const beginWorktreePtySpawn = vi.fn((operationPath: string) => {
if (operationPath === '/repo/removing/nested') {
throw new Error('Remote worktree deletion already in progress')
}
return finishSiblingAdmission
})
handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn })
await expect(
dispatcher.callRequest('pty.spawn', {
cwd: '/repo/removing/nested',
env: { ORCA_WORKTREE_ID: 'repo-id::/repo/sibling' }
})
).rejects.toThrow('Remote worktree deletion already in progress')
expect(beginWorktreePtySpawn.mock.calls.map(([operationPath]) => operationPath)).toEqual([
'/repo/sibling',
'/repo/removing/nested'
])
expect(finishSiblingAdmission).toHaveBeenCalledOnce()
expect(mockPtySpawn).not.toHaveBeenCalled()
})
describe('onPtyPoolEmpty', () => {
it('fires once when natural exit drains the last PTY, never while one remains', async () => {
const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = []
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCallbacks.push(cb)
})
})
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
await spawnPty()
await spawnPty()
expect(onExitCallbacks).toHaveLength(2)
onExitCallbacks[0]({ exitCode: 0 })
expect(handler.activePtyCount).toBe(1)
expect(poolEmpty).not.toHaveBeenCalled()
onExitCallbacks[1]({ exitCode: 0 })
expect(handler.activePtyCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
it('fires when the dead-shell reap inside attach removes the last PTY', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
await spawnPty()
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
try {
await expect(
attachPty({ id: testPtyId(1), suppressReplayNotification: true })
).rejects.toThrow(`PTY "${testPtyId(1)}" not found`)
} finally {
aliveSpy.mockRestore()
}
expect(handler.activePtyCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
it('fires when dispose-for-shutdown removes the last PTY', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
await spawnPty()
await spawnPty()
await handler.dispose({ waitForPhysicalExit: false })
expect(handler.activePtyCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
it('fires when a spawn fails before the PTY ever reaches the pool', async () => {
// Why: removePty can only announce a PTY it stored, so a creation that dies mid-flight
// would otherwise leave the relay believing it is still non-idle forever.
mockPtySpawn.mockImplementation(() => {
throw new Error('posix_spawnp failed')
})
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
await expect(spawnPty()).rejects.toThrow()
expect(handler.activePtyCount).toBe(0)
expect(handler.pendingPtyCreationCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
it('stays silent when a failing creation settles while another is still admitted', async () => {
let spawnCall = 0
mockPtySpawn.mockImplementation(() => {
spawnCall += 1
if (spawnCall === 1) {
throw new Error('posix_spawnp failed')
}
return { ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }
})
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
// Both admissions land before either creation resolves, so the failing one must not
// announce an empty pool while the surviving one still owns a shell.
const failing = spawnPty()
const succeeding = spawnPty()
expect(handler.pendingPtyCreationCount).toBe(2)
await expect(failing).rejects.toThrow()
await succeeding
expect(handler.activePtyCount).toBe(1)
expect(poolEmpty).not.toHaveBeenCalled()
})
it('stops notifying after the returned unsubscribe runs', async () => {
const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = []
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCallbacks.push(cb)
})
})
const poolEmpty = vi.fn()
const unsubscribe = handler.onPtyPoolEmpty(poolEmpty)
await spawnPty()
onExitCallbacks[0]({ exitCode: 0 })
expect(poolEmpty).toHaveBeenCalledTimes(1)
unsubscribe()
await spawnPty()
onExitCallbacks[1]({ exitCode: 0 })
expect(handler.activePtyCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
})
describe('onPtyPoolActive', () => {
it('fires at admission, while the pool is still empty', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolCounts: number[] = []
handler.onPtyPoolActive(() => {
poolCounts.push(handler.activePtyCount)
})
const pending = spawnPty()
// The relay must learn it is non-idle here, not after the creation resolves.
expect(poolCounts).toEqual([0])
await pending
expect(poolCounts).toEqual([0, 1])
})
it('fires for a revived PTY whose creation was admitted before the pool was empty', async () => {
await spawnPty({ cols: 80, rows: 24, cwd: '/tmp' })
const state = (await dispatcher.callRequest('pty.serialize', {
ids: [testPtyId(1)]
})) as string
await handler.dispose({ waitForPhysicalExit: false })
dispatcher = createMockDispatcher()
handler = createTestPtyHandler(dispatcher)
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolActive = vi.fn()
handler.onPtyPoolActive(poolActive)
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
expect(handler.activePtyCount).toBe(1)
expect(poolActive).toHaveBeenCalled()
})
it('stops notifying after the returned unsubscribe runs', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolActive = vi.fn()
const unsubscribe = handler.onPtyPoolActive(poolActive)
await spawnPty()
const callsWhileSubscribed = poolActive.mock.calls.length
expect(callsWhileSubscribed).toBeGreaterThan(0)
unsubscribe()
await spawnPty()
expect(poolActive).toHaveBeenCalledTimes(callsWhileSubscribed)
})
})
it('counts a spawn admitted but not yet pooled as a pending creation', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
expect(handler.pendingPtyCreationCount).toBe(0)
const pending = spawnPty()
// The shell is already owned even though activePtyCount still reads zero.
expect(handler.activePtyCount).toBe(0)
expect(handler.pendingPtyCreationCount).toBe(1)
await pending
expect(handler.activePtyCount).toBe(1)
expect(handler.pendingPtyCreationCount).toBe(0)
})
})