Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

`openclaude attach <id-or-name>` currently reports the matching session and
points to `openclaude logs <id> -f`; full terminal reattach is not implemented
Expand Down
69 changes: 67 additions & 2 deletions src/cli/bg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, expect, it } from 'bun:test'
import {
buildBackgroundSessionLaunch,
buildBackgroundChildProcessConfig,
confirmBackgroundSessionLaunch,
followLogFile,
killBackgroundSession,
printExistingLog,
Expand Down Expand Up @@ -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'],
Expand All @@ -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')
Expand All @@ -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 () => {
Expand All @@ -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',
])
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
})

describe('background session process termination safety', () => {
Expand Down
99 changes: 93 additions & 6 deletions src/cli/bg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export type BuildBackgroundChildProcessConfigInput = {
processEnv: NodeJS.ProcessEnv
sessionName?: string
stdoutLogPath: string
backgroundSessionId: string
launcherPid?: number
}

type PrResumeSelector = true | string
Expand All @@ -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'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const DEFAULT_TERM_GRACE_MS = 2_000
const DEFAULT_KILL_GRACE_MS = 2_000
const DEFAULT_KILL_POLL_INTERVAL_MS = 100
Expand Down Expand Up @@ -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(
Expand All @@ -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]
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
delete env[HEAP_RELAUNCHED_ENV]

return {
command: input.execPath,
args: [
...safeNodeExecArgvForBackground(input.execArgv),
...safeNodeExecArgvForBackground(
input.execPath,
input.execArgv,
input.processEnv,
),
input.entrypoint,
...input.childArgs,
],
Expand Down Expand Up @@ -882,6 +935,30 @@ export async function killBackgroundSession(
return await markKilled(session)
}

type ConfirmBackgroundSessionLaunchOptions = {
isProcessAlive?: (pid: number) => boolean
refreshStatuses?: () => Promise<BackgroundSession[]>
resolveSession?: (id: string) => Promise<BackgroundSession>
}

export async function confirmBackgroundSessionLaunch(
session: BackgroundSession,
options: ConfirmBackgroundSessionLaunchOptions = {},
): Promise<BackgroundSession> {
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
}
Comment thread
jatmn marked this conversation as resolved.
Resolved

export async function psHandler(_args: string[]): Promise<void> {
const sessions = await refreshBackgroundSessionStatuses()
printSessionTable(sessions)
Expand Down Expand Up @@ -978,6 +1055,8 @@ export async function handleBgFlag(args: string[]): Promise<void> {
processEnv: process.env,
sessionName: parsed.name,
stdoutLogPath: logPaths.stdoutLogPath,
backgroundSessionId: id,
launcherPid: process.pid,
})

let stdoutFd: number | undefined
Expand Down Expand Up @@ -1023,7 +1102,7 @@ export async function handleBgFlag(args: string[]): Promise<void> {
}

const command = [childConfig.command, ...childConfig.args]
const session = await createBackgroundSession({
let session = await createBackgroundSession({
id,
name: parsed.name,
pid: child.pid,
Expand All @@ -1041,7 +1120,15 @@ export async function handleBgFlag(args: string[]): Promise<void> {
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}`)
Expand Down
36 changes: 36 additions & 0 deletions src/cli/bgFinalizer.fixture.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading