Skip to content

macOS: locale-dependent PID start-time comparison deletes live app-server daemon tracking record #43608

Closed

Description

@jqwn

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

No private session data, credentials, or live socket contents are included.

Activity

  1. added
    bugSomething isn't working
    CLIIssues related to the Codex CLI
    app-serverIssues involving app server protocol or interfaces
    on Sep 7, 2026
  2. argszero commented on Sep 7, 2026

    @argszero

    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_time calls ps -p PID -o lstart= with no locale/env normalization (codex-rs/app-server-daemon/src/backend/pid.rs:625-627 on current main):

    async fn read_process_start_time(pid: u32) -> Result<String> {
        let output = Command::new("ps")
            .args(["-p", &pid.to_string(), "-o", "lstart="])
            .output()
            .await
            ...
    }

    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_time runs the same ps command again and compares the strings for equality (pid.rs:501-509). The whole identity check is a raw string equality between two ps lstart outputs.

    Why the locale matters

    ps lstart= is locale-bound. I verified this directly on this macOS host (same pid across all four invocations):

    LC_ALL=C                → Tue Sep  8 07:58:23 2026
    LC_ALL=en_AU.UTF-8      → Tue  8 Sep 07:58:23 2026   (day/month order swaps)
    LANG=C                  → 二  9月/ 8 07:58:23 2026    (non-English!)
    

    So the same process yields a different string under a different locale. The bug fires when the writer and reader run under different locales:

    • Capture at launch uses the daemon-spawner's environment (pid_start.rs — e.g. a Codex tool shell forcing LC_ALL=C.UTF-8).
    • Compare later (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_active returns false → the daemon treats the live record as stale → refresh_after_stale_record deletes the pid file → app-server daemon restart reports "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_time capture 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:

    1. Pin the environment for the ps call so the month/day names and ordering are invariant — e.g. run it with a fixed LC_ALL=C (and note timezone: ps lstart shows local time; a differing TZ between spawn and check would also break the string equality, so pin TZ too, or set it to UTC). The capture and comparison then always produce the same string.
    2. Backfill compatibility: already-persisted pid files were written under an unpinned locale, so a record created under 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 lstart format 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 forcing LC_ALL=C + TZ and comparing strings) is the pragmatic fix.

    Happy to prepare the patch — it's contained to pid.rs + pid_start.rs + a regression test (the pid_tests.rs harness already calls read_process_start_time(std::process::id()), so an env-pinned test is straightforward).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    CLIIssues related to the Codex CLIapp-serverIssues involving app server protocol or interfacesbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions