Repository navigation
macOS: locale-dependent PID start-time comparison deletes live app-server daemon tracking record #43608
Description
Summary
On macOS, changing the locale between daemon creation and a later daemon command can cause Codex to discard the PID record of a still-running managed app-server. The server then becomes "unmanaged", and codex app-server daemon restart fails with:
Error: app server is running but is not managed by codex app-server daemon
I reproduced this with the installed codex-cli 0.153.4, using an isolated temporary CODEX_HOME and PID record pointing to the same live server. Changing only LC_ALL from C to en_AU.UTF-8 changed daemon recognition and deleted the temporary PID record.
Environment
- macOS 27.0 (26A5425a), arm64
- CLI and running app-server: 0.153.4
- System locale: en_AU
- Codex tool shell environment: LC_ALL=C.UTF-8
- Daemon backend: pid
Actual behavior
The same process has different ps -p PID -o lstart= output depending on locale:
LC_ALL=C: Tue Sep 8 08:07:23 2026
LC_ALL=en_AU.UTF-8: Tue 8 Sep 08:07:23 2026
Given the same PID record containing the C-formatted start time:
| Command environment | daemon version output | PID record after command |
|---|---|---|
| LC_ALL=C | status=running, backend=pid | preserved |
| LC_ALL=en_AU.UTF-8 | status=running, backend omitted | deleted |
The live process remains running throughout. In the real installation, this leaves the Unix socket alive but the daemon unable to restart the server. Recreating the daemon from the Codex tool shell is only a temporary recovery if later commands run under another locale.
Isolated reproduction
Prerequisite: a running local app-server on the default control socket. Identify its PID with process inspection and replace LIVE_SERVER_PID below. This test does not stop/restart the live server or modify its real PID record. It invokes daemon version against the existing socket using a temporary CODEX_HOME and a temporary PID record. It requires Python 3.
import json
import os
import pathlib
import shutil
import subprocess
import tempfile
pid = LIVE_SERVER_PID
binary = shutil.which("codex")
live_home = pathlib.Path(os.environ.get("CODEX_HOME", pathlib.Path.home() / ".codex"))
socket = live_home / "app-server-control" / "app-server-control.sock"
start = subprocess.check_output(
["/bin/ps", "-p", str(pid), "-o", "lstart="],
env={**os.environ, "LC_ALL": "C"}, text=True,
).strip()
# Use /tmp to stay below the Unix socket path-length limit on macOS.
with tempfile.TemporaryDirectory(prefix="cx-", dir="/tmp") as tmp:
home = pathlib.Path(tmp)
(home / "app-server-daemon").mkdir()
(home / "app-server-control").mkdir()
(home / "app-server-control" / "app-server-control.sock").symlink_to(socket)
record = home / "app-server-daemon" / "app-server.pid"
for locale in ["C", "en_AU.UTF-8"]:
record.write_text(json.dumps({"pid": pid, "processStartTime": start}))
result = subprocess.run(
[binary, "app-server", "daemon", "version"],
env={**os.environ, "CODEX_HOME": tmp, "LC_ALL": locale},
text=True, capture_output=True,
)
print("LOCALE:", locale)
print("OUTPUT:", result.stdout.strip(), result.stderr.strip())
print("PID RECORD STILL EXISTS:", record.exists())Observed: True for C and False for en_AU.UTF-8; backend: "pid" appears only for C. managedCodexVersion: null in this isolated test is expected because the temporary home contains no managed installation.
Expected behavior
Daemon process identity checks should be independent of the caller's locale. A locale change must not invalidate/delete the management record of a live process.
Likely implementation cause
The locally available 0.144.1 source in codex-rs/app-server-daemon/src/backend/pid.rs stores a formatted process start-time string in PidRecord.process_start_time. read_process_start_time invokes ps -p PID -o lstart= without normalizing its locale, and the process matching/stale-record handling relies on that value. The current installed 0.153.4 binary exhibits the behavior above; I have not verified its exact source implementation.
A locale-independent process birth-time value would avoid this class of bug. If retaining ps output, its locale (and potentially timezone) must be stable, with consideration for existing records. Timezone behavior was not tested against the daemon.
Related reports
- app-server becomes unmanaged/stale after daemon state is lost #37893 describes the same symptom (live app-server, missing PID records, unmanaged error), but does not establish a locale cause. This report supplies a focused locale-dependent reproducer; it is not a claim that both incidents have the same cause.
- macOS: LC_ALL forced to C.UTF-8 in codex CLI shell commands (unified exec) #9354 reports Codex tool shells forcing LC_ALL=C.UTF-8, explaining how tool-launched daemon management can differ from an interactive terminal's locale.
No private session data, credentials, or live socket contents are included.
Activity
argszero commentedon Sep 7, 2026
Confirmed the root cause against current
main— this is a genuine locale-sensitivity bug in the pid-managed app-server backend, and the mechanism is precise.The locus
read_process_start_timecallsps -p PID -o lstart=with no locale/env normalization (codex-rs/app-server-daemon/src/backend/pid.rs:625-627on current main):That value is formatted exactly once at launch into
PidRecord.process_start_time(pid_start.rs:225,229-231) and persisted in the pid file. Later,process_matches_record→read_process_start_timeruns the samepscommand again and compares the strings for equality (pid.rs:501-509). The whole identity check is a raw string equality between twops lstartoutputs.Why the locale matters
ps lstart=is locale-bound. I verified this directly on this macOS host (same pid across all four invocations):So the same process yields a different string under a different locale. The bug fires when the writer and reader run under different locales:
pid_start.rs— e.g. a Codex tool shell forcingLC_ALL=C.UTF-8).daemon version/restart/ a stale-record cleanup) uses the command's environment, which can be the interactive terminal's locale (en_AU.UTF-8).The two no longer match →
record_is_activereturns false → the daemon treats the live record as stale →refresh_after_stale_recorddeletes the pid file →app-server daemon restartreports "app server is running but is not managed".This also explains the related reports: #37893 (same symptom, no locale cause established) and #9354 (tool shells forcing
LC_ALL=C.UTF-8, which is exactly the spawn-time vs terminal-time locale divergence).Fix shape (small, contained)
Both
read_process_start_timecapture and comparison go through the same function, so the cleanest fix is to make the value environment-stable regardless of what the writer/reader inherit:pscall so the month/day names and ordering are invariant — e.g. run it with a fixedLC_ALL=C(and note timezone:ps lstartshows local time; a differingTZbetween spawn and check would also break the string equality, so pinTZtoo, or set it to UTC). The capture and comparison then always produce the same string.en_AU.UTF-8(Tue 8 Sep ...) won't match a C-pinned reader (Tue Sep 8 ...). Consider a parser that normalizes both to an epoch/time_t(or a canonical%Y-%m-%d %H:%M:%S %z) before comparison, which also future-proofs against timezone drift.The
lstartformat is actually{day} {mon} {daynum} {HH:MM:SS} {year}and the parser would need to handle both%e-style (space-padded day) and the localized month tokens — so a small normalization helper (or forcingLC_ALL=C+TZand comparing strings) is the pragmatic fix.Happy to prepare the patch — it's contained to
pid.rs+pid_start.rs+ a regression test (thepid_tests.rsharness already callsread_process_start_time(std::process::id()), so an env-pinned test is straightforward).