Files
orca/src/main/codex-cli/codex-home-process-lock.test.ts
T
1ef83986a1 fix(codex): stop a surviving descendant wedging the Codex home process lock forever (#12410)
* fix(codex): stop a surviving descendant wedging the Codex home process lock

The per-Codex-home process lock was released only by the child's 'close'
event. 'close' fires when the child AND every process that inherited its
stdio have exited, so a grandchild that outlives codex (an MCP server, a
helper, an `sh -c` wrapper) keeps the pipe open and 'close' never arrives.
killProcessTree only SIGKILLs the direct child on POSIX, so Stop and the
60s generation timeout both leave the lock held. Every later AI commit
message, PR field, branch name, and quota probe for that home then queued
forever with no error and no recovery until the app restarted.

Release the hold on 'exit' as well as 'close': once the codex process
itself is gone it can no longer rotate that home's auth.json, which is the
race the lock exists to prevent, so waiting on descendants bought nothing.
As a backstop for a child that never reports exit at all (a kill that does
not land), bound the hold: while another run is queued behind it, a hold
that outlives CODEX_HOME_PROCESS_LOCK_MAX_HOLD_MS releases the queue. The
cap is armed only when someone is waiting and only counts time the entry
actually holds the lock, so uncontended runs arm no timer and queueing
behind a slow predecessor never shortens a run's own budget.

* fix(codex): preserve home lock exclusivity

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-03 23:25:14 -07:00

159 lines
5.0 KiB
TypeScript

import { join } from 'node:path'
import { homedir } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import {
resolveCodexHomeProcessLockKey,
resolveCodexHomeProcessLockKeyForSpawnEnv,
withCodexHomeProcessLock
} from './codex-home-process-lock'
function deferred<T = void>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('withCodexHomeProcessLock', () => {
it('serializes runs that share a lock key', async () => {
const events: string[] = []
const firstGate = deferred()
const first = withCodexHomeProcessLock('home-a', async () => {
events.push('first:start')
await firstGate.promise
events.push('first:end')
return 1
})
const second = withCodexHomeProcessLock('home-a', async () => {
events.push('second:start')
return 2
})
await Promise.resolve()
expect(events).toEqual(['first:start'])
firstGate.resolve()
await expect(first).resolves.toBe(1)
await expect(second).resolves.toBe(2)
expect(events).toEqual(['first:start', 'first:end', 'second:start'])
})
it('runs different lock keys concurrently', async () => {
const events: string[] = []
const firstGate = deferred()
const first = withCodexHomeProcessLock('home-a', async () => {
events.push('a:start')
await firstGate.promise
})
const second = withCodexHomeProcessLock('home-b', async () => {
events.push('b:start')
})
await second
expect(events).toEqual(['a:start', 'b:start'])
firstGate.resolve()
await first
})
it('releases the lock after a rejected run', async () => {
await expect(
withCodexHomeProcessLock('home-a', async () => {
throw new Error('boom')
})
).rejects.toThrow('boom')
await expect(withCodexHomeProcessLock('home-a', async () => 'after')).resolves.toBe('after')
})
it('does not release a running lock based on elapsed time', async () => {
vi.useFakeTimers()
try {
const events: string[] = []
const gate = deferred()
const first = withCodexHomeProcessLock('home-long-running', async () => {
events.push('first:start')
await gate.promise
})
const second = withCodexHomeProcessLock('home-long-running', async () => {
events.push('second:start')
})
await vi.advanceTimersByTimeAsync(60 * 60_000)
expect(events).toEqual(['first:start'])
gate.resolve()
await Promise.all([first, second])
expect(events).toEqual(['first:start', 'second:start'])
} finally {
vi.useRealTimers()
}
})
it('keys explicit and default host homes consistently', () => {
const previousCodexHome = process.env.CODEX_HOME
delete process.env.CODEX_HOME
try {
const defaultKey = resolveCodexHomeProcessLockKey(null)
expect(resolveCodexHomeProcessLockKey(join(homedir(), '.codex'))).toBe(defaultKey)
expect(resolveCodexHomeProcessLockKeyForSpawnEnv(undefined)).toBe(defaultKey)
expect(resolveCodexHomeProcessLockKey('/somewhere/else')).not.toBe(defaultKey)
} finally {
if (previousCodexHome !== undefined) {
process.env.CODEX_HOME = previousCodexHome
}
}
})
it('keys a stripped child env to the real default home, not ambient CODEX_HOME', () => {
const previousCodexHome = process.env.CODEX_HOME
process.env.CODEX_HOME = '/nested-orca/managed-home'
try {
expect(resolveCodexHomeProcessLockKeyForSpawnEnv({ PATH: process.env.PATH })).toBe(
resolveCodexHomeProcessLockKey(join(homedir(), '.codex'))
)
expect(resolveCodexHomeProcessLockKeyForSpawnEnv(undefined)).toBe(
resolveCodexHomeProcessLockKey('/nested-orca/managed-home')
)
} finally {
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = previousCodexHome
}
}
})
it('keys a WSL UNC probe home and a WSL spawn env to the same lock', () => {
const probeKey = resolveCodexHomeProcessLockKey('\\\\wsl$\\Ubuntu\\home\\user\\.codex')
const spawnKey = resolveCodexHomeProcessLockKeyForSpawnEnv(
{ CODEX_HOME: '/home/user/.codex' },
'Ubuntu'
)
expect(spawnKey).toBe(probeKey)
})
it('uses the WSL default sentinel when launcher filtering strips an ambient home', () => {
const previousCodexHome = process.env.CODEX_HOME
process.env.CODEX_HOME = '/host-only/codex-home'
try {
const inheritedKey = resolveCodexHomeProcessLockKeyForSpawnEnv(
{ CODEX_HOME: '/host-only/codex-home' },
'Ubuntu'
)
const strippedKey = resolveCodexHomeProcessLockKeyForSpawnEnv({}, 'Ubuntu')
expect(inheritedKey).toBe(strippedKey)
} finally {
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = previousCodexHome
}
}
})
})