Files
deepseek-harness/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts
T
turtle1999 aaa02a3970 fix(subprocess): settle a Linux scope left active with no processes
Range observation waited for an active transient scope to leave `active`
before it would prove the managed range empty. systemd ends a scope only when
it observes the populated-to-empty transition, so a payload killed before the
manager placed it in the scope's cgroup leaves the unit active with an empty
cgroup forever. `waitForExit()` then polled a unit that could not change, and
`LocalSubprocessRuntime.disposeManagedProcesses()` awaited it without bound —
teardown hung for any command cancelled immediately after spawn.

Each state query now also reads the unit's process count. Once termination was
requested and the launcher has exited, a still-active unit reporting no
processes is the empty range: the launcher forked every process it will ever
fork, so nothing later joins that cgroup. The owner stops the leftover unit so
transient units cannot accumulate, and an unreported or `[not set]` count
stays unknown and keeps waiting.

The race needs a live user-systemd manager, so it stayed dormant wherever the
native scope path was unavailable and surfaced once CI routed lanes onto the
Blacksmith hosted image, whose runners accept that path. Ten repetitions of
`local.spec.ts > disposal kills still-running processes and awaits their exit`
in a Linux container with a user manager failed seven times before the change
and passed ten times after it.
2026-09-10 17:27:52 +08:00

749 lines
33 KiB
TypeScript

import { EventEmitter } from 'node:events'
import { existsSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
launchLinuxScope,
prepareLinuxTerminalScope,
probeLinuxBootstrap,
probeLinuxManager,
probeLinuxNative,
probeLinuxScope,
} from '../src/linux-scope.ts'
import type { LinuxScopeInternals } from '../src/linux-scope.ts'
import {
consumeLinuxLaunchRequest,
linuxLaunchFilesFromLocator,
writeLinuxStartupError,
} from '../src/runner-protocol.ts'
import { SUBPROCESS_RUNNER_ENV } from '../src/runner-launch.ts'
import { bindManagedProcess } from '../src/spawn.ts'
const childProcessMocks = vi.hoisted(() => ({
execFile: vi.fn(),
spawn: vi.fn(),
spawnSync: vi.fn(),
}))
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>()
return {
...actual,
execFile: childProcessMocks.execFile as unknown as typeof actual.execFile,
spawn: childProcessMocks.spawn as typeof actual.spawn,
spawnSync: childProcessMocks.spawnSync as typeof actual.spawnSync,
}
})
class FakeChild extends EventEmitter {
pid: number | undefined = 321
exitCode: number | null = null
signalCode: NodeJS.Signals | null = null
stdin = new PassThrough()
stdout = new PassThrough()
stderr = new PassThrough()
kills: NodeJS.Signals[] = []
kill(signal: NodeJS.Signals): boolean {
this.kills.push(signal)
return true
}
exit(exitCode: number | null, signal: NodeJS.Signals | null): void {
this.exitCode = exitCode
this.signalCode = signal
this.emit('exit', exitCode, signal)
}
}
const directories: string[] = []
afterEach(() => {
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
vi.restoreAllMocks()
childProcessMocks.execFile.mockReset()
childProcessMocks.spawn.mockReset()
childProcessMocks.spawnSync.mockReset()
})
function missingUnit() {
return { status: 1, stdout: '', stderr: 'Unit dsh.scope could not be found.' }
}
function activeUnit(state = 'active') {
return { status: 0, stdout: `LoadState=loaded\nActiveState=${state}\n`, stderr: '' }
}
function unloadedUnit() {
return { status: 0, stdout: 'LoadState=not-found\nActiveState=inactive\n', stderr: '' }
}
function unitState(loadState: string, activeState: string) {
return { status: 0, stdout: `LoadState=${loadState}\nActiveState=${activeState}\n`, stderr: '' }
}
function activeUnitWithTasks(tasks: string) {
return { status: 0, stdout: `LoadState=loaded\nActiveState=active\nTasksCurrent=${tasks}\n`, stderr: '' }
}
/** Deny every real process-group signal so a fake child never reaches a live host group. */
function denyProcessGroups(): void {
vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('missing process group') })
}
/** Record the systemctl invocations a scope owner makes, succeeding unless a case overrides it. */
function recordingSystemctl() {
return vi.fn((_command: string, _args: readonly string[]) => ({ status: 0, stdout: '', stderr: '' }))
}
function spec() {
return {
argv: ['tool', 'literal arg'],
cwd: '/target',
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
graceMs: 100,
env: { TARGET: 'yes' },
} as const
}
function launch(
query: LinuxScopeInternals['systemctlQuery'],
overrides: LinuxScopeInternals = {},
) {
const child = new FakeChild()
let options: { env?: NodeJS.ProcessEnv; cwd?: string; detached?: boolean } | undefined
const spawn = vi.fn((_command: string, _args: readonly string[], received: typeof options) => {
options = received
return child
})
const spawnSync = vi.fn(() => ({ status: 0, stdout: '', stderr: '' }))
const systemctlQuery = overrides.systemctlQuery ?? query
const result = launchLinuxScope(spec(), { TARGET: 'yes' }, {
spawn: overrides.spawn ?? spawn as never,
spawnSync: overrides.spawnSync ?? spawnSync as never,
...systemctlQuery === undefined ? {} : { systemctlQuery },
systemdRun: overrides.systemdRun ?? '/bin/systemd-run',
systemctl: overrides.systemctl ?? '/bin/systemctl',
runnerInvocation: overrides.runnerInvocation ?? ['/usr/bin/node', '/runner.js'],
...overrides.runnerAvailable === undefined ? {} : { runnerAvailable: overrides.runnerAvailable },
...overrides.loadLinuxExecve === undefined ? {} : { loadLinuxExecve: overrides.loadLinuxExecve },
...overrides.sleep === undefined ? {} : { sleep: overrides.sleep },
})
const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV]
if (requestPath === undefined) throw new Error('launch did not publish a request locator')
directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
return { child, result, requestPath, spawn, spawnSync, options }
}
describe('Linux native capability selection', () => {
it('rechecks bootstrap and literal transient-scope support', () => {
const spawnSync = vi.fn(() => ({ status: 0, error: undefined }))
const runnerAvailable = vi.fn(() => true)
const loadLinuxExecve = vi.fn(() => vi.fn() as never)
const inputs = {
spawnSync: spawnSync as never,
runnerAvailable,
runnerInvocation: ['/usr/bin/node', '/runner.js'] as [string, ...string[]],
loadLinuxExecve,
systemdRun: '/bin/systemd-run',
systemctl: '/bin/systemctl',
}
expect(probeLinuxNative(inputs)).toBe(true)
expect(probeLinuxNative(inputs)).toBe(true)
expect(runnerAvailable).toHaveBeenCalledTimes(2)
expect(loadLinuxExecve).toHaveBeenCalledTimes(2)
expect(spawnSync).toHaveBeenCalledTimes(2)
expect(probeLinuxBootstrap({
...inputs,
loadLinuxExecve: () => { throw new Error('libc execve missing') },
})).toBe(false)
})
it('reports each failed dynamic prerequisite without executing a target', () => {
expect(probeLinuxScope({
spawnSync: vi.fn(() => ({ status: null, error: new Error('missing') })) as never,
})).toBe(false)
expect(probeLinuxBootstrap({
loadLinuxExecve: () => vi.fn() as never,
runnerInvocation: ['/missing'],
runnerAvailable: () => false,
})).toBe(false)
expect(probeLinuxBootstrap({
loadLinuxExecve: () => vi.fn() as never,
resolveRunnerInvocation: () => { throw new Error('runner resolution failed') },
})).toBe(false)
})
it('uses the default command adapters and runner resolution', () => {
childProcessMocks.spawnSync.mockReturnValue({ status: 0, error: undefined })
expect(probeLinuxScope()).toBe(true)
expect(probeLinuxManager()).toBe(true)
expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(2)
expect(probeLinuxBootstrap({ loadLinuxExecve: () => vi.fn() as never })).toBe(true)
expect(probeLinuxBootstrap({
runnerInvocation: [process.execPath],
runnerAvailable: () => true,
})).toBe(process.platform !== 'win32')
})
it('keeps quieting on the transient-scope probe but preserves manager diagnostics', () => {
const spawnSync = vi.fn((
_command: string,
_args: readonly string[],
_options: unknown,
) => ({ status: 0, error: undefined }))
expect(probeLinuxScope({ spawnSync: spawnSync as never })).toBe(true)
expect(probeLinuxManager({ spawnSync: spawnSync as never })).toBe(true)
const scopeOptions = spawnSync.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv }
const managerOptions = spawnSync.mock.calls[1]?.[2] as { env: NodeJS.ProcessEnv }
expect(scopeOptions.env).toMatchObject({ LC_ALL: 'C', SYSTEMD_LOG_TARGET: 'null' })
expect(managerOptions.env).toMatchObject({ LC_ALL: 'C' })
expect(managerOptions.env).not.toHaveProperty('SYSTEMD_LOG_TARGET')
expect(probeLinuxManager({
spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as never,
})).toBe(false)
expect(probeLinuxManager({
spawnSync: vi.fn(() => ({ status: null, error: new Error('missing') })) as never,
})).toBe(false)
})
})
describe('Linux scope establishment and quiescence', () => {
it.each(['abort', 'terminate'] as const)('settles and cleans a managed handle after early %s', async (action) => {
const { child, result, requestPath } = launch(async () => missingUnit())
const controller = new AbortController()
const handle = bindManagedProcess({ ...spec(), signal: controller.signal }, result)
if (action === 'abort') controller.abort(new Error('cancelled'))
else handle.terminate()
expect(child.kills).toEqual(['SIGTERM'])
child.exit(null, 'SIGTERM')
child.stdout.end()
child.stderr.end()
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
await expect(handle.waitForExit()).resolves.toBe(true)
expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
})
it.each([
{ exitCode: 1, signal: null },
{ exitCode: null, signal: 'SIGSEGV' as const },
])('retains unrelated bootstrap failure $exitCode / $signal after a termination request', async (outcome) => {
const { child, result } = launch(async () => missingUnit())
result.owner.signal('SIGTERM')
child.exit(outcome.exitCode, outcome.signal)
await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
await expect(result.owner.waitForExit()).resolves.toBeUndefined()
result.owner.cleanup?.()
})
it('does not mistake pre-establishment unit absence for quiescence and settles an empty range after cancellation', async () => {
const { child, result, requestPath, spawnSync } = launch(async () => missingUnit())
const waiting = result.owner.waitForExit()
result.owner.signal('SIGTERM')
expect(child.kills).toEqual(['SIGTERM'])
expect(spawnSync).toHaveBeenCalledWith('/bin/systemctl', expect.arrayContaining([
'kill', '--kill-whom=all', '--signal=SIGTERM',
]), expect.anything())
const direct = expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
child.exit(null, 'SIGTERM')
await direct
await expect(waiting).resolves.toBeUndefined()
expect(existsSync(requestPath)).toBe(true)
result.owner.cleanup?.()
})
it('accepts request consumption followed by rapid --collect unload as stopped', async () => {
const states = [activeUnit(), unloadedUnit()]
const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit())
expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
const waiting = result.owner.waitForExit()
child.exit(0, null)
await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
await expect(waiting).resolves.toBeUndefined()
result.owner.signal('SIGKILL')
result.owner.cleanup?.()
expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
})
it('uses the scope alone after establishment and the direct range only when scope signalling fails', async () => {
const spawnSync = vi.fn()
.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' })
.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'scope signal failed' })
const { child, result, requestPath } = launch(async () => activeUnit(), {
spawnSync: spawnSync as never,
})
consumeLinuxLaunchRequest(requestPath)
const processKill = vi.spyOn(process, 'kill').mockReturnValue(true)
result.owner.signal('SIGTERM')
expect(processKill).not.toHaveBeenCalled()
result.owner.signal('SIGKILL')
expect(processKill).toHaveBeenCalledExactlyOnceWith(-321, 'SIGKILL')
expect(spawnSync).toHaveBeenCalledTimes(2)
child.exit(null, 'SIGKILL')
await expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
result.owner.cleanup?.()
})
it('uses manager-observed unit existence as establishment proof', async () => {
const { child, result } = launch(async () => activeUnit('inactive'))
await expect(result.owner.waitForExit()).resolves.toBeUndefined()
child.exit(1, null)
await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
result.owner.cleanup?.()
})
it('keeps waiting while the unit is absent and the direct launcher is still running', async () => {
const states = [missingUnit(), activeUnit('inactive')]
const { child, result } = launch(async () => states.shift() ?? activeUnit('inactive'))
await expect(result.owner.waitForExit()).resolves.toBeUndefined()
child.exit(1, null)
await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
result.owner.cleanup?.()
})
it('treats status-zero not-found as pending until the direct launcher proves the range was never created', async () => {
const state: { child?: FakeChild } = {}
const launched = launch(async () => unloadedUnit(), {
sleep: async () => { state.child?.exit(127, null) },
})
state.child = launched.child
await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
await expect(launched.result.direct).rejects.toThrow('before its bootstrap consumed')
launched.result.owner.cleanup?.()
})
it('polls promptly before establishment and backs off established active scopes', async () => {
const delays: number[] = []
const states = [
missingUnit(),
activeUnit(),
activeUnit(),
activeUnit(),
activeUnit(),
activeUnit(),
activeUnit(),
activeUnit(),
activeUnit(),
activeUnit('inactive'),
]
const launched = launch(
async () => states.shift() ?? activeUnit('inactive'),
{ sleep: async (delayMs) => { delays.push(delayMs) } },
)
await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
expect(delays).toEqual([50, 50, 100, 200, 400, 800, 1_600, 3_200, 5_000])
launched.result.owner.cleanup?.()
})
it('keeps reloading scopes active and lets terminate wake a backed-off observation', async () => {
const states = [activeUnit('reloading'), activeUnit('inactive')]
const sleeping = Promise.withResolvers<undefined>()
const sleep = vi.fn(async (_delayMs: number, signal?: AbortSignal) => {
sleeping.resolve(undefined)
if (signal === undefined) throw new Error('missing sleep cancellation signal')
await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
})
const launched = launch(async () => states.shift() ?? activeUnit('inactive'), { sleep })
consumeLinuxLaunchRequest(launched.requestPath)
const waiting = launched.result.owner.waitForExit()
await sleeping.promise
launched.result.owner.signal('SIGTERM')
await expect(waiting).resolves.toBeUndefined()
expect(sleep).toHaveBeenCalledOnce()
expect(sleep.mock.calls[0]?.[0]).toBe(50)
expect(sleep.mock.calls[0]?.[1]?.aborted).toBe(true)
expect(launched.spawnSync).toHaveBeenCalledOnce()
launched.result.owner.cleanup?.()
})
it('skips the next poll delay when terminate arrives during a manager query', async () => {
const firstQuery = Promise.withResolvers<ReturnType<typeof activeUnit>>()
const query = vi.fn()
.mockImplementationOnce(async () => await firstQuery.promise)
.mockResolvedValueOnce(activeUnit('inactive'))
const sleep = vi.fn(async () => {})
const launched = launch(query, { sleep })
consumeLinuxLaunchRequest(launched.requestPath)
const waiting = launched.result.owner.waitForExit()
launched.result.owner.signal('SIGTERM')
firstQuery.resolve(activeUnit())
await expect(waiting).resolves.toBeUndefined()
expect(sleep).not.toHaveBeenCalled()
launched.result.owner.cleanup?.()
})
it.each([
{ exitCode: 127, signal: null },
{ exitCode: null, signal: 'SIGTERM' as const },
])('rejects unexpected bootstrap exit $exitCode / $signal and settles the empty range', async (outcome) => {
const { child, result } = launch(async () => missingUnit())
child.exit(outcome.exitCode, outcome.signal)
await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
await expect(result.owner.waitForExit()).resolves.toBeUndefined()
result.owner.cleanup?.()
})
it('preserves a recorded pre-exec failure even when cancellation also terminates the bootstrap', async () => {
const { child, result, requestPath } = launch(async () => missingUnit())
const files = linuxLaunchFilesFromLocator(requestPath)
result.owner.signal('SIGTERM')
unlinkSync(requestPath)
writeLinuxStartupError(files, {
type: 'error',
error: { name: 'Error', message: 'spawn tool ENOENT', code: 'ENOENT' },
})
child.exit(null, 'SIGTERM')
await expect(result.direct).rejects.toMatchObject({ code: 'ENOENT' })
result.owner.cleanup?.()
})
it('retries a failed state query and rejects unknown states or failed final kills', async () => {
const query = vi.fn()
.mockResolvedValueOnce({ status: null, stdout: '', stderr: '', error: new Error('query failed') })
.mockResolvedValueOnce(missingUnit())
const { result, requestPath } = launch(query)
unlinkSync(requestPath)
await expect(result.owner.waitForExit()).rejects.toThrow('query failed')
await expect(result.owner.waitForExit()).resolves.toBeUndefined()
result.owner.cleanup?.()
const unknown = launch(async () => activeUnit('mystery'))
await expect(unknown.result.owner.waitForExit()).rejects.toThrow('unknown ActiveState')
unknown.result.owner.cleanup?.()
const unknownLoad = launch(async () => unitState('masked', 'inactive'))
await expect(unknownLoad.result.owner.waitForExit()).rejects.toThrow('unknown state')
unknownLoad.result.owner.cleanup?.()
const killFailed = launch(async () => activeUnit(), {
spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never,
})
killFailed.result.owner.signal('SIGKILL')
await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('could not signal')
killFailed.result.owner.cleanup?.()
})
it('reports command-query failures from the default systemctl adapter', async () => {
childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => {
const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void
const options = args[2] as { env: NodeJS.ProcessEnv }
expect(options.env).toMatchObject({ LC_ALL: 'C' })
expect(options.env).not.toHaveProperty('SYSTEMD_LOG_TARGET')
callback(null, 'LoadState=loaded\nActiveState=inactive\n', 'manager diagnostic remains readable')
return new EventEmitter()
})
const stopped = launch(undefined)
await expect(stopped.result.owner.waitForExit()).resolves.toBeUndefined()
stopped.result.owner.cleanup?.()
const queryError = Object.assign(new Error('systemctl execution failed'), { code: 'ENOENT' })
childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => {
const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void
callback(queryError, '', '')
return new EventEmitter()
})
const failed = launch(undefined)
await expect(failed.result.owner.waitForExit()).rejects.toBe(queryError)
failed.result.owner.cleanup?.()
})
it('rejects malformed, duplicate, incomplete, and extra manager state fields', async () => {
for (const [stdout, message] of [
['loaded\nActiveState=active\n', 'malformed state'],
['LoadState=loaded\nLoadState=loaded\nActiveState=active\n', 'duplicate LoadState'],
['LoadState=loaded\n', 'incomplete state'],
['LoadState=loaded\nActiveState=inactive\nOther=value\n', 'incomplete state'],
['LoadState=loaded\nActiveState=active\nTasksCurrent=0\nOther=value\n', 'incomplete state'],
] as const) {
const launched = launch(async () => ({ status: 0, stdout, stderr: '' }))
await expect(launched.result.owner.waitForExit()).rejects.toThrow(message)
launched.result.owner.cleanup?.()
}
})
it('rejects a manager process count that is neither numeric nor the unset sentinel', async () => {
const launched = launch(async () => activeUnitWithTasks('many'))
await expect(launched.result.owner.waitForExit()).rejects.toThrow('non-numeric TasksCurrent')
launched.result.owner.cleanup?.()
})
it('releases an active scope left with no processes once its client has gone', async () => {
// Regression: the manager's empty cgroup never ends this unit on its own.
denyProcessGroups()
const spawnSync = recordingSystemctl()
const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
launched.result.owner.signal('SIGKILL')
launched.child.exit(null, 'SIGKILL')
await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
expect(spawnSync.mock.calls.map(call => call[1])).toEqual([
['--user', 'kill', '--kill-whom=all', '--signal=SIGKILL', expect.stringMatching(/\.scope$/u)],
['--user', 'stop', expect.stringMatching(/\.scope$/u)],
])
launched.result.owner.cleanup?.()
})
it('concludes the empty range even when releasing the leftover scope fails', async () => {
denyProcessGroups()
const spawnSync = recordingSystemctl()
.mockImplementationOnce(() => ({ status: 0, stdout: '', stderr: '' }))
.mockImplementationOnce(() => { throw new Error('systemctl is gone') })
const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
launched.result.owner.signal('SIGKILL')
launched.child.exit(null, 'SIGKILL')
await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
launched.result.owner.cleanup?.()
})
it('keeps waiting while the client still owns an active scope with no processes', async () => {
denyProcessGroups()
const spawnSync = recordingSystemctl()
const states = [activeUnitWithTasks('0'), unloadedUnit()]
const launched = launch(async () => states.shift() ?? unloadedUnit(), {
spawnSync: spawnSync as never,
})
launched.result.owner.signal('SIGTERM')
await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
expect(spawnSync.mock.calls.map(call => call[1]?.[1])).toEqual(['kill'])
launched.result.owner.cleanup?.()
})
it('keeps waiting for an active empty scope no termination has requested', async () => {
const states = [activeUnitWithTasks('0'), unloadedUnit()]
const launched = launch(async () => states.shift() ?? unloadedUnit())
await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
expect(launched.spawnSync).not.toHaveBeenCalled()
launched.result.owner.cleanup?.()
})
it('treats an unset process count as unknown and keeps waiting', async () => {
const states = [activeUnitWithTasks('[not set]'), unloadedUnit()]
const launched = launch(async () => states.shift() ?? unloadedUnit())
await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
expect(launched.spawnSync).not.toHaveBeenCalled()
launched.result.owner.cleanup?.()
})
it('keeps signal failures scoped to final kill proof and stays idempotent after stop', async () => {
const spawnSync = vi.fn()
.mockReturnValueOnce({ status: 1, stdout: '', stderr: '' })
.mockReturnValueOnce({ status: 1, stderr: 'Unit dsh.scope could not be found.' })
.mockReturnValueOnce({ status: 1, stdout: '', stderr: '' })
.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' })
const states = [activeUnit(), activeUnit('failed')]
const launched = launch(async () => states.shift() ?? missingUnit(), {
spawnSync: spawnSync as never,
})
launched.child.pid = undefined
unlinkSync(launched.requestPath)
launched.result.owner.signal('SIGTERM')
launched.result.owner.signal('SIGKILL')
launched.result.owner.signal('SIGKILL')
launched.result.owner.signal('SIGKILL')
await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
launched.result.owner.terminateForHostExit()
expect(spawnSync).toHaveBeenCalledTimes(4)
launched.result.owner.cleanup?.()
})
it('reports unreadable manager output and a failed kill before establishment', async () => {
const withOutput = launch(async () => ({
status: 5, stdout: '', stderr: 'permission denied',
}))
await expect(withOutput.result.owner.waitForExit()).rejects.toThrow('permission denied')
withOutput.result.owner.cleanup?.()
const withoutOutput = launch(async () => ({ status: null, stdout: '', stderr: '' }))
await expect(withoutOutput.result.owner.waitForExit()).rejects.toThrow('exit null')
withoutOutput.result.owner.cleanup?.()
const killFailed = launch(async () => missingUnit(), {
spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'kill denied' })) as never,
})
vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('missing process group') })
killFailed.result.owner.signal('SIGKILL')
await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('kill denied')
killFailed.result.owner.cleanup?.()
})
it('settles direct outcomes once and reports malformed startup errors', async () => {
const childError = launch(async () => missingUnit())
const spawnError = new Error('systemd-run failed')
childError.child.emit('error', spawnError)
childError.child.exit(1, null)
await expect(childError.result.direct).rejects.toBe(spawnError)
childError.result.owner.cleanup?.()
const lateError = launch(async () => missingUnit())
consumeLinuxLaunchRequest(lateError.requestPath)
lateError.child.exit(0, null)
lateError.child.emit('error', new Error('late child error'))
await expect(lateError.result.direct).resolves.toEqual({ exitCode: 0, signal: null })
lateError.result.owner.cleanup?.()
const malformed = launch(async () => missingUnit())
const files = linuxLaunchFilesFromLocator(malformed.requestPath)
unlinkSync(malformed.requestPath)
writeFileSync(files.startupErrorPath, '{', { mode: 0o600 })
malformed.child.exit(127, null)
await expect(malformed.result.direct).rejects.toBeInstanceOf(SyntaxError)
malformed.result.owner.cleanup?.()
})
it('does not signal a direct group before the launcher publishes a pid', async () => {
const launched = launch(async () => activeUnit('inactive'))
launched.child.pid = undefined
const processKill = vi.spyOn(process, 'kill')
launched.result.owner.signal('SIGTERM')
expect(processKill).not.toHaveBeenCalled()
expect(launched.child.kills).toEqual([])
consumeLinuxLaunchRequest(launched.requestPath)
launched.child.exit(0, null)
await expect(launched.result.direct).resolves.toEqual({ exitCode: 0, signal: null })
launched.result.owner.cleanup?.()
})
it('does not signal the direct group after the launcher exits', async () => {
const { child, result, requestPath, spawnSync } = launch(async () => activeUnit())
consumeLinuxLaunchRequest(requestPath)
child.exit(0, null)
await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
const processKill = vi.spyOn(process, 'kill')
result.owner.signal('SIGTERM')
result.owner.terminateForHostExit()
expect(processKill).not.toHaveBeenCalled()
expect(child.kills).toEqual([])
expect(spawnSync).toHaveBeenCalledTimes(2)
result.owner.cleanup?.()
})
it('runs direct fallback before the exact synchronous scope kill on host exit', () => {
const events: string[] = []
const { child, result } = launch(async () => missingUnit(), {
spawnSync: vi.fn(() => { events.push('scope'); return { status: 0 } }) as never,
})
child.kill = vi.fn(() => { events.push('direct'); return true })
vi.spyOn(process, 'kill').mockImplementation(() => { events.push('direct'); return true })
result.owner.terminateForHostExit()
expect(events).toEqual(['direct', 'scope'])
result.owner.cleanup?.()
})
})
describe('Linux PTY bootstrap reuse', () => {
const terminalSpec = {
argv: ['bash', '--noprofile'],
cwd: '/target',
env: { TARGET: 'yes' },
rows: 24,
cols: 80,
graceMs: 100,
} as const
it.each(['SIGTERM', 'SIGKILL'] as const)('preserves %s before bootstrap consumption and joins the empty scope', async (signal) => {
const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' }, {
spawnSync: vi.fn(() => missingUnit()) as never,
systemctlQuery: async () => missingUnit(),
})
const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
if (requestPath === undefined) throw new Error('missing PTY request')
directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
let running = true
const kill = vi.fn()
const owner = scope.bindOwner({ running: () => running, signal: kill })
owner.signal(signal)
expect(kill).toHaveBeenCalledExactlyOnceWith(signal)
running = false
expect(existsSync(requestPath)).toBe(true)
expect(scope.resolveOutcome({ exitCode: 0, signal })).toEqual({ exitCode: 0, signal })
await expect(owner.waitForExit()).resolves.toBeUndefined()
scope.cleanup()
expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
})
it('uses the same request/bootstrap, preserves argv, and cleans after owner settlement', async () => {
const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' }, {
systemdRun: '/bin/systemd-run',
systemctl: '/bin/systemctl',
runnerInvocation: ['/usr/bin/node', '/runner.js'],
spawnSync: vi.fn(() => ({ status: 0 })) as never,
systemctlQuery: async () => missingUnit(),
})
const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
if (requestPath === undefined) throw new Error('missing PTY request')
expect(scope.args.slice(-3)).toEqual(['--', 'bash', '--noprofile'])
expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
const owner = scope.bindOwner({ running: () => false, signal: vi.fn() })
await expect(owner.waitForExit()).resolves.toBeUndefined()
expect(scope.resolveOutcome({ exitCode: 0, signal: null })).toEqual({ exitCode: 0, signal: null })
scope.cleanup()
expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
})
it('surfaces PTY pre-exec errors instead of launcher outcomes', () => {
const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' })
const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
if (requestPath === undefined) throw new Error('missing PTY request')
const files = linuxLaunchFilesFromLocator(requestPath)
unlinkSync(requestPath)
writeLinuxStartupError(files, {
type: 'error', error: { name: 'Error', message: 'bad cwd', code: 'ENOENT' },
})
expect(() => scope.resolveOutcome({ exitCode: 127, signal: null })).toThrow('bad cwd')
scope.cleanup()
})
it('uses default owner dependencies and rejects an unconsumed request', () => {
const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' })
const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
if (requestPath === undefined) throw new Error('missing PTY request')
directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
scope.bindOwner({ running: () => true, signal: vi.fn() })
expect(() => scope.resolveOutcome({ exitCode: 1, signal: null })).toThrow(
'before its bootstrap consumed',
)
scope.cleanup()
})
})
describe('Linux ordinary launch adapters', () => {
it('uses the default launch dependencies without changing the target request', async () => {
const child = new FakeChild()
childProcessMocks.spawn.mockReturnValue(child)
const result = launchLinuxScope(spec(), { TARGET: 'yes' })
const call = childProcessMocks.spawn.mock.calls[0]
const options = call?.[2] as { env?: NodeJS.ProcessEnv } | undefined
const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV]
if (requestPath === undefined) throw new Error('launch did not publish a request locator')
directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
expect(call?.[0]).toBe('systemd-run')
expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
child.exit(0, null)
await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
result.owner.cleanup?.()
})
it('removes the private launch directory when spawn throws synchronously', () => {
const spawnError = new Error('synchronous spawn failure')
let requestPath: string | undefined
expect(() => launchLinuxScope(spec(), { TARGET: 'yes' }, {
runnerInvocation: ['/usr/bin/node', '/runner.js'],
spawn: vi.fn((_command: string, _args: readonly string[], options: { env?: NodeJS.ProcessEnv }) => {
requestPath = options.env?.[SUBPROCESS_RUNNER_ENV]
throw spawnError
}) as never,
})).toThrow(spawnError)
if (requestPath === undefined) throw new Error('spawn did not receive a request locator')
expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
})
})