openai / openai/codex

[macOS] CODEX_HOME symlink triggers unbounded computer-use worker leak and V8 OOM in ~90s

Open
#39,732 7 comments 3 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

app bug computer-use performance
Dominant language
Rust
Stars
125k
Forks
19.4k
PR merge metrics
PR metrics pending

Description

ChatGPT desktop (macOS) aborts ~90 s after every launch: unbounded computer-use worker-thread leak when CODEX_HOME is reached through a symlink

Related reports with the same worker-leak/OOM symptom but without this symlink-vs-real-path isolation: #38813, #39110, #38769.

Summary

If CODEX_HOME is given as a path that traverses a symlink, the app leaks Node worker_threads named computer-use at roughly 4/s from startup. None are ever released. At ~320 total threads V8 can no longer obtain memory and calls node::OOMErrorHandler(), aborting the whole process. This happens with no user interaction — the app dies before it is usable.

Passing the resolved real path for the exact same directory makes the app completely stable. The data is identical; only the string differs.

User impact

The app-server log database shows the ~85 s crash-restart pattern confined to a single
day, 2026-08-20: 32 process sessions, 24 of them lasting between 60 s and 130 s, median
85 s. The preceding days show normal long-lived sessions (2026-08-14: 7 sessions, median
3.9 h, none under 130 s). This matches the timestamp at which Codex Computer Use.app
was updated to 26.818.1000790 — 20:51 local time, six minutes before the first crash at
20:52:37.

After applying the real-path CODEX_HOME workaround, 2026-08-21 shows 4 sessions with a
median span of 12.8 h.

Environment

App ChatGPT 26.818.21641 (build 6849), bundleID com.openai.codex
app-server 0.148.0-alpha.21
OS macOS 26.5.2 (25F84)
Hardware Mac mini (Apple M4), 16 GB RAM
Layout /Users/<user>/.codex is a symlink → /Volumes/<ExternalSSD>/.codex (APFS, external SSD, permanently mounted)

Symptom

14 consecutive crashes in 76 minutes with no user interaction, then reproduced on demand. Time from process launch to abort, from the .ips reports:

89.2s  83.6s  81.5s  82.9s  82.1s  90.5s  90.9s  84.4s  89.9s  88.2s

Crash signature

exception    : EXC_CRASH (SIGABRT)   [some reports: EXC_BREAKPOINT / SIGTRAP]
legacyInfo   : {"threadTriggered": {"name": "computer-use"}}
asi          : {"libsystem_c.dylib": ["abort() called"]}

Faulting stack:

node::OOMErrorHandler(char const*, v8::OOMDetails const&)
v8::CppHeap::CollectGarbageInYoungGenerationForTesting(...)
v8::ScriptCompiler::StartStreaming(...)
node::WorkerThreadsTaskRunner::WorkerThreadsTaskRunner(int, node::PlatformDebugLogLevel)
_pthread_start

Thread census, consistent across all 15 crash reports collected:

total= 323  computer-use= 187  V8Worker= 3  libuv-worker= 4
total= 320  computer-use= 185  V8Worker= 3  libuv-worker= 4
total= 322  computer-use= 185  V8Worker= 3  libuv-worker= 4
total= 319  computer-use= 184  V8Worker= 3  libuv-worker= 4
  ... (184–187 every time)

This is not a large-heap OOM

Running the binary directly and capturing stderr shows the isolate heap is tiny at the moment of death:

<--- Last few GCs --->
[pid:0x...]   43 ms: Scavenge 2.3 (4.2) -> 2.0 (4.8) MB, pooled: 0.0 MB
[pid:0x...]  197 ms: Scavenge 7.0 (8.9) -> 6.1 (9.4) MB, pooled: 0.0 MB
[pid:0x...]  254 ms: Scavenge 7.6 (10.4) -> 7.0 (10.7) MB, pooled: 0.0 MB

FATAL ERROR: MarkCompactCollector: young object promotion failed
             Allocation failed - JavaScript heap out of memory

7 MB of heap, on a machine with ample free RAM. The allocation fails because ~186 live worker isolates have exhausted what the process can reserve. Dispatch Thread Soft Limit Reached also appears in the reports.

Thread growth is linear from launch until the ceiling is hit:

t+15s  threads=133   t+45s  threads=219   t+75s  threads=299
t+30s  threads=180   t+60s  threads=258   t+90s  ABORT

Reproduction — the deciding variable

Same directory, same inode, same data. Only the CODEX_HOME string differs. [IpcRouter] I am the router is logged once per IPC socket connection, so it is a direct proxy for the leak rate.

Run CODEX_HOME Result IPC connections in 90 s
F /Users/<user>/.codex (symlink) crash at 90 s 434
G /Volumes/<SSD>/.codex (real path) survived 105 s 4
G2 real path survived 105 s 4
F2 symlink crash at 105 s 434
G3 real path survived 105 s 4

Order-independent (G2 → F2 → G3 alternating gives the same verdict). With the real path the process is flat at 59–61 threads and ~520 MB RSS for 3.5 minutes and beyond, with the computer-use plugin fully enabled.

Note that a normal Dock/Finder launch has CODEX_HOME unset, so the app derives ~/.codex — the symlink form — and always crashes.

Minimal repro
mv ~/.codex /some/other/volume/.codex
ln -s /some/other/volume/.codex ~/.codex
open -a ChatGPT          # aborts after ~90 s, every time

launchctl setenv CODEX_HOME /some/other/volume/.codex
open -a ChatGPT          # stable

Supporting evidence for a path-comparison mismatch

state_5.sqlite stores threads.rollout_path in both forms:

73 rows  /Users/<user>/.codex/sessions/...      (symlink form)
 4 rows  /Volumes/<SSD>/.codex/sessions/...     (real form)

and the app-server logs, on every thread/list:

WARN codex_rollout::state_db  state db discrepancy during list_threads_db: stale_db_path_retained
WARN codex_rollout::state_db  state db list_threads returned stale rollout path for thread <id>: /Users/<user>/.codex/sessions/...

Two spellings of the same file never compare equal, so the retained-path check never converges.

Where the threads come from

From Contents/Resources/app.asar, the exported requestComputerUseWorker handler:

b = async e => {
  let t = new P9(`computer-use`, { startupConfig: i });
  w(t);
  let n = a.subscribe(e => { t.emitAppEvent(e) }),
      r = !1,
      o = () => { r || (r = !0, n.dispose(), m.delete(t), t.dispose()) };
  m.set(t, o);
  try {
    ...
    t.startWorker();
    let r = await t.requestFromHost(e);   // <-- never settles
    ...
  } finally { o() }                        // <-- therefore never runs
}

and the worker's thread name is the manager id:

let f = new M.Worker(t, { name: this.id, workerData: d, ... });

which is why the leaked threads are all named computer-use. A second path, T(requestId), keeps managers in a map keyed by requestId and has the same exposure.

The disposal path is reachable only after the awaited request resolves or rejects. There is no timeout, so a request that never settles leaks one Node worker — with its own V8 isolate — permanently. Whatever retries the request does so about four times per second, which is enough to kill the process in 90 seconds.

Ruled out

Each of these was tested by reconstructing a CODEX_HOME on the internal disk and adding components back additively, 105 s per configuration. None of them reproduced the crash, including the configuration holding essentially the entire home directory:

  • the computer-use, browser-use and chrome plugins — also renamed away on disk in the real home, and it still crashed
  • session-history size — 809 MB across 70 rollout files, including a single 499 MB rollout and threads with tokens_used over 178,000,000
  • .codex-global-state.json, state_5.sqlite, session_index.jsonl, sessions/
  • plugins/ (306 MB), .tmp/ (161 MB), skills/
  • logs_2.sqlite, memories_1.sqlite, goals_1.sqlite, queue_1.sqlite, thread_history_1.sqlite, models_cache.json, caches
  • MCP server configuration (node_repl, codex_apps)

Disabling the plugin through config.toml ([plugins."computer-use@openai-bundled"] enabled = false) and through computer-use-bundled-plugin-auto-install-disabled in .codex-global-state.json had no effect — the app still crashed at 88.9 s and 90 s respectively. The app did honour the setting (it removed SKY_CUA_SERVICE_PATH from the node_repl env block), but the worker spawn loop lives in the Electron main process and is not gated by it.

Workaround

launchctl setenv CODEX_HOME /Volumes/<SSD>/.codex

Persisted with a LaunchAgent that runs the same launchctl setenv at login.

Suggested fixes

  1. Canonicalize CODEX_HOME at startup (realpath) and store only resolved paths, or compare paths after resolution rather than as strings. This addresses the trigger and the stale_db_path_retained warnings together.
  2. Bound the computer-use worker lifecycle independently of the awaited request — a timeout plus disposal on the failure path, and a cap on concurrent computer-use managers. As written, any request that never settles leaks a Node worker with its own V8 isolate, so a retry loop can take the whole app down regardless of what caused the request to hang. The second fix matters even if the first lands, because it turns a hung request into a degraded feature rather than a crash.

Side issue found while investigating

A thread created from the Chrome extension side panel could not be deleted from the UI: the row stayed in state_5.sqlite with archived = 0 after the deletion attempt reported an error. The thread had tokens_used = 0 and has_user_event = 0, i.e. it was never run. It was written by CLI version 0.147.0-alpha.6.5. Removing it required deleting the row, the rollout file, the session_index.jsonl entry, and a heartbeat-thread-permissions-by-id key in .codex-global-state.json by hand. This may or may not share a root cause with the above.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by examining the exported requestComputerUseWorker handler and the T(requestId) manager path in Contents/Resources/app.asar, then compare them with the state_5.sqlite stale rollout-path evidence. Reproduce with symlink and resolved CODEX_HOME values. Done means the symlink case no longer creates unbounded computer-use workers and hung requests are bounded or disposed rather than leaking until OOM.

Written by the indexing model from the issue text.

Assessment

Tech stack
electron, javascript, node.js, rust
Domain
backend, databases, desktop, performance
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.