Summary
Running multiple codex CLI instances against the same $CODEX_HOME causes
TUI freezes: input echo lags by seconds, and streamed assistant output can
deadlock entirely (only ctrl-C recovers). The root cause is contention on the
shared state_5.sqlite and logs_2.sqlite, combined with the absence of
SQLITE_BUSY retry logic in codex-rs/state/src/runtime/logs.rs.
Environment
- codex-cli 0.125.0
- macOS 26.3.1, Apple Silicon
- Single
$CODEX_HOME shared across multiple terminals (default usage)
Reproduction
- Open 2+ terminals, all running
codex against the same $CODEX_HOME.
- In each terminal, send a prompt at roughly the same instant.
- Observe: TUI input lag, streamed output stalling mid-response, occasional
permanent freeze requiring kill.
Evidence
logs_2.sqlite (the OTel trace sink) grew to 249 MB / 45,000+ rows in
~1.5 days of normal use; every SSE chunk emits a TRACE row.
state_5.sqlite is in WAL mode with busy_timeout = 5s (set in
state/src/runtime.rs), but no BUSY retry is implemented in
state/src/runtime/logs.rs::insert_logs. On contention the call surfaces
the error to upstream stream-handling code, which appears to drop the
channel and leave the TUI waiting forever.
- Truncating
logs_2.sqlite (DELETE FROM logs; VACUUM;) immediately
reduces the freeze frequency by ~80% — confirming the OTel sink is the
dominant contender.
- Even after truncation, simultaneous prompts across terminals still
produce ~1-2s input lag, indicating residual contention on state_5.sqlite
(threads / agent_jobs writes).
Suggested fixes
- Add BUSY retry to
insert_logs (and other sqlx writes against
logs_2.sqlite / state_5.sqlite): wrap in a bounded retry loop with
exponential backoff before propagating the error to TUI.
- Increase
busy_timeout from 5 s to 30 s (or make it configurable).
5 s is too short for 200 MB+ WAL files.
- Per-process OTel sink: write
logs_2.sqlite to a per-PID file
(e.g. logs_2.<pid>.sqlite) and merge offline. OTel traces are
write-only and rarely read interactively, so sharding is safe.
- Bound the OTel TRACE level by default: writing every SSE chunk at
TRACE inflates the DB by ~150 MB/day. Default to INFO and let users
opt into TRACE.
- Treat sink-write failure as non-fatal for the stream pipeline:
even if the log insert fails, the model stream consumer should not
abandon the channel — log the error and keep reading SSE.
Bonus issue (related, may want a separate ticket)
With $CODEX_HOME set to a non-default directory, codex appears to load
hooks from both $CODEX_HOME/hooks.json and ~/.codex/hooks.json,
firing each Stop hook twice. Confirmed by paired log entries with identical
timestamps in the hook script's own log file.
Summary
Running multiple
codexCLI instances against the same$CODEX_HOMEcausesTUI freezes: input echo lags by seconds, and streamed assistant output can
deadlock entirely (only ctrl-C recovers). The root cause is contention on the
shared
state_5.sqliteandlogs_2.sqlite, combined with the absence ofSQLITE_BUSY retry logic in
codex-rs/state/src/runtime/logs.rs.Environment
$CODEX_HOMEshared across multiple terminals (default usage)Reproduction
codexagainst the same$CODEX_HOME.permanent freeze requiring kill.
Evidence
logs_2.sqlite(the OTel trace sink) grew to 249 MB / 45,000+ rows in~1.5 days of normal use; every SSE chunk emits a TRACE row.
state_5.sqliteis in WAL mode withbusy_timeout = 5s(set instate/src/runtime.rs), but no BUSY retry is implemented instate/src/runtime/logs.rs::insert_logs. On contention the call surfacesthe error to upstream stream-handling code, which appears to drop the
channel and leave the TUI waiting forever.
logs_2.sqlite(DELETE FROM logs; VACUUM;) immediatelyreduces the freeze frequency by ~80% — confirming the OTel sink is the
dominant contender.
produce ~1-2s input lag, indicating residual contention on
state_5.sqlite(threads / agent_jobs writes).
Suggested fixes
insert_logs(and other sqlx writes againstlogs_2.sqlite/state_5.sqlite): wrap in a bounded retry loop withexponential backoff before propagating the error to TUI.
busy_timeoutfrom 5 s to 30 s (or make it configurable).5 s is too short for 200 MB+ WAL files.
logs_2.sqliteto a per-PID file(e.g.
logs_2.<pid>.sqlite) and merge offline. OTel traces arewrite-only and rarely read interactively, so sharding is safe.
TRACE inflates the DB by ~150 MB/day. Default to INFO and let users
opt into TRACE.
even if the log insert fails, the model stream consumer should not
abandon the channel — log the error and keep reading SSE.
Bonus issue (related, may want a separate ticket)
With
$CODEX_HOMEset to a non-default directory, codex appears to loadhooks from both
$CODEX_HOME/hooks.jsonand~/.codex/hooks.json,firing each Stop hook twice. Confirmed by paired log entries with identical
timestamps in the hook script's own log file.