refactor(runtime): collapse speculative portability layers
Remove the one-consumer bounded-read primitive and shared terminal lifecycle controller, make terminal cleanup one awaited provider operation, and reuse one Code Runtime contract suite. Keep only reproduced cancellation and policy fixes; defer unproven replacement, prompt-attribution, and streaming-frame concerns to scoped markers.
This commit is contained in:
@@ -81,7 +81,7 @@ describe('LocalSubprocessService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('validates terminal spawn specs before allocating a PTY', async () => {
|
||||
it('validates terminal allocation inputs before allocating a PTY', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalSubprocessService)
|
||||
const base: SubprocessTerminalSpawnSpec = {
|
||||
@@ -89,9 +89,6 @@ describe('LocalSubprocessService', () => {
|
||||
}
|
||||
await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [] })).rejects.toThrow('must contain a program')
|
||||
await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [''] })).rejects.toThrow('must contain a program')
|
||||
await expect(ctx.subprocess.spawnTerminal({ ...base, rows: 1.5 })).rejects.toThrow('rows')
|
||||
await expect(ctx.subprocess.spawnTerminal({ ...base, cols: 0 })).rejects.toThrow('cols')
|
||||
await expect(ctx.subprocess.spawnTerminal({ ...base, graceMs: 0 })).rejects.toThrow('graceMs')
|
||||
await expect(ctx.subprocess.spawnTerminal({ ...base, signal: AbortSignal.abort('stop') })).rejects.toBe('stop')
|
||||
await fiber.dispose()
|
||||
})
|
||||
@@ -99,8 +96,7 @@ describe('LocalSubprocessService', () => {
|
||||
it('terminates and joins an owned terminal during disposal', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalSubprocessService)
|
||||
const terminate = vi.fn()
|
||||
const waitForExit = vi.fn(async () => true)
|
||||
const terminate = vi.fn(async () => {})
|
||||
const terminal: SubprocessTerminalHandle = {
|
||||
pid: 1,
|
||||
output: new PassThrough(),
|
||||
@@ -109,13 +105,11 @@ describe('LocalSubprocessService', () => {
|
||||
inspectForeground: async () => undefined,
|
||||
signalForeground: async () => 1,
|
||||
terminate,
|
||||
waitForExit,
|
||||
}
|
||||
const terminals = (ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
|
||||
terminals.add(terminal)
|
||||
await fiber.dispose()
|
||||
expect(terminate).toHaveBeenCalledOnce()
|
||||
expect(waitForExit).toHaveBeenCalledOnce()
|
||||
expect(terminals.size).toBe(0)
|
||||
})
|
||||
|
||||
@@ -124,8 +118,8 @@ describe('LocalSubprocessService', () => {
|
||||
const fiber = await ctx.plugin(LocalSubprocessService)
|
||||
const service = ctx.subprocess
|
||||
const runtimeRoot = service.runtimeRoot
|
||||
const firstFailure = new Error('first retryable cleanup failure')
|
||||
const secondFailure = new Error('second retryable cleanup failure')
|
||||
const firstFailure = new Error('first cleanup failure')
|
||||
const secondFailure = new Error('second cleanup failure')
|
||||
const disposalErrors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
|
||||
const failedTerminal: SubprocessTerminalHandle = {
|
||||
@@ -135,22 +129,19 @@ describe('LocalSubprocessService', () => {
|
||||
write: async () => {},
|
||||
inspectForeground: async () => undefined,
|
||||
signalForeground: async () => 1,
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(async () => { throw firstFailure }),
|
||||
terminate: vi.fn(async () => { throw firstFailure }),
|
||||
}
|
||||
const secondFailedTerminal: SubprocessTerminalHandle = {
|
||||
...failedTerminal,
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(async () => { throw secondFailure }),
|
||||
terminate: vi.fn(async () => { throw secondFailure }),
|
||||
}
|
||||
let finishCleanup!: () => void
|
||||
const cleanup = new Promise<boolean>((resolve) => {
|
||||
finishCleanup = () => { resolve(true) }
|
||||
const cleanup = new Promise<void>((resolve) => {
|
||||
finishCleanup = resolve
|
||||
})
|
||||
const drainingTerminal: SubprocessTerminalHandle = {
|
||||
...failedTerminal,
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(() => cleanup),
|
||||
terminate: vi.fn(() => cleanup),
|
||||
}
|
||||
const terminals = (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
|
||||
terminals.add(failedTerminal)
|
||||
@@ -187,8 +178,7 @@ describe('LocalSubprocessService', () => {
|
||||
write: async () => {},
|
||||
inspectForeground: async () => undefined,
|
||||
signalForeground: async () => 1,
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(async () => { throw failure }),
|
||||
terminate: vi.fn(async () => { throw failure }),
|
||||
}
|
||||
const terminals = (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
|
||||
terminals.add(terminal)
|
||||
@@ -247,7 +237,7 @@ describe('LocalSubprocessService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('contains a terminal release failure after top-level exit', async () => {
|
||||
it('retains a terminal whose automatic cleanup fails', async () => {
|
||||
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
|
||||
const terminal = {
|
||||
pid: 123,
|
||||
@@ -264,6 +254,8 @@ describe('LocalSubprocessService', () => {
|
||||
try {
|
||||
const { default: IsolatedLocalSubprocessService } = await import('../src/index.ts')
|
||||
const ctx = new Context()
|
||||
const disposalErrors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
|
||||
const fiber = await ctx.plugin(IsolatedLocalSubprocessService)
|
||||
const alive = new Set([124])
|
||||
;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessService>).terminalInspector = {
|
||||
@@ -281,10 +273,9 @@ describe('LocalSubprocessService', () => {
|
||||
exitListener?.({ exitCode: 0 })
|
||||
await handle.done
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
alive.clear()
|
||||
handle.terminate()
|
||||
await handle.waitForExit()
|
||||
expect((ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
|
||||
await fiber.dispose()
|
||||
expect(disposalErrors).toHaveLength(1)
|
||||
} finally {
|
||||
vi.doUnmock('node-pty')
|
||||
vi.resetModules()
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('LocalTerminalHandle', () => {
|
||||
handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
|
||||
pty.emitData('hello €')
|
||||
await handle.write(Buffer.from('input\r'))
|
||||
await handle.write('input\r')
|
||||
expect(pty.writes).toEqual(['input\r'])
|
||||
expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true })
|
||||
expect(await handle.signalForeground('SIGINT')).toBe(456)
|
||||
@@ -98,16 +98,14 @@ describe('LocalTerminalHandle', () => {
|
||||
pty.emitExit(7, 9)
|
||||
pty.emitExit(0)
|
||||
expect(await handle.done).toEqual({ exitCode: null, signal: 'SIGKILL' })
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
await handle.terminate()
|
||||
expect(Buffer.concat(chunks).toString('utf8')).toBe('hello €')
|
||||
})
|
||||
|
||||
it('rejects invalid input and unsafe foreground signals', async () => {
|
||||
it('rejects unsafe foreground signals and writes after exit', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
await expect(handle.write(Uint8Array.from([0xff]))).rejects.toThrow('valid UTF-8')
|
||||
|
||||
inspector.pgid = handle.pid
|
||||
await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
|
||||
inspector.pgid = undefined
|
||||
@@ -116,8 +114,8 @@ describe('LocalTerminalHandle', () => {
|
||||
|
||||
pty.emitExit(3)
|
||||
expect(await handle.done).toEqual({ exitCode: 3, signal: null })
|
||||
await handle.waitForExit()
|
||||
await expect(handle.write(Buffer.from('late'))).rejects.toThrow('has exited')
|
||||
await handle.terminate()
|
||||
await expect(handle.write('late')).rejects.toThrow('has exited')
|
||||
})
|
||||
|
||||
it('keeps the shell alive until forced descendants leave', async () => {
|
||||
@@ -129,15 +127,14 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
|
||||
handle.terminate()
|
||||
const quiescent = handle.waitForExit()
|
||||
const quiescent = handle.terminate()
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
|
||||
expect(pty.kills).toEqual([])
|
||||
|
||||
inspector.alive.delete(124)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(await quiescent).toBe(true)
|
||||
await quiescent
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
@@ -149,17 +146,16 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const waiting = handle.waitForExit()
|
||||
pty.emitExit()
|
||||
const waiting = handle.terminate()
|
||||
let settled = false
|
||||
void waiting.then(() => { settled = true })
|
||||
|
||||
pty.emitExit()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
inspector.alive.delete(124)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(await waiting).toBe(true)
|
||||
await waiting
|
||||
})
|
||||
|
||||
it('cleans a same-session descendant after the top-level shell exits naturally', async () => {
|
||||
@@ -172,7 +168,7 @@ describe('LocalTerminalHandle', () => {
|
||||
|
||||
pty.emitExit()
|
||||
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM']])
|
||||
})
|
||||
|
||||
@@ -188,7 +184,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.members = []
|
||||
pty.emitExit()
|
||||
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM']])
|
||||
})
|
||||
|
||||
@@ -209,8 +205,7 @@ describe('LocalTerminalHandle', () => {
|
||||
return []
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
handle.terminate()
|
||||
await handle.waitForExit()
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
@@ -225,14 +220,13 @@ describe('LocalTerminalHandle', () => {
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
|
||||
handle.terminate()
|
||||
await handle.waitForExit()
|
||||
await handle.terminate()
|
||||
|
||||
expect(inspector.processes).toEqual([[late.pid, 'SIGTERM']])
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('keeps a failed post-shell sweep retryable until its survivor leaves', async () => {
|
||||
it('retries failed cleanup after a surviving descendant leaves', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
@@ -244,14 +238,15 @@ describe('LocalTerminalHandle', () => {
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
|
||||
handle.terminate()
|
||||
const failed = expect(handle.waitForExit()).rejects.toThrow('surviving pids: 124')
|
||||
const first = handle.terminate()
|
||||
const failed = expect(first).rejects.toThrow('surviving pids: 124')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await failed
|
||||
|
||||
inspector.alive.delete(late.pid)
|
||||
handle.terminate()
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
const retry = handle.terminate()
|
||||
expect(retry).not.toBe(first)
|
||||
await retry
|
||||
expect(inspector.processes).toEqual([[late.pid, 'SIGTERM'], [late.pid, 'SIGKILL']])
|
||||
})
|
||||
|
||||
@@ -268,82 +263,35 @@ describe('LocalTerminalHandle', () => {
|
||||
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
handle.terminate()
|
||||
const quiescent = handle.waitForExit()
|
||||
const quiescent = handle.terminate()
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
expect(await quiescent).toBe(true)
|
||||
await quiescent
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
|
||||
})
|
||||
|
||||
it('allows cleanup to retry after a surviving descendant leaves', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
|
||||
handle.terminate()
|
||||
const first = expect(handle.waitForExit(new AbortController().signal)).rejects.toThrow('surviving pids: 124')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await first
|
||||
|
||||
inspector.alive.delete(124)
|
||||
handle.terminate()
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('bounds waits and reports a top-level process that ignores escalation', async () => {
|
||||
it('reports a top-level process that ignores escalation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
pty.autoExitOnKill = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10)
|
||||
expect(await handle.waitForExit(AbortSignal.abort())).toBe(false)
|
||||
const controller = new AbortController()
|
||||
const bounded = handle.waitForExit(controller.signal)
|
||||
controller.abort()
|
||||
expect(await bounded).toBe(false)
|
||||
|
||||
handle.terminate()
|
||||
const failed = expect(handle.waitForExit()).rejects.toThrow('surviving pid: 123')
|
||||
const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await failed
|
||||
expect(pty.kills).toEqual(['SIGTERM', 'SIGKILL'])
|
||||
|
||||
pty.emitExit(0, 999)
|
||||
expect(await handle.done).toEqual({ exitCode: null, signal: null })
|
||||
handle.terminate()
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
await handle.terminate()
|
||||
})
|
||||
|
||||
it('contains process races and reacts to lifetime cancellation', async () => {
|
||||
it('contains process races while reporting surviving descendants', async () => {
|
||||
const pty = new FakePty()
|
||||
pty.throwKill = true
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.throwProcess = true
|
||||
const controller = new AbortController()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1, controller.signal)
|
||||
controller.abort()
|
||||
const failed = expect(handle.waitForExit()).rejects.toThrow('surviving pids: 124')
|
||||
await failed
|
||||
|
||||
inspector.alive.delete(124)
|
||||
pty.throwKill = false
|
||||
handle.terminate()
|
||||
await handle.waitForExit()
|
||||
|
||||
const preAbortedPty = new FakePty()
|
||||
const preAborted = new LocalTerminalHandle(
|
||||
preAbortedPty.asPty(),
|
||||
new FakeInspector(),
|
||||
1,
|
||||
AbortSignal.abort('stop'),
|
||||
)
|
||||
await preAborted.waitForExit()
|
||||
expect(preAbortedPty.kills).toEqual(['SIGTERM'])
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1)
|
||||
await expect(handle.terminate()).rejects.toThrow('surviving pids: 124')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user