You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Windows (Git Bash): codex bridge respawns every few seconds and old bridges accumulate — launcher and bridge record the pidfile in different PID namespaces #458
agmsg v1.1.10, Windows 11, Git for Windows 2.53 (MSYS2 runtime), Node 22, codex CLI 0.144.5 (npm)
Repro'd with a single TUI: delivery.sh set monitor codex <project> → shim-launched codex → $agmsg actas <role> as the first turn
Mechanism re-verified against current main (3f87d60) — all code paths cited below are present there
Observed
Within ~2 minutes of the bridge arming:
a new codex-bridge.js process was spawned every ~7–15 s (launcher backoff cadence),
none of the old ones were ever killed — 13 live bridges at the 2-minute mark,
every one of them subscribed (duplicate-delivery risk),
run/codex-bridge.<team>.<role>.log shows one resumed thread … / armed … pair per
respawn,
the pidfile value alternates between two kinds of numbers (large MSYS pids and
native Windows pids — see below),
delivery.sh status reports the armed bridge as stale,
delivery.sh set off cannot kill the live bridges (it believes they are already
dead and only removes their run files).
The only reason this stays bounded in practice is the bridge's own exit-on-close:
when the TUI quits and the app-server goes away, every accumulated bridge exits on
websocket close. During a long session, the count grows without limit.
(Full respawn timeline / bridge-log excerpts available on request — happy to attach.)
Root cause
The bridge pidfile is written in two different PID namespaces:
writer
value
namespace
launcher, right after spawn (codex-bridge-launcher.sh:324-325 — launched_pid=$! → pidfile)
nohup's $!
MSYS
bridge, on startup (codex-bridge.jswriteMeta() — process.pid)
node's pid
native Windows
Every reader then checks liveness in its own namespace only:
the launcher's reuse check does kill -0 "$bridge_pid" — Git Bash kill only sees
MSYS pids, so once writeMeta() has overwritten the pidfile with the native pid,
the check is permanently false. The launcher concludes the bridge is dead,
removes the pidfile (without killing anything — it "knows" the process is gone)
and spawns a replacement. Repeat forever: that is the accumulation.
the bridge's ensureSingleInstance() does process.kill(existing, 0) — node uses
native pids, so the MSYS pid the launcher recorded raises ESRCH, the guard clears
the "stale" files and proceeds. The single-instance defence is bypassed in the
other direction, which is why the replacements never refuse to start. (The Fix Codex bridge self-PID restart loop #444
self-PID clause doesn't engage here: on Windows the launcher's $! is an MSYS pid
and never equals the bridge's own process.pid, so the guard always lands in the
ESRCH branch.)
stop_codex_bridge() in delivery.sh and the status probe in codex _delivery.sh
use the same bare kill -0, hence the wrong status output and the no-op set off.
On Linux/macOS $! and process.pid live in the same namespace, so the double write
is harmless — this is Windows-only.
Unify the pidfile on the native namespace and reuse the existing helpers:
launcher: record _compat_get_winpid "$!" (from lib/compat.sh) instead of $!
on MSYS; keep $! for wait. Measured on Git for Windows 2.53 with the default
native Node: the WINPID of $!is the bridge's own process.pid (the MSYS
"exec" of a native binary maps the MSYS pid to the exec'd child), so the record
converges with what writeMeta() publishes and ensureSingleInstance()'s
existing self-PID clause keeps working unchanged. (Non-default wrappers/shims
would need their own verification.) If the WINPID mapping cannot be resolved,
fail closed — never publish a raw MSYS pid, since every reader would judge it
permanently dead.
liveness via _agmsg_pid_alive; teardown via taskkill /PID <pid> /T /F on
MSYS (tree kill also reaps the leftover MSYS exec stub), plain kill elsewhere.
stop_codex_bridge() / the launcher reuse check / codex _delivery.sh status:
same helpers, plus a cmdline identity check before killing (matching the idiom
the app-server teardown already uses). A live pid whose cmdline cannot be read
right now (transient CIM failure) must be treated conservatively — neither
killed nor forgotten — or the failure path quietly re-creates the accumulation.
Off Windows, the pid-conversion paths are MSYS-gated and _agmsg_pid_alive
degrades to kill -0, so the default node …/codex-bridge.js path behaves as
before; the added cmdline identity guard is the one behavioral addition there
(it declines to kill a live pid it cannot attribute to the bridge).
Validation of the fix (we run it downstream)
We have been running this fix on the same Windows machine since 2026-07-20:
a single bridge stayed stable for the whole observation window — 14 samples over
3.5 minutes at 15 s intervals, zero respawns (the one pid change observed was Codex bridge reuses a live bridge attached to the wrong thread #350's designed rebind when the role-session record arrived, not a respawn);
a second session showed 90 s from launch with no respawn at all,
delivery.sh set off now reports Stopped 1 Codex bridge process(es) and the
process is verifiably gone (tasklist confirms),
accumulation has not re-appeared across multiple sessions.
Happy to turn this into a PR if that's welcome. It should also resolve the kill -0 side of #415, assuming no further publication-order edge cases surface
in that flow.
Environment
delivery.sh set monitor codex <project>→ shim-launchedcodex→$agmsg actas <role>as the first turnmain(3f87d60) — all code paths cited below are present thereObserved
Within ~2 minutes of the bridge arming:
codex-bridge.jsprocess was spawned every ~7–15 s (launcher backoff cadence),every one of them subscribed (duplicate-delivery risk),
run/codex-bridge.<team>.<role>.logshows oneresumed thread … / armed …pair perrespawn,
native Windows pids — see below),
delivery.sh statusreports the armed bridge as stale,delivery.sh set offcannot kill the live bridges (it believes they are alreadydead and only removes their run files).
The only reason this stays bounded in practice is the bridge's own exit-on-close:
when the TUI quits and the app-server goes away, every accumulated bridge exits on
websocket close. During a long session, the count grows without limit.
(Full respawn timeline / bridge-log excerpts available on request — happy to attach.)
Root cause
The bridge pidfile is written in two different PID namespaces:
codex-bridge-launcher.sh:324-325—launched_pid=$!→ pidfile)$!codex-bridge.jswriteMeta()—process.pid)Every reader then checks liveness in its own namespace only:
kill -0 "$bridge_pid"— Git Bashkillonly seesMSYS pids, so once
writeMeta()has overwritten the pidfile with the native pid,the check is permanently false. The launcher concludes the bridge is dead,
removes the pidfile (without killing anything — it "knows" the process is gone)
and spawns a replacement. Repeat forever: that is the accumulation.
ensureSingleInstance()doesprocess.kill(existing, 0)— node usesnative pids, so the MSYS pid the launcher recorded raises ESRCH, the guard clears
the "stale" files and proceeds. The single-instance defence is bypassed in the
other direction, which is why the replacements never refuse to start. (The Fix Codex bridge self-PID restart loop #444
self-PID clause doesn't engage here: on Windows the launcher's
$!is an MSYS pidand never equals the bridge's own
process.pid, so the guard always lands in theESRCH branch.)
stop_codex_bridge()indelivery.shand the status probe in codex_delivery.shuse the same bare
kill -0, hence the wrong status output and the no-opset off.On Linux/macOS
$!andprocess.pidlive in the same namespace, so the double writeis harmless — this is Windows-only.
Relation to known issues:
the bridge died on its own reservation, so processes never accumulated. This is
the Windows sibling with the opposite failure mode: the guard is bypassed
(ESRCH on the MSYS pid), so replacements start and old bridges survive. The Fix Codex bridge self-PID restart loop #444
fix does not cover it because the namespace split survives the self-PID check.
kill -0blindness on the status path(
stale pidfilefor a live bridge). What's new here is that the launcher's reusecheck shares that blind probe, which escalates a cosmetic status bug into a
respawn/accumulation loop, and that
set offinherits it too (it removes the runfiles of bridges it silently failed to kill). A fix for this issue should resolve
delivery.sh status reports 'stale pidfile' for a live bridge on Windows (MSYS kill -0 cannot see native node PIDs) #415's
kill -0symptom as a side effect. (Note: on Windows,statushas asecond, independent false-stale layer in the metadata compare that fires before
the pid probe — filed separately as delivery.sh status (Windows): verbatim metadata project compare labels every live bridge "stale pidfile (metadata mismatch)" — a second false-stale layer independent of #415 #459.)
(the MSYS ppid walk cannot cross into native processes). Different code path,
same underlying fact: on Git for Windows, MSYS tools and native Node do not
share a PID namespace, and any pid that crosses that boundary unconverted is
invisible to the other side.
_agmsg_pid_alive()inlib/instance-id.sh(added for Windows compatibility: malformed JSON in delivery.sh + actas_lock_sid_alive() always false #134) falls back totaskliston Windows exactly because of this blindness — the bridge lifecyclepaths just don't use it.
Suggested fix (happy to send a PR)
Unify the pidfile on the native namespace and reuse the existing helpers:
_compat_get_winpid "$!"(fromlib/compat.sh) instead of$!on MSYS; keep
$!forwait. Measured on Git for Windows 2.53 with the defaultnative Node: the WINPID of
$!is the bridge's ownprocess.pid(the MSYS"exec" of a native binary maps the MSYS pid to the exec'd child), so the record
converges with what
writeMeta()publishes andensureSingleInstance()'sexisting self-PID clause keeps working unchanged. (Non-default wrappers/shims
would need their own verification.) If the WINPID mapping cannot be resolved,
fail closed — never publish a raw MSYS pid, since every reader would judge it
permanently dead.
_agmsg_pid_alive; teardown viataskkill /PID <pid> /T /FonMSYS (tree kill also reaps the leftover MSYS exec stub), plain
killelsewhere.stop_codex_bridge()/ the launcher reuse check / codex_delivery.shstatus:same helpers, plus a cmdline identity check before killing (matching the idiom
the app-server teardown already uses). A live pid whose cmdline cannot be read
right now (transient CIM failure) must be treated conservatively — neither
killed nor forgotten — or the failure path quietly re-creates the accumulation.
Off Windows, the pid-conversion paths are MSYS-gated and
_agmsg_pid_alivedegrades to
kill -0, so the defaultnode …/codex-bridge.jspath behaves asbefore; the added cmdline identity guard is the one behavioral addition there
(it declines to kill a live pid it cannot attribute to the bridge).
Validation of the fix (we run it downstream)
We have been running this fix on the same Windows machine since 2026-07-20:
3.5 minutes at 15 s intervals, zero respawns (the one pid change observed was
Codex bridge reuses a live bridge attached to the wrong thread #350's designed rebind when the role-session record arrived, not a respawn);
a second session showed 90 s from launch with no respawn at all,
delivery.sh set offnow reportsStopped 1 Codex bridge process(es)and theprocess is verifiably gone (
tasklistconfirms),statusreportsalive (pid N)for the live bridge (this also needs themetadata-compare fix from delivery.sh status (Windows): verbatim metadata project compare labels every live bridge "stale pidfile (metadata mismatch)" — a second false-stale layer independent of #415 #459 on Windows),
Happy to turn this into a PR if that's welcome. It should also resolve the
kill -0side of #415, assuming no further publication-order edge cases surfacein that flow.