From b5e761e209644164e85e4604f03ffc3e932826ba Mon Sep 17 00:00:00 2001 From: chioarub Date: Sat, 15 Aug 2026 21:07:42 +0300 Subject: [PATCH 1/2] fix(bg): preserve detached session terminal outcomes --- README.md | 8 +- src/cli/bg.test.ts | 69 ++- src/cli/bg.ts | 99 +++- src/cli/bgFinalizer.fixture.ts | 36 ++ src/cli/bgFinalizer.test.ts | 527 ++++++++++++++++++++++ src/cli/bgFinalizer.ts | 233 ++++++++++ src/cli/bgRegistry.test.ts | 467 +++++++++++++++++++ src/cli/bgRegistry.ts | 455 ++++++++++++++++++- src/cli/print.ts | 2 + src/entrypoints/cli.test.ts | 24 + src/entrypoints/cli.tsx | 14 +- src/utils/backgroundSessionTermination.ts | 27 ++ src/utils/gracefulShutdown.ts | 4 + 13 files changed, 1945 insertions(+), 20 deletions(-) create mode 100644 src/cli/bgFinalizer.fixture.ts create mode 100644 src/cli/bgFinalizer.test.ts create mode 100644 src/cli/bgFinalizer.ts create mode 100644 src/utils/backgroundSessionTermination.ts diff --git a/README.md b/README.md index 9983154727..b3bb1554a7 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,13 @@ usually `~/.openclaude/bg-sessions/`; `OPENCLAUDE_CONFIG_DIR` can point OpenClaude somewhere else. `CLAUDE_CONFIG_DIR` is ignored for OpenClaude background-session storage. Session names can be reused after older sessions reach a terminal state; use the session ID to inspect older logs with the same -name. +name. A naturally finished session is recorded as `exited` when its process +returns zero and `failed` when it returns nonzero or handles a termination +signal. `stale` remains the conservative result when the process disappears +without an observed outcome; an explicit successful `openclaude kill` is +recorded as `killed`. OpenClaude does not infer POSIX signal names on Windows. +Unobservable force termination, host crashes, and power loss remain `stale` on +every platform. `openclaude attach ` currently reports the matching session and points to `openclaude logs -f`; full terminal reattach is not implemented diff --git a/src/cli/bg.test.ts b/src/cli/bg.test.ts index 3288537351..378a28ffe5 100644 --- a/src/cli/bg.test.ts +++ b/src/cli/bg.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from 'bun:test' import { buildBackgroundSessionLaunch, buildBackgroundChildProcessConfig, + confirmBackgroundSessionLaunch, followLogFile, killBackgroundSession, printExistingLog, @@ -453,7 +454,7 @@ describe('background session CLI parsing', () => { }) }) - it('preserves Node exec flags and lets the launcher manage heap relaunch state', () => { + 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'], @@ -465,6 +466,8 @@ describe('background session CLI parsing', () => { }, sessionName: 'tests', stdoutLogPath: '/tmp/bg.out.log', + backgroundSessionId: 'bg-tests', + launcherPid: 700, }) expect(config.command).toBe('/usr/bin/node') @@ -475,11 +478,37 @@ describe('background session CLI parsing', () => { '--print', 'fix failing tests', ]) - expect(config.env.OPENCLAUDE_HEAP_RELAUNCHED).toBeUndefined() + 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.OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID).toBe( + 'bg-tests', + ) + expect(config.env.OPENCLAUDE_INTERNAL_BACKGROUND_LAUNCHER_PID).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', + 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('escalates process-tree termination and waits for exit before returning', async () => { @@ -502,6 +531,42 @@ describe('background session CLI parsing', () => { 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', + ) + expect(calls).toEqual([ + 'refresh', + 'resolve:bg-finalizer-not-installed', + ]) + }) }) describe('background session process termination safety', () => { diff --git a/src/cli/bg.ts b/src/cli/bg.ts index 13e46b1937..f53f59ca25 100644 --- a/src/cli/bg.ts +++ b/src/cli/bg.ts @@ -50,6 +50,8 @@ export type BuildBackgroundChildProcessConfigInput = { processEnv: NodeJS.ProcessEnv sessionName?: string stdoutLogPath: string + backgroundSessionId: string + launcherPid?: number } type PrResumeSelector = true | string @@ -61,6 +63,12 @@ export type BuildBackgroundSessionLaunchDeps = { } const HEAP_RELAUNCHED_ENV = 'OPENCLAUDE_HEAP_RELAUNCHED' +const HEAP_SIZE_ENV = 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB' +const DEFAULT_HEAP_SIZE_MB = 8192 +const BACKGROUND_SESSION_ID_ENV = + 'OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID' +const BACKGROUND_SESSION_LAUNCHER_PID_ENV = + 'OPENCLAUDE_INTERNAL_BACKGROUND_LAUNCHER_PID' const DEFAULT_TERM_GRACE_MS = 2_000 const DEFAULT_KILL_GRACE_MS = 2_000 const DEFAULT_KILL_POLL_INTERVAL_MS = 100 @@ -165,13 +173,43 @@ const SPACE_OPTIONAL_VALUE_FLAGS = new Set([ '-r', ]) -function safeNodeExecArgvForBackground(execArgv: string[]): string[] { - return execArgv.filter( +function isNodeExecutable(execPath: string): boolean { + return /^node(?:\.exe)?$/i.test(basename(execPath)) +} + +function hasNodeFlag(args: string[], flag: string): boolean { + return args.some(arg => arg === flag || arg.startsWith(`${flag}=`)) +} + +function safeNodeExecArgvForBackground( + execPath: string, + execArgv: string[], + processEnv: NodeJS.ProcessEnv, +): string[] { + const safeArgs = execArgv.filter( arg => arg === '--expose-gc' || arg.startsWith('--max-old-space-size') || arg.startsWith('--heapsnapshot-near-heap-limit'), ) + if (!isNodeExecutable(execPath)) return safeArgs + + const nodeOptions = (processEnv.NODE_OPTIONS ?? '') + .split(/\s+/) + .filter(Boolean) + const effectiveArgs = [...safeArgs, ...nodeOptions] + if (!hasNodeFlag(effectiveArgs, '--max-old-space-size')) { + const configuredHeap = Number.parseInt(processEnv[HEAP_SIZE_ENV] ?? '', 10) + const heapSize = + Number.isSafeInteger(configuredHeap) && configuredHeap > 0 + ? configuredHeap + : DEFAULT_HEAP_SIZE_MB + safeArgs.push(`--max-old-space-size=${heapSize}`) + } + if (!hasNodeFlag(effectiveArgs, '--expose-gc')) { + safeArgs.push('--expose-gc') + } + return safeArgs } export function buildBackgroundChildProcessConfig( @@ -185,13 +223,28 @@ export function buildBackgroundChildProcessConfig( ...(input.sessionName ? { CLAUDE_CODE_SESSION_NAME: input.sessionName } : {}), + [BACKGROUND_SESSION_ID_ENV]: input.backgroundSessionId, + [BACKGROUND_SESSION_LAUNCHER_PID_ENV]: String( + input.launcherPid ?? process.pid, + ), + } + if (isNodeExecutable(input.execPath)) { + // Keep the registered detached PID stable. The installed launcher normally + // already supplied these flags to its parent; safeNodeExecArgvForBackground + // fills either one if needed before preventing another spawnSync relaunch. + env[HEAP_RELAUNCHED_ENV] = '1' + } else { + delete env[HEAP_RELAUNCHED_ENV] } - delete env[HEAP_RELAUNCHED_ENV] return { command: input.execPath, args: [ - ...safeNodeExecArgvForBackground(input.execArgv), + ...safeNodeExecArgvForBackground( + input.execPath, + input.execArgv, + input.processEnv, + ), input.entrypoint, ...input.childArgs, ], @@ -882,6 +935,30 @@ export async function killBackgroundSession( return await markKilled(session) } +type ConfirmBackgroundSessionLaunchOptions = { + isProcessAlive?: (pid: number) => boolean + refreshStatuses?: () => Promise + resolveSession?: (id: string) => Promise +} + +export async function confirmBackgroundSessionLaunch( + session: BackgroundSession, + options: ConfirmBackgroundSessionLaunchOptions = {}, +): Promise { + if ((options.isProcessAlive ?? isProcessRunning)(session.pid)) return session + + await (options.refreshStatuses ?? refreshBackgroundSessionStatuses)() + const resolved = await ( + options.resolveSession ?? resolveBackgroundSession + )(session.id) + if (resolved.status === 'stale') { + throw new Error( + `Background session ${session.id} exited before finalization was installed`, + ) + } + return resolved +} + export async function psHandler(_args: string[]): Promise { const sessions = await refreshBackgroundSessionStatuses() printSessionTable(sessions) @@ -978,6 +1055,8 @@ export async function handleBgFlag(args: string[]): Promise { processEnv: process.env, sessionName: parsed.name, stdoutLogPath: logPaths.stdoutLogPath, + backgroundSessionId: id, + launcherPid: process.pid, }) let stdoutFd: number | undefined @@ -1023,7 +1102,7 @@ export async function handleBgFlag(args: string[]): Promise { } const command = [childConfig.command, ...childConfig.args] - const session = await createBackgroundSession({ + let session = await createBackgroundSession({ id, name: parsed.name, pid: child.pid, @@ -1041,7 +1120,15 @@ export async function handleBgFlag(args: string[]): Promise { fail(errorMessage(error)) }) - console.log(`Started background session ${session.id}.`) + session = await confirmBackgroundSessionLaunch(session).catch(error => { + fail(errorMessage(error)) + }) + + console.log( + isTerminalBackgroundSession(session) + ? `Background session ${session.id} finished with status ${session.status}.` + : `Started background session ${session.id}.`, + ) if (session.name) console.log(`Name: ${session.name}`) console.log(`PID: ${session.pid}`) console.log(`Logs: ${session.stdoutLogPath}`) diff --git a/src/cli/bgFinalizer.fixture.ts b/src/cli/bgFinalizer.fixture.ts new file mode 100644 index 0000000000..72072a950d --- /dev/null +++ b/src/cli/bgFinalizer.fixture.ts @@ -0,0 +1,36 @@ +import { writeFile } from 'node:fs/promises' +import { handleBgFlag } from './bg.js' +import { prepareBackgroundSessionFinalizer } from './bgFinalizer.js' +import { noteBackgroundSessionTerminationSignal } from '../utils/backgroundSessionTermination.js' + +const invocation = process.argv.slice(2) +if (invocation[0] === 'launcher') { + await handleBgFlag(['--bg', invocation[1] ?? 'success']) +} else { + const mode = invocation.at(-1) + await prepareBackgroundSessionFinalizer() + + if (mode === 'throw') { + throw new Error('intentional background finalizer fixture failure') + } + if (mode === 'fail') { + process.exitCode = 23 + } + if (mode === 'sigint') { + process.once('SIGINT', () => { + noteBackgroundSessionTerminationSignal('SIGINT') + process.exit(0) + }) + } + if (mode === 'sigterm') { + process.once('SIGTERM', () => { + noteBackgroundSessionTerminationSignal('SIGTERM') + process.exit(143) + }) + } + const readyPath = process.env.OPENCLAUDE_BG_FINALIZER_FIXTURE_READY + if (readyPath) await writeFile(readyPath, 'ready') + if (mode === 'wait' || mode === 'sigint' || mode === 'sigterm') { + setInterval(() => {}, 1_000) + } +} diff --git a/src/cli/bgFinalizer.test.ts b/src/cli/bgFinalizer.test.ts new file mode 100644 index 0000000000..50ab4426bf --- /dev/null +++ b/src/cli/bgFinalizer.test.ts @@ -0,0 +1,527 @@ +import { once } from 'node:events' +import { spawn } from 'node:child_process' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { + BACKGROUND_SESSION_ID_ENV, + BACKGROUND_SESSION_LAUNCHER_PID_ENV, + prepareBackgroundSessionFinalizer, +} from './bgFinalizer.js' +import { buildBackgroundChildProcessConfig } from './bg.js' +import { + _setBackgroundSessionsRootForTesting, + listBackgroundSessions, + refreshBackgroundSessionStatuses, + type BackgroundSession, +} from './bgRegistry.js' + +const fixturePath = join(import.meta.dir, 'bgFinalizer.fixture.ts') +const installedLauncherPath = join(import.meta.dir, '../../bin/openclaude') + +describe('background session finalizer', () => { + let configDir: string + let sessionsRoot: string + + beforeEach(async () => { + configDir = await mkdtemp(join(tmpdir(), 'openclaude-bg-finalizer-')) + sessionsRoot = join(configDir, 'bg-sessions') + _setBackgroundSessionsRootForTesting(sessionsRoot) + }) + + afterEach(async () => { + _setBackgroundSessionsRootForTesting(undefined) + await rm(configDir, { recursive: true, force: true }) + }) + + function ownedSession(id: string, pid: number): BackgroundSession { + return { + id, + pid, + cwd: '/repo', + status: 'running', + sessionId: `conversation-${id}`, + startedAt: '2026-08-15T08:00:00.000Z', + updatedAt: '2026-08-15T08:00:00.000Z', + command: ['openclaude', '--print', 'work'], + stdoutLogPath: '/tmp/stdout.log', + stderrLogPath: '/tmp/stderr.log', + } + } + + it('ignores missing and invalid private routing metadata', async () => { + expect( + await prepareBackgroundSessionFinalizer({ env: {} }), + ).toBe('not-background') + + const env = { + [BACKGROUND_SESSION_ID_ENV]: '../unsafe', + [BACKGROUND_SESSION_LAUNCHER_PID_ENV]: '123', + } + expect(await prepareBackgroundSessionFinalizer({ env })).toBe( + 'invalid-routing', + ) + expect(env[BACKGROUND_SESSION_ID_ENV]).toBeUndefined() + expect(env[BACKGROUND_SESSION_LAUNCHER_PID_ENV]).toBeUndefined() + }) + + it('falls through when routing metadata does not own this PID', async () => { + const env = { + [BACKGROUND_SESSION_ID_ENV]: 'bg-mismatch', + [BACKGROUND_SESSION_LAUNCHER_PID_ENV]: '123', + } + const preparation = await prepareBackgroundSessionFinalizer({ + env, + pid: 500, + readSession: async () => ownedSession('bg-mismatch', 501), + isLauncherAlive: () => true, + }) + + expect(preparation).toBe('invalid-routing') + expect(env[BACKGROUND_SESSION_ID_ENV]).toBeUndefined() + }) + + it('accepts a PID 1 launcher while waiting for exact ownership', async () => { + const env = { + [BACKGROUND_SESSION_ID_ENV]: 'bg-pid-one-launcher', + [BACKGROUND_SESSION_LAUNCHER_PID_ENV]: '1', + } + let reads = 0 + + const preparation = await prepareBackgroundSessionFinalizer({ + env, + pid: 500, + readSession: async () => + ++reads === 1 ? null : ownedSession('bg-pid-one-launcher', 500), + sleep: async () => {}, + registrationWaitMs: 2, + registrationPollMs: 1, + registerCleanup: () => () => {}, + onBeforeExit: () => {}, + onExit: () => {}, + }) + + expect(preparation).toBe('installed') + expect(reads).toBe(2) + }) + + it('rechecks exact ownership before timing out after launcher exit', async () => { + const env = { + [BACKGROUND_SESSION_ID_ENV]: 'bg-launcher-exit-race', + [BACKGROUND_SESSION_LAUNCHER_PID_ENV]: '123', + } + let reads = 0 + + const preparation = await prepareBackgroundSessionFinalizer({ + env, + pid: 500, + readSession: async () => + ++reads === 1 ? null : ownedSession('bg-launcher-exit-race', 500), + isLauncherAlive: () => false, + sleep: async () => {}, + registrationWaitMs: 2, + registrationPollMs: 1, + registerCleanup: () => () => {}, + onBeforeExit: () => {}, + onExit: () => {}, + }) + + expect(preparation).toBe('installed') + expect(reads).toBe(2) + }) + + it('fails after the bounded registration wait without exact ownership', async () => { + const env = { + [BACKGROUND_SESSION_ID_ENV]: 'bg-registration-timeout', + [BACKGROUND_SESSION_LAUNCHER_PID_ENV]: '123', + } + let reads = 0 + + await expect( + prepareBackgroundSessionFinalizer({ + env, + pid: 500, + readSession: async () => { + reads += 1 + return null + }, + isLauncherAlive: () => true, + sleep: async () => {}, + registrationWaitMs: 2, + registrationPollMs: 1, + }), + ).rejects.toThrow('Background session registration was not established') + expect(reads).toBe(3) + expect(env[BACKGROUND_SESSION_ID_ENV]).toBeUndefined() + expect(env[BACKGROUND_SESSION_LAUNCHER_PID_ENV]).toBeUndefined() + }) + + it('waits for exact ownership before installing awaited and sync finalizers', async () => { + const env = { + [BACKGROUND_SESSION_ID_ENV]: 'bg-owned', + [BACKGROUND_SESSION_LAUNCHER_PID_ENV]: '123', + } + let reads = 0 + let cleanup: (() => void | Promise) | undefined + let beforeExitListener: (() => void | Promise) | undefined + let exitListener: ((code: number) => void) | undefined + const finalized: number[] = [] + const finalizedSync: number[] = [] + + const preparation = await prepareBackgroundSessionFinalizer({ + env, + pid: 500, + readSession: async () => + ++reads < 3 ? null : ownedSession('bg-owned', 500), + isLauncherAlive: () => true, + sleep: async () => {}, + registrationWaitMs: 10, + registrationPollMs: 1, + registerCleanup: fn => { + cleanup = fn + return () => {} + }, + onBeforeExit: listener => { + beforeExitListener = listener + }, + onExit: listener => { + exitListener = listener + }, + finalize: async (_id, termination) => { + finalized.push(termination.exitCode ?? -1) + return ownedSession('bg-owned', 500) + }, + finalizeSync: (_id, termination) => { + finalizedSync.push(termination.exitCode ?? -1) + }, + }) + + expect(preparation).toBe('installed') + expect(reads).toBe(3) + expect(env[BACKGROUND_SESSION_ID_ENV]).toBeUndefined() + const previousExitCode = process.exitCode + process.exitCode = '7' + try { + await beforeExitListener?.() + await cleanup?.() + exitListener?.(7) + } finally { + process.exitCode = previousExitCode ?? 0 + } + expect(finalized).toEqual([7]) + expect(finalizedSync).toEqual([]) + }) + + it('keeps the original exit code when both persistence paths fail', async () => { + let cleanup: (() => void | Promise) | undefined + let exitListener: ((code: number) => void) | undefined + const diagnostics: string[] = [] + const previousExitCode = process.exitCode + process.exitCode = 29 + try { + await prepareBackgroundSessionFinalizer({ + env: { + [BACKGROUND_SESSION_ID_ENV]: 'bg-write-failure', + [BACKGROUND_SESSION_LAUNCHER_PID_ENV]: '123', + }, + pid: 500, + readSession: async () => ownedSession('bg-write-failure', 500), + isLauncherAlive: () => true, + registerCleanup: fn => { + cleanup = fn + return () => {} + }, + onBeforeExit: () => {}, + onExit: listener => { + exitListener = listener + }, + finalize: async () => { + throw Object.assign(new Error('private path /secret'), { code: 'EIO' }) + }, + finalizeSync: () => { + throw new Error('private sync details') + }, + debug: message => { + diagnostics.push(message) + throw new Error('diagnostic sink failed') + }, + }) + + await cleanup?.() + exitListener?.(29) + expect(process.exitCode).toBe(29) + } finally { + process.exitCode = previousExitCode ?? 0 + } + expect(diagnostics).toHaveLength(2) + expect(diagnostics[0]).toContain('(EIO)') + expect(diagnostics.join('\n')).not.toContain('/secret') + expect(diagnostics.join('\n')).not.toContain('private sync details') + }) + + it('records an observed shutdown signal instead of a successful exit code', async () => { + let cleanup: (() => void | Promise) | undefined + let termination: { exitCode?: number; signal?: string } | undefined + const previousExitCode = process.exitCode + process.exitCode = 0 + try { + await prepareBackgroundSessionFinalizer({ + env: { + [BACKGROUND_SESSION_ID_ENV]: 'bg-observed-sigint', + [BACKGROUND_SESSION_LAUNCHER_PID_ENV]: '123', + }, + pid: 500, + readSession: async () => ownedSession('bg-observed-sigint', 500), + isLauncherAlive: () => true, + registerCleanup: fn => { + cleanup = fn + return () => {} + }, + onBeforeExit: () => {}, + onExit: () => {}, + getObservedSignal: () => 'SIGINT', + finalize: async (_id, observed) => { + termination = observed + return ownedSession('bg-observed-sigint', 500) + }, + }) + + await cleanup?.() + } finally { + process.exitCode = previousExitCode ?? 0 + } + expect(termination).toEqual({ signal: 'SIGINT' }) + }) + + async function runFixture( + mode: 'success' | 'fail' | 'throw' | 'wait' | 'sigint' | 'sigterm', + ): Promise<{ + id: string + child: ReturnType + readyPath: string + }> { + const id = `bg-fixture-${mode}` + const readyPath = join(configDir, `${id}.ready`) + const child = spawn(process.execPath, [fixturePath, mode], { + env: { + ...process.env, + OPENCLAUDE_CONFIG_DIR: configDir, + [BACKGROUND_SESSION_ID_ENV]: id, + [BACKGROUND_SESSION_LAUNCHER_PID_ENV]: String(process.pid), + OPENCLAUDE_BG_FINALIZER_FIXTURE_READY: readyPath, + }, + stdio: ['ignore', 'ignore', 'ignore'], + }) + if (!child.pid) throw new Error('fixture did not start') + + await mkdir(join(sessionsRoot, 'sessions'), { recursive: true }) + await writeFile( + join(sessionsRoot, 'sessions', `${id}.json`), + JSON.stringify(ownedSession(id, child.pid)), + ) + return { id, child, readyPath } + } + + async function waitForFile(path: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (await Bun.file(path).exists()) return + await new Promise(resolve => setTimeout(resolve, 5)) + } + throw new Error('fixture readiness file was not created') + } + + async function runBuiltCliSession( + id: string, + args: string[], + ): Promise { + const processEnv: NodeJS.ProcessEnv = { + ...process.env, + OPENCLAUDE_CONFIG_DIR: configDir, + } + delete processEnv.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN + const childConfig = buildBackgroundChildProcessConfig({ + execPath: 'node', + execArgv: [], + entrypoint: installedLauncherPath, + childArgs: args, + processEnv, + stdoutLogPath: join(configDir, `${id}.out.log`), + backgroundSessionId: id, + launcherPid: process.pid, + }) + const child = spawn(childConfig.command, childConfig.args, { + env: childConfig.env, + stdio: ['ignore', 'ignore', 'ignore'], + }) + if (!child.pid) throw new Error('built CLI fixture did not start') + + await mkdir(join(sessionsRoot, 'sessions'), { recursive: true }) + await writeFile( + join(sessionsRoot, 'sessions', `${id}.json`), + JSON.stringify(ownedSession(id, child.pid)), + ) + const [code] = (await once(child, 'exit')) as [number] + return code + } + + async function waitForTerminalSession(): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const [session] = await listBackgroundSessions() + if ( + session && + (session.status === 'exited' || session.status === 'failed') + ) { + return session + } + await new Promise(resolve => setTimeout(resolve, 5)) + } + throw new Error('detached fixture did not persist a terminal outcome') + } + + async function runDetachedLaunch( + mode: 'success' | 'fail', + ): Promise<{ stdout: string; session: BackgroundSession }> { + const launcher = spawn(process.execPath, [fixturePath, 'launcher', mode], { + env: { + ...process.env, + OPENCLAUDE_CONFIG_DIR: configDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + launcher.stdout?.setEncoding('utf8') + launcher.stderr?.setEncoding('utf8') + launcher.stdout?.on('data', chunk => { + stdout += chunk + }) + launcher.stderr?.on('data', chunk => { + stderr += chunk + }) + const [code] = (await once(launcher, 'exit')) as [number] + expect(code).toBe(0) + expect(stderr).toBe('') + return { stdout, session: await waitForTerminalSession() } + } + + for (const expectation of [ + { mode: 'success' as const, status: 'exited', exitCode: 0 }, + { mode: 'fail' as const, status: 'failed', exitCode: 23 }, + { mode: 'throw' as const, status: 'failed', exitCode: 1 }, + ]) { + it(`records a real ${expectation.mode} child outcome`, async () => { + const { id, child } = await runFixture(expectation.mode) + const [code] = (await once(child, 'exit')) as [number] + expect(code).toBe(expectation.exitCode) + + expect((await listBackgroundSessions())[0]).toMatchObject({ + id, + status: expectation.status, + exitCode: expectation.exitCode, + terminalReason: 'exit_code', + }) + }) + } + + for (const expectation of [ + { + mode: 'sigint' as const, + signal: 'SIGINT' as NodeJS.Signals, + childExitCode: 0, + }, + { + mode: 'sigterm' as const, + signal: 'SIGTERM' as NodeJS.Signals, + childExitCode: 143, + }, + ]) { + it(`records an observed ${expectation.signal} as a failed signal fact`, async () => { + const { id, child, readyPath } = await runFixture(expectation.mode) + await waitForFile(readyPath) + child.kill(expectation.signal) + const [code, signal] = (await once(child, 'exit')) as [ + number, + NodeJS.Signals | null, + ] + expect(code).toBe(expectation.childExitCode) + expect(signal).toBeNull() + expect((await listBackgroundSessions())[0]).toMatchObject({ + id, + status: 'failed', + signal: expectation.signal, + terminalReason: 'signal', + }) + expect('exitCode' in (await listBackgroundSessions())[0]!).toBe(false) + }) + } + + for (const expectation of [ + { mode: 'success' as const, status: 'exited', exitCode: 0 }, + { mode: 'fail' as const, status: 'failed', exitCode: 23 }, + ]) { + it(`finalizes a detached ${expectation.mode} launch through handleBgFlag`, async () => { + const { stdout, session } = await runDetachedLaunch(expectation.mode) + + expect(stdout).toMatch( + new RegExp( + `(?:Started background session ${session.id}\\.|Background session ${session.id} finished with status ${expectation.status}\\.)`, + ), + ) + expect(session).toMatchObject({ + status: expectation.status, + exitCode: expectation.exitCode, + terminalReason: 'exit_code', + }) + expect(await Bun.file(session.stdoutLogPath).exists()).toBe(true) + expect(await Bun.file(session.stderrLogPath).exists()).toBe(true) + }) + } + + it('keeps the installed launcher PID stable and shows outcomes truthfully in ps', async () => { + expect(await runBuiltCliSession('bg-built-success', ['--version'])).toBe(0) + expect( + await runBuiltCliSession('bg-built-failure', [ + '--provider-env-file', + join(configDir, 'missing.env'), + '--print', + 'noop', + ]), + ).toBe(1) + + const ps = spawn('node', [installedLauncherPath, 'ps'], { + env: { ...process.env, OPENCLAUDE_CONFIG_DIR: configDir }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + ps.stdout?.setEncoding('utf8') + ps.stderr?.setEncoding('utf8') + ps.stdout?.on('data', chunk => { + stdout += chunk + }) + ps.stderr?.on('data', chunk => { + stderr += chunk + }) + const [psCode] = (await once(ps, 'exit')) as [number] + + expect(psCode).toBe(0) + expect(stderr).toBe('') + expect(stdout).toMatch(/bg-built-success\s+exited/) + expect(stdout).toMatch(/bg-built-failure\s+failed/) + }) + + it('does not invent success when a fixture is forcibly destroyed', async () => { + const { child, readyPath } = await runFixture('wait') + await waitForFile(readyPath) + child.kill('SIGKILL') + await once(child, 'exit') + + const refreshed = await refreshBackgroundSessionStatuses({ + isProcessAlive: () => false, + }) + expect(refreshed[0]?.status).toBe('stale') + expect('exitCode' in refreshed[0]!).toBe(false) + expect('terminalReason' in refreshed[0]!).toBe(false) + }) +}) diff --git a/src/cli/bgFinalizer.ts b/src/cli/bgFinalizer.ts new file mode 100644 index 0000000000..0a41ca323e --- /dev/null +++ b/src/cli/bgFinalizer.ts @@ -0,0 +1,233 @@ +import { isProcessRunning } from '../utils/genericProcessUtils.js' +import { logForDebugging } from '../utils/debug.js' +import { registerCleanup } from '../utils/cleanupRegistry.js' +import { + beginBackgroundSessionSignalTracking, + type ObservedBackgroundSessionSignal, +} from '../utils/backgroundSessionTermination.js' +import { + readBackgroundSessionForOwner, + recordBackgroundSessionNaturalTermination, + recordBackgroundSessionNaturalTerminationSync, + type BackgroundSession, +} from './bgRegistry.js' + +export const BACKGROUND_SESSION_ID_ENV = + 'OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID' +export const BACKGROUND_SESSION_LAUNCHER_PID_ENV = + 'OPENCLAUDE_INTERNAL_BACKGROUND_LAUNCHER_PID' + +const SAFE_ID_RE = /^[A-Za-z0-9._-]+$/ +const DEFAULT_REGISTRATION_WAIT_MS = 5_000 +const DEFAULT_REGISTRATION_POLL_MS = 10 + +type PrepareBackgroundSessionFinalizerOptions = { + env?: NodeJS.ProcessEnv + pid?: number + readSession?: (id: string) => Promise + isLauncherAlive?: (pid: number) => boolean + sleep?: (ms: number) => Promise + registrationWaitMs?: number + registrationPollMs?: number + registerCleanup?: (fn: () => void | Promise) => () => void + onBeforeExit?: (listener: () => void | Promise) => void + onExit?: (listener: (code: number) => void) => void + finalize?: typeof recordBackgroundSessionNaturalTermination + finalizeSync?: typeof recordBackgroundSessionNaturalTerminationSync + getObservedSignal?: () => ObservedBackgroundSessionSignal | undefined + debug?: (message: string) => void +} + +export type BackgroundSessionFinalizerPreparation = + | 'not-background' + | 'invalid-routing' + | 'installed' + +function boundedFailureKind(error: unknown): string { + if (error && typeof error === 'object') { + if ('code' in error && typeof error.code === 'string') { + return error.code.slice(0, 32) + } + if ('name' in error && typeof error.name === 'string') { + return error.name.slice(0, 32) + } + } + return 'unknown' +} + +function defaultDebug(message: string): void { + logForDebugging(message, { level: 'error' }) +} + +function currentProcessExitCode(): number { + const value = process.exitCode + if (value === undefined) return 0 + const parsed = + typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value + return typeof parsed === 'number' && + Number.isSafeInteger(parsed) && + parsed >= 0 + ? parsed + : 1 +} + +function reportFinalizationFailure( + debug: (message: string) => void, + error: unknown, +): void { + try { + debug( + `Background session finalization failed (${boundedFailureKind(error)})`, + ) + } catch { + // Diagnostics must never replace the child process's original outcome. + } +} + +function parsePositivePid(value: string | undefined): number | undefined { + if (!value || !/^\d+$/.test(value)) return undefined + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : undefined +} + +function isBackgroundLauncherAlive(pid: number): boolean { + // PID 1 is a valid launcher inside a container. The shared helper excludes + // it because it also serves kill/lock callers, but this bounded registration + // poll only needs to know that the container's init process still exists. + return pid === 1 || isProcessRunning(pid) +} + +function scrubRoutingEnvironment(env: NodeJS.ProcessEnv): void { + delete env[BACKGROUND_SESSION_ID_ENV] + delete env[BACKGROUND_SESSION_LAUNCHER_PID_ENV] +} + +async function waitForOwnedSession( + id: string, + ownerPid: number, + launcherPid: number, + options: Required< + Pick< + PrepareBackgroundSessionFinalizerOptions, + | 'readSession' + | 'isLauncherAlive' + | 'sleep' + | 'registrationWaitMs' + | 'registrationPollMs' + > + >, +): Promise<'owned' | 'mismatch' | 'timeout'> { + const attempts = Math.max( + 1, + Math.ceil(options.registrationWaitMs / options.registrationPollMs), + ) + for (let attempt = 0; attempt < attempts; attempt += 1) { + const session = await options.readSession(id) + if (session) return session.pid === ownerPid ? 'owned' : 'mismatch' + if (!options.isLauncherAlive(launcherPid)) { + const finalSession = await options.readSession(id) + if (finalSession) { + return finalSession.pid === ownerPid ? 'owned' : 'mismatch' + } + return 'timeout' + } + await options.sleep(options.registrationPollMs) + } + const session = await options.readSession(id) + if (session) return session.pid === ownerPid ? 'owned' : 'mismatch' + return 'timeout' +} + +export async function prepareBackgroundSessionFinalizer( + options: PrepareBackgroundSessionFinalizerOptions = {}, +): Promise { + const env = options.env ?? process.env + const id = env[BACKGROUND_SESSION_ID_ENV] + if (!id) return 'not-background' + + const launcherPid = parsePositivePid( + env[BACKGROUND_SESSION_LAUNCHER_PID_ENV], + ) + if (!SAFE_ID_RE.test(id) || launcherPid === undefined) { + scrubRoutingEnvironment(env) + return 'invalid-routing' + } + + const ownerPid = options.pid ?? process.pid + const ownership = await waitForOwnedSession(id, ownerPid, launcherPid, { + readSession: options.readSession ?? readBackgroundSessionForOwner, + isLauncherAlive: options.isLauncherAlive ?? isBackgroundLauncherAlive, + sleep: + options.sleep ?? + (ms => new Promise(resolve => setTimeout(resolve, ms))), + registrationWaitMs: + options.registrationWaitMs ?? DEFAULT_REGISTRATION_WAIT_MS, + registrationPollMs: + options.registrationPollMs ?? DEFAULT_REGISTRATION_POLL_MS, + }) + if (ownership === 'mismatch') { + scrubRoutingEnvironment(env) + return 'invalid-routing' + } + if (ownership === 'timeout') { + scrubRoutingEnvironment(env) + throw new Error('Background session registration was not established') + } + + scrubRoutingEnvironment(env) + const finalize = + options.finalize ?? recordBackgroundSessionNaturalTermination + const finalizeSync = + options.finalizeSync ?? recordBackgroundSessionNaturalTerminationSync + const getObservedSignal = + options.getObservedSignal ?? beginBackgroundSessionSignalTracking() + const debug = options.debug ?? defaultDebug + let finalized = false + + const currentTermination = () => { + const signal = getObservedSignal() + return signal === undefined + ? { exitCode: currentProcessExitCode() } + : { signal } + } + + const finalizeAwaited = async () => { + if (finalized) return + try { + await finalize(id, currentTermination(), { ownerPid }) + finalized = true + } catch (error) { + reportFinalizationFailure(debug, error) + } + } + const registerFinalizerCleanup = options.registerCleanup ?? registerCleanup + registerFinalizerCleanup(finalizeAwaited) + + if (options.onBeforeExit) { + options.onBeforeExit(finalizeAwaited) + } else { + process.once('beforeExit', finalizeAwaited) + } + + const onExit = (code: number) => { + if (finalized) return + try { + const signal = getObservedSignal() + finalizeSync( + id, + signal === undefined ? { exitCode: code } : { signal }, + { ownerPid }, + ) + finalized = true + } catch (error) { + reportFinalizationFailure(debug, error) + } + } + if (options.onExit) { + options.onExit(onExit) + } else { + process.once('exit', onExit) + } + + return 'installed' +} diff --git a/src/cli/bgRegistry.test.ts b/src/cli/bgRegistry.test.ts index c1defa3bfa..d4d40e0bde 100644 --- a/src/cli/bgRegistry.test.ts +++ b/src/cli/bgRegistry.test.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import * as backgroundSessionRegistry from './bgRegistry.js' import { _setBackgroundSessionsRootForTesting, createBackgroundSession, @@ -16,6 +17,11 @@ import { type BackgroundSession, } from './bgRegistry.js' +const { + recordBackgroundSessionNaturalTermination, + recordBackgroundSessionNaturalTerminationSync, +} = backgroundSessionRegistry + describe('background session registry', () => { let configDir: string @@ -24,6 +30,27 @@ describe('background session registry', () => { return join(configDir, 'bg-sessions', 'names', `${digest}.json`) } + function terminalFactPath( + id: string, + kind: 'natural' | 'killed', + ): string { + return join(configDir, 'bg-sessions', 'terminal', `${id}.${kind}.json`) + } + + async function writeTerminalFact( + id: string, + kind: 'natural' | 'killed', + fact: Record, + ): Promise { + await mkdir(join(configDir, 'bg-sessions', 'terminal'), { + recursive: true, + }) + await writeFile( + terminalFactPath(id, kind), + JSON.stringify({ version: 1, id, ...fact }), + ) + } + async function writeNameReservation( name: string, reservation: { @@ -632,6 +659,435 @@ describe('background session registry', () => { }) }) + it('reads metadata written without optional terminal fields', async () => { + await createBackgroundSession({ + id: 'bg-old-metadata', + pid: 332, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-old-metadata', + }) + + const oldMetadata = (await listBackgroundSessions())[0]! + expect(oldMetadata).toMatchObject({ + id: 'bg-old-metadata', + status: 'running', + }) + expect('finishedAt' in oldMetadata).toBe(false) + expect('exitCode' in oldMetadata).toBe(false) + expect('terminalReason' in oldMetadata).toBe(false) + }) + + it('keeps an authoritative successful completion stronger than a stale refresh', async () => { + await createBackgroundSession({ + id: 'bg-natural-success', + pid: 333, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-success', + now: new Date('2026-06-15T08:00:00.000Z'), + }) + await writeTerminalFact('bg-natural-success', 'natural', { + pid: 333, + status: 'exited', + finishedAt: '2026-06-15T08:04:00.000Z', + exitCode: 0, + terminalReason: 'exit_code', + }) + + const refreshed = await refreshBackgroundSessionStatuses({ + isProcessAlive: () => false, + now: new Date('2026-06-15T08:05:00.000Z'), + }) + + expect(refreshed[0]).toMatchObject({ + id: 'bg-natural-success', + status: 'exited', + finishedAt: '2026-06-15T08:04:00.000Z', + exitCode: 0, + terminalReason: 'exit_code', + }) + expect((await listBackgroundSessions())[0]?.status).toBe('exited') + }) + + it('preserves an authoritative nonzero completion and exact exit code', async () => { + await createBackgroundSession({ + id: 'bg-natural-failure', + pid: 334, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-failure', + }) + await writeTerminalFact('bg-natural-failure', 'natural', { + pid: 334, + status: 'failed', + finishedAt: '2026-06-15T08:04:00.000Z', + exitCode: 23, + terminalReason: 'exit_code', + }) + + expect(await resolveBackgroundSession('bg-natural-failure')).toMatchObject({ + status: 'failed', + finishedAt: '2026-06-15T08:04:00.000Z', + exitCode: 23, + terminalReason: 'exit_code', + }) + }) + + it('gives explicit kill precedence without erasing observed natural details', async () => { + await createBackgroundSession({ + id: 'bg-kill-precedence', + pid: 335, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-kill-precedence', + }) + await writeTerminalFact('bg-kill-precedence', 'natural', { + pid: 335, + status: 'failed', + finishedAt: '2026-06-15T08:04:00.000Z', + exitCode: 17, + terminalReason: 'exit_code', + }) + await writeTerminalFact('bg-kill-precedence', 'killed', { + pid: 335, + status: 'killed', + finishedAt: '2026-06-15T08:05:00.000Z', + terminalReason: 'explicit_kill', + }) + + expect(await resolveBackgroundSession('bg-kill-precedence')).toMatchObject({ + status: 'killed', + finishedAt: '2026-06-15T08:04:00.000Z', + exitCode: 17, + terminalReason: 'explicit_kill', + }) + }) + + it('releases a name when an authoritative natural completion is present', async () => { + await createBackgroundSession({ + id: 'bg-natural-name-old', + name: 'natural-name', + pid: 336, + cwd: '/repo', + command: ['openclaude', '--print', 'old'], + sessionId: 'conversation-natural-name-old', + }) + await writeTerminalFact('bg-natural-name-old', 'natural', { + pid: 336, + status: 'exited', + finishedAt: '2026-06-15T08:04:00.000Z', + exitCode: 0, + terminalReason: 'exit_code', + }) + + const replacement = await createBackgroundSession({ + id: 'bg-natural-name-new', + name: 'natural-name', + pid: 337, + cwd: '/repo', + command: ['openclaude', '--print', 'new'], + sessionId: 'conversation-natural-name-new', + }) + + expect(replacement.name).toBe('natural-name') + expect((await resolveBackgroundSession('natural-name')).id).toBe( + 'bg-natural-name-new', + ) + }) + + it('records natural completion only for the exact owning PID', async () => { + await createBackgroundSession({ + id: 'bg-owner-checked', + pid: 338, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-owner-checked', + }) + + await expect( + recordBackgroundSessionNaturalTermination( + 'bg-owner-checked', + { exitCode: 0 }, + { ownerPid: 339 }, + ), + ).rejects.toThrow('does not own') + expect((await resolveBackgroundSession('bg-owner-checked')).status).toBe( + 'running', + ) + + const completed = await recordBackgroundSessionNaturalTermination( + 'bg-owner-checked', + { exitCode: 0 }, + { + ownerPid: 338, + now: new Date('2026-06-15T08:06:00.000Z'), + }, + ) + expect(completed).toMatchObject({ + status: 'exited', + finishedAt: '2026-06-15T08:06:00.000Z', + exitCode: 0, + }) + }) + + it('does not let a late natural finalizer replace the first valid fact', async () => { + await createBackgroundSession({ + id: 'bg-first-fact', + pid: 340, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-first-fact', + }) + await recordBackgroundSessionNaturalTermination( + 'bg-first-fact', + { exitCode: 19 }, + { + ownerPid: 340, + now: new Date('2026-06-15T08:07:00.000Z'), + }, + ) + await recordBackgroundSessionNaturalTermination( + 'bg-first-fact', + { exitCode: 0 }, + { + ownerPid: 340, + now: new Date('2026-06-15T08:08:00.000Z'), + }, + ) + + expect(await resolveBackgroundSession('bg-first-fact')).toMatchObject({ + status: 'failed', + finishedAt: '2026-06-15T08:07:00.000Z', + exitCode: 19, + }) + }) + + it('converges concurrent natural finalizers on one immutable fact', async () => { + await createBackgroundSession({ + id: 'bg-concurrent-natural', + pid: 346, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-concurrent-natural', + }) + + const results = await Promise.all([ + recordBackgroundSessionNaturalTermination( + 'bg-concurrent-natural', + { exitCode: 31 }, + { + ownerPid: 346, + now: new Date('2026-06-15T08:10:00.000Z'), + }, + ), + recordBackgroundSessionNaturalTermination( + 'bg-concurrent-natural', + { exitCode: 0 }, + { + ownerPid: 346, + now: new Date('2026-06-15T08:11:00.000Z'), + }, + ), + ]) + + const facts = results.map(result => { + if (result.finishedAt === undefined || result.exitCode === undefined) { + throw new Error('natural terminal fact was incomplete') + } + return { + status: result.status, + finishedAt: result.finishedAt, + exitCode: result.exitCode, + } + }) + expect(new Set(facts.map(fact => JSON.stringify(fact))).size).toBe(1) + expect([ + { + status: 'failed', + finishedAt: '2026-06-15T08:10:00.000Z', + exitCode: 31, + }, + { + status: 'exited', + finishedAt: '2026-06-15T08:11:00.000Z', + exitCode: 0, + }, + ]).toContainEqual(facts[0]) + expect(await resolveBackgroundSession('bg-concurrent-natural')).toMatchObject( + facts[0]!, + ) + }) + + it('corrects a stale guess with a later exact-owner natural fact', async () => { + await createBackgroundSession({ + id: 'bg-stale-correction', + pid: 341, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-stale-correction', + }) + await refreshBackgroundSessionStatuses({ isProcessAlive: () => false }) + + const corrected = await recordBackgroundSessionNaturalTermination( + 'bg-stale-correction', + { exitCode: 0 }, + { ownerPid: 341 }, + ) + expect(corrected).toMatchObject({ status: 'exited', exitCode: 0 }) + }) + + it('does not return or persist stale over a finalizer racing the refresh write', async () => { + await createBackgroundSession({ + id: 'bg-refresh-race', + pid: 343, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-refresh-race', + }) + let releaseWrite!: () => void + const writeMayContinue = new Promise(resolve => { + releaseWrite = resolve + }) + let refreshReachedWrite!: () => void + const refreshAtWrite = new Promise(resolve => { + refreshReachedWrite = resolve + }) + + const refreshing = refreshBackgroundSessionStatuses({ + isProcessAlive: () => false, + beforeStatusWrite: async () => { + refreshReachedWrite() + await writeMayContinue + }, + }) + await refreshAtWrite + await recordBackgroundSessionNaturalTermination( + 'bg-refresh-race', + { exitCode: 0 }, + { ownerPid: 343 }, + ) + releaseWrite() + + expect((await refreshing)[0]).toMatchObject({ + status: 'exited', + exitCode: 0, + }) + expect((await listBackgroundSessions())[0]).toMatchObject({ + status: 'exited', + exitCode: 0, + }) + }) + + it('records a bounded observed signal without inventing an exit code', async () => { + await createBackgroundSession({ + id: 'bg-observed-signal', + pid: 344, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-observed-signal', + }) + const failed = await recordBackgroundSessionNaturalTermination( + 'bg-observed-signal', + { signal: 'SIGTERM' }, + { ownerPid: 344 }, + ) + + expect(failed).toMatchObject({ + status: 'failed', + signal: 'SIGTERM', + terminalReason: 'signal', + }) + expect('exitCode' in failed).toBe(false) + }) + + it('does not let a late natural finalizer overwrite an explicit kill fact', async () => { + await createBackgroundSession({ + id: 'bg-killed-absorbing', + pid: 342, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-killed-absorbing', + }) + await markBackgroundSessionKilled('bg-killed-absorbing', { + now: new Date('2026-06-15T08:09:00.000Z'), + }) + + const late = await recordBackgroundSessionNaturalTermination( + 'bg-killed-absorbing', + { exitCode: 0 }, + { ownerPid: 342 }, + ) + expect(late).toMatchObject({ + status: 'killed', + finishedAt: '2026-06-15T08:09:00.000Z', + terminalReason: 'explicit_kill', + }) + expect( + await Bun.file( + terminalFactPath('bg-killed-absorbing', 'natural'), + ).exists(), + ).toBe(false) + }) + + it('does not let the sync finalizer overwrite an explicit kill fact', async () => { + await createBackgroundSession({ + id: 'bg-sync-killed-absorbing', + pid: 347, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-sync-killed-absorbing', + }) + await markBackgroundSessionKilled('bg-sync-killed-absorbing', { + now: new Date('2026-06-15T08:09:30.000Z'), + }) + + recordBackgroundSessionNaturalTerminationSync( + 'bg-sync-killed-absorbing', + { exitCode: 0 }, + { ownerPid: 347 }, + ) + + expect( + await resolveBackgroundSession('bg-sync-killed-absorbing'), + ).toMatchObject({ + status: 'killed', + finishedAt: '2026-06-15T08:09:30.000Z', + terminalReason: 'explicit_kill', + }) + expect( + await Bun.file( + terminalFactPath('bg-sync-killed-absorbing', 'natural'), + ).exists(), + ).toBe(false) + }) + + it('keeps killed strongest when kill and natural completion race', async () => { + await createBackgroundSession({ + id: 'bg-kill-natural-race', + pid: 345, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-kill-natural-race', + }) + + await Promise.all([ + recordBackgroundSessionNaturalTermination( + 'bg-kill-natural-race', + { exitCode: 0 }, + { ownerPid: 345 }, + ), + markBackgroundSessionKilled('bg-kill-natural-race'), + ]) + + expect(await resolveBackgroundSession('bg-kill-natural-race')).toMatchObject( + { + status: 'killed', + terminalReason: 'explicit_kill', + }, + ) + }) + it('keeps running sessions fresh when their process identity still matches', async () => { await createBackgroundSession({ id: 'bg-running', @@ -729,6 +1185,7 @@ describe('background session registry', () => { it('marks a session killed without deleting its logs or metadata', async () => { await createBackgroundSession({ id: 'bg-kill', + name: 'reusable-after-kill', pid: 444, cwd: '/repo', command: ['openclaude', '--print', 'work'], @@ -742,6 +1199,16 @@ describe('background session registry', () => { expect(killed.status).toBe('killed') expect(killed.updatedAt).toBe('2026-06-15T08:10:00.000Z') expect((await listBackgroundSessions()).map(s => s.id)).toEqual(['bg-kill']) + + const replacement = await createBackgroundSession({ + id: 'bg-after-kill', + name: 'reusable-after-kill', + pid: 445, + cwd: '/repo', + command: ['openclaude', '--print', 'new work'], + sessionId: 'conversation-after-kill', + }) + expect(replacement.name).toBe('reusable-after-kill') }) it('ignores malformed metadata files instead of returning unsafe sessions', async () => { diff --git a/src/cli/bgRegistry.ts b/src/cli/bgRegistry.ts index 0eef9c0809..e909c39ce6 100644 --- a/src/cli/bgRegistry.ts +++ b/src/cli/bgRegistry.ts @@ -1,5 +1,7 @@ import { + link, mkdir, + open, readFile, readdir, rename, @@ -7,6 +9,16 @@ import { unlink, writeFile, } from 'node:fs/promises' +import { + closeSync, + fsyncSync, + linkSync, + mkdirSync, + openSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs' import { createHash, randomUUID } from 'node:crypto' import { basename, join } from 'node:path' import { getClaudeConfigHomeDir } from '../utils/envUtils.js' @@ -38,8 +50,32 @@ export type BackgroundSession = { command: string[] stdoutLogPath: string stderrLogPath: string + finishedAt?: string + exitCode?: number + signal?: string + terminalReason?: BackgroundSessionTerminalReason +} + +export type BackgroundSessionTerminalReason = + | 'exit_code' + | 'signal' + | 'explicit_kill' + +type BackgroundSessionTerminalFact = { + version: 1 + id: string + pid: number + status: 'exited' | 'failed' | 'killed' + finishedAt: string + terminalReason: BackgroundSessionTerminalReason + exitCode?: number + signal?: string } +export type BackgroundSessionNaturalTermination = + | { exitCode: number; signal?: never } + | { exitCode?: never; signal: string } + export type CreateBackgroundSessionInput = { id: string name?: string @@ -74,6 +110,7 @@ const ALL_STATUSES = new Set([ ...TERMINAL_STATUSES, ]) const SAFE_ID_RE = /^[A-Za-z0-9._-]+$/ +const SAFE_SIGNAL_RE = /^SIG[A-Z0-9]{1,24}$/ let backgroundSessionsRootForTesting: string | undefined export function _setBackgroundSessionsRootForTesting( @@ -101,6 +138,10 @@ function getBackgroundSessionNamesDir(): string { return join(getBackgroundSessionsRoot(), 'names') } +function getBackgroundSessionTerminalDir(): string { + return join(getBackgroundSessionsRoot(), 'terminal') +} + function metadataPathForId(id: string): string { assertSafeId(id) return join(getBackgroundSessionMetadataDir(), `${id}.json`) @@ -111,6 +152,14 @@ function nameReservationPathForName(name: string): string { return join(getBackgroundSessionNamesDir(), `${digest}.json`) } +function terminalFactPathForId( + id: string, + kind: 'natural' | 'killed', +): string { + assertSafeId(id) + return join(getBackgroundSessionTerminalDir(), `${id}.${kind}.json`) +} + function assertSafeId(id: string): void { if (!SAFE_ID_RE.test(id)) { throw new Error(`Invalid background session id: ${id}`) @@ -149,6 +198,10 @@ export async function ensureBackgroundSessionDirs(): Promise { }) await mkdir(getBackgroundSessionLogsDir(), { recursive: true, mode: 0o700 }) await mkdir(getBackgroundSessionNamesDir(), { recursive: true, mode: 0o700 }) + await mkdir(getBackgroundSessionTerminalDir(), { + recursive: true, + mode: 0o700, + }) } async function writeSession(session: BackgroundSession): Promise { @@ -193,6 +246,15 @@ async function readSessionFile(path: string): Promise } } +function readSessionFileSync(path: string): BackgroundSession | null { + try { + const parsed = jsonParse(readFileSync(path, 'utf8')) + return isBackgroundSession(parsed, basename(path, '.json')) ? parsed : null + } catch { + return null + } +} + async function readNameReservation( path: string, ): Promise { @@ -230,6 +292,18 @@ async function releaseNameReservation( await unlink(path).catch(() => {}) } +function releaseNameReservationSync(name: string, id: string): void { + const path = nameReservationPathForName(name) + try { + const parsed = jsonParse( + readFileSync(path, 'utf8'), + ) as Partial + if (parsed?.id === id) unlinkSync(path) + } catch { + // Effective terminal-state reads recover stale reservations later. + } +} + async function unlinkStaleNameReservation(path: string): Promise { try { await unlink(path) @@ -257,7 +331,11 @@ async function isLiveNameReservation( const owner = await readSessionFile(metadataPathForId(reservation.id)) if (owner) { - return owner.name === name && !isTerminalBackgroundSession(owner) + const effectiveOwner = await applyAuthoritativeTerminalFacts(owner) + return ( + effectiveOwner.name === name && + !isTerminalBackgroundSession(effectiveOwner) + ) } return ( @@ -308,6 +386,24 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every(item => typeof item === 'string') } +function isSafeExitCode(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 + ) +} + +function isSafeSignal(value: unknown): value is string { + return typeof value === 'string' && SAFE_SIGNAL_RE.test(value) +} + +function isTerminalReason( + value: unknown, +): value is BackgroundSessionTerminalReason { + return ( + value === 'exit_code' || value === 'signal' || value === 'explicit_kill' + ) +} + function isBackgroundSession( value: unknown, expectedId: string, @@ -334,10 +430,211 @@ function isBackgroundSession( typeof candidate.updatedAt === 'string' && isStringArray(candidate.command) && typeof candidate.stdoutLogPath === 'string' && - typeof candidate.stderrLogPath === 'string' + typeof candidate.stderrLogPath === 'string' && + (candidate.finishedAt === undefined || + typeof candidate.finishedAt === 'string') && + (candidate.exitCode === undefined || isSafeExitCode(candidate.exitCode)) && + (candidate.signal === undefined || isSafeSignal(candidate.signal)) && + (candidate.terminalReason === undefined || + isTerminalReason(candidate.terminalReason)) + ) +} + +function isBackgroundSessionTerminalFact( + value: unknown, + expectedId: string, + kind: 'natural' | 'killed', +): value is BackgroundSessionTerminalFact { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + if ( + candidate.version !== 1 || + candidate.id !== expectedId || + !SAFE_ID_RE.test(candidate.id) || + typeof candidate.pid !== 'number' || + !Number.isInteger(candidate.pid) || + candidate.pid <= 0 || + typeof candidate.finishedAt !== 'string' || + !isTerminalReason(candidate.terminalReason) || + (candidate.exitCode !== undefined && + !isSafeExitCode(candidate.exitCode)) || + (candidate.signal !== undefined && !isSafeSignal(candidate.signal)) + ) { + return false + } + + if (kind === 'killed') { + return ( + candidate.status === 'killed' && + candidate.terminalReason === 'explicit_kill' && + candidate.exitCode === undefined && + candidate.signal === undefined + ) + } + + if (candidate.status === 'exited') { + return ( + candidate.terminalReason === 'exit_code' && + candidate.exitCode === 0 && + candidate.signal === undefined + ) + } + if (candidate.status !== 'failed') return false + if (candidate.terminalReason === 'exit_code') { + return ( + candidate.exitCode !== undefined && + candidate.exitCode !== 0 && + candidate.signal === undefined + ) + } + return ( + candidate.terminalReason === 'signal' && + candidate.exitCode === undefined && + candidate.signal !== undefined + ) +} + +async function readTerminalFact( + id: string, + kind: 'natural' | 'killed', +): Promise { + try { + const parsed = jsonParse( + await readFile(terminalFactPathForId(id, kind), 'utf8'), + ) + return isBackgroundSessionTerminalFact(parsed, id, kind) ? parsed : null + } catch { + return null + } +} + +function readTerminalFactSync( + id: string, + kind: 'natural' | 'killed', +): BackgroundSessionTerminalFact | null { + try { + const parsed = jsonParse( + readFileSync(terminalFactPathForId(id, kind), 'utf8'), + ) + return isBackgroundSessionTerminalFact(parsed, id, kind) ? parsed : null + } catch { + return null + } +} + +async function applyAuthoritativeTerminalFacts( + session: BackgroundSession, +): Promise { + const natural = await readTerminalFact(session.id, 'natural') + const killed = await readTerminalFact(session.id, 'killed') + let effective = session + + if ( + natural?.pid === session.pid && + (session.status === 'running' || + session.status === 'unknown' || + session.status === 'stale') + ) { + effective = { + ...session, + status: natural.status, + updatedAt: natural.finishedAt, + finishedAt: natural.finishedAt, + terminalReason: natural.terminalReason, + ...(natural.exitCode !== undefined + ? { exitCode: natural.exitCode } + : {}), + ...(natural.signal !== undefined ? { signal: natural.signal } : {}), + } + } + + if (killed?.pid === session.pid) { + effective = { + ...effective, + status: 'killed', + updatedAt: killed.finishedAt, + finishedAt: effective.finishedAt ?? killed.finishedAt, + terminalReason: 'explicit_kill', + } + } + + return effective +} + +function terminalFactTempPath(id: string): string { + return join( + getBackgroundSessionTerminalDir(), + `${id}.${process.pid}.${randomUUID()}.tmp`, ) } +async function installTerminalFact( + fact: BackgroundSessionTerminalFact, + kind: 'natural' | 'killed', +): Promise { + await ensureBackgroundSessionDirs() + const target = terminalFactPathForId(fact.id, kind) + const tmp = terminalFactTempPath(fact.id) + let handle: Awaited> | undefined + try { + handle = await open(tmp, 'wx', 0o600) + await handle.writeFile(jsonStringify(fact)) + await handle.sync() + await handle.close() + handle = undefined + await link(tmp, target) + return fact + } catch (error) { + if (isErrno(error, 'EEXIST')) { + const existing = await readTerminalFact(fact.id, kind) + if (existing) return existing + throw new Error(`Invalid background session ${kind} terminal fact`) + } + throw error + } finally { + await handle?.close().catch(() => {}) + await unlink(tmp).catch(() => {}) + } +} + +function installTerminalFactSync( + fact: BackgroundSessionTerminalFact, + kind: 'natural' | 'killed', +): BackgroundSessionTerminalFact { + mkdirSync(getBackgroundSessionTerminalDir(), { + recursive: true, + mode: 0o700, + }) + const target = terminalFactPathForId(fact.id, kind) + const tmp = terminalFactTempPath(fact.id) + let fd: number | undefined + try { + fd = openSync(tmp, 'wx', 0o600) + writeFileSync(fd, jsonStringify(fact)) + fsyncSync(fd) + closeSync(fd) + fd = undefined + linkSync(tmp, target) + return fact + } catch (error) { + if (isErrno(error, 'EEXIST')) { + const existing = readTerminalFactSync(fact.id, kind) + if (existing) return existing + throw new Error(`Invalid background session ${kind} terminal fact`) + } + throw error + } finally { + if (fd !== undefined) { + try { + closeSync(fd) + } catch {} + } + try { + unlinkSync(tmp) + } catch {} + } +} + export async function listBackgroundSessions(): Promise { let entries: string[] try { @@ -352,12 +649,19 @@ export async function listBackgroundSessions(): Promise { const session = await readSessionFile( join(getBackgroundSessionMetadataDir(), entry), ) - if (session) sessions.push(session) + if (session) sessions.push(await applyAuthoritativeTerminalFacts(session)) } return sessions.sort((a, b) => a.startedAt.localeCompare(b.startedAt)) } +export async function readBackgroundSessionForOwner( + id: string, +): Promise { + assertSafeId(id) + return await readSessionFile(metadataPathForId(id)) +} + export async function assertBackgroundSessionNameAvailable( name: string | undefined, ): Promise { @@ -467,6 +771,10 @@ export async function refreshBackgroundSessionStatuses(options?: { isProcessAlive?: (pid: number) => boolean getProcessCommand?: (pid: number) => string | null now?: Date + beforeStatusWrite?: ( + session: BackgroundSession, + nextStatus: BackgroundSessionStatus, + ) => Promise }): Promise { const timestamp = iso(options?.now) const sessions = await listBackgroundSessions() @@ -495,8 +803,9 @@ export async function refreshBackgroundSessionStatuses(options?: { status: nextStatus, updatedAt: timestamp, } + await options?.beforeStatusWrite?.(session, nextStatus) await writeSession(updated) - refreshed.push(updated) + refreshed.push(await applyAuthoritativeTerminalFacts(updated)) continue } @@ -657,18 +966,144 @@ export function isBackgroundSessionProcessAlive( ) } +function naturalTerminalFact( + id: string, + pid: number, + termination: BackgroundSessionNaturalTermination, + now: Date | undefined, +): BackgroundSessionTerminalFact { + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error('Invalid background session owner PID') + } + const finishedAt = iso(now) + if (termination.signal !== undefined) { + if (!isSafeSignal(termination.signal)) { + throw new Error('Invalid background session termination signal') + } + return { + version: 1, + id, + pid, + status: 'failed', + finishedAt, + terminalReason: 'signal', + signal: termination.signal, + } + } + if (!isSafeExitCode(termination.exitCode)) { + throw new Error('Invalid background session exit code') + } + return { + version: 1, + id, + pid, + status: termination.exitCode === 0 ? 'exited' : 'failed', + finishedAt, + terminalReason: 'exit_code', + exitCode: termination.exitCode, + } +} + +function assertNaturalFinalizationOwner( + session: BackgroundSession | null, + id: string, + ownerPid: number, +): asserts session is BackgroundSession { + if (!session || session.id !== id || session.pid !== ownerPid) { + throw new Error('Background session finalizer does not own this session') + } +} + +export async function recordBackgroundSessionNaturalTermination( + id: string, + termination: BackgroundSessionNaturalTermination, + options: { ownerPid?: number; now?: Date } = {}, +): Promise { + assertSafeId(id) + const ownerPid = options.ownerPid ?? process.pid + const session = await readSessionFile(metadataPathForId(id)) + assertNaturalFinalizationOwner(session, id, ownerPid) + + const effective = await applyAuthoritativeTerminalFacts(session) + if ( + effective.status === 'killed' || + session.status === 'exited' || + session.status === 'failed' + ) { + return effective + } + if ( + session.status !== 'running' && + session.status !== 'unknown' && + session.status !== 'stale' + ) { + throw new Error('Background session is not eligible for natural finalization') + } + + await installTerminalFact( + naturalTerminalFact(id, ownerPid, termination, options.now), + 'natural', + ) + if (session.name) await releaseNameReservation(session.name, session.id) + return await applyAuthoritativeTerminalFacts(session) +} + +export function recordBackgroundSessionNaturalTerminationSync( + id: string, + termination: BackgroundSessionNaturalTermination, + options: { ownerPid?: number; now?: Date } = {}, +): void { + assertSafeId(id) + const ownerPid = options.ownerPid ?? process.pid + const session = readSessionFileSync(metadataPathForId(id)) + assertNaturalFinalizationOwner(session, id, ownerPid) + if ( + session.status === 'killed' || + session.status === 'exited' || + session.status === 'failed' || + readTerminalFactSync(id, 'killed')?.pid === ownerPid + ) { + return + } + if ( + session.status !== 'running' && + session.status !== 'unknown' && + session.status !== 'stale' + ) { + throw new Error('Background session is not eligible for natural finalization') + } + + installTerminalFactSync( + naturalTerminalFact(id, ownerPid, termination, options.now), + 'natural', + ) + if (session.name) releaseNameReservationSync(session.name, session.id) +} + export async function markBackgroundSessionKilled( target: string, options?: { now?: Date }, ): Promise { const session = await resolveBackgroundSession(target) - const updated: BackgroundSession = { - ...session, - status: 'killed', - updatedAt: iso(options?.now), + const rawSession = await readSessionFile(metadataPathForId(session.id)) + if (!rawSession || rawSession.pid !== session.pid) { + throw new Error('Background session changed before it could be marked killed') + } + await installTerminalFact( + { + version: 1, + id: session.id, + pid: session.pid, + status: 'killed', + finishedAt: iso(options?.now), + terminalReason: 'explicit_kill', + }, + 'killed', + ) + if (rawSession.name) { + await releaseNameReservation(rawSession.name, rawSession.id) } - await writeSession(updated) - return updated + return await applyAuthoritativeTerminalFacts(rawSession) } export async function backgroundSessionLogExists(path: string): Promise { diff --git a/src/cli/print.ts b/src/cli/print.ts index a049ef0f32..112f09380c 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -16,6 +16,7 @@ import { } from 'src/commands.js' import { createStreamlinedTransformer } from 'src/utils/streamlinedTransform.js' import { installStreamJsonStdoutGuard } from 'src/utils/streamJsonStdoutGuard.js' +import { noteBackgroundSessionTerminationSignal } from 'src/utils/backgroundSessionTermination.js' import type { ToolPermissionContext } from 'src/Tool.js' import type { ThinkingConfig } from 'src/utils/thinking.js' import { assembleToolPool, filterToolsByDenyRules } from 'src/tools.js' @@ -1211,6 +1212,7 @@ function runHeadlessStreaming( // gracefulShutdown persists session state and flushes analytics, with a // failsafe timer that force-exits if cleanup hangs. const sigintHandler = () => { + noteBackgroundSessionTerminationSignal('SIGINT') logForDiagnosticsNoPII('info', 'shutdown_signal', { signal: 'SIGINT' }) options.heartbeat?.setPhase('shutting_down') if (abortController && !abortController.signal.aborted) { diff --git a/src/entrypoints/cli.test.ts b/src/entrypoints/cli.test.ts index 14974985b9..6b8ed97fa3 100644 --- a/src/entrypoints/cli.test.ts +++ b/src/entrypoints/cli.test.ts @@ -37,6 +37,7 @@ const mockLogsHandler = mock(async (_args: string[]) => {}) const mockAttachHandler = mock(async (_args: string[]) => {}) const mockKillHandler = mock(async (_args: string[]) => {}) const mockHandleBgFlag = mock(async (_args: string[]) => {}) +const mockPrepareBackgroundSessionFinalizer = mock(async () => 'installed') const mockLoadEnvFile = mock((_filePath: string) => ({})) const mockParseProviderEnvFileArgs = mock((_args: string[]) => ({ paths: [] })) const mockReapplyRememberedEnvFileValues = mock(() => {}) @@ -74,6 +75,7 @@ const runtimeMocks = [ mockAttachHandler, mockKillHandler, mockHandleBgFlag, + mockPrepareBackgroundSessionFinalizer, mockLoadEnvFile, mockParseProviderEnvFileArgs, mockReapplyRememberedEnvFileValues, @@ -334,6 +336,10 @@ describe('cli.tsx — background routing behavior', () => { killHandler: mockKillHandler, handleBgFlag: mockHandleBgFlag, }), + bgFinalizer: async () => ({ + prepareBackgroundSessionFinalizer: + mockPrepareBackgroundSessionFinalizer, + }), envFile: async () => ({ loadEnvFile: mockLoadEnvFile, parseProviderEnvFileArgs: mockParseProviderEnvFileArgs, @@ -427,6 +433,24 @@ describe('cli.tsx — background routing behavior', () => { } }) + it('establishes background finalizer ownership before any command path', async () => { + process.env.OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID = 'bg-entrypoint' + mockPrepareBackgroundSessionFinalizer.mockImplementationOnce(async () => { + throw new Error('finalizer ownership not ready') + }) + try { + await expect(runCliEntrypoint(['ps'], bgOptions)).rejects.toThrow( + 'finalizer ownership not ready', + ) + } finally { + delete process.env.OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID + } + + expect(mockPrepareBackgroundSessionFinalizer).toHaveBeenCalledTimes(1) + expect(mockPsHandler).not.toHaveBeenCalled() + expect(mockEnableConfigs).not.toHaveBeenCalled() + }) + it('keeps management commands on the management path even with --bg arguments', async () => { const cases: Array<[string, typeof mockPsHandler]> = [ ['ps', mockPsHandler], diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 5a944f1a85..5ca1ccfed7 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -249,6 +249,7 @@ type CliEntrypointOptions = { type CliEntrypointImporters = { startupProfiler: () => Promise bg: () => Promise + bgFinalizer: () => Promise providerFlag: () => Promise envFile: () => Promise config: () => Promise @@ -276,6 +277,7 @@ type CliEntrypointImporters = { const defaultCliEntrypointImporters: CliEntrypointImporters = { startupProfiler: () => import('../utils/startupProfiler.js'), bg: () => import('../cli/bg.js'), + bgFinalizer: () => import('../cli/bgFinalizer.js'), providerFlag: () => import('../utils/providerFlag.js'), envFile: () => import('../utils/envFile.js'), config: () => import('../utils/config.js'), @@ -312,8 +314,18 @@ export async function main( args: string[] = process.argv.slice(2), options: CliEntrypointOptions = {}, ): Promise { - const bgSessionsEnabled = isBgSessionsEnabled(options) const importers = getCliEntrypointImporters(options.importers) + // The detached CLI is the registered background-session PID. Establish + // exact registry ownership and install its terminal finalizer before any + // fast path or startup validation can call process.exit(). The private env + // value only routes this check; the registry's exact ID/PID match is the + // authority. + if (process.env.OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID) { + const { prepareBackgroundSessionFinalizer } = await importers.bgFinalizer() + await prepareBackgroundSessionFinalizer() + } + + const bgSessionsEnabled = isBgSessionsEnabled(options) let reapplyProviderEnvFileValues = () => {} let reapplyProviderFlagValues = () => {} const reapplyExplicitProviderInputs = () => { diff --git a/src/utils/backgroundSessionTermination.ts b/src/utils/backgroundSessionTermination.ts new file mode 100644 index 0000000000..8e6745b111 --- /dev/null +++ b/src/utils/backgroundSessionTermination.ts @@ -0,0 +1,27 @@ +export type ObservedBackgroundSessionSignal = + | 'SIGINT' + | 'SIGTERM' + | 'SIGHUP' + +let tracking = false +let observedSignal: ObservedBackgroundSessionSignal | undefined + +/** + * Start process-local signal tracking only after a detached CLI has proven + * exact registry ownership. The returned reader keeps the finalizer decoupled + * from the signal handlers that initiate graceful shutdown. + */ +export function beginBackgroundSessionSignalTracking(): () => + | ObservedBackgroundSessionSignal + | undefined { + tracking = true + observedSignal = undefined + return () => observedSignal +} + +/** Record the first termination signal actually handled by this process. */ +export function noteBackgroundSessionTerminationSignal( + signal: ObservedBackgroundSessionSignal, +): void { + if (tracking && observedSignal === undefined) observedSignal = signal +} diff --git a/src/utils/gracefulShutdown.ts b/src/utils/gracefulShutdown.ts index 9a5e397a64..d2b295495f 100644 --- a/src/utils/gracefulShutdown.ts +++ b/src/utils/gracefulShutdown.ts @@ -35,6 +35,7 @@ import { logEvent, } from '../services/analytics/index.js' import type { AppState } from '../state/AppState.js' +import { noteBackgroundSessionTerminationSignal } from './backgroundSessionTermination.js' import { runCleanupFunctions } from './cleanupRegistry.js' import { createCombinedAbortSignal } from './combinedAbortSignal.js' import { logForDebugging } from './debug.js' @@ -267,15 +268,18 @@ export const setupGracefulShutdown = memoize(() => { if (process.argv.includes('-p') || process.argv.includes('--print')) { return } + noteBackgroundSessionTerminationSignal('SIGINT') logForDiagnosticsNoPII('info', 'shutdown_signal', { signal: 'SIGINT' }) void gracefulShutdown(0) }) process.on('SIGTERM', () => { + noteBackgroundSessionTerminationSignal('SIGTERM') logForDiagnosticsNoPII('info', 'shutdown_signal', { signal: 'SIGTERM' }) void gracefulShutdown(143) // Exit code 143 (128 + 15) for SIGTERM }) if (process.platform !== 'win32') { process.on('SIGHUP', () => { + noteBackgroundSessionTerminationSignal('SIGHUP') logForDiagnosticsNoPII('info', 'shutdown_signal', { signal: 'SIGHUP' }) void gracefulShutdown(129) // Exit code 129 (128 + 1) for SIGHUP }) From 29785155b55b56c56fe68fe05adc7de5dc2008de Mon Sep 17 00:00:00 2001 From: chioarub Date: Sat, 15 Aug 2026 21:50:06 +0300 Subject: [PATCH 2/2] fix(bg): harden terminal outcome routing --- README.md | 6 +- src/cli/bg.test.ts | 106 ++++++++++++++++++++++++++++++++++-- src/cli/bg.ts | 23 ++++---- src/cli/bgFinalizer.test.ts | 76 +++++++++++++++----------- src/cli/bgFinalizer.ts | 23 +++++--- src/cli/bgRegistry.test.ts | 44 ++++++++++++++- src/cli/bgRegistry.ts | 8 ++- src/cli/bgRouting.ts | 4 ++ src/entrypoints/cli.test.ts | 20 ++++++- src/entrypoints/cli.tsx | 9 ++- 10 files changed, 254 insertions(+), 65 deletions(-) create mode 100644 src/cli/bgRouting.ts diff --git a/README.md b/README.md index b3bb1554a7..0996aa2665 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,11 @@ name. A naturally finished session is recorded as `exited` when its process returns zero and `failed` when it returns nonzero or handles a termination signal. `stale` remains the conservative result when the process disappears without an observed outcome; an explicit successful `openclaude kill` is -recorded as `killed`. OpenClaude does not infer POSIX signal names on Windows. +recorded as `killed`, and `killed` takes precedence over a natural `exited` or +`failed` outcome for the same process. Terminal outcomes are stored separately +under `bg-sessions/terminal/`; deleting that directory makes finished sessions +fall back to liveness-derived status. OpenClaude does not infer POSIX signal +names on Windows. Unobservable force termination, host crashes, and power loss remain `stale` on every platform. diff --git a/src/cli/bg.test.ts b/src/cli/bg.test.ts index 378a28ffe5..e98959f0d1 100644 --- a/src/cli/bg.test.ts +++ b/src/cli/bg.test.ts @@ -16,6 +16,10 @@ import { parseBackgroundInvocation, parseLogsInvocation, } from './bg.js' +import { + BACKGROUND_SESSION_ID_ENV, + BACKGROUND_SESSION_LAUNCHER_PID_ENV, +} from './bgFinalizer.js' import type { BackgroundSession, BackgroundSessionProcessIdentity, @@ -483,10 +487,8 @@ describe('background session CLI parsing', () => { 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.OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID).toBe( - 'bg-tests', - ) - expect(config.env.OPENCLAUDE_INTERNAL_BACKGROUND_LAUNCHER_PID).toBe('700') + 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', () => { @@ -511,6 +513,24 @@ describe('background session CLI parsing', () => { 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', + 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('escalates process-tree termination and waits for exit before returning', async () => { const signals: Array = [] let aliveChecks = 0 @@ -560,13 +580,89 @@ describe('background session CLI parsing', () => { }, }), ).rejects.toThrow( - 'Background session bg-finalizer-not-installed exited before finalization was installed', + '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', () => { diff --git a/src/cli/bg.ts b/src/cli/bg.ts index f53f59ca25..3ad2da90bc 100644 --- a/src/cli/bg.ts +++ b/src/cli/bg.ts @@ -23,6 +23,10 @@ import { type BackgroundSessionProcessIdentity, type BackgroundSessionProcessIdentityOptions, } from './bgRegistry.js' +import { + BACKGROUND_SESSION_ID_ENV, + BACKGROUND_SESSION_LAUNCHER_PID_ENV, +} from './bgRouting.js' export type ParsedBackgroundInvocation = { name?: string @@ -65,10 +69,6 @@ export type BuildBackgroundSessionLaunchDeps = { const HEAP_RELAUNCHED_ENV = 'OPENCLAUDE_HEAP_RELAUNCHED' const HEAP_SIZE_ENV = 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB' const DEFAULT_HEAP_SIZE_MB = 8192 -const BACKGROUND_SESSION_ID_ENV = - 'OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID' -const BACKGROUND_SESSION_LAUNCHER_PID_ENV = - 'OPENCLAUDE_INTERNAL_BACKGROUND_LAUNCHER_PID' const DEFAULT_TERM_GRACE_MS = 2_000 const DEFAULT_KILL_GRACE_MS = 2_000 const DEFAULT_KILL_POLL_INTERVAL_MS = 100 @@ -228,14 +228,10 @@ export function buildBackgroundChildProcessConfig( input.launcherPid ?? process.pid, ), } - if (isNodeExecutable(input.execPath)) { - // Keep the registered detached PID stable. The installed launcher normally - // already supplied these flags to its parent; safeNodeExecArgvForBackground - // fills either one if needed before preventing another spawnSync relaunch. - env[HEAP_RELAUNCHED_ENV] = '1' - } else { - delete env[HEAP_RELAUNCHED_ENV] - } + // Keep the registered detached PID stable under every runtime. The installed + // launcher otherwise relaunches itself before finalizer ownership is checked. + // Node-only heap flags are still supplied by safeNodeExecArgvForBackground. + env[HEAP_RELAUNCHED_ENV] = '1' return { command: input.execPath, @@ -953,7 +949,8 @@ export async function confirmBackgroundSessionLaunch( )(session.id) if (resolved.status === 'stale') { throw new Error( - `Background session ${session.id} exited before finalization was installed`, + `Background session ${session.id} exited before finalization was installed. ` + + `Logs were retained at ${session.stdoutLogPath} and ${session.stderrLogPath}.`, ) } return resolved diff --git a/src/cli/bgFinalizer.test.ts b/src/cli/bgFinalizer.test.ts index 50ab4426bf..a88bcdf96d 100644 --- a/src/cli/bgFinalizer.test.ts +++ b/src/cli/bgFinalizer.test.ts @@ -55,6 +55,14 @@ describe('background session finalizer', () => { await prepareBackgroundSessionFinalizer({ env: {} }), ).toBe('not-background') + const partialEnv = { + [BACKGROUND_SESSION_LAUNCHER_PID_ENV]: '123', + } + expect( + await prepareBackgroundSessionFinalizer({ env: partialEnv }), + ).toBe('invalid-routing') + expect(partialEnv[BACKGROUND_SESSION_LAUNCHER_PID_ENV]).toBeUndefined() + const env = { [BACKGROUND_SESSION_ID_ENV]: '../unsafe', [BACKGROUND_SESSION_LAUNCHER_PID_ENV]: '123', @@ -201,7 +209,7 @@ describe('background session finalizer', () => { expect(reads).toBe(3) expect(env[BACKGROUND_SESSION_ID_ENV]).toBeUndefined() const previousExitCode = process.exitCode - process.exitCode = '7' + process.exitCode = '7' as unknown as number try { await beforeExitListener?.() await cleanup?.() @@ -436,24 +444,27 @@ describe('background session finalizer', () => { childExitCode: 143, }, ]) { - it(`records an observed ${expectation.signal} as a failed signal fact`, async () => { - const { id, child, readyPath } = await runFixture(expectation.mode) - await waitForFile(readyPath) - child.kill(expectation.signal) - const [code, signal] = (await once(child, 'exit')) as [ - number, - NodeJS.Signals | null, - ] - expect(code).toBe(expectation.childExitCode) - expect(signal).toBeNull() - expect((await listBackgroundSessions())[0]).toMatchObject({ - id, - status: 'failed', - signal: expectation.signal, - terminalReason: 'signal', - }) - expect('exitCode' in (await listBackgroundSessions())[0]!).toBe(false) - }) + it.skipIf(process.platform === 'win32')( + `records an observed ${expectation.signal} as a failed signal fact`, + async () => { + const { id, child, readyPath } = await runFixture(expectation.mode) + await waitForFile(readyPath) + child.kill(expectation.signal) + const [code, signal] = (await once(child, 'exit')) as [ + number, + NodeJS.Signals | null, + ] + expect(code).toBe(expectation.childExitCode) + expect(signal).toBeNull() + expect((await listBackgroundSessions())[0]).toMatchObject({ + id, + status: 'failed', + signal: expectation.signal, + terminalReason: 'signal', + }) + expect('exitCode' in (await listBackgroundSessions())[0]!).toBe(false) + }, + ) } for (const expectation of [ @@ -511,17 +522,20 @@ describe('background session finalizer', () => { expect(stdout).toMatch(/bg-built-failure\s+failed/) }) - it('does not invent success when a fixture is forcibly destroyed', async () => { - const { child, readyPath } = await runFixture('wait') - await waitForFile(readyPath) - child.kill('SIGKILL') - await once(child, 'exit') + it.skipIf(process.platform === 'win32')( + 'does not invent success when a fixture is forcibly destroyed', + async () => { + const { child, readyPath } = await runFixture('wait') + await waitForFile(readyPath) + child.kill('SIGKILL') + await once(child, 'exit') - const refreshed = await refreshBackgroundSessionStatuses({ - isProcessAlive: () => false, - }) - expect(refreshed[0]?.status).toBe('stale') - expect('exitCode' in refreshed[0]!).toBe(false) - expect('terminalReason' in refreshed[0]!).toBe(false) - }) + const refreshed = await refreshBackgroundSessionStatuses({ + isProcessAlive: () => false, + }) + expect(refreshed[0]?.status).toBe('stale') + expect('exitCode' in refreshed[0]!).toBe(false) + expect('terminalReason' in refreshed[0]!).toBe(false) + }, + ) }) diff --git a/src/cli/bgFinalizer.ts b/src/cli/bgFinalizer.ts index 0a41ca323e..a6afc63360 100644 --- a/src/cli/bgFinalizer.ts +++ b/src/cli/bgFinalizer.ts @@ -11,11 +11,15 @@ import { recordBackgroundSessionNaturalTerminationSync, type BackgroundSession, } from './bgRegistry.js' +import { + BACKGROUND_SESSION_ID_ENV, + BACKGROUND_SESSION_LAUNCHER_PID_ENV, +} from './bgRouting.js' -export const BACKGROUND_SESSION_ID_ENV = - 'OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID' -export const BACKGROUND_SESSION_LAUNCHER_PID_ENV = - 'OPENCLAUDE_INTERNAL_BACKGROUND_LAUNCHER_PID' +export { + BACKGROUND_SESSION_ID_ENV, + BACKGROUND_SESSION_LAUNCHER_PID_ENV, +} from './bgRouting.js' const SAFE_ID_RE = /^[A-Za-z0-9._-]+$/ const DEFAULT_REGISTRATION_WAIT_MS = 5_000 @@ -143,12 +147,13 @@ export async function prepareBackgroundSessionFinalizer( ): Promise { const env = options.env ?? process.env const id = env[BACKGROUND_SESSION_ID_ENV] - if (!id) return 'not-background' + const launcherPidValue = env[BACKGROUND_SESSION_LAUNCHER_PID_ENV] + if (id === undefined && launcherPidValue === undefined) { + return 'not-background' + } - const launcherPid = parsePositivePid( - env[BACKGROUND_SESSION_LAUNCHER_PID_ENV], - ) - if (!SAFE_ID_RE.test(id) || launcherPid === undefined) { + const launcherPid = parsePositivePid(launcherPidValue) + if (!id || !SAFE_ID_RE.test(id) || launcherPid === undefined) { scrubRoutingEnvironment(env) return 'invalid-routing' } diff --git a/src/cli/bgRegistry.test.ts b/src/cli/bgRegistry.test.ts index d4d40e0bde..4f267a1a84 100644 --- a/src/cli/bgRegistry.test.ts +++ b/src/cli/bgRegistry.test.ts @@ -678,6 +678,48 @@ describe('background session registry', () => { expect('terminalReason' in oldMetadata).toBe(false) }) + it('ignores a terminal fact owned by a different PID', async () => { + await createBackgroundSession({ + id: 'bg-fact-pid-mismatch', + pid: 350, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-fact-pid-mismatch', + }) + await writeTerminalFact('bg-fact-pid-mismatch', 'natural', { + pid: 351, + status: 'exited', + finishedAt: '2026-06-15T08:04:00.000Z', + exitCode: 0, + terminalReason: 'exit_code', + }) + + const session = await resolveBackgroundSession('bg-fact-pid-mismatch') + expect(session.status).toBe('running') + expect('exitCode' in session).toBe(false) + }) + + it('ignores a killed terminal fact that carries an exit code', async () => { + await createBackgroundSession({ + id: 'bg-fact-malformed-kill', + pid: 352, + cwd: '/repo', + command: ['openclaude', '--print', 'work'], + sessionId: 'conversation-fact-malformed-kill', + }) + await writeTerminalFact('bg-fact-malformed-kill', 'killed', { + pid: 352, + status: 'killed', + finishedAt: '2026-06-15T08:04:00.000Z', + terminalReason: 'explicit_kill', + exitCode: 0, + }) + + const session = await resolveBackgroundSession('bg-fact-malformed-kill') + expect(session.status).toBe('running') + expect('exitCode' in session).toBe(false) + }) + it('keeps an authoritative successful completion stronger than a stale refresh', async () => { await createBackgroundSession({ id: 'bg-natural-success', @@ -956,7 +998,7 @@ describe('background session registry', () => { const refreshing = refreshBackgroundSessionStatuses({ isProcessAlive: () => false, - beforeStatusWrite: async () => { + _beforeStatusWriteForTesting: async () => { refreshReachedWrite() await writeMayContinue }, diff --git a/src/cli/bgRegistry.ts b/src/cli/bgRegistry.ts index e909c39ce6..ac65cea3d5 100644 --- a/src/cli/bgRegistry.ts +++ b/src/cli/bgRegistry.ts @@ -771,7 +771,7 @@ export async function refreshBackgroundSessionStatuses(options?: { isProcessAlive?: (pid: number) => boolean getProcessCommand?: (pid: number) => string | null now?: Date - beforeStatusWrite?: ( + _beforeStatusWriteForTesting?: ( session: BackgroundSession, nextStatus: BackgroundSessionStatus, ) => Promise @@ -803,7 +803,7 @@ export async function refreshBackgroundSessionStatuses(options?: { status: nextStatus, updatedAt: timestamp, } - await options?.beforeStatusWrite?.(session, nextStatus) + await options?._beforeStatusWriteForTesting?.(session, nextStatus) await writeSession(updated) refreshed.push(await applyAuthoritativeTerminalFacts(updated)) continue @@ -1037,6 +1037,8 @@ export async function recordBackgroundSessionNaturalTermination( session.status !== 'unknown' && session.status !== 'stale' ) { + // Retain an exhaustive guard so future status additions require a deliberate + // natural-finalization policy. throw new Error('Background session is not eligible for natural finalization') } @@ -1070,6 +1072,8 @@ export function recordBackgroundSessionNaturalTerminationSync( session.status !== 'unknown' && session.status !== 'stale' ) { + // Retain an exhaustive guard so future status additions require a deliberate + // natural-finalization policy. throw new Error('Background session is not eligible for natural finalization') } diff --git a/src/cli/bgRouting.ts b/src/cli/bgRouting.ts new file mode 100644 index 0000000000..a3494f8a35 --- /dev/null +++ b/src/cli/bgRouting.ts @@ -0,0 +1,4 @@ +export const BACKGROUND_SESSION_ID_ENV = + 'OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID' +export const BACKGROUND_SESSION_LAUNCHER_PID_ENV = + 'OPENCLAUDE_INTERNAL_BACKGROUND_LAUNCHER_PID' diff --git a/src/entrypoints/cli.test.ts b/src/entrypoints/cli.test.ts index 6b8ed97fa3..284e95f27f 100644 --- a/src/entrypoints/cli.test.ts +++ b/src/entrypoints/cli.test.ts @@ -16,6 +16,10 @@ import { import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { + BACKGROUND_SESSION_ID_ENV, + BACKGROUND_SESSION_LAUNCHER_PID_ENV, +} from '../cli/bgRouting.js' import { applyLoadedEnvFileValues, loadEnvFile, @@ -434,7 +438,7 @@ describe('cli.tsx — background routing behavior', () => { }) it('establishes background finalizer ownership before any command path', async () => { - process.env.OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID = 'bg-entrypoint' + process.env[BACKGROUND_SESSION_ID_ENV] = 'bg-entrypoint' mockPrepareBackgroundSessionFinalizer.mockImplementationOnce(async () => { throw new Error('finalizer ownership not ready') }) @@ -443,7 +447,7 @@ describe('cli.tsx — background routing behavior', () => { 'finalizer ownership not ready', ) } finally { - delete process.env.OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID + delete process.env[BACKGROUND_SESSION_ID_ENV] } expect(mockPrepareBackgroundSessionFinalizer).toHaveBeenCalledTimes(1) @@ -451,6 +455,18 @@ describe('cli.tsx — background routing behavior', () => { expect(mockEnableConfigs).not.toHaveBeenCalled() }) + it('routes partial background metadata through the finalizer before dispatch', async () => { + process.env[BACKGROUND_SESSION_LAUNCHER_PID_ENV] = '123' + try { + await runCliEntrypoint(['ps'], bgOptions) + } finally { + delete process.env[BACKGROUND_SESSION_LAUNCHER_PID_ENV] + } + + expect(mockPrepareBackgroundSessionFinalizer).toHaveBeenCalledTimes(1) + expect(mockPsHandler).toHaveBeenCalledTimes(1) + }) + it('keeps management commands on the management path even with --bg arguments', async () => { const cases: Array<[string, typeof mockPsHandler]> = [ ['ps', mockPsHandler], diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 5ca1ccfed7..d4e10f2107 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -1,4 +1,8 @@ import { feature } from 'bun:bundle'; +import { + BACKGROUND_SESSION_ID_ENV, + BACKGROUND_SESSION_LAUNCHER_PID_ENV, +} from '../cli/bgRouting.js' // Defensive compatibility guard for environments where globalThis.File is // unexpectedly absent. OpenClaude's supported runtime is Node >=22; this is @@ -320,7 +324,10 @@ export async function main( // fast path or startup validation can call process.exit(). The private env // value only routes this check; the registry's exact ID/PID match is the // authority. - if (process.env.OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID) { + if ( + process.env[BACKGROUND_SESSION_ID_ENV] !== undefined || + process.env[BACKGROUND_SESSION_LAUNCHER_PID_ENV] !== undefined + ) { const { prepareBackgroundSessionFinalizer } = await importers.bgFinalizer() await prepareBackgroundSessionFinalizer() }