mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
1707 lines
50 KiB
TypeScript
1707 lines
50 KiB
TypeScript
import { EventEmitter } from 'node:events'
|
|
import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { describe, expect, it } from 'bun:test'
|
|
import {
|
|
buildBackgroundSessionLaunch,
|
|
buildBackgroundChildProcessConfig,
|
|
buildBackgroundSessionDisplayCommand,
|
|
confirmBackgroundSessionLaunch,
|
|
followLogFile,
|
|
killBackgroundSession,
|
|
printExistingLog,
|
|
terminateBackgroundSessionProcessTree,
|
|
terminateBackgroundProcessTree,
|
|
LOG_STREAM_CHUNK_SIZE,
|
|
parseBackgroundInvocation,
|
|
parseLogsInvocation,
|
|
} from './bg.js'
|
|
import {
|
|
BACKGROUND_SESSION_ID_ENV,
|
|
BACKGROUND_SESSION_LAUNCHER_PID_ENV,
|
|
} from './bgFinalizer.js'
|
|
import {
|
|
BACKGROUND_PROCESS_MARKER_FLAG,
|
|
backgroundProcessMarkerToken,
|
|
generateBackgroundProcessMarker,
|
|
} from './bgRouting.js'
|
|
import type {
|
|
BackgroundSession,
|
|
BackgroundSessionProcessIdentity,
|
|
} from './bgRegistry.js'
|
|
|
|
const TEST_PROCESS_MARKER = 'a'.repeat(64)
|
|
const OTHER_PROCESS_MARKER = 'b'.repeat(64)
|
|
|
|
class TestOutput extends EventEmitter {
|
|
chunks: Buffer[] = []
|
|
destroyed = false
|
|
writableDestroyed = false
|
|
writeResults: boolean[] = []
|
|
writeError: unknown
|
|
|
|
write(chunk: Uint8Array): boolean {
|
|
if (this.writeError) throw this.writeError
|
|
if (this.destroyed || this.writableDestroyed) {
|
|
throw Object.assign(new Error('stdout closed'), { code: 'EPIPE' })
|
|
}
|
|
this.chunks.push(Buffer.from(chunk))
|
|
return this.writeResults.shift() ?? true
|
|
}
|
|
|
|
bytes(): Buffer {
|
|
return Buffer.concat(this.chunks)
|
|
}
|
|
}
|
|
|
|
function createManualScheduler() {
|
|
let intervalCallback: (() => void) | undefined
|
|
let cleared = false
|
|
|
|
return {
|
|
setInterval(callback: () => void): ReturnType<typeof setInterval> {
|
|
intervalCallback = callback
|
|
return 1 as unknown as ReturnType<typeof setInterval>
|
|
},
|
|
clearInterval(): void {
|
|
cleared = true
|
|
},
|
|
tick(): void {
|
|
intervalCallback?.()
|
|
},
|
|
get cleared(): boolean {
|
|
return cleared
|
|
},
|
|
}
|
|
}
|
|
|
|
function deferred<T = void>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void
|
|
let reject!: (reason?: unknown) => void
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise
|
|
reject = rejectPromise
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
async function waitFor(condition: () => boolean): Promise<void> {
|
|
for (let i = 0; i < 50; i++) {
|
|
if (condition()) return
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
}
|
|
throw new Error('condition was not met')
|
|
}
|
|
|
|
async function withTempFile<T>(
|
|
name: string,
|
|
run: (path: string) => Promise<T>,
|
|
): Promise<T> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'openclaude-bg-test-'))
|
|
try {
|
|
return await run(join(dir, name))
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
describe('background session CLI parsing', () => {
|
|
it('generates a fresh bounded lower-case hex marker from 32 random bytes', () => {
|
|
const first = generateBackgroundProcessMarker(size => {
|
|
expect(size).toBe(32)
|
|
return new Uint8Array(size).fill(0x11)
|
|
})
|
|
const second = generateBackgroundProcessMarker(size =>
|
|
new Uint8Array(size).fill(0x22),
|
|
)
|
|
|
|
expect(first).toBe('11'.repeat(32))
|
|
expect(second).toBe('22'.repeat(32))
|
|
expect(second).not.toBe(first)
|
|
})
|
|
|
|
it('strips inherited marker options before -- but preserves prompt text after it', () => {
|
|
const inline = backgroundProcessMarkerToken(TEST_PROCESS_MARKER)
|
|
for (const inherited of [
|
|
[inline],
|
|
[BACKGROUND_PROCESS_MARKER_FLAG, TEST_PROCESS_MARKER],
|
|
]) {
|
|
const parsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
...inherited,
|
|
'--print',
|
|
'--',
|
|
inline,
|
|
])
|
|
|
|
expect(parsed.prompt).toBe(inline)
|
|
expect(parsed.childArgs).toEqual(['--print', '--', inline])
|
|
}
|
|
})
|
|
|
|
it('preserves marker-looking required-option values', () => {
|
|
const markerLookingValue = backgroundProcessMarkerToken(TEST_PROCESS_MARKER)
|
|
const parsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'--system-prompt',
|
|
markerLookingValue,
|
|
'actual prompt',
|
|
])
|
|
|
|
expect(parsed.prompt).toBe('actual prompt')
|
|
expect(parsed.childArgs).toEqual([
|
|
'--system-prompt',
|
|
markerLookingValue,
|
|
'--print',
|
|
'actual prompt',
|
|
])
|
|
})
|
|
|
|
it('builds a print-mode child command and preserves provider/model flags', () => {
|
|
const parsed = parseBackgroundInvocation([
|
|
'--provider',
|
|
'openai',
|
|
'--model',
|
|
'gpt-5',
|
|
'--bg',
|
|
'--name',
|
|
'auth-refactor',
|
|
'refactor auth middleware',
|
|
])
|
|
|
|
expect(parsed.name).toBe('auth-refactor')
|
|
expect(parsed.prompt).toBe('refactor auth middleware')
|
|
expect(parsed.childArgs).toEqual([
|
|
'--provider',
|
|
'openai',
|
|
'--model',
|
|
'gpt-5',
|
|
'--name',
|
|
'auth-refactor',
|
|
'--print',
|
|
'refactor auth middleware',
|
|
])
|
|
})
|
|
|
|
it('preserves provider env-file values before the prompt', () => {
|
|
const parsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'--provider-env-file',
|
|
'.env',
|
|
'fix failing tests',
|
|
])
|
|
|
|
expect(parsed.prompt).toBe('fix failing tests')
|
|
expect(parsed.childArgs).toEqual([
|
|
'--provider-env-file',
|
|
'.env',
|
|
'--print',
|
|
'fix failing tests',
|
|
])
|
|
})
|
|
|
|
it('preserves provider env-file values after the prompt', () => {
|
|
const parsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'fix failing tests',
|
|
'--provider-env-file',
|
|
'.env',
|
|
])
|
|
|
|
expect(parsed.prompt).toBe('fix failing tests')
|
|
expect(parsed.childArgs).toEqual([
|
|
'--print',
|
|
'fix failing tests',
|
|
'--provider-env-file',
|
|
'.env',
|
|
])
|
|
})
|
|
|
|
it('preserves repeated provider env-file values while finding the prompt', () => {
|
|
const parsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'--provider-env-file',
|
|
'.env.local',
|
|
'fix failing tests',
|
|
'--provider-env-file',
|
|
'.env.ci',
|
|
])
|
|
|
|
expect(parsed.prompt).toBe('fix failing tests')
|
|
expect(parsed.childArgs).toEqual([
|
|
'--provider-env-file',
|
|
'.env.local',
|
|
'--print',
|
|
'fix failing tests',
|
|
'--provider-env-file',
|
|
'.env.ci',
|
|
])
|
|
})
|
|
|
|
it('preserves inline provider env-file values', () => {
|
|
const parsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'fix failing tests',
|
|
'--provider-env-file=.env',
|
|
])
|
|
|
|
expect(parsed.prompt).toBe('fix failing tests')
|
|
expect(parsed.childArgs).toEqual([
|
|
'--print',
|
|
'fix failing tests',
|
|
'--provider-env-file=.env',
|
|
])
|
|
})
|
|
|
|
it('preserves provider env-file paths containing spaces', () => {
|
|
const parsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'fix failing tests',
|
|
'--provider-env-file',
|
|
'config files/provider.env',
|
|
])
|
|
|
|
expect(parsed.prompt).toBe('fix failing tests')
|
|
expect(parsed.childArgs).toEqual([
|
|
'--print',
|
|
'fix failing tests',
|
|
'--provider-env-file',
|
|
'config files/provider.env',
|
|
])
|
|
})
|
|
|
|
it('keeps provider env-file-looking prompts after -- positional', () => {
|
|
const parsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'--',
|
|
'--provider-env-file=.env',
|
|
])
|
|
|
|
expect(parsed.prompt).toBe('--provider-env-file=.env')
|
|
expect(parsed.childArgs).toEqual([
|
|
'--print',
|
|
'--',
|
|
'--provider-env-file=.env',
|
|
])
|
|
})
|
|
|
|
it('does not duplicate --print when the user already passed it', () => {
|
|
const parsed = parseBackgroundInvocation([
|
|
'--background',
|
|
'--print',
|
|
'--max-turns',
|
|
'2',
|
|
'fix failing tests',
|
|
])
|
|
|
|
expect(parsed.childArgs).toEqual([
|
|
'--print',
|
|
'--max-turns',
|
|
'2',
|
|
'fix failing tests',
|
|
])
|
|
})
|
|
|
|
it('preserves the prompt when --debug has no inline filter', () => {
|
|
const parsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'--debug',
|
|
'fix failing tests',
|
|
])
|
|
|
|
expect(parsed.prompt).toBe('fix failing tests')
|
|
expect(parsed.childArgs).toEqual(['--debug', '--print', 'fix failing tests'])
|
|
})
|
|
|
|
it('preserves inline --debug filters while finding the prompt', () => {
|
|
const parsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'--debug=api,hooks',
|
|
'fix failing tests',
|
|
])
|
|
|
|
expect(parsed.prompt).toBe('fix failing tests')
|
|
expect(parsed.childArgs).toEqual([
|
|
'--debug=api,hooks',
|
|
'--print',
|
|
'fix failing tests',
|
|
])
|
|
})
|
|
|
|
it('preserves space-separated resume and PR option values', () => {
|
|
const sessionId = '550e8400-e29b-41d4-a716-446655440000'
|
|
const resumeParsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'--resume',
|
|
sessionId,
|
|
])
|
|
const fromPrParsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'--from-pr',
|
|
'1642',
|
|
])
|
|
const shortResumeParsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'-r',
|
|
sessionId,
|
|
])
|
|
const inlineResumeParsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'--resume=auth',
|
|
])
|
|
|
|
expect(resumeParsed.prompt).toBeUndefined()
|
|
expect(resumeParsed.childArgs).toEqual([
|
|
'--resume',
|
|
sessionId,
|
|
'--print',
|
|
])
|
|
expect(fromPrParsed.prompt).toBeUndefined()
|
|
expect(fromPrParsed.childArgs).toEqual([
|
|
'--from-pr',
|
|
'1642',
|
|
'--print',
|
|
])
|
|
expect(shortResumeParsed.prompt).toBeUndefined()
|
|
expect(shortResumeParsed.childArgs).toEqual(['-r', sessionId, '--print'])
|
|
expect(inlineResumeParsed.prompt).toBeUndefined()
|
|
expect(inlineResumeParsed.childArgs).toEqual(['--resume=auth', '--print'])
|
|
})
|
|
|
|
it('finds the prompt after a space-separated resume option value', () => {
|
|
const sessionId = '550e8400-e29b-41d4-a716-446655440000'
|
|
const parsed = parseBackgroundInvocation([
|
|
'--bg',
|
|
'--resume',
|
|
sessionId,
|
|
'continue the fix',
|
|
])
|
|
|
|
expect(parsed.prompt).toBe('continue the fix')
|
|
expect(parsed.childArgs).toEqual([
|
|
'--resume',
|
|
sessionId,
|
|
'--print',
|
|
'continue the fix',
|
|
])
|
|
})
|
|
|
|
it('does not inject a generated session id when resuming without forking', async () => {
|
|
const resumeSessionId = '550e8400-e29b-41d4-a716-446655440000'
|
|
const generatedSessionId = '00000000-0000-4000-8000-000000000001'
|
|
|
|
const launch = await buildBackgroundSessionLaunch(
|
|
['--resume', resumeSessionId, '--print'],
|
|
generatedSessionId,
|
|
)
|
|
|
|
expect(launch.sessionId).toBe(resumeSessionId)
|
|
expect(launch.childArgs).toEqual(['--resume', resumeSessionId, '--print'])
|
|
})
|
|
|
|
it('preserves an explicit session id without injecting a generated one', async () => {
|
|
const explicitSessionId = '550e8400-e29b-41d4-a716-446655440000'
|
|
const generatedSessionId = '00000000-0000-4000-8000-000000000001'
|
|
|
|
const launch = await buildBackgroundSessionLaunch(
|
|
['--session-id', explicitSessionId, '--print', 'fix failing tests'],
|
|
generatedSessionId,
|
|
)
|
|
|
|
expect(launch.sessionId).toBe(explicitSessionId)
|
|
expect(launch.childArgs).toEqual([
|
|
'--session-id',
|
|
explicitSessionId,
|
|
'--print',
|
|
'fix failing tests',
|
|
])
|
|
})
|
|
|
|
it('uses a generated session id for forked background resumes', async () => {
|
|
const resumeSessionId = '550e8400-e29b-41d4-a716-446655440000'
|
|
const generatedSessionId = '00000000-0000-4000-8000-000000000001'
|
|
|
|
const launch = await buildBackgroundSessionLaunch(
|
|
['--resume', resumeSessionId, '--fork-session', '--print'],
|
|
generatedSessionId,
|
|
)
|
|
|
|
expect(launch.sessionId).toBe(generatedSessionId)
|
|
expect(launch.childArgs).toEqual([
|
|
'--resume',
|
|
resumeSessionId,
|
|
'--fork-session',
|
|
'--print',
|
|
'--session-id',
|
|
generatedSessionId,
|
|
])
|
|
})
|
|
|
|
it('registers non-forked PR resumes under the selected transcript id', async () => {
|
|
const generatedSessionId = '00000000-0000-4000-8000-000000000001'
|
|
const prSessionId = '550e8400-e29b-41d4-a716-446655440000'
|
|
const seenSelectors: unknown[] = []
|
|
|
|
const launch = await buildBackgroundSessionLaunch(
|
|
['--from-pr', '1642', '--print'],
|
|
generatedSessionId,
|
|
{
|
|
resolvePrResumeSessionId: async selector => {
|
|
seenSelectors.push(selector)
|
|
return prSessionId
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(seenSelectors).toEqual(['1642'])
|
|
expect(launch.sessionId).toBe(prSessionId)
|
|
expect(launch.childArgs).toEqual(['--from-pr', '1642', '--print'])
|
|
})
|
|
|
|
it('fails when a non-forked PR resume selector cannot be resolved', async () => {
|
|
await expect(
|
|
buildBackgroundSessionLaunch(
|
|
['--from-pr', '1642', '--print'],
|
|
'00000000-0000-4000-8000-000000000001',
|
|
{
|
|
resolvePrResumeSessionId: async () => null,
|
|
},
|
|
),
|
|
).rejects.toThrow('No conversation found linked to PR selector: 1642')
|
|
})
|
|
|
|
it('inserts generated flags before -- so dash-prefixed prompts stay positional', () => {
|
|
const parsed = parseBackgroundInvocation(['--bg', '--', '--fix-tests'])
|
|
|
|
expect(parsed.prompt).toBe('--fix-tests')
|
|
expect(parsed.childArgs).toEqual(['--print', '--', '--fix-tests'])
|
|
})
|
|
|
|
it('injects print mode when the prompt after -- looks like a print flag', () => {
|
|
const longFlagParsed = parseBackgroundInvocation(['--bg', '--', '--print'])
|
|
const shortFlagParsed = parseBackgroundInvocation(['--bg', '--', '-p'])
|
|
|
|
expect(longFlagParsed.prompt).toBe('--print')
|
|
expect(longFlagParsed.childArgs).toEqual(['--print', '--', '--print'])
|
|
expect(shortFlagParsed.prompt).toBe('-p')
|
|
expect(shortFlagParsed.childArgs).toEqual(['--print', '--', '-p'])
|
|
})
|
|
|
|
it('does not strip --bg when it appears after -- as the prompt', () => {
|
|
const parsed = parseBackgroundInvocation(['--bg', '--', '--bg'])
|
|
|
|
expect(parsed.prompt).toBe('--bg')
|
|
expect(parsed.childArgs).toEqual(['--print', '--', '--bg'])
|
|
})
|
|
|
|
it('parses log follow mode', () => {
|
|
expect(parseLogsInvocation(['auth-refactor', '-f'])).toEqual({
|
|
target: 'auth-refactor',
|
|
follow: true,
|
|
stream: 'stdout',
|
|
})
|
|
expect(parseLogsInvocation(['auth-refactor', '--stderr'])).toEqual({
|
|
target: 'auth-refactor',
|
|
follow: false,
|
|
stream: 'stderr',
|
|
})
|
|
expect(parseLogsInvocation(['auth-refactor', '--stdout', '-f'])).toEqual({
|
|
target: 'auth-refactor',
|
|
follow: true,
|
|
stream: 'stdout',
|
|
})
|
|
expect(parseLogsInvocation(['auth-refactor', '-f', '--stderr'])).toEqual({
|
|
target: 'auth-refactor',
|
|
follow: true,
|
|
stream: 'stderr',
|
|
})
|
|
})
|
|
|
|
it('preserves Node exec flags while keeping the registered child PID stable', () => {
|
|
const config = buildBackgroundChildProcessConfig({
|
|
execPath: '/usr/bin/node',
|
|
execArgv: ['--max-old-space-size=8192', '--expose-gc'],
|
|
entrypoint: '/repo/bin/openclaude',
|
|
childArgs: ['--print', 'fix failing tests'],
|
|
processEnv: {
|
|
OPENCLAUDE_HEAP_RELAUNCHED: '1',
|
|
OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '8192',
|
|
},
|
|
sessionName: 'tests',
|
|
stdoutLogPath: '/tmp/bg.out.log',
|
|
backgroundSessionId: 'bg-tests',
|
|
processMarker: TEST_PROCESS_MARKER,
|
|
launcherPid: 700,
|
|
})
|
|
|
|
expect(config.command).toBe('/usr/bin/node')
|
|
expect(config.args).toEqual([
|
|
'--max-old-space-size=8192',
|
|
'--expose-gc',
|
|
'/repo/bin/openclaude',
|
|
backgroundProcessMarkerToken(TEST_PROCESS_MARKER),
|
|
'--print',
|
|
'fix failing tests',
|
|
])
|
|
expect(config.env.OPENCLAUDE_HEAP_RELAUNCHED).toBe('1')
|
|
expect(config.env.OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB).toBe('8192')
|
|
expect(config.env.CLAUDE_CODE_SESSION_KIND).toBe('bg')
|
|
expect(config.env.CLAUDE_CODE_SESSION_LOG).toBe('/tmp/bg.out.log')
|
|
expect(config.env.CLAUDE_CODE_SESSION_NAME).toBe('tests')
|
|
expect(config.env[BACKGROUND_SESSION_ID_ENV]).toBe('bg-tests')
|
|
expect(config.env[BACKGROUND_SESSION_LAUNCHER_PID_ENV]).toBe('700')
|
|
})
|
|
|
|
it('supplies launcher heap flags instead of relaunching to a different PID', () => {
|
|
const config = buildBackgroundChildProcessConfig({
|
|
execPath: '/usr/bin/node',
|
|
execArgv: [],
|
|
entrypoint: '/repo/bin/openclaude',
|
|
childArgs: ['--print', 'fix failing tests'],
|
|
processEnv: {
|
|
OPENCLAUDE_HEAP_RELAUNCHED: '1',
|
|
OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '4096',
|
|
},
|
|
stdoutLogPath: '/tmp/bg.out.log',
|
|
backgroundSessionId: 'bg-no-wrapper',
|
|
processMarker: TEST_PROCESS_MARKER,
|
|
launcherPid: 701,
|
|
})
|
|
|
|
expect(config.args.slice(0, 2)).toEqual([
|
|
'--max-old-space-size=4096',
|
|
'--expose-gc',
|
|
])
|
|
expect(config.env.OPENCLAUDE_HEAP_RELAUNCHED).toBe('1')
|
|
})
|
|
|
|
it('prevents the installed launcher from replacing a non-Node registered PID', () => {
|
|
const config = buildBackgroundChildProcessConfig({
|
|
execPath: '/usr/local/bin/bun',
|
|
execArgv: [],
|
|
entrypoint: '/repo/bin/openclaude',
|
|
childArgs: ['--print', 'work'],
|
|
processEnv: {},
|
|
stdoutLogPath: '/tmp/bg.out.log',
|
|
backgroundSessionId: 'bg-bun-owner',
|
|
processMarker: TEST_PROCESS_MARKER,
|
|
launcherPid: 702,
|
|
})
|
|
|
|
expect(config.command).toBe('/usr/local/bin/bun')
|
|
expect(config.env.OPENCLAUDE_HEAP_RELAUNCHED).toBe('1')
|
|
expect(config.env[BACKGROUND_SESSION_ID_ENV]).toBe('bg-bun-owner')
|
|
expect(config.env[BACKGROUND_SESSION_LAUNCHER_PID_ENV]).toBe('702')
|
|
})
|
|
|
|
it('injects one fresh marker immediately after a spaced entrypoint', () => {
|
|
const inherited = backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)
|
|
const promptMarker = backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)
|
|
const config = buildBackgroundChildProcessConfig({
|
|
execPath: 'C:\\Program Files\\nodejs\\node.exe',
|
|
execArgv: ['--expose-gc'],
|
|
entrypoint: 'C:\\repo path\\dist\\cli.mjs',
|
|
childArgs: [
|
|
inherited,
|
|
'--provider',
|
|
'openai',
|
|
'--model',
|
|
'gpt-5',
|
|
'--session-id',
|
|
'550e8400-e29b-41d4-a716-446655440000',
|
|
'--from-pr',
|
|
'1642',
|
|
'--print',
|
|
'--',
|
|
promptMarker,
|
|
],
|
|
processEnv: {},
|
|
stdoutLogPath: 'C:\\logs path\\bg.out.log',
|
|
backgroundSessionId: 'bg-spaced-paths',
|
|
processMarker: TEST_PROCESS_MARKER,
|
|
launcherPid: 703,
|
|
})
|
|
const markerToken = backgroundProcessMarkerToken(TEST_PROCESS_MARKER)
|
|
const entrypointIndex = config.args.indexOf('C:\\repo path\\dist\\cli.mjs')
|
|
|
|
expect(config.args[entrypointIndex + 1]).toBe(markerToken)
|
|
expect(config.args.filter(arg => arg === markerToken)).toHaveLength(1)
|
|
expect(config.args.slice(0, config.args.indexOf('--'))).not.toContain(
|
|
inherited,
|
|
)
|
|
expect(config.args.slice(config.args.indexOf('--'))).toEqual([
|
|
'--',
|
|
promptMarker,
|
|
])
|
|
})
|
|
|
|
it('keeps marker-looking required-option values during defensive injection', () => {
|
|
const markerLookingValue = backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)
|
|
const config = buildBackgroundChildProcessConfig({
|
|
execPath: '/usr/bin/node',
|
|
execArgv: [],
|
|
entrypoint: '/repo/bin/openclaude',
|
|
childArgs: [
|
|
'--system-prompt',
|
|
markerLookingValue,
|
|
'--print',
|
|
'work',
|
|
],
|
|
processEnv: {},
|
|
stdoutLogPath: '/tmp/bg.out.log',
|
|
backgroundSessionId: 'bg-marker-looking-value',
|
|
processMarker: TEST_PROCESS_MARKER,
|
|
launcherPid: 704,
|
|
})
|
|
|
|
expect(config.args).toEqual([
|
|
'--max-old-space-size=8192',
|
|
'--expose-gc',
|
|
'/repo/bin/openclaude',
|
|
backgroundProcessMarkerToken(TEST_PROCESS_MARKER),
|
|
'--system-prompt',
|
|
markerLookingValue,
|
|
'--print',
|
|
'work',
|
|
])
|
|
})
|
|
|
|
it('omits only the internal marker from the displayed launch command', () => {
|
|
const markerToken = backgroundProcessMarkerToken(TEST_PROCESS_MARKER)
|
|
const promptMarker = backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)
|
|
|
|
expect(
|
|
buildBackgroundSessionDisplayCommand(
|
|
[
|
|
'node',
|
|
'/repo path/dist/cli.mjs',
|
|
markerToken,
|
|
'--provider',
|
|
'openai',
|
|
'--print',
|
|
'--',
|
|
promptMarker,
|
|
],
|
|
TEST_PROCESS_MARKER,
|
|
),
|
|
).toEqual([
|
|
'node',
|
|
'/repo path/dist/cli.mjs',
|
|
'--provider',
|
|
'openai',
|
|
'--print',
|
|
'--',
|
|
promptMarker,
|
|
])
|
|
})
|
|
|
|
it('escalates process-tree termination and waits for exit before returning', async () => {
|
|
const signals: Array<string | number | undefined> = []
|
|
let aliveChecks = 0
|
|
|
|
await terminateBackgroundProcessTree(123, {
|
|
isProcessAlive: () => {
|
|
aliveChecks++
|
|
return aliveChecks < 4
|
|
},
|
|
killTree: async (_pid, signal) => {
|
|
signals.push(signal)
|
|
},
|
|
sleep: async () => {},
|
|
termGraceMs: 1,
|
|
killGraceMs: 1,
|
|
pollIntervalMs: 1,
|
|
})
|
|
|
|
expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
|
|
})
|
|
|
|
it('fails a detached launch that becomes stale before finalizer installation', async () => {
|
|
const session: BackgroundSession = {
|
|
id: 'bg-finalizer-not-installed',
|
|
pid: 4243,
|
|
cwd: '/repo',
|
|
status: 'running',
|
|
startedAt: '2026-07-10T08:00:00.000Z',
|
|
updatedAt: '2026-07-10T08:00:00.000Z',
|
|
sessionId: 'conversation-finalizer-not-installed',
|
|
command: ['node', 'openclaude', '--print', 'work'],
|
|
stdoutLogPath: '/tmp/bg-finalizer-not-installed.out.log',
|
|
stderrLogPath: '/tmp/bg-finalizer-not-installed.err.log',
|
|
}
|
|
const calls: string[] = []
|
|
|
|
await expect(
|
|
confirmBackgroundSessionLaunch(session, {
|
|
isProcessAlive: () => false,
|
|
refreshStatuses: async () => {
|
|
calls.push('refresh')
|
|
return []
|
|
},
|
|
resolveSession: async id => {
|
|
calls.push(`resolve:${id}`)
|
|
return { ...session, status: 'stale' }
|
|
},
|
|
}),
|
|
).rejects.toThrow(
|
|
'Background session bg-finalizer-not-installed exited before finalization was installed. ' +
|
|
'Logs were retained at /tmp/bg-finalizer-not-installed.out.log and /tmp/bg-finalizer-not-installed.err.log.',
|
|
)
|
|
expect(calls).toEqual([
|
|
'refresh',
|
|
'resolve:bg-finalizer-not-installed',
|
|
])
|
|
})
|
|
|
|
it('returns a live launch without consulting the registry', async () => {
|
|
const session: BackgroundSession = {
|
|
id: 'bg-live-confirmation',
|
|
pid: 4244,
|
|
cwd: '/repo',
|
|
status: 'running',
|
|
startedAt: '2026-07-10T08:00:00.000Z',
|
|
updatedAt: '2026-07-10T08:00:00.000Z',
|
|
sessionId: 'conversation-live-confirmation',
|
|
command: ['node', 'openclaude', '--print', 'work'],
|
|
stdoutLogPath: '/tmp/bg-live-confirmation.out.log',
|
|
stderrLogPath: '/tmp/bg-live-confirmation.err.log',
|
|
}
|
|
const calls: string[] = []
|
|
|
|
const confirmed = await confirmBackgroundSessionLaunch(session, {
|
|
isProcessAlive: () => true,
|
|
refreshStatuses: async () => {
|
|
calls.push('refresh')
|
|
return []
|
|
},
|
|
resolveSession: async id => {
|
|
calls.push(`resolve:${id}`)
|
|
return session
|
|
},
|
|
})
|
|
|
|
expect(confirmed).toBe(session)
|
|
expect(calls).toEqual([])
|
|
})
|
|
|
|
it('returns an authoritative terminal launch after refreshing a dead PID', async () => {
|
|
const session: BackgroundSession = {
|
|
id: 'bg-terminal-confirmation',
|
|
pid: 4245,
|
|
cwd: '/repo',
|
|
status: 'running',
|
|
startedAt: '2026-07-10T08:00:00.000Z',
|
|
updatedAt: '2026-07-10T08:00:00.000Z',
|
|
sessionId: 'conversation-terminal-confirmation',
|
|
command: ['node', 'openclaude', '--print', 'work'],
|
|
stdoutLogPath: '/tmp/bg-terminal-confirmation.out.log',
|
|
stderrLogPath: '/tmp/bg-terminal-confirmation.err.log',
|
|
}
|
|
const terminal: BackgroundSession = {
|
|
...session,
|
|
status: 'failed',
|
|
updatedAt: '2026-07-10T08:00:01.000Z',
|
|
finishedAt: '2026-07-10T08:00:01.000Z',
|
|
exitCode: 23,
|
|
terminalReason: 'exit_code',
|
|
}
|
|
const calls: string[] = []
|
|
|
|
const confirmed = await confirmBackgroundSessionLaunch(session, {
|
|
isProcessAlive: () => false,
|
|
refreshStatuses: async () => {
|
|
calls.push('refresh')
|
|
return [terminal]
|
|
},
|
|
resolveSession: async id => {
|
|
calls.push(`resolve:${id}`)
|
|
return terminal
|
|
},
|
|
})
|
|
|
|
expect(confirmed).toMatchObject({
|
|
status: 'failed',
|
|
finishedAt: '2026-07-10T08:00:01.000Z',
|
|
exitCode: 23,
|
|
terminalReason: 'exit_code',
|
|
})
|
|
expect(calls).toEqual(['refresh', 'resolve:bg-terminal-confirmation'])
|
|
})
|
|
})
|
|
|
|
describe('background session process termination safety', () => {
|
|
const session: BackgroundSession = {
|
|
id: 'bg-safety',
|
|
name: 'safety',
|
|
pid: 4242,
|
|
cwd: '/repo',
|
|
status: 'running',
|
|
startedAt: '2026-07-10T08:00:00.000Z',
|
|
updatedAt: '2026-07-10T08:00:00.000Z',
|
|
sessionId: 'conversation-safety',
|
|
command: ['node', 'openclaude', '--session-id', 'conversation-safety'],
|
|
stdoutLogPath: '/tmp/stdout.log',
|
|
stderrLogPath: '/tmp/stderr.log',
|
|
}
|
|
const markedSession: BackgroundSession = {
|
|
...session,
|
|
processMarker: TEST_PROCESS_MARKER,
|
|
command: [
|
|
'node',
|
|
'openclaude',
|
|
backgroundProcessMarkerToken(TEST_PROCESS_MARKER),
|
|
'--session-id',
|
|
'conversation-safety',
|
|
],
|
|
}
|
|
|
|
function identity(
|
|
state: BackgroundSessionProcessIdentity['state'],
|
|
overrides: Partial<BackgroundSessionProcessIdentity> = {},
|
|
): BackgroundSessionProcessIdentity {
|
|
return {
|
|
state,
|
|
backgroundSessionId: session.id,
|
|
pid: session.pid,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
it('verifies the selected session immediately before SIGTERM', async () => {
|
|
const calls: string[] = []
|
|
let aliveChecks = 0
|
|
|
|
await terminateBackgroundSessionProcessTree(session, {
|
|
isProcessAlive: () => ++aliveChecks <= 2,
|
|
getProcessCommand: pid => {
|
|
calls.push(`verify:${pid}`)
|
|
return 'node openclaude --session-id conversation-safety'
|
|
},
|
|
killTree: async (pid, signal) => {
|
|
calls.push(`signal:${pid}:${signal}`)
|
|
},
|
|
sleep: async () => {},
|
|
termGraceMs: 1,
|
|
pollIntervalMs: 1,
|
|
})
|
|
|
|
expect(calls).toEqual([
|
|
'verify:4242',
|
|
'signal:4242:SIGTERM',
|
|
])
|
|
})
|
|
|
|
it('signals a marked session only after its exact token is freshly verified', async () => {
|
|
const calls: string[] = []
|
|
let aliveChecks = 0
|
|
|
|
await terminateBackgroundSessionProcessTree(markedSession, {
|
|
isProcessAlive: () => ++aliveChecks <= 2,
|
|
getProcessCommand: pid => {
|
|
calls.push(`verify:${pid}`)
|
|
return `node openclaude ${backgroundProcessMarkerToken(TEST_PROCESS_MARKER)} --session-id conversation-safety`
|
|
},
|
|
killTree: async (pid, signal) => {
|
|
calls.push(`signal:${pid}:${signal}`)
|
|
},
|
|
sleep: async () => {},
|
|
termGraceMs: 1,
|
|
pollIntervalMs: 1,
|
|
})
|
|
|
|
expect(calls).toEqual([
|
|
'verify:4242',
|
|
'signal:4242:SIGTERM',
|
|
])
|
|
})
|
|
|
|
it('refuses a mismatched identity before SIGTERM and does not mark killed', async () => {
|
|
const calls: string[] = []
|
|
|
|
let refusal: unknown
|
|
try {
|
|
await killBackgroundSession(
|
|
{
|
|
...session,
|
|
status: 'running',
|
|
command: [...session.command, 'private prompt value'],
|
|
},
|
|
{
|
|
isProcessAlive: () => true,
|
|
verifySessionIdentity: () => identity('mismatch'),
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
markKilled: async selected => {
|
|
calls.push(`mark:${selected.id}`)
|
|
return { ...selected, status: 'killed' }
|
|
},
|
|
},
|
|
)
|
|
} catch (error) {
|
|
refusal = error
|
|
}
|
|
|
|
expect(String(refusal)).toContain('refused to signal an unverified process')
|
|
expect(String(refusal)).toContain(
|
|
'This older background session could not be verified safely',
|
|
)
|
|
expect(String(refusal)).toContain('terminate PID 4242 manually')
|
|
expect(String(refusal)).not.toContain('private prompt value')
|
|
expect(calls).toEqual([])
|
|
})
|
|
|
|
it('does not signal or mark a marked session whose token mismatches', async () => {
|
|
const calls: string[] = []
|
|
|
|
await expect(
|
|
killBackgroundSession(markedSession, {
|
|
isProcessAlive: () => true,
|
|
getProcessCommand: () =>
|
|
`node openclaude ${backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)} --session-id conversation-safety`,
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
markKilled: async selected => {
|
|
calls.push(`mark:${selected.id}`)
|
|
return { ...selected, status: 'killed' }
|
|
},
|
|
}),
|
|
).rejects.toThrow('refused to signal an unverified process')
|
|
|
|
expect(calls).toEqual([])
|
|
})
|
|
|
|
it('does not verify or signal authoritative terminal records when a reused PID would match', async () => {
|
|
for (const status of ['killed', 'exited', 'failed'] as const) {
|
|
const calls: string[] = []
|
|
|
|
const killed = await killBackgroundSession(
|
|
{ ...session, status },
|
|
{
|
|
verifySessionIdentity: () => {
|
|
calls.push('verify')
|
|
return identity('matches')
|
|
},
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
markKilled: async selected => {
|
|
calls.push(`mark:${selected.id}`)
|
|
return { ...selected, status: 'killed' }
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(killed.status).toBe('killed')
|
|
expect(calls).toEqual(['mark:bg-safety'])
|
|
}
|
|
})
|
|
|
|
it('fails closed instead of marking a stale session whose identity mismatches', async () => {
|
|
const calls: string[] = []
|
|
|
|
await expect(
|
|
killBackgroundSession(
|
|
{ ...markedSession, status: 'stale' },
|
|
{
|
|
isProcessAlive: () => true,
|
|
getProcessCommand: () =>
|
|
`node openclaude ${backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)} --session-id conversation-safety`,
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
markKilled: async selected => {
|
|
calls.push(`mark:${selected.id}`)
|
|
return { ...selected, status: 'killed' }
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow('refused to signal an unverified process')
|
|
|
|
expect(calls).toEqual([])
|
|
})
|
|
|
|
it('refuses a matching legacy identity after the session was already stale', async () => {
|
|
const calls: string[] = []
|
|
|
|
await expect(
|
|
killBackgroundSession(
|
|
{ ...session, status: 'stale' },
|
|
{
|
|
isProcessAlive: () => true,
|
|
getProcessCommand: () =>
|
|
'node openclaude --resume conversation-safety',
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
markKilled: async selected => {
|
|
calls.push(`mark:${selected.id}`)
|
|
return { ...selected, status: 'killed' }
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow('PID ownership cannot be re-established safely')
|
|
|
|
expect(calls).toEqual([])
|
|
})
|
|
|
|
it('allows an exact marked identity to authorize a previously stale session', async () => {
|
|
const calls: string[] = []
|
|
const states: BackgroundSessionProcessIdentity['state'][] = [
|
|
'matches',
|
|
'matches',
|
|
]
|
|
|
|
const killed = await killBackgroundSession(
|
|
{ ...markedSession, status: 'stale' },
|
|
{
|
|
isProcessAlive: () => false,
|
|
verifySessionIdentity: () => {
|
|
const state = states.shift()!
|
|
calls.push(`verify:${state}`)
|
|
return identity(state)
|
|
},
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
markKilled: async selected => {
|
|
calls.push(`mark:${selected.id}`)
|
|
return { ...selected, status: 'killed' }
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(killed.status).toBe('killed')
|
|
expect(calls).toEqual([
|
|
'verify:matches',
|
|
'verify:matches',
|
|
'signal:SIGTERM',
|
|
'mark:bg-safety',
|
|
])
|
|
})
|
|
|
|
it('marks a stale session killed without signalling when its PID is gone', async () => {
|
|
const calls: string[] = []
|
|
|
|
const killed = await killBackgroundSession(
|
|
{ ...session, status: 'stale' },
|
|
{
|
|
isProcessAlive: () => false,
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
markKilled: async selected => {
|
|
calls.push(`mark:${selected.id}`)
|
|
return { ...selected, status: 'killed' }
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(killed.status).toBe('killed')
|
|
expect(calls).toEqual(['mark:bg-safety'])
|
|
})
|
|
|
|
it('freshly verifies an unknown session that becomes readable before killing', async () => {
|
|
const calls: string[] = []
|
|
const states: BackgroundSessionProcessIdentity['state'][] = [
|
|
'matches',
|
|
'matches',
|
|
]
|
|
|
|
const killed = await killBackgroundSession(
|
|
{ ...session, status: 'unknown' },
|
|
{
|
|
isProcessAlive: () => false,
|
|
verifySessionIdentity: () => {
|
|
const state = states.shift()!
|
|
calls.push(`verify:${state}`)
|
|
return identity(state)
|
|
},
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
markKilled: async selected => {
|
|
calls.push(`mark:${selected.id}`)
|
|
return { ...selected, status: 'killed' }
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(killed.status).toBe('killed')
|
|
expect(calls).toEqual([
|
|
'verify:matches',
|
|
'verify:matches',
|
|
'signal:SIGTERM',
|
|
'mark:bg-safety',
|
|
])
|
|
})
|
|
|
|
it('treats an unknown session that exits during fresh verification as terminated', async () => {
|
|
const calls: string[] = []
|
|
let aliveChecks = 0
|
|
|
|
const killed = await killBackgroundSession(
|
|
{ ...session, status: 'unknown' },
|
|
{
|
|
isProcessAlive: () => ++aliveChecks === 1,
|
|
getProcessCommand: () => null,
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
markKilled: async selected => {
|
|
calls.push(`mark:${selected.id}`)
|
|
return { ...selected, status: 'killed' }
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(killed.status).toBe('killed')
|
|
expect(calls).toEqual(['mark:bg-safety'])
|
|
})
|
|
|
|
it('does not escalate when identity changes during the SIGTERM grace period', async () => {
|
|
const calls: string[] = []
|
|
const states: BackgroundSessionProcessIdentity['state'][] = [
|
|
'matches',
|
|
'mismatch',
|
|
]
|
|
|
|
await expect(
|
|
terminateBackgroundSessionProcessTree(session, {
|
|
isProcessAlive: () => true,
|
|
verifySessionIdentity: () => {
|
|
const state = states.shift()!
|
|
calls.push(`verify:${state}`)
|
|
return identity(state)
|
|
},
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
sleep: async () => {
|
|
calls.push('sleep')
|
|
},
|
|
termGraceMs: 1,
|
|
pollIntervalMs: 1,
|
|
}),
|
|
).rejects.toThrow('refused to signal an unverified process')
|
|
|
|
expect(calls).toEqual([
|
|
'verify:matches',
|
|
'signal:SIGTERM',
|
|
'sleep',
|
|
'verify:mismatch',
|
|
])
|
|
})
|
|
|
|
it('does not send SIGKILL when a marked token changes after SIGTERM', async () => {
|
|
const calls: string[] = []
|
|
const commands = [
|
|
`node openclaude ${backgroundProcessMarkerToken(TEST_PROCESS_MARKER)} --session-id conversation-safety`,
|
|
`node openclaude ${backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)} --session-id conversation-safety`,
|
|
]
|
|
|
|
await expect(
|
|
terminateBackgroundSessionProcessTree(markedSession, {
|
|
isProcessAlive: () => true,
|
|
getProcessCommand: () => {
|
|
calls.push('verify')
|
|
return commands.shift()!
|
|
},
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
sleep: async () => {
|
|
calls.push('sleep')
|
|
},
|
|
termGraceMs: 1,
|
|
pollIntervalMs: 1,
|
|
}),
|
|
).rejects.toThrow('refused to signal an unverified process')
|
|
|
|
expect(calls).toEqual([
|
|
'verify',
|
|
'signal:SIGTERM',
|
|
'sleep',
|
|
'verify',
|
|
])
|
|
})
|
|
|
|
it('fails closed when the live process identity is unreadable', async () => {
|
|
const signals: Array<string | number> = []
|
|
|
|
await expect(
|
|
terminateBackgroundSessionProcessTree(session, {
|
|
isProcessAlive: () => true,
|
|
getProcessCommand: () => null,
|
|
killTree: async (_pid, signal) => {
|
|
signals.push(signal)
|
|
},
|
|
}),
|
|
).rejects.toThrow('refused to signal an unverified process')
|
|
|
|
expect(signals).toEqual([])
|
|
})
|
|
|
|
it('does not mark a marked session killed when identity is unreadable', async () => {
|
|
const calls: string[] = []
|
|
|
|
await expect(
|
|
killBackgroundSession(markedSession, {
|
|
isProcessAlive: () => true,
|
|
getProcessCommand: () => null,
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
markKilled: async selected => {
|
|
calls.push(`mark:${selected.id}`)
|
|
return { ...selected, status: 'killed' }
|
|
},
|
|
}),
|
|
).rejects.toThrow('refused to signal an unverified process')
|
|
|
|
expect(calls).toEqual([])
|
|
})
|
|
|
|
it('refuses signalling when liveness becomes unreadable during command lookup', async () => {
|
|
const calls: string[] = []
|
|
let probes = 0
|
|
|
|
await expect(
|
|
terminateBackgroundSessionProcessTree(session, {
|
|
signalProcess: () => {
|
|
probes++
|
|
calls.push(`probe:${probes}`)
|
|
if (probes > 1) {
|
|
throw Object.assign(new Error('access denied'), { code: 'EPERM' })
|
|
}
|
|
},
|
|
getProcessCommand: () => {
|
|
calls.push('command')
|
|
return 'node openclaude --session-id conversation-safety'
|
|
},
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
sleep: async () => {},
|
|
termGraceMs: 1,
|
|
pollIntervalMs: 1,
|
|
}),
|
|
).rejects.toThrow('refused to signal an unverified process')
|
|
|
|
expect(calls).toEqual(['probe:1', 'command', 'probe:2'])
|
|
})
|
|
|
|
it('succeeds without signalling when the process exits before verification', async () => {
|
|
const calls: string[] = []
|
|
|
|
const killed = await killBackgroundSession(session, {
|
|
isProcessAlive: () => false,
|
|
getProcessCommand: () => {
|
|
calls.push('command')
|
|
return null
|
|
},
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
markKilled: async selected => {
|
|
calls.push(`mark:${selected.id}`)
|
|
return { ...selected, status: 'killed' }
|
|
},
|
|
})
|
|
|
|
expect(killed.status).toBe('killed')
|
|
expect(calls).toEqual(['mark:bg-safety'])
|
|
})
|
|
|
|
it('accepts a natural exit after verification without a misleading error', async () => {
|
|
const calls: string[] = []
|
|
|
|
await terminateBackgroundSessionProcessTree(session, {
|
|
isProcessAlive: () => false,
|
|
verifySessionIdentity: () => {
|
|
calls.push('verify')
|
|
return identity('matches')
|
|
},
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
sleep: async () => {
|
|
calls.push('sleep')
|
|
},
|
|
termGraceMs: 1,
|
|
pollIntervalMs: 1,
|
|
})
|
|
|
|
expect(calls).toEqual(['verify', 'signal:SIGTERM'])
|
|
})
|
|
|
|
it('revalidates a stable identity immediately before SIGKILL', async () => {
|
|
const calls: string[] = []
|
|
let aliveChecks = 0
|
|
|
|
await terminateBackgroundSessionProcessTree(session, {
|
|
isProcessAlive: () => ++aliveChecks < 4,
|
|
verifySessionIdentity: () => {
|
|
calls.push('verify')
|
|
return identity('matches')
|
|
},
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
sleep: async () => {
|
|
calls.push('sleep')
|
|
},
|
|
termGraceMs: 1,
|
|
killGraceMs: 1,
|
|
pollIntervalMs: 1,
|
|
})
|
|
|
|
expect(calls).toEqual([
|
|
'verify',
|
|
'signal:SIGTERM',
|
|
'sleep',
|
|
'verify',
|
|
'signal:SIGKILL',
|
|
'sleep',
|
|
])
|
|
})
|
|
|
|
it('rejects stale verifier results for another session or PID', async () => {
|
|
for (const staleIdentity of [
|
|
identity('matches', { backgroundSessionId: 'bg-unrelated' }),
|
|
identity('matches', { pid: 9001 }),
|
|
]) {
|
|
const signals: Array<string | number> = []
|
|
|
|
await expect(
|
|
terminateBackgroundSessionProcessTree(session, {
|
|
isProcessAlive: () => true,
|
|
verifySessionIdentity: () => staleIdentity,
|
|
killTree: async (_pid, signal) => {
|
|
signals.push(signal)
|
|
},
|
|
}),
|
|
).rejects.toThrow('refused to signal an unverified process')
|
|
|
|
expect(signals).toEqual([])
|
|
}
|
|
})
|
|
|
|
it('sanitizes throwing injected identity verifiers without signalling or marking', async () => {
|
|
const calls: string[] = []
|
|
let refusal: unknown
|
|
|
|
try {
|
|
await killBackgroundSession(session, {
|
|
verifySessionIdentity: () => {
|
|
throw new Error('private verifier details')
|
|
},
|
|
killTree: async (_pid, signal) => {
|
|
calls.push(`signal:${signal}`)
|
|
},
|
|
markKilled: async selected => {
|
|
calls.push(`mark:${selected.id}`)
|
|
return { ...selected, status: 'killed' }
|
|
},
|
|
})
|
|
} catch (error) {
|
|
refusal = error
|
|
}
|
|
|
|
expect(String(refusal)).toContain('refused to signal an unverified process')
|
|
expect(String(refusal)).not.toContain('private verifier details')
|
|
expect(calls).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('background session log streaming', () => {
|
|
it('emits a multi-megabyte existing log exactly with bounded allocations', async () => {
|
|
await withTempFile('stdout.log', async path => {
|
|
const chunkSize = LOG_STREAM_CHUNK_SIZE
|
|
const contents = Buffer.alloc(chunkSize * 32 + 123)
|
|
for (let i = 0; i < contents.length; i++) contents[i] = i % 251
|
|
await writeFile(path, contents)
|
|
|
|
const allocations: number[] = []
|
|
const output = new TestOutput()
|
|
const offset = await printExistingLog(path, {
|
|
output,
|
|
chunkSize,
|
|
createBuffer: size => {
|
|
allocations.push(size)
|
|
return Buffer.alloc(size)
|
|
},
|
|
})
|
|
|
|
expect(offset).toBe(contents.length)
|
|
expect(output.bytes()).toEqual(contents)
|
|
expect(Math.max(...allocations)).toBeLessThanOrEqual(chunkSize)
|
|
expect(allocations.length).toBeGreaterThan(1)
|
|
})
|
|
})
|
|
|
|
it('follow mode emits existing and appended content exactly once in order', async () => {
|
|
await withTempFile('stdout.log', async path => {
|
|
const output = new TestOutput()
|
|
await writeFile(path, Buffer.from('existing-'))
|
|
|
|
const offset = await printExistingLog(path, { output, chunkSize: 4 })
|
|
const scheduler = createManualScheduler()
|
|
const abort = new AbortController()
|
|
const following = followLogFile(path, offset, {
|
|
output,
|
|
chunkSize: 4,
|
|
signal: abort.signal,
|
|
setInterval: scheduler.setInterval,
|
|
clearInterval: scheduler.clearInterval,
|
|
})
|
|
|
|
await writeFile(path, Buffer.from('existing-appended'))
|
|
scheduler.tick()
|
|
await waitFor(() => output.bytes().toString() === 'existing-appended')
|
|
abort.abort()
|
|
await following
|
|
|
|
expect(output.bytes().toString()).toBe('existing-appended')
|
|
})
|
|
})
|
|
|
|
it('splits a large appended range into bounded chunks', async () => {
|
|
await withTempFile('stdout.log', async path => {
|
|
const chunkSize = 16
|
|
await writeFile(path, Buffer.from('seed'))
|
|
const appended = Buffer.alloc(chunkSize * 2 + 5, 7)
|
|
await writeFile(path, Buffer.concat([Buffer.from('seed'), appended]))
|
|
|
|
const output = new TestOutput()
|
|
const scheduler = createManualScheduler()
|
|
const abort = new AbortController()
|
|
const following = followLogFile(path, 4, {
|
|
output,
|
|
chunkSize,
|
|
signal: abort.signal,
|
|
setInterval: scheduler.setInterval,
|
|
clearInterval: scheduler.clearInterval,
|
|
})
|
|
|
|
scheduler.tick()
|
|
await waitFor(() => output.bytes().length === appended.length)
|
|
abort.abort()
|
|
await following
|
|
|
|
expect(output.bytes()).toEqual(appended)
|
|
expect(output.chunks.map(chunk => chunk.length)).toEqual([16, 16, 5])
|
|
})
|
|
})
|
|
|
|
it('waits for drain before reading or writing more when stdout applies backpressure', async () => {
|
|
await withTempFile('stdout.log', async path => {
|
|
await writeFile(path, Buffer.from('abcdef'))
|
|
const output = new TestOutput()
|
|
output.writeResults.push(false)
|
|
|
|
let settled = false
|
|
const printing = printExistingLog(path, { output, chunkSize: 3 }).then(
|
|
offset => {
|
|
settled = true
|
|
return offset
|
|
},
|
|
)
|
|
|
|
await waitFor(() => output.chunks.length === 1)
|
|
expect(output.bytes().toString()).toBe('abc')
|
|
expect(settled).toBe(false)
|
|
|
|
output.emit('drain')
|
|
await expect(printing).resolves.toBe(6)
|
|
expect(output.bytes().toString()).toBe('abcdef')
|
|
expect(output.chunks.map(chunk => chunk.length)).toEqual([3, 3])
|
|
})
|
|
})
|
|
|
|
it('resets the follow read position when the log is truncated', async () => {
|
|
await withTempFile('stdout.log', async path => {
|
|
await writeFile(path, Buffer.from('abcdef'))
|
|
const output = new TestOutput()
|
|
const scheduler = createManualScheduler()
|
|
const abort = new AbortController()
|
|
const following = followLogFile(path, 6, {
|
|
output,
|
|
chunkSize: 8,
|
|
signal: abort.signal,
|
|
setInterval: scheduler.setInterval,
|
|
clearInterval: scheduler.clearInterval,
|
|
})
|
|
|
|
await writeFile(path, Buffer.from('xy'))
|
|
scheduler.tick()
|
|
await waitFor(() => output.bytes().toString() === 'xy')
|
|
abort.abort()
|
|
await following
|
|
|
|
expect(output.bytes().toString()).toBe('xy')
|
|
})
|
|
})
|
|
|
|
it('tolerates temporary file disappearance while following', async () => {
|
|
await withTempFile('stdout.log', async path => {
|
|
await writeFile(path, Buffer.from('seed'))
|
|
const output = new TestOutput()
|
|
const scheduler = createManualScheduler()
|
|
const abort = new AbortController()
|
|
const following = followLogFile(path, 4, {
|
|
output,
|
|
chunkSize: 8,
|
|
signal: abort.signal,
|
|
setInterval: scheduler.setInterval,
|
|
clearInterval: scheduler.clearInterval,
|
|
})
|
|
|
|
await unlink(path)
|
|
scheduler.tick()
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
expect(output.bytes().length).toBe(0)
|
|
|
|
await writeFile(path, Buffer.from('new'))
|
|
scheduler.tick()
|
|
await waitFor(() => output.bytes().toString() === 'new')
|
|
abort.abort()
|
|
await following
|
|
|
|
expect(output.bytes().toString()).toBe('new')
|
|
})
|
|
})
|
|
|
|
it('prevents writes after signal cleanup during an in-flight poll', async () => {
|
|
const output = new TestOutput()
|
|
const scheduler = createManualScheduler()
|
|
const abort = new AbortController()
|
|
const readStarted = deferred()
|
|
const releaseRead = deferred()
|
|
let closed = false
|
|
|
|
const handle = {
|
|
stat: async () => ({ size: 4 }),
|
|
read: async (buffer: Buffer) => {
|
|
readStarted.resolve()
|
|
await releaseRead.promise
|
|
buffer.write('late')
|
|
return { bytesRead: 4, buffer }
|
|
},
|
|
close: async () => {
|
|
closed = true
|
|
},
|
|
}
|
|
|
|
let resolved = false
|
|
const following = followLogFile('/tmp/stdout.log', 0, {
|
|
output,
|
|
chunkSize: 4,
|
|
signal: abort.signal,
|
|
setInterval: scheduler.setInterval,
|
|
clearInterval: scheduler.clearInterval,
|
|
openFile: async () => handle,
|
|
}).then(() => {
|
|
resolved = true
|
|
})
|
|
|
|
scheduler.tick()
|
|
await readStarted.promise
|
|
abort.abort()
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
expect(resolved).toBe(false)
|
|
releaseRead.resolve()
|
|
await following
|
|
scheduler.tick()
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
|
expect(scheduler.cleared).toBe(true)
|
|
expect(closed).toBe(true)
|
|
expect(output.bytes().length).toBe(0)
|
|
})
|
|
|
|
it('preserves streamed progress when a later close failure occurs', async () => {
|
|
const output = new TestOutput()
|
|
const scheduler = createManualScheduler()
|
|
const abort = new AbortController()
|
|
let openCount = 0
|
|
|
|
const following = followLogFile('/tmp/stdout.log', 0, {
|
|
output,
|
|
chunkSize: 4,
|
|
signal: abort.signal,
|
|
setInterval: scheduler.setInterval,
|
|
clearInterval: scheduler.clearInterval,
|
|
openFile: async () => {
|
|
openCount++
|
|
return {
|
|
stat: async () => ({ size: 4 }),
|
|
read: async (buffer: Buffer) => {
|
|
buffer.write('once')
|
|
return { bytesRead: 4 }
|
|
},
|
|
close: async () => {
|
|
if (openCount === 1) throw new Error('close failed')
|
|
},
|
|
}
|
|
},
|
|
})
|
|
|
|
scheduler.tick()
|
|
await waitFor(() => output.bytes().toString() === 'once')
|
|
scheduler.tick()
|
|
await waitFor(() => openCount === 2)
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
abort.abort()
|
|
await following
|
|
|
|
expect(output.bytes().toString()).toBe('once')
|
|
})
|
|
|
|
it('surfaces non-follow file read failures', async () => {
|
|
let closed = false
|
|
const readError = Object.assign(new Error('read failed'), {
|
|
code: 'EIO',
|
|
})
|
|
|
|
await expect(
|
|
printExistingLog('/tmp/stdout.log', {
|
|
chunkSize: 4,
|
|
openFile: async () => ({
|
|
stat: async () => ({ size: 4 }),
|
|
read: async () => {
|
|
throw readError
|
|
},
|
|
close: async () => {
|
|
closed = true
|
|
},
|
|
}),
|
|
}),
|
|
).rejects.toThrow('read failed')
|
|
expect(closed).toBe(true)
|
|
})
|
|
|
|
it('handles EPIPE and destroyed stdout without throwing', async () => {
|
|
await withTempFile('stdout.log', async path => {
|
|
await writeFile(path, Buffer.from('closed-pipe'))
|
|
|
|
const epipeOutput = new TestOutput()
|
|
epipeOutput.writeError = Object.assign(new Error('broken pipe'), {
|
|
code: 'EPIPE',
|
|
})
|
|
await expect(
|
|
printExistingLog(path, { output: epipeOutput, chunkSize: 4 }),
|
|
).resolves.toBe(0)
|
|
|
|
const destroyedOutput = new TestOutput()
|
|
destroyedOutput.destroyed = true
|
|
await expect(
|
|
printExistingLog(path, { output: destroyedOutput, chunkSize: 4 }),
|
|
).resolves.toBe(0)
|
|
expect(destroyedOutput.bytes().length).toBe(0)
|
|
})
|
|
})
|
|
})
|