Multi-terminal codex CLI freezes due to SQLite lock contention with no BUSY retry
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_HOMEshared across multiple terminals (default usage)
Reproduction
- Open 2+ terminals, all running
codexagainst 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.sqliteis in WAL mode withbusy_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_timeoutfrom 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.sqliteto 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.
7 Comments
Thanks for the report, but I don't think your analysis is correct.
Current code does use shared
state_5.sqlite/logs_2.sqlite, WAL mode, and a 5sbusy_timeout, andinsert_logsdoes not have an explicit retry loop. It’s also plausible that the persistent log DB can grow quickly because the TUI installs the SQLite log layer at TRACE and SSE processing logs each stream event.That said, the local SQLite log sink is intentionally asynchronous: tracing events are sent through a bounded background queue, and the background flush currently ignores
insert_logsfailures. So a BUSY error inlogs_2.sqliteshould not directly propagate into stream handling or drop the model stream/TUI channel. It may drop log rows or add background I/O pressure, but there is no direct “log insert failure aborts stream consumer” path in current code.Adding a Windows Desktop data point that looks like the same root cause, with a source-level pointer.
Environment:
26.429.2026.0@openai/codex:0.128.0Local evidence:
%USERPROFILE%\.codex\logs_2.sqlitegrew to about1.01 GBlogstable had318,757rowsapp-serverprocess averaged about26.6%CPU and had disk read/write burstsPRAGMA wal_checkpoint(TRUNCATE)taking5.58sduringthread/listINSERT INTO logs (...)The largest local log contributors were:
codex_api::endpoint::responses_websocket: ~83k rows / ~365 MB body textcodex_otel.log_only: ~85k rows / ~109 MB body textcodex_otel.trace_safe: ~84k rows / ~100 MB body textSource-level signal from current upstream:
Targets::new().with_default(Level::TRACE):https://github.com/openai/codex/blob/f50c02d7bcd4c06a23173389da8ed2c68c03d81d/codex-rs/app-server/src/lib.rs#L581-L584
https://github.com/openai/codex/blob/f50c02d7bcd4c06a23173389da8ed2c68c03d81d/codex-rs/codex-api/src/endpoint/responses_websocket.rs#L548-L556
https://github.com/openai/codex/blob/f50c02d7bcd4c06a23173389da8ed2c68c03d81d/codex-rs/codex-api/src/endpoint/responses_websocket.rs#L597-L599
So this does not appear to be only multi-terminal contention. In Desktop, normal app-server use can persist very large TRACE rows, including full
responses_websocketrequest/event payloads, intologs_2.sqlite. Once the DB is large enough, checkpoints and inserts become visible as CPU/disk bursts and UI slowness.Workaround that helped locally: quit Codex, then clear/checkpoint/vacuum only
logs_2.sqlitewith:Do not run that against
state_5.sqlite; this workaround only clears local log rows.@etraut-openai yeah, that makes sense for the narrow failure path: I agree a
BUSYor failedinsert_logsprobably should not directly abort the stream if the log sink is queuing and dropping insert errors.I’m wondering if the Desktop slowdown could still come from the log sink indirectly, though, rather than through error propagation:
So I’m not thinking “log insert failure aborts stream.” More like:
logs_2.sqliteis large, checkpoint/WAL work may become visible latency; I sawPRAGMA wal_checkpoint(TRUNCATE)take5.58sunder athread/listspanDoes that path sound plausible, or is there something about the app-server/runtime split that should prevent this from affecting Desktop RPC latency? The part that keeps me suspicious is that clearing only the
logstable plus checkpoint/vacuum was followed by Desktop going from very slow back to normal immediately.Thanks, it worked for me too.
The strict five-second timeout is a pain in the neck for users who want to launch ten, twenty, or more Codex CLI instances.
I reproduced a closely related shared-
CODEX_HOMEcontention case in Codex CLI 0.144.1, with an additional misleading corruption symptom. A fully stopped, read-only SQLite check subsequently returnedok, so the evidence points to lock contention and/or transient corruption reporting rather than persistent damage tologs_2.sqlite.Environment
codex execworkersReproduction
codex execworkers against the same default Codex state directory (this is the workaround for the inability to spawn models other than the parent).At the time of failure, the files were:
logs_2.sqlite: 3,769,585,664 byteslogs_2.sqlite-wal: 987,209,712 byteslogs_2.sqlite-shm: 1,933,312 byteslsofshowed the bundled Codex app-server holding the database, WAL, and SHM.Observed behavior
Within one active-app period, the same database alternated between:
database disk image is malformeddatabase is lockedSanitized UTC observations:
Some startup-maintenance operations took roughly 20–32 seconds before failing. The workers could continue through rollout-file fallback, but the repeated initialization delays and contradictory diagnosis added substantial overhead.
Fully stopped integrity result
I then quit every ChatGPT/Codex app, CLI, integration, and app-server. A holder check returned no processes. Before testing, I made byte-for-byte backups of the database, WAL, and SHM and verified their SHA-256 hashes.
Running
sqlite3 -readonly logs_2.sqlite 'PRAGMA quick_check;'returned exit code 0 and:The database and WAL sizes and hashes were identical before and after the check:
243644645850e2a3ceaea7f7fc0287a933e0d386beb965013175d84daec0323f94d6222161f36e9ac627bc31ed50afaa81dd4d2a4f05fa542ebd0b7b12900b6eThe transient SHM file was rebuilt by the read-only WAL access, shrinking from 1,933,312 bytes to 32,768 bytes. That caused my conservative whole-file-set “unchanged” check to report
no, but the database and WAL themselves did not change. The stopped-state result therefore does not support persistent database corruption.Impact and unaffected evidence
lockedversusmalformedreporting for the same databaseI found no evidence of repository/source corruption, model-allocation corruption, or loss of the rollout records. I am not publishing or uploading the raw database because it may contain private operational data.
Expected behavior
This appears to be the same general contention class as #20213, now reproduced with the macOS app-server and CLI workers rather than only multiple interactive terminals. The stopped-state
quick_check: okis the main reason I am commenting here rather than treating this as the persistent-corruption/recovery case in #24001.Adding a current Windows Desktop data point and a narrower mitigation request.
Environment:
26.707.3748.0(bundled app-server reports26.707.31428)%USERPROFILE%\.codexObserved:
logs_2.sqlitereached 1,339,052,032 bytes / 351,361 rows in about 10 days.Current upstream still makes the database sink independent from
RUST_LOG:codex-rs/app-server/src/lib.rsappliesEnvFilter::from_default_env()only to stderr.log_db::default_filter().codex-rs/state/src/log_db.rs::default_filter()usesTRACEas its default.[analytics] enabled = falsedoes not gate creation of the SQLite log layer.Current upstream does add per-thread and per-process partition caps in
state/src/runtime/logs.rs, but those do not provide a global database cap when many thread IDs/process UUIDs accumulate. Deleted rows also leave allocated pages until vacuum/checkpoint maintenance.As a local proof of mitigation, I installed a SQLite
BEFORE INSERTfilter that ignores levels below WARN, deleted existing TRACE/DEBUG/INFO rows, checkpointed, and vacuumed. The live database fell from 210,731,008 bytes to 1,650,688 bytes,PRAGMA quick_checkreturnedok, and only WARN/ERROR remained. This is an unsupported local workaround, but it demonstrates that filtering before persistence is effective and avoids write amplification.Requested product fix:
[logging] sqlite_level = "warn"(Desktop default should probably be WARN or INFO, not TRACE).format_feedback_log_body.