-
Notifications
You must be signed in to change notification settings - Fork 2k
Fix test broker leaks, state races, and signal-masked command failures #541
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
878c863
96875ff
2f6cc6a
f51a8a4
c0b5eb2
64fdef1
c7f2cbe
75b6cda
47e48f8
7f69f4a
f923bbf
b5362a7
422fe24
958e7d3
df3b460
dc263cb
7186d79
ad3d852
d990937
f3204e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import fs from "node:fs"; | ||
|
|
||
| const DEFAULT_TIMEOUT_MS = 5000; | ||
| const DEFAULT_STALE_MS = 30000; | ||
| const RETRY_DELAY_MS = 25; | ||
|
|
||
| function sleepSync(ms) { | ||
| Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); | ||
| } | ||
|
|
||
| function sleep(ms) { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
|
|
||
| function tryAcquire(lockDir) { | ||
| try { | ||
| fs.mkdirSync(lockDir); | ||
| return true; | ||
| } catch (error) { | ||
| if (error?.code === "EEXIST") { | ||
| return false; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| function reclaimIfStale(lockDir, staleMs) { | ||
| try { | ||
| const age = Date.now() - fs.statSync(lockDir).mtimeMs; | ||
| if (age > staleMs) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we make stale-lock reclamation conditional on the exact lock instance observed here? Another process can replace the directory between
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in |
||
| fs.rmdirSync(lockDir); | ||
| } | ||
| } catch { | ||
| // Lock released (or reclaimed by someone else) between stat and rmdir. | ||
| } | ||
| } | ||
|
|
||
| export function acquireLockSync(lockDir, options = {}) { | ||
| const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| const staleMs = options.staleMs ?? DEFAULT_STALE_MS; | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (!tryAcquire(lockDir)) { | ||
| reclaimIfStale(lockDir, staleMs); | ||
| if (Date.now() >= deadline) { | ||
| throw new Error(`Timed out waiting for lock: ${lockDir}`); | ||
| } | ||
| sleepSync(RETRY_DELAY_MS); | ||
| } | ||
| } | ||
|
|
||
| export async function acquireLock(lockDir, options = {}) { | ||
| const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| const staleMs = options.staleMs ?? DEFAULT_STALE_MS; | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (!tryAcquire(lockDir)) { | ||
| reclaimIfStale(lockDir, staleMs); | ||
| if (Date.now() >= deadline) { | ||
| throw new Error(`Timed out waiting for lock: ${lockDir}`); | ||
| } | ||
| await sleep(RETRY_DELAY_MS); | ||
| } | ||
| } | ||
|
|
||
| export function releaseLock(lockDir) { | ||
| try { | ||
| fs.rmdirSync(lockDir); | ||
| } catch { | ||
| // Already released or reclaimed as stale; nothing left to do. | ||
| } | ||
| } | ||
|
|
||
| export function withLockSync(lockDir, fn, options = {}) { | ||
| acquireLockSync(lockDir, options); | ||
| try { | ||
| return fn(); | ||
| } finally { | ||
| releaseLock(lockDir); | ||
| } | ||
| } | ||
|
|
||
| export async function withLock(lockDir, fn, options = {}) { | ||
| await acquireLock(lockDir, options); | ||
| try { | ||
| return await fn(); | ||
| } finally { | ||
| releaseLock(lockDir); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import fs from "node:fs"; | |
| import os from "node:os"; | ||
| import path from "node:path"; | ||
|
|
||
| import { withLockSync } from "./locking.mjs"; | ||
| import { resolveWorkspaceRoot } from "./workspace.mjs"; | ||
|
|
||
| const STATE_VERSION = 1; | ||
|
|
@@ -89,9 +90,29 @@ function removeFileIfExists(filePath) { | |
| } | ||
| } | ||
|
|
||
| export function saveState(cwd, state) { | ||
| function resolveStateLockDir(cwd) { | ||
| return path.join(resolveStateDir(cwd), ".state.lock"); | ||
| } | ||
|
|
||
| function writeStateFileAtomic(stateFile, nextState) { | ||
| const tmpFile = `${stateFile}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`; | ||
| try { | ||
| const fd = fs.openSync(tmpFile, "w"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we create the replacement with the existing file's permissions, or explicitly use
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in |
||
| try { | ||
| fs.writeFileSync(fd, `${JSON.stringify(nextState, null, 2)}\n`, "utf8"); | ||
| fs.fsyncSync(fd); | ||
| } finally { | ||
| fs.closeSync(fd); | ||
| } | ||
| fs.renameSync(tmpFile, stateFile); | ||
| } catch (error) { | ||
| removeFileIfExists(tmpFile); | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| function saveStateLocked(cwd, state) { | ||
| const previousJobs = loadState(cwd).jobs; | ||
| ensureStateDir(cwd); | ||
| const nextJobs = pruneJobs(state.jobs ?? []); | ||
| const nextState = { | ||
| version: STATE_VERSION, | ||
|
|
@@ -111,14 +132,22 @@ export function saveState(cwd, state) { | |
| removeFileIfExists(job.logFile); | ||
| } | ||
|
|
||
| fs.writeFileSync(resolveStateFile(cwd), `${JSON.stringify(nextState, null, 2)}\n`, "utf8"); | ||
| writeStateFileAtomic(resolveStateFile(cwd), nextState); | ||
| return nextState; | ||
| } | ||
|
|
||
| export function saveState(cwd, state) { | ||
| ensureStateDir(cwd); | ||
| return withLockSync(resolveStateLockDir(cwd), () => saveStateLocked(cwd, state)); | ||
| } | ||
|
|
||
| export function updateState(cwd, mutate) { | ||
| const state = loadState(cwd); | ||
| mutate(state); | ||
| return saveState(cwd, state); | ||
| ensureStateDir(cwd); | ||
| return withLockSync(resolveStateLockDir(cwd), () => { | ||
| const state = loadState(cwd); | ||
| mutate(state); | ||
| return saveStateLocked(cwd, state); | ||
| }); | ||
| } | ||
|
|
||
| export function generateJobId(prefix = "job") { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
|
|
||
| import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; | ||
| import { makeTempDir } from "./helpers.mjs"; | ||
| import { | ||
| ensureBrokerSession, | ||
| loadBrokerSession, | ||
| sendBrokerShutdown | ||
| } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; | ||
|
|
||
| test("concurrent ensureBrokerSession calls share a single broker", async () => { | ||
| const workspace = makeTempDir(); | ||
| const binDir = makeTempDir(); | ||
| installFakeCodex(binDir); | ||
| const env = buildEnv(binDir); | ||
|
|
||
| let first = null; | ||
| try { | ||
| const [left, right] = await Promise.all([ | ||
| ensureBrokerSession(workspace, { env }), | ||
| ensureBrokerSession(workspace, { env }) | ||
| ]); | ||
| first = left ?? right; | ||
|
|
||
| assert.ok(left, "first ensureBrokerSession returned no session"); | ||
| assert.ok(right, "second ensureBrokerSession returned no session"); | ||
| assert.equal(left.endpoint, right.endpoint); | ||
| assert.equal(left.pid, right.pid); | ||
|
|
||
| const persisted = loadBrokerSession(workspace); | ||
| assert.ok(persisted); | ||
| assert.equal(persisted.endpoint, left.endpoint); | ||
| } finally { | ||
| if (first?.endpoint) { | ||
| await sendBrokerShutdown(first.endpoint); | ||
| } | ||
| } | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,11 +3,89 @@ import os from "node:os"; | |
| import path from "node:path"; | ||
| import process from "node:process"; | ||
| import { spawnSync } from "node:child_process"; | ||
| import { after } from "node:test"; | ||
|
|
||
| import { loadBrokerSession, sendBrokerShutdown, teardownBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; | ||
| import { terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs"; | ||
|
|
||
| const trackedTempDirs = []; | ||
|
|
||
| export function makeTempDir(prefix = "codex-plugin-test-") { | ||
| return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | ||
| const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | ||
| trackedTempDirs.push(dir); | ||
| return dir; | ||
| } | ||
|
|
||
| function sleep(ms) { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
|
|
||
| function pidAlive(pid) { | ||
| try { | ||
| process.kill(pid, 0); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function readBrokerPid(session) { | ||
| if (Number.isFinite(session.pid)) { | ||
| return session.pid; | ||
| } | ||
| try { | ||
| const parsed = Number.parseInt(fs.readFileSync(session.pidFile, "utf8").trim(), 10); | ||
| return Number.isFinite(parsed) ? parsed : null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| // Global teardown: any broker a test started (directly or lazily) and did not | ||
| // tear down is shut down here, so the suite never leaves broker/app-server | ||
| // processes behind — even when a test fails or returns early. | ||
| after(async () => { | ||
| const leakedPids = []; | ||
| const leakedSessions = []; | ||
| for (const dir of trackedTempDirs) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can teardown verify that this endpoint belongs to a broker started by the fixture and put a deadline on shutdown? An existing runtime fixture stores
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in |
||
| const session = loadBrokerSession(dir); | ||
| if (!session) { | ||
| continue; | ||
| } | ||
| leakedSessions.push(session); | ||
| if (session.endpoint) { | ||
| await sendBrokerShutdown(session.endpoint); | ||
| } | ||
| const pid = readBrokerPid(session); | ||
| if (Number.isFinite(pid)) { | ||
| leakedPids.push(pid); | ||
| } | ||
| } | ||
|
|
||
| const deadline = Date.now() + 3000; | ||
| for (const pid of leakedPids) { | ||
| while (pidAlive(pid) && Date.now() < deadline) { | ||
| await sleep(50); | ||
| } | ||
| if (pidAlive(pid)) { | ||
| terminateProcessTree(pid); | ||
| await sleep(200); | ||
| } | ||
| if (pidAlive(pid)) { | ||
| throw new Error(`Leaked broker process ${pid} survived suite teardown.`); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In environments where the detached broker has exited but remains as a zombie under PID 1, Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in |
||
| } | ||
| } | ||
|
|
||
| for (const session of leakedSessions) { | ||
| teardownBrokerSession({ | ||
| endpoint: session.endpoint ?? null, | ||
| pidFile: session.pidFile ?? null, | ||
| logFile: session.logFile ?? null, | ||
| sessionDir: session.sessionDir ?? null | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| export function writeExecutable(filePath, source) { | ||
| fs.writeFileSync(filePath, source, { encoding: "utf8", mode: 0o755 }); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we record the lock owner's PID and reclaim a dead owner's lock immediately?
staleMsis 30 seconds, but state acquisition times out after five seconds and broker startup after ten. Killing a worker while it owns the lock can therefore break cancellation and exhaust the SessionEnd cleanup deadline.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in
96875ff. Each lock now records a private owner file with the PID, process start identity, and a UUID token. Acquisition reclaims immediately when that exact owner is no longer running, beforestaleMs; regression coverage is inlock immediately reclaims an owner process that exited.