mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
* 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>
159 lines
5.0 KiB
TypeScript
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
|
|
}
|
|
}
|
|
})
|
|
})
|