fix(runtime): close lifecycle teardown races
This commit is contained in:
@@ -187,6 +187,51 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
expect(initialized).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('forwards setup cancellation only while terminal allocation is unpublished', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
|
||||
const publishedController = new AbortController()
|
||||
let publishedSignal: AbortSignal | undefined
|
||||
const published = new LocalPtyBackend(
|
||||
ctx,
|
||||
config(),
|
||||
async (spawnSpec) => {
|
||||
publishedSignal = spawnSpec.signal
|
||||
return terminalHandle()
|
||||
},
|
||||
() => stubLocalSession(),
|
||||
)
|
||||
await published.spawn(spec(agent(ctx), publishedController.signal))
|
||||
expect(publishedSignal).toBeDefined()
|
||||
expect(publishedSignal).not.toBe(publishedController.signal)
|
||||
publishedController.abort(new Error('originating turn ended'))
|
||||
expect(publishedSignal?.aborted).toBe(false)
|
||||
|
||||
const pendingController = new AbortController()
|
||||
const seen = Promise.withResolvers<AbortSignal>()
|
||||
const pending = new LocalPtyBackend(
|
||||
ctx,
|
||||
config(),
|
||||
async spawnSpec => await new Promise<SubprocessTerminalHandle>((_resolve, reject) => {
|
||||
const setupSignal = spawnSpec.signal as AbortSignal
|
||||
seen.resolve(setupSignal)
|
||||
const onAbort = (): void => {
|
||||
reject(setupSignal.reason instanceof Error ? setupSignal.reason : new Error(String(setupSignal.reason)))
|
||||
}
|
||||
setupSignal.addEventListener('abort', onAbort, { once: true })
|
||||
}),
|
||||
() => stubLocalSession(),
|
||||
)
|
||||
const spawning = pending.spawn(spec(agent(ctx), pendingController.signal))
|
||||
const pendingSignal = await seen.promise
|
||||
const reason = new Error('cancel pending allocation')
|
||||
pendingController.abort(reason)
|
||||
await expect(spawning).rejects.toBe(reason)
|
||||
expect(pendingSignal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('composes the default local session around a spawned terminal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -156,6 +156,47 @@ describe('pty-local real shell', () => {
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
}, 10_000)
|
||||
|
||||
it('reaps a disowned same-session descendant after the shell exits naturally', async () => {
|
||||
const { ctx, root, agent } = await harness('danger-full-access')
|
||||
const created = await ctx.pty.spawn(agent, { type: 'shell' })
|
||||
const pidFile = join(root, 'disowned.pid')
|
||||
let pid: number | undefined
|
||||
try {
|
||||
const background = ctx.pty.startSend(agent, created.sessionId, {
|
||||
text: `sh -c 'trap "" TERM; printf "%s" "$$" > "$1"; sleep 60' dsh "${pidFile}" & disown`,
|
||||
submit: true,
|
||||
})
|
||||
await background.done
|
||||
const pidDeadline = Date.now() + 2_000
|
||||
let childPid = 0
|
||||
while (childPid === 0 && Date.now() < pidDeadline) {
|
||||
if (existsSync(pidFile)) childPid = Number(readFileSync(pidFile, 'utf8'))
|
||||
if (childPid > 0) break
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
expect(existsSync(pidFile), ctx.pty.read(agent, created.sessionId, { offset: 0, count: 100 }).text).toBe(true)
|
||||
expect(childPid).toBeGreaterThan(0)
|
||||
pid = childPid
|
||||
expect(() => process.kill(childPid, 0)).not.toThrow()
|
||||
await ctx.pty.startSend(agent, created.sessionId, { text: 'exit', submit: true }).done
|
||||
const deadline = Date.now() + 2_000
|
||||
while (ctx.pty.list(agent)[0]?.status.kind !== 'exited' && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
expect(ctx.pty.list(agent)[0]?.status.kind).toBe('exited')
|
||||
await ctx.pty.kill(agent, created.sessionId)
|
||||
expect(() => process.kill(childPid, 0)).toThrow()
|
||||
} finally {
|
||||
if (pid !== undefined) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch (_alreadyReaped) {
|
||||
// Product cleanup is the expected path; this only contains a failed regression.
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 10_000)
|
||||
|
||||
it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => {
|
||||
const { ctx, agent } = await harness('danger-full-access', {
|
||||
idleSilenceMs: 10_000,
|
||||
|
||||
@@ -27,6 +27,7 @@ class FakeInspector implements ProcessInspector {
|
||||
foregroundPgid() { return this.pgid }
|
||||
isStdinWaiting() { return this.waiting }
|
||||
processTree() { return this.members }
|
||||
processSession() { return [] }
|
||||
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
|
||||
signalGroup(pgid: number, signal: PtySignal) {
|
||||
if (this.throwGroup) throw new Error('group failed')
|
||||
@@ -325,6 +326,32 @@ describe('LocalPtySession readiness and output', () => {
|
||||
expect((await next.done).waitReason).toBe('inferred_idle')
|
||||
})
|
||||
|
||||
it('retains send ownership when cancellation signalling fails during an asynchronous write', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const writeGate = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await writeGate.promise }
|
||||
terminal.signalForeground = async () => { throw new Error('interrupt failed') }
|
||||
const operation = session.startSend({ text: 'slow write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const rejected = expect(operation.done).rejects.toThrow('interrupt failed')
|
||||
expect(operation.cancel()).toBe(true)
|
||||
await rejected
|
||||
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
|
||||
|
||||
writeGate.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const next = session.startSend({ text: '', submit: false })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect((await next.done).waitReason).toBe('inferred_idle')
|
||||
})
|
||||
|
||||
it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
|
||||
vi.useFakeTimers()
|
||||
const startupTerminal = new FakeTerminal()
|
||||
|
||||
Reference in New Issue
Block a user