Skip to content

Multi-terminal codex CLI freezes due to SQLite lock contention with no BUSY retry #20213

Description

@solosvip

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

  1. Open 2+ terminals, all running codex against the same $CODEX_HOME.
  2. In each terminal, send a prompt at roughly the same instant.
  3. 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

  1. 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.
  2. Increase busy_timeout from 5 s to 30 s (or make it configurable).
    5 s is too short for 200 MB+ WAL files.
  3. 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.
  4. 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.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    CLIIssues related to the Codex CLIbugSomething isn't workingperformance

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions