Skip to content
This repository was archived by the owner on Jul 4, 2026. It is now read-only.

fix(codex_app_server): kill the whole process group on close - #8

Merged
h4x3rotab merged 1 commit into
phalafrom
fix/codex-app-server-process-group
May 24, 2026
Merged

fix(codex_app_server): kill the whole process group on close#8
h4x3rotab merged 1 commit into
phalafrom
fix/codex-app-server-process-group

Conversation

@h4x3rotab

Copy link
Copy Markdown

Summary

`CodexAppServerClient` spawns the codex subprocess without a dedicated process group. On the shipped npm install, `/usr/local/bin/codex` is a node wrapper that re-execs the native `codex` binary as a child, so `Popen.terminate()` / `Popen.kill()` only signal the wrapper — the native binary survives as an orphan grandchild still holding the rollout sqlite.

Every retired session (turn-level timeout, post-tool watchdog, manual `/codex-runtime` toggle, etc.) leaks one of these orphans. Once enough pile up, fresh codex spawns time out at the 10 s `initialize` deadline waiting for the sqlite lock — what users see as:

`codex app-server startup failed: codex app-server method 'initialize' timed out after 10.0s`

A live k3s pod running this fork accumulated ~130 orphans over 12 hours before the codex runtime fully wedged. lsof on `/data/.codex/logs_2.sqlite` showed one of the orphans holding 20+ file descriptors against it.

Fix

Two-line behavioural change:

  • `Popen(..., start_new_session=True)` at spawn — codex subprocess (and any descendant it execs) lives in its own POSIX session/process group, separated from the gateway's group. Windows ignores the flag, irrelevant since the wrapper-vs-native split is a Linux/macOS packaging artifact.

  • `close()` signals the process group via `os.killpg` so wrapper + native binary die together. Falls back to the old `terminate()`/`kill()` path when `getpgid`/`killpg` are unavailable (Windows) or the group is already gone. SIGTERM → 3 s wait → SIGKILL escalation preserved.

Test plan

`tests/agent/transports/test_codex_app_server_process_group.py`:

  • Reaps native grandchild: spawn a sh wrapper that fork-execs a 60-second sleep grandchild, capture both pids, call `client.close()`. Assert (a) the wrapper is in its own session (`getpgid(pid) == pid`, confirming `start_new_session=True` took effect), (b) the grandchild inherits that pgid, (c) both are reaped after `close()`. Without the fix the grandchild stays alive past close.
  • Idempotent against already-exited child: `close()` after natural exit is a no-op.

Both tests carry `@pytest.mark.live_system_guard_bypass` since they intentionally do real `Popen` + signal delivery against their own child subtree (the existing repo guard otherwise blocks raw `os.kill`).

`pytest tests/agent/transports/test_codex_app_server_process_group.py tests/agent/transports/test_codex_app_server_session.py` → 59/59 pass (2 new + 57 pre-existing, no regressions).

Operational note

Pods already wedged by orphans need a one-time cleanup before the fix takes effect on the live process:

```
supervisorctl stop hermes-agent
pkill -KILL -f 'codex app-server'
supervisorctl start hermes-agent
```

After this PR lands and the image rebuilds, that manual cleanup stops being necessary.

🤖 Generated with Claude Code

``CodexAppServerClient`` spawned the codex subprocess without a dedicated
session/process group. On the shipped install ``/usr/local/bin/codex``
is a node wrapper that re-execs the native ``codex`` binary as a child,
so ``Popen.terminate()`` / ``Popen.kill()`` only signalled the wrapper
— the native binary survived as an orphan grandchild still holding the
rollout sqlite open.

Every retired session (turn-level timeout, post-tool watchdog,
manual ``/codex-runtime`` toggle, etc.) leaked one of these orphans.
Once enough piled up, fresh codex spawns timed out at the 10s
``initialize`` deadline waiting for the sqlite lock — surfaced to the
user as ``codex app-server method 'initialize' timed out after 10.0s``.
Live pods accumulated 130+ orphans over ~12h before the lock contention
fully wedged the runtime.

This commit:

- ``Popen(..., start_new_session=True)`` so the codex subprocess (and
  any descendant it execs) lives in its own POSIX session/process
  group, separated from the gateway's group. Windows ignores the flag
  — irrelevant because the wrapper-vs-native split is a Linux/macOS
  packaging artifact.

- ``close()`` now signals the **process group** via ``os.killpg`` so
  the wrapper + native binary die together. Falls back to the old
  ``terminate()``/``kill()`` path when ``getpgid``/``killpg`` are
  unavailable (Windows) or the group is already gone
  (``ProcessLookupError``). The SIGTERM → wait → SIGKILL escalation
  is preserved.

Test plan (``tests/agent/transports/test_codex_app_server_process_group.py``):
- Spawn a sh wrapper that fork-execs a 60-second sleep grandchild,
  capture both pids, call ``client.close()``. Assert the wrapper is in
  its own session (``getpgid(pid) == pid``), the grandchild inherits
  that pgid, and both are reaped after ``close()``. Without the fix
  the grandchild stays alive past close.
- ``close()`` is idempotent against an already-exited subprocess.
- Both tests carry ``@pytest.mark.live_system_guard_bypass`` since
  they intentionally do real ``Popen`` + signal delivery against their
  own child subtree.

59/59 pass (2 new + 57 pre-existing session tests, no regressions).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@h4x3rotab
h4x3rotab merged commit 0b3408f into phala May 24, 2026
4 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant