Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
878c863
Fix test broker leaks, state races, and signal-masked failures
apedroheringer Jul 22, 2026
96875ff
Fix broker ownership and lock reclamation
apedroheringer Jul 23, 2026
2f6cc6a
Reclaim stale sockets left by owned brokers
apedroheringer Jul 23, 2026
f51a8a4
Harden stale-socket reclaim after adversarial review
apedroheringer Jul 23, 2026
c0b5eb2
Fix tokenless legacy broker upgrades
apedroheringer Jul 25, 2026
64fdef1
Fix reused-PID lock ownership across platforms
apedroheringer Jul 25, 2026
c7f2cbe
Refactor private file and process helpers
apedroheringer Jul 25, 2026
75b6cda
Serialize broker teardown and session cleanup
apedroheringer Jul 25, 2026
47e48f8
Fix shutdown edge cases and consolidate duplicated helpers
apedroheringer Jul 25, 2026
7f69f4a
Keep Linux processes with unreadable /proc metadata running
apedroheringer Jul 25, 2026
f923bbf
Tighten remaining duplication in broker entry points
apedroheringer Jul 25, 2026
b5362a7
Verify legacy brokers by session artifacts, not launch cwd
apedroheringer Jul 25, 2026
422fe24
Honor verified legacy ownership during forced shutdown
apedroheringer Jul 25, 2026
958e7d3
Trust proven process ownership for endpoint cleanup
apedroheringer Jul 25, 2026
df3b460
Consolidate broker teardown and defer process identity lookup
apedroheringer Jul 25, 2026
dc263cb
Drop unused verifyProcess override from token matching
apedroheringer Jul 25, 2026
7186d79
Confirm broker spawn before persisting session state
apedroheringer Jul 25, 2026
ad3d852
Fail closed on pid file errors and unwind failed session persists
apedroheringer Jul 25, 2026
d990937
Pin broker liveness checks to a persisted process identity
apedroheringer Jul 25, 2026
f3204e8
Reclaim sessions whose dead leader left an orphaned process group
apedroheringer Jul 25, 2026
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
11 changes: 11 additions & 0 deletions plugins/codex/scripts/lib/broker-lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import process from "node:process";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { createBrokerEndpoint, parseBrokerEndpoint } from "./broker-endpoint.mjs";
import { withLock } from "./locking.mjs";
import { resolveStateDir } from "./state.mjs";

export const PID_FILE_ENV = "CODEX_COMPANION_APP_SERVER_PID_FILE";
Expand Down Expand Up @@ -111,6 +112,16 @@ async function isBrokerEndpointReady(endpoint) {
}

export async function ensureBrokerSession(cwd, options = {}) {
const stateDir = resolveStateDir(cwd);
fs.mkdirSync(stateDir, { recursive: true });
// Serialize the check-then-create sequence per workspace so two concurrent
// callers cannot both miss the existing broker and spawn a duplicate.
return withLock(path.join(stateDir, ".broker.lock"), () => ensureBrokerSessionLocked(cwd, options), {
timeoutMs: options.lockTimeoutMs ?? 10000
});
}

async function ensureBrokerSessionLocked(cwd, options = {}) {
const existing = loadBrokerSession(cwd);
if (existing && (await isBrokerEndpointReady(existing.endpoint))) {
return existing;
Expand Down
88 changes: 88 additions & 0 deletions plugins/codex/scripts/lib/locking.mjs
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;

Copy link
Copy Markdown

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? staleMs is 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.

Copy link
Copy Markdown
Author

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, before staleMs; regression coverage is in lock immediately reclaims an owner process that exited.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 statSync() and rmdirSync(), so removing the replacement lets multiple state writers or broker creators enter the critical section. The unconditional unlock has the same ownership problem.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 96875ff. The owner UUID is part of the ownership filename, and release/reclaim unlinks only the exact observed generation before removing an empty directory. The regression test proves that an old handle cannot release a successor lock.

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);
}
}
4 changes: 3 additions & 1 deletion plugins/codex/scripts/lib/process.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export function runCommand(command, args = [], options = {}) {
return {
command,
args,
status: result.status ?? 0,
// A null status with a signal means the process was killed; never report
// signal-terminated commands as exit 0.
status: result.status ?? (result.signal != null ? 1 : 0),
signal: result.signal ?? null,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
Expand Down
41 changes: 35 additions & 6 deletions plugins/codex/scripts/lib/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 0o600? This temporary file uses the process umask, so replacing a 0600 state file typically changes it to 0644 even though it contains complete background-task prompts.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 96875ff. Atomic state replacements are created with wx and mode 0o600, fchmoded before writing, and chmoded after rename for defense in depth. state, job, and log artifacts are private verifies the resulting modes.

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,
Expand All @@ -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") {
Expand Down
39 changes: 39 additions & 0 deletions tests/broker-lifecycle.test.mjs
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);
}
}
});
80 changes: 79 additions & 1 deletion tests/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 unix:/tmp/fake-broker.sock without ownership metadata, so this hook can send broker/shutdown to an unrelated process, unlink its socket, or hang indefinitely waiting for a response.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 96875ff. Global teardown only touches sessions carrying the fixture-created instanceToken and routes them through shutdownBrokerSession(), which verifies the RPC/process identity before kill or unlink. The fixture supplies a 1000 ms timeout, failures are aggregated, and regressions cover both deadline enforcement and preservation of an unowned endpoint.

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.`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not fail teardown on defunct broker PIDs

In environments where the detached broker has exited but remains as a zombie under PID 1, process.kill(pid, 0) still succeeds, so this new teardown path reports a leak that cannot be terminated. I hit this with npm test: all tests passed, then this hook failed with Leaked broker process ..., and ps showed that PID as [MainThread] <defunct>. That makes the suite fail nondeterministically depending on how quickly the container init reaps orphaned detached brokers; the leak check needs to distinguish defunct processes from live brokers before throwing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 96875ff. Teardown no longer relies on a raw kill(pid, 0) liveness check. isProcessRunning() now reads Linux process state and treats Z/X as exited, with regression coverage in tests/process.test.mjs. The full suite at current HEAD passes 112/112.

}
}

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 });
}
Expand Down
19 changes: 18 additions & 1 deletion tests/process.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,24 @@
import process from "node:process";
import test from "node:test";
import assert from "node:assert/strict";

import { terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs";
import { runCommand, runCommandChecked, terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs";

const SELF_TERMINATING_SCRIPT = "process.kill(process.pid, 'SIGTERM'); setInterval(() => {}, 1000);";

test("runCommand reports a signal-terminated process as a failure", { skip: process.platform === "win32" }, () => {
const result = runCommand(process.execPath, ["-e", SELF_TERMINATING_SCRIPT]);

assert.equal(result.signal, "SIGTERM");
assert.notEqual(result.status, 0);
});

test("runCommandChecked throws when the process dies from a signal", { skip: process.platform === "win32" }, () => {
assert.throws(
() => runCommandChecked(process.execPath, ["-e", SELF_TERMINATING_SCRIPT]),
/signal=SIGTERM/
);
});

test("terminateProcessTree uses taskkill on Windows", () => {
let captured = null;
Expand Down
10 changes: 10 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2233,6 +2233,16 @@ test("status reports shared session runtime when a lazy broker is active", () =>

assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Session runtime: shared session/);

const cleanup = run("node", [SESSION_HOOK, "SessionEnd"], {
cwd: repo,
env: buildEnv(binDir),
input: JSON.stringify({
hook_event_name: "SessionEnd",
cwd: repo
})
});
assert.equal(cleanup.status, 0, cleanup.stderr);
});

test("setup and status honor --cwd when reading shared session runtime", () => {
Expand Down
Loading