app-server daemon: auto-update force-kills active turns after a 60s drain budget, and cannot be disabled
app-server daemon: auto-update force-kills active turns after a 60s drain budget, and cannot be disabled
Summary
On a long-lived codex remote-control daemon, the auto-updater restarts the app-server as soon as a new release lands. The app-server implements a correct graceful drain that waits for running assistant turns — but the daemon only grants it 60 seconds before sending SIGKILL. For any workload where a turn routinely lasts minutes (agents running build/test suites), the drain can essentially never complete, so every auto-update destroys in-flight work across every thread on the host.
There is also no supported way to turn auto-update off, so an operator cannot opt out of the behavior.
Environment
- codex-cli / app-server 0.149.0 → 0.150.0 (standalone managed install), Debian 13 x86_64
- daemon started via
codex remote-control start; multiple TUI clients attached over an SSH-forwarded unix socket - workload: 13 long-lived threads; typical turn runs a Go build/test suite lasting 3–30 minutes
What happened
2026-08-26, unattended:
20:16— updater installed0.150.0(symlink~/.codex/packages/standalone/currentre-pointed).20:16 → 20:17:51— app-server restarted; every attached client connection dropped simultaneously; turns that were mid-execution were terminated. The ~90s gap matches the grace period elapsing before the forced kill.- Recovery required reconnecting each client and interrupting/continuing each thread; turns that were awaiting an exec at kill time exhibited the hang described in #40306.
Mechanism (source refs, rust-v0.150.0)
The graceful path exists and is well-implemented:
app-server/src/lib.rs:250-253— on SIGTERM the server logs "received shutdown signal; entering graceful restart drain (connections=…, runningAssistantTurns=…, requests still accepted until no assistant turns are running)".app-server/src/lib.rs:257-262—update()returnsShutdownAction::Finishonly whenself.forced || running_turn_count == 0, and otherwise logs "waiting for N running assistant turn(s) to finish".
The daemon then overrides it:
app-server-daemon/src/backend/pid.rs:520—stop()sendsSIGTERM.app-server-daemon/src/backend/pid.rs:23-24—STOP_GRACE_PERIOD = 60s,STOP_TIMEOUT = 70s.app-server-daemon/src/backend/pid.rs:253-277— afterSTOP_GRACE_PERIODelapses,force_terminate_process(pid)→SIGKILL(pid.rs:535).
Restart eligibility is version-only and has no turn awareness:
app-server-daemon/src/lib.rs:804-818—restart_decisioncomparesinfo.app_server_versionagainst the managed version.app-server-daemon/src/lib.rs:363-372— theRestartIfRunningOutcome::Busybranch reflects the daemon operation lock, not running turns.
Auto-update cannot be disabled:
app-server-daemon/src/settings.rs:11-13—DaemonSettingshas exactly one field,remote_control_enabled;settings.jsoncannot express an update preference.app-server-daemon/src/lib.rs:608-612— bootstrap unconditionally startspid_update_loop_backend; there is no gate.app-server-daemon/src/lib.rs:619—auto_update_enabled: truein the status payload is a hardcoded literal, not a setting, which makes the reported field misleading.
No CLI flag or environment variable appears to control it. The only working workaround is to kill the codex app-server daemon pid-update-loop process after every daemon start (it does not respawn on its own, and killing it does not disturb the running app-server), or to bypass codex app-server daemon entirely and supervise codex app-server directly.
Requests
- Make the drain budget configurable, and allow it to be unbounded. A server operator should be able to say "never
SIGKILLan app-server that is still draining turns" (or set a budget in the tens of minutes). The 60s default is sensible for an interactive desktop session and unusable for turns that run build/test suites. The forced path should remain available explicitly (e.g. a second signal /--force), ason_signalalready models. - Provide a supported way to disable auto-update — a
DaemonSettingsfield pluscodex app-server daemon disable-auto-update(mirroring the existingenable-remote-control/disable-remote-controlpair) would fit the current surface. Operators running unattended fleets need updates to be a deliberate, scheduled action. - Optionally: defer rather than kill. Since the updater already loops with
RESTART_RETRY_INTERVALonBusy, treating "turns are running" as a retryable condition — rather than starting a drain that will be killed — would make auto-update safe by default without any configuration.
Also worth fixing regardless: autoUpdateEnabled in the status output currently reports a constant, so tooling cannot detect the actual state.
Related
- #40306 — turns awaiting an exec hang indefinitely when their client connection dies; the forced kill in this issue is one way to trigger that state.
4 Comments
Verified your source refs against current
main(37a5149822, 2026-08-27) — they all hold. Updated line numbers (your rust-v0.150.0 refs → main):| Your ref (v0.150.0) | Current main | Symbol |
|---|---|---|
|
pid.rs:23-24|pid.rs:23-24|STOP_GRACE_PERIOD = 60s,STOP_TIMEOUT = 70s||
pid.rs:520|pid.rs:240|PidBackend::stop()— SIGTERM viaterminate_process(pid.rs:517) ||
pid.rs:253-277|pid.rs:255, 274| grace deadline +force_terminate_process→ SIGKILL (pid.rs:532) ||
lib.rs:804-818|lib.rs:804|restart_decision— version-only comparison, no turn awareness ||
lib.rs:363-372|lib.rs:363|try_restart_if_running—Busy= operation lock only ||
lib.rs:608-612, 619|lib.rs:587, 619| bootstrap always starts the update loop;auto_update_enabled: trueliteral (unchanged) ||
app-server/lib.rs:250-262|app-server/lib.rs:235, 251, 256, 276|ShutdownState::on_signal/update()drain |Two additions to the mechanism picture, both relevant to your request 3 (defer rather than kill):
ProbeInfo(client.rs:38-41) carries onlyapp_server_version— the restart decision literally cannot see turns. "Turns running → defer" therefore needs a small additive protocol change: expose the running-turn count over the control socket. The good news is the app-server already maintains exactly this number for the drain —ThreadWatchManager::running_turn_count_tx(thread_status.rs:22, 235) →subscribe_running_assistant_turn_count(thread_processor.rs:3514,message_processor.rs:819) → consumed atapp-server/src/lib.rs:918. Extending the probe (or adding a status request) to surface it is straightforward.update_once(update_loop.rs:109-135) installs the new standalone before the restart decision and only re-loops withRESTART_RETRY_INTERVAL = 50msonBusy. A turn-drain defer needs its own longer interval (e.g. 30–60s) so the daemon doesn't spin at 20 Hz against the control socket for the whole drain.Design notes:
ShutdownState::on_signalsetsforcedon a second SIGTERM andupdate()returnsShutdownAction::Finishimmediately (app-server/src/lib.rs:235-245, 261-271). So "no automatic SIGKILL" should still keep an explicit force — a second SIGTERM (not SIGKILL) for the operator, with the daemon'sSTOP_TIMEOUTas a final fallback. This matters because of the #40306 hang class (a turn awaiting exec with a dead client never completes): an unbounded drain on such a turn would otherwise wedge the daemon forever.stoptoo —Daemon::stopand the update path sharePidBackend::stop(). Keeping the default at 60/70s leaves behavior unchanged unless the operator opts in.Implementation gotcha for your request 2:
DaemonSettings(settings.rs:9-12) derivesDeserializewithout#[serde(default)], so adding a non-Optionfield would break loading of existingsettings.jsonfiles (missing-field error). New fields (auto_update_enabled, grace/timeout) need#[serde(default)]orOptionfor backward compatibility — and the same applies to thestop_grace_period/stop_timeoutfields in request 1.Happy to implement this. The smallest high-value slice (mirroring requests 1+2, both low-risk and fully testable in the daemon crate):
stop_grace_period_secs/stop_timeout_secsinDaemonSettings(null= never auto-SIGKILL), plumbed intoPidBackend, default 60/70 unchanged;auto_update_enabledsetting +enable-auto-update/disable-auto-updatemirroring the existing remote-control pair, gating the update loop at bootstrap and replacing the hardcodedautoUpdateEnabledstatus literal.Defer-on-by-default (your request 3) is the nicer long-term behavior but needs the probe extension above; happy to take that on as a follow-up once maintainers confirm the intended default semantics.
I've prepared an implementation for requests 1 + 2 (configurable/unbounded drain budget + auto-update toggle). Design summary:
Settings (settings.json, daemon-managed)
| field | meaning |
|---|---|
|
stopGracePeriodSecs| seconds after SIGTERM before force-terminating (SIGKILL). Absent → 60 (unchanged);0→ unbounded (never auto-SIGKILL while draining);n→ n seconds ||
stopTimeoutSecs| total stop budget before giving up. Absent → 70 (unchanged);0→ wait indefinitely ||
autoUpdateEnabled|false→ update loop not started at bootstrap; running loop stopped by the toggle |0= "no limit" follows the systemdTimeoutStopSec=0convention. New fields are optional withskip_serializing_if— existingsettings.jsonfiles keep working unchanged, and the default behavior (60/70, auto-update on) is bit-for-bit today's.Design decisions worth confirming
ShutdownState::forced→ immediateShutdownAction::Finish) is preserved, so an operator can still force-exit an unbounded drain explicitly. This matters because of the #40306 hang class — a turn awaiting exec with a dead client never completes on its own, so a truly unkillable drain would wedge the daemon.disable-auto-updatestops the running update loop immediately (mirrors your workaround of killing the pid-update-loop process, but as a supported command pairenable-auto-update/disable-auto-update).is_bootstrappedis extended to also treat a running app-server backend as bootstrapped, socodex app-server daemon startafter disabling auto-update won't re-bootstrap and silently restart the update loop.stoptoo (both paths sharePidBackend::stop()), default unchanged.Scope
Touch:
DaemonSettings(settings.rs),PidBackend::stop()policy (backend/pid.rs),BackendPathsplumbing (backend/mod.rs), bootstrap gating +set_auto_update(lib.rs), CLI subcommand pair (cli/main.rs). Request 3 (defer rather than kill by default) is intentionally out of this slice — it needs the probe protocol change from my earlier comment and is a natural follow-up.I can push this as a PR — I have the patch ready. (Note: my fork push has been intermittently blocked by a local sandbox restriction; will land as soon as it clears.)
Implementation update — requests 1 + 2 are now built and tested on a fork branch (no PR yet; still waiting on the invitation gate for PR creation).
Branch:
argszero/codex:fix/app-server-daemon-stop-policy-and-auto-update-toggle@293a744e9aWhat's in it:
Configurable stop policy (
settings.json, absent = current behavior,0= unbounded):stopGracePeriodSecs— grace after SIGTERM before SIGKILL; default 60,0= never auto-SIGKILL while draining. Second-signal force is preserved, so an operator can still interrupt an unbounded drain.stopTimeoutSecs— total stop budget; default 70,0= wait indefinitely.autoUpdateEnabled— default true. Disables the update loop at bootstrap;codex app-server daemon disable-auto-update/enable-auto-updatestop/start a running loop and persist the setting.Key semantics
bootstrap_lockedloads existing settings instead of replacing them → a disabled auto-update survives re-bootstrap (this was the trap in the currentupdater.start()unconditional path).is_bootstrappedtreats a running app-server backend as bootstrapped →ensure_remote_control_startedwon't re-bootstrap and resurrect the update loop.disable-auto-update.PidBackend::new/new_update_loopsignatures unchanged (zero test churn); onlywith_stop_policyis added.Tests:
cargo test -p codex-app-server-daemon36 passed;cargo test -p codex-cli --bin codex279 passed (new parse tests included); clippy + fmt clean.Request 3 ("defer rather than kill") still needs the probe-protocol change (expose running-turn count in the daemon's probe) so the daemon can choose to defer restart — separate follow-up.
Design for request 3 — "defer rather than kill" on auto-update (follow-up to my P1 implementation comment)
Key facts that shape the design
ShutdownState::update→ShutdownAction::Noopuntilrunning_turn_count == 0; only a second signal forces). So with P1'sstopGracePeriodSecs: 0, auto-update already stops killing — the app-server drains on the old binary, exits on its own when idle, and the daemon then restarts with the new binary.Proposed design
1. Probe protocol — expose the running-turn count
InitializeResponse:#[serde(default, skip_serializing_if = "Option::is_none")] running_turn_count: Option<usize>(camelCase:runningTurnCount).message_processor::subscribe_running_assistant_turn_count()→watch::Receiver<usize>. Pass a cloned receiver intoInitializeRequestProcessor(it derivesClone, andwatch::Receiver::borrow()gives the latest value per request).2. Daemon side — bounded defer on restart
ProbeInfogainsrunning_turn_count: Option<usize>;probe()parses it.deferRestartTimeoutSecs(absent → 0 → no defer, current P1 behavior):> 0and auto-update restart is pending withrunningTurnCount > 0→ poll the probe every ~2–5 s until the count reaches 0 or the deadline; log awarn!per poll ("auto-update deferred: waiting for N running assistant turn(s)") so the pending update is visible.warn!("deferred restart timed out with N running assistant turn(s); proceeding with configured stop policy")and fall through to the normal graceful stop (SIGTERM → drain →stopGracePeriodSecs→ SIGKILL).3. Escape hatches compose, not conflict
deferRestartTimeoutSecsonly delays the start of the graceful stop; it is orthogonal tostopGracePeriodSecs/stopTimeoutSecs.stopGracePeriodSecs: 0still means "never auto-SIGKILL".Why this shape
initialize).I can implement this on the same branch pattern as P1 (fork branch + tests) once it looks reasonable — happy to adjust the knob shape or naming if you'd prefer
waitForIdleTimeoutSecsor similar.