Skip to content

Fix test broker leaks, state races, and signal-masked command failures#541

Open
apedroheringer wants to merge 20 commits into
openai:mainfrom
apedroheringer:fix/broker-teardown-state-locking
Open

Fix test broker leaks, state races, and signal-masked command failures#541
apedroheringer wants to merge 20 commits into
openai:mainfrom
apedroheringer:fix/broker-teardown-state-locking

Conversation

@apedroheringer

Copy link
Copy Markdown

Problem

Running npm test leaves dozens of orphaned processes behind (broker app-server-broker.mjs serve instances plus fake codex app-server children spawned from /tmp/codex-plugin-test-* workspaces). On one machine a single suite run left ~30 brokers + ~29 fake app-servers running after the temp dirs had already been cleaned up. Root causes, verified against main (db52e28):

  1. Test teardown is per-test and easy to miss. Most tests that lazily start a broker never run the SessionEnd hook (e.g. the lazy-broker status test at tests/runtime.test.mjs:2209, while its neighbours at :2153/:2198 do clean up). Any test failure or early return also skips cleanup.
  2. state.json writes are a read-modify-write with no lock and no atomic rename (lib/state.mjssaveState/updateState use a bare fs.writeFileSync). Two concurrent commands can lose updates or tear the file.
  3. ensureBrokerSession is check-then-create with no serialization (lib/broker-lifecycle.mjs). Two concurrent callers can both miss the existing broker and spawn duplicates, orphaning one.
  4. Signal-terminated commands are reported as success: lib/process.mjs coalesces spawnSync's status: null to 0, so runCommandChecked treats a SIGTERM/SIGKILL death as exit 0.

Changes

  • tests/helpers.mjs: makeTempDir now tracks created workspaces, and a global after() hook shuts down any broker a test left running (graceful broker/shutdown, then terminateProcessTree escalation), removes its session dir, and fails the suite loudly if a broker survives teardown — so leaks cannot reappear silently.
  • tests/runtime.test.mjs: the lazy-broker status test now runs SessionEnd like its neighbours.
  • lib/locking.mjs (new): minimal mkdir-based advisory lock with stale reclaim; sync and async variants; no new dependencies.
  • lib/state.mjs: saveState/updateState are serialized per workspace and write via temp file + fsync + atomic rename.
  • lib/broker-lifecycle.mjs: the check-then-create sequence in ensureBrokerSession is serialized per workspace.
  • lib/process.mjs: status: null with a signal is normalized to a non-zero status; runCommandChecked now reports signal=SIGTERM failures.

Testing

  • npm test: 95/95 passing (91 existing + 4 new regression tests: signal-terminated command reported as failure ×2, concurrent upsertJob from two processes loses no updates, concurrent ensureBrokerSession calls share a single broker).
  • Verified post-suite process table twice: zero leftover brokers / fake app-servers / temp session dirs.
  • npm run check-version passes.

Related work

🤖 Generated with Claude Code

https://claude.ai/code/session_01K9WP1dAojgJe3xXJMV9DM1

- tests/helpers.mjs: track temp workspaces and add a global after() teardown
  that shuts down any broker a test left running (graceful shutdown, then
  terminateProcessTree escalation) and removes its session dir; the suite
  now fails loudly if a broker survives teardown.
- tests/runtime.test.mjs: the lazy-broker status test now runs the
  SessionEnd hook like its neighbors instead of leaking its broker.
- lib/locking.mjs (new): minimal mkdir-based advisory lock with stale
  reclaim, sync and async variants.
- lib/state.mjs: serialize saveState/updateState per workspace and write
  state.json via temp file + fsync + atomic rename, so concurrent
  read-modify-write cycles no longer lose updates or tear the file.
- lib/broker-lifecycle.mjs: serialize ensureBrokerSession's check-then-create
  sequence per workspace so concurrent callers cannot spawn duplicate brokers.
- lib/process.mjs: stop coalescing a null spawnSync status to 0 when the
  process died from a signal; runCommandChecked now reports it as a failure.
- New regression tests: signal-terminated commands, concurrent upsertJob
  from two processes, concurrent ensureBrokerSession sharing one broker.
@apedroheringer
apedroheringer requested a review from a team July 22, 2026 21:52

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 878c86314f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread tests/helpers.mjs Outdated
Comment on lines +74 to +75
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.

@provencher-oai provencher-oai left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found four additional correctness and safety issues beyond the existing automated review comment.

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.

Comment thread plugins/codex/scripts/lib/locking.mjs Outdated
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.

Comment thread plugins/codex/scripts/lib/state.mjs Outdated
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.

Comment thread tests/helpers.mjs
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.

@provencher-oai provencher-oai left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Some important changes to fix

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 96875ff3ce

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +365 to +366
if (!ownershipVerified && endpointArtifactExists(session.endpoint)) {
throw new Error("Codex app-server broker endpoint ownership could not be verified; persisted state was preserved.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reclaim stale owned broker sockets

When an owned broker is killed or crashes after binding its Unix socket, the socket pathname is left behind while the persisted PID is no longer running. shutdownBrokerSession() then gets no shutdown ack, observes exited === true, but this check still throws because ownershipVerified is only set by an RPC ack; ensureBrokerSession() calls this path before starting a replacement for a non-ready saved broker, so one stale socket can make every future command in that workspace fail until the user manually deletes the state/socket files.

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 2f6cc6a and hardened in f51a8a4. Stale socket reclaim now requires all independent signals: the endpoint is inside the private session directory, both the recorded PID and process group are gone, and connecting is refused. Negative regressions preserve live, reused, or external endpoints.

A broker that is killed after binding its Unix socket can never send a
shutdown ack, so ownershipVerified stayed false while the socket file
survived on disk. shutdownBrokerSession() observed exited === true and
still threw, and because ensureBrokerSession() shuts the previous session
down before spawning a replacement, a single stale socket made every
later command in that workspace fail until the state and socket files
were deleted by hand.

Reclaim the endpoint instead of throwing, but only on two independent
signals: the recorded PID is no longer running AND the endpoint refuses
connections (ECONNREFUSED/ENOENT). A missing ack alone is ambiguous,
since a live-but-hung broker also fails to answer, and a bare PID check
is defeated by PID reuse. Anything else — a timeout, an unexpected error,
a malformed endpoint — stays conservative and keeps the existing failure,
so a socket owned by an unrelated process is never unlinked.
Three gaps in the previous commit, found while reviewing it:

Confine reclaiming to the session directory. A refused connection only
proves nothing is listening at that instant — a process that has bound
but not yet called listen() also refuses — so a refused connect plus a
dead PID was not enough to call the socket ours. Reclaiming now also
requires the endpoint to sit inside the mkdtemp 0700 directory this
plugin created, which an unrelated process cannot write into.

Stop treating a reused PID as dead. isProcessTreeRunning() inspects the
process *group* on Linux, so a recycled PID belonging to another group
read as gone. Pair it with the plain PID check before concluding the
owner exited.

Normalise the probe timeout. socket.setTimeout(0) disables the timer, so
an explicit zero could leave the probe promise pending forever.

Adds regression tests for the two evidence combinations that must never
reclaim: live owner with a refused endpoint, and a fully stale-looking
endpoint outside the session directory.
@apedroheringer

Copy link
Copy Markdown
Author

Confirmed — this is a real bug, and it's now fixed in 2f6cc6a + f51a8a4. Thanks for the catch.

Tracing it. For an owned session, sendBrokerShutdown() resolves null on ECONNREFUSED (broker-lifecycle.mjs:113), so shutdownAck is null and ownershipVerified stays false. exited correctly becomes true for the dead PID, but the endpoint check at broker-lifecycle.mjs:365 then throws because the stale socket file is still on disk. ensureBrokerSessionLocked() awaits that call at :408 with no try/catch before spawning the replacement, so the workspace stays wedged until the state and socket files are deleted by hand. canDiscardUnownedSession() doesn't cover it either: it only applies to sessions without an instanceToken, and additionally requires the endpoint artifact to be absent.

The fix. That path is now reclaimable instead of fatal, but deliberately not on the absence of an ack alone — sendBrokerShutdown() also returns null on timeout, which is what a live-but-hung broker produces. Reclaiming requires all of:

  1. the endpoint sits inside the mkdtemp 0700 session directory the plugin created;
  2. neither the recorded PID nor its process group is running;
  3. connecting to the endpoint is refused (ECONNREFUSED/ENOENT).

Anything else — timeout, EACCES/EPERM, an unexpected error, a malformed endpoint — keeps the current failure, so the bias stays on the safe side: a false "alive" preserves the wedge, but no socket owned by another process is ever unlinked.

The session-directory containment in (1) is what closes the gap a plain refused-connect leaves open: a process that has bind()-ed but not yet listen()-ed also refuses connections, so "refused" alone does not establish that the socket is ours. Requirement (2) uses both isProcessTreeRunning() and isProcessRunning(), because the former inspects the process group on Linux and a recycled PID in a different group would otherwise read as dead.

Tests. Five regression tests in tests/broker-lifecycle.test.mjs: a stale socket is reclaimed; ensureBrokerSession() recovers end-to-end instead of throwing; and three negative cases that must never reclaim — a live listener on the endpoint, a live owner with a refused endpoint, and an otherwise stale-looking endpoint outside the session directory. Suite is at 105 passing.


Two things worth flagging separately, both pre-existing on main rather than introduced here — happy to open issues or fold either into this PR if you'd prefer:

  1. handleSessionEnd() calls shutdownBrokerSession() directly (session-lifecycle-hook.mjs:89) without taking the per-workspace .broker.lock that ensureBrokerSession() holds. A SessionEnd running against a stale snapshot can therefore reach clearBrokerSession() after a concurrent ensure has already persisted a replacement, orphaning the new broker. The reclaim path makes that window mutate the filesystem, so it seemed worth naming.

  2. Four tests fail on main today, independent of this branch (verified by running them at db52e28): resolveStateDir uses a temp-backed per-workspace directory fails whenever CLAUDE_PLUGIN_DATA points outside os.tmpdir() — which is the case inside Claude Code itself, the plugin's own environment — and three runtime.test.mjs cases (status shows phases…, status preserves adversarial review kind labels, result returns the stored output…) fail with "No finished Codex jobs found for this repository yet."

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f51a8a442a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

}

const pid = resolveBrokerPid(session);
if (session.endpoint && !session.instanceToken) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle tokenless legacy broker sessions before rejecting

When upgrading while a broker written by the previous version is still alive, its persisted broker.json has an endpoint/PID/sessionDir but no instanceToken. This branch classifies that live endpoint as unowned before attempting any legacy shutdown path, so SessionEnd throws ownership could not be verified and preserves the state/process until manual cleanup even though it is the plugin's own broker. Please retire or migrate legacy sessions (for example by verifying the saved PID/sessionDir or sending the old unauthenticated shutdown) before rejecting tokenless state.

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 c0b5eb2. A tokenless session is retired only when its endpoint exactly matches the endpoint derived from sessionDir, its PID file is canonical, and the live PID command line matches the complete legacy broker launch sequence. The old unauthenticated shutdown is then allowed and awaited. A positive upgrade regression and a negative test for a new authenticated broker whose persisted token was lost are both included. npm test passes 112/112 and npm run check-version passes.

@apedroheringer

apedroheringer commented Jul 25, 2026

Copy link
Copy Markdown
Author

Hi @provencher-oai — I addressed all four requested changes in 96875ff and replied to each review thread with the corresponding implementation and regression coverage. I also addressed every subsequent automated finding in 2f6cc6a, f51a8a4, c0b5eb2, and 64fdef1, including stale sockets, safe migration of tokenless legacy brokers, and reused-PID lock ownership on macOS and Windows. Current HEAD passes npm test (116/116) and npm run check-version. Would you mind taking another look when convenient? Thanks.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c0b5eb243f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +148 to +152
processRunning(state.owner.pid, {
identity: state.owner.processIdentity ?? undefined
})
) {
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Distinguish reused PIDs before preserving abandoned locks

On macOS and Windows, getProcessIdentity() always records null, so if a lock-owning process crashes and its PID is subsequently reused by an unrelated long-lived process, this branch treats that process as the lock owner forever. Because staleMs is only applied to empty locks, every subsequent acquisition of the affected state or broker lock times out indefinitely; persist and verify a platform-specific process identity, or otherwise safely expire abandoned owned locks.

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 64fdef1. Lock owners now persist a platform-specific process start identity: /proc start time on Linux, ps lstart on macOS/other POSIX systems, and PowerShell StartTime ticks on Windows. isProcessRunning() compares that identity before accepting a live PID as the owner, so a reused PID is reclaimed immediately. The identity lookup is cached once per owner process to avoid adding cost to every state update. Existing macOS/Windows lock files that already contain no identity are reclaimed after staleMs, preventing permanent wedges during upgrade. Added regressions for macOS identity, Windows identity, non-Linux PID reuse, and stale identity-less locks. npm test passes 116/116; npm run check-version passes.

@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 64fdef1cb0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 75b6cda7ad

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Behavioral fixes:
- sendBrokerShutdown honors timeoutMs: 0 instead of disabling the timer.
- Broker shutdown ACK uses destroySoon and destroys lingering client
  sockets, so a half-open client no longer leaks the broker process.
- Lock owner records fail closed on malformed pid/token/processIdentity.
- teardownBrokerSession only removes the canonical broker files inside a
  session directory this plugin provably created (real cxc-* dir directly
  under tmpdir, non-symlink), never externally supplied paths.
- SessionEnd aggregate error now includes the underlying failure text.

Consolidation (no behavior change):
- Single isValidPid, ENOENT-tolerant removeFileIfExists, shared
  ps/PowerShell process-table query, shared /proc cmdline reader, single
  endpoint connect probe, and one lock acquire step shared by the
  sync/async paths.
- Drop dead writeJsonFile, unused PID/LOG env constants, options.session,
  and demote broker helpers that no longer have external consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018dXaCHsST2XTpdu63v4Ty4
@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 47e48f8497

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread plugins/codex/scripts/lib/process.mjs Outdated
Comment on lines +140 to +143
if (platform === "linux") {
const readProcessStat = options.readProcessStat ?? readLinuxProcessStat;
const stat = readProcessStat(pid);
if (!stat) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat unreadable Linux process metadata as unknown

On Linux systems where /proc/<pid>/stat is unavailable or temporarily unreadable, kill(pid, 0) may confirm that the process exists but this branch still reports it as stopped. reclaimAbandonedLock consequently removes a live owner's lock immediately, allowing concurrent state updates or broker creation despite the lock; broker exit checks can likewise proceed while the process remains alive. Only an explicit missing-process result should prove exit, while failure to inspect /proc should conservatively remain running and be retried.

Useful? React with 👍 / 👎.

kill(pid, 0) succeeding proves the process exists; a null /proc stat only
means inspection failed. Reporting such a process as exited let a stale-lock
reclaim remove a live owner's lock. Fail conservative instead: the process
stays running until an explicit missing-process signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018dXaCHsST2XTpdu63v4Ty4
@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 7f69f4aea7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

- Register the SIGTERM/SIGINT shutdown handler in one loop.
- Reuse removeFileIfExists for broker socket/pid cleanup, closing the
  exists/unlink race.
- loadBrokerSession relies on its catch instead of a pre-existence check.
- canDiscardUnownedSession forwards caller options to the process-tree
  check like every other ownership probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018dXaCHsST2XTpdu63v4Ty4
@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 958e7d390b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Extract teardownAndClear() for the two teardown+clear call sites in
shutdownBrokerSessionLocked, keeping the legacy-discard path pinned to an
unverified endpoint so the unverified-endpoint guard in
teardownBrokerSession stays load-bearing.

Compute the lock owner's process identity lazily on first acquisition
instead of at module import, so CLI invocations that never take a lock no
longer spawn ps/PowerShell on macOS and Windows.

Read the broker pid file in a single syscall with failures treated as an
absent pid, removing the exists/read TOCTOU window; an unreadable pid
file now degrades to the persisted state pid instead of aborting
shutdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S29rWAKA85b1N7zAFC6NtB
@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Unknown error
ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: df3b4601e3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

The verifyProcess escape hatch was threaded through earlier revisions of
this branch, but its last caller was refactored away during the helper
consolidation, so the ternary can never take the override arm. Collapse
processMatchesInstanceToken to the real launch-token probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S29rWAKA85b1N7zAFC6NtB
@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Unknown error
ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc263cbb39

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

pid: child.pid ?? null,
instanceToken
};
saveBrokerSession(cwd, session);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid persisting the broker before spawn succeeds

When spawn() fails asynchronously before assigning a PID—for example because the workspace directory disappears—the child emits an unhandled error, but this state has already been written with pid: null. The current invocation crashes, and later invocations cannot recover because shutdownBrokerSessionLocked() rejects tokened sessions without a valid PID while preserving broker.json, permanently wedging broker startup for that workspace. Handle the child’s spawn error and clean up, or defer persistence until the spawn is confirmed.

Useful? React with 👍 / 👎.

child_process.spawn() reports failures like a vanished workspace directory
asynchronously, as an "error" event on the next tick. ensureBrokerSession
persisted broker.json before that event could fire, so a failed spawn
crashed the invocation via the unhandled event and left behind a tokenized
session with pid: null — a combination shutdownBrokerSessionLocked()
rejects fatally before any reclaim path runs, wedging every later
invocation in that workspace.

Await the child's spawn/error outcome before writing state: on failure,
tear down the just-created session artifacts and surface a descriptive
error instead. Regression test covers the rejection, the absence of
persisted state, and the cleanup of the temporary session directory.

Reported by the Codex GitHub reviewer on this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S29rWAKA85b1N7zAFC6NtB
@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Unknown error
ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 7186d7938e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Final-audit fixes on the broker lifecycle and locking paths:

- resolveBrokerPid() swallowed every pid file read error, so an unreadable
  file (EACCES, EISDIR) silently skipped the PID mismatch check and fell
  back to session.pid alone. Only ENOENT is treated as an absent file now;
  any other failure aborts the shutdown path with the state preserved.

- ensureBrokerSessionLocked() persisted broker.json without guarding the
  write. A persist failure (ENOSPC, EACCES) after a confirmed spawn left
  the detached child running with no state pointing at it, so later
  invocations could stack a second broker on top. On failure the child
  tree is killed, the session artifacts are torn down, and the original
  error is rethrown.

- tryAcquire() resolved the process identity between creating the
  candidate directory and publishing it, so a crash during a slow
  ps/PowerShell lookup could orphan the candidate. The lookup now happens
  before mkdir, shrinking the window back to the write-and-rename itself.

- Reworded the stale null-identity reclaim comment: this module never
  shipped without identity records, so null means the lookup failed at
  acquire time, and reclaiming after staleMs remains the deliberate
  lesser risk versus wedging every later caller behind a reused PID.

- Dropped the dead node:fs import from app-server-broker.mjs.

Regression tests cover the fail-closed pid file read and the
kill-and-teardown unwind after a failed persist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uAqG4qqx6YKWzXjd7YMDG
@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Unknown error
ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ad3d852593

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +552 to +553
pid: child.pid ?? null,
instanceToken

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Persist broker identity to handle PID reuse

If a crashed broker's PID is reused before the stale session is reclaimed, the session has no process start identity with which to distinguish the replacement. A replacement that is a process-group leader makes shutdownBrokerSessionLocked() preserve the state as apparently live, while any replacement PID makes canReclaimStaleEndpoint() refuse to remove a leftover socket; ensureBrokerSession() then remains wedged indefinitely even though processHasLaunchToken() proves the process is not this broker. Persist the broker's start identity alongside its PID and use it in the liveness checks.

Useful? React with 👍 / 👎.

Comment on lines +560 to +564
try {
killProcess(pid);
shutdownOptions.killProcess(child.pid);
} catch {
// Ignore missing or already-exited broker processes.
// Teardown must still run and the original persist error must still
// surface; a failure to kill the child is secondary to both.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Confirm termination before discarding failed-spawn artifacts

When persisting broker.json fails after the child has spawned, killProcess() only delivers a signal and may also throw, but this path immediately deletes the socket and session directory without waiting for exit. If the broker's signal handler is still closing a hung app server, or termination is denied, the process tree survives with no state or endpoint through which a later invocation can identify and stop it—the exact untracked leak this catch is intended to prevent. Wait for confirmed process-tree exit and surface termination failure before removing the recovery artifacts.

Useful? React with 👍 / 👎.

The Codex GitHub reviewer flagged two P2 gaps on the previous commit:
sessions persist only a PID, so a recycled PID wedges the workspace (a
group-leader lookalike makes shutdown throw "ownership could not be
verified" forever, and any live holder of the PID blocks stale-socket
reclaim), and the failed-persist unwind killed and deleted artifacts
without confirming the child actually exited.

Persist the broker's process identity (its start time) in broker.json at
spawn time and pin every liveness and exit check on the persisted pid to
it. The identity follows the pid+creation-time convention (psutil, OCSF
common-process-id) and fails closed end to end:

- On Linux the identity is boot_id:starttime -- starttime alone is in
  clock ticks since boot and would collide across reboots. When boot_id
  cannot be read, no identity is recorded and none is compared; a bare
  tick count is never emitted or matched, and a transient read failure
  is not cached.
- isProcessTreeRunning() only treats the tree as gone on a CONFIRMED
  replacement (pid alive with a different readable identity). POSIX
  keeps a group leader's pid reserved while its group has members, so a
  confirmed replacement also proves the recorded group is gone; an
  absent or unreadable leader still falls through to the group check,
  keeping live descendants visible.
- ps-based process-table queries run under LC_ALL=C TZ=UTC so the
  recorded lstart rendering cannot drift with the caller's locale or
  timezone and falsely mismatch a still-live broker.
- A persisted identity is honored only when it is a non-empty string;
  malformed values fall back to the legacy no-identity behavior instead
  of reading a live broker as replaced.

The failed-persist unwind now mirrors the ownership-before-kill
invariant of the forced-shutdown path: it only signals the child when
its argv still carries this session's launch token, waits for a
confirmed exit pinned to the recorded identity, and preserves the
session artifacts with a descriptive error when the exit cannot be
confirmed instead of deleting them out from under a live process.

Regression tests cover the reused-pid mismatch, the dead-leader with a
surviving descendant, the preserved-artifacts branch, and the confirmed
teardown, each verified to fail against the unfixed code; test cleanups
now assert the processes they spawn actually exited.

Reported by the Codex GitHub reviewer on this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uAqG4qqx6YKWzXjd7YMDG
@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Unknown error
ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d99093734e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +441 to +444
const ownsPersistedProcess = ownsBrokerProcess(session, pid, legacySession, options);
if (!ownsPersistedProcess) {
if (isProcessTreeRunning(pid, livenessOptions)) {
throw new Error("Codex app-server broker ownership could not be verified; persisted state was preserved.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reclaim groups after the broker leader exits

When the broker crashes or exits while its Codex child remains in the detached process group, waitForProcessExit(..., { tree: true }) correctly reports the tree as running, but token verification against the now-dead leader PID necessarily fails here. Shutdown therefore preserves broker.json without terminating the owned descendant group, and every later ensureBrokerSession() fails on the same state until it is removed manually. The persisted process identity and process-group ID should permit recovery when the recorded leader is gone but its group survives.

Useful? React with 👍 / 👎.

The Codex GitHub reviewer flagged the remaining wedge on this branch:
when the broker leader dies but its codex app-server child survives in
the detached process group, the tree correctly reads as running, yet
ownership can only be proven against the dead leader's argv. Shutdown
therefore threw "ownership could not be verified" on every invocation
until the orphan happened to exit, wedging the workspace.

Recognize that state explicitly instead. When the session carries an
instance token and the recorded leader is confirmed gone (or provably
replaced, per the persisted identity) while its group still has members,
shutdown now reclaims the session state without signaling anything:
POSIX reserves a leader's pid for as long as its group survives, but a
single observation cannot tell the original broker's descendants from a
later generation that recycled the same pgid, so the group is
deliberately left untouched -- it may not even be idle. Nothing can be
accepting on a refused endpoint, so the socket, pid file, log file and
broker.json are safe to remove, and the next ensureBrokerSession starts
a fresh broker in a fresh session directory. canReclaimStaleEndpoint
gains an explicit allowOrphanedTree flag for exactly this caller;
legacy sessions keep the full process-group veto and still fail closed.

Regression tests cover the reclaim (kill spy proves nothing is ever
signaled, the orphan survives, all artifacts are removed), the
subsequent ensureBrokerSession with the orphan still alive, and the
unchanged legacy behavior; all verified to fail against the previous
code.

Reported by the Codex GitHub reviewer on this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uAqG4qqx6YKWzXjd7YMDG
@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Unknown error
ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@apedroheringer

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: f3204e8db5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants