Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
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;
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) {
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");
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) {
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.`);
Comment on lines +74 to +75

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 👍 / 👎.

}
}

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