[macOS] CODEX_HOME symlink triggers unbounded computer-use worker leak and V8 OOM in ~90s
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-useandchromeplugins — 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_usedover 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
- Canonicalize
CODEX_HOMEat startup (realpath) and store only resolved paths, or compare paths after resolution rather than as strings. This addresses the trigger and thestale_db_path_retainedwarnings together. - 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-usemanagers. 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.
4 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Possible unintended quota/credit consumption
There may also have been account-level usage impact. At approximately August 14, 2026, 8:20 PM JST, the weekly usage allowance became exhausted unexpectedly, despite the separate five-hour limit and without usage that would reasonably explain consuming an entire week's allowance at once. The desktop app then remained unusable until the real-path workaround was identified on August 20.
The local evidence proves a retry/worker-spawn storm (about 434 IPC connections per 90 seconds in the failing configuration), but it cannot establish whether those retries reached OpenAI services or were counted against the account. Please correlate the account's server-side usage telemetry for the incident window with the crash/retry timestamps and determine whether this bug caused unintended quota or credit consumption.
If unintended consumption is confirmed, I request a refund or restoration of the equivalent credits/usage allowance. A substantial amount of additional time and third-party Claude Code usage was also required to isolate and document this deterministic product bug.
I understand that billing adjustments may need to be handled privately by Support; please advise or route this report to the appropriate team.
Independent replication — confirmed, with a known-bad control
Your symlink finding holds up. I reproduced it on a Mac that had been crashing 7 out of 7
launches, using
26.810.41047as a control build and changing exactly one variable:|
~/.codex| Result (idle, same machine, same script) ||---|---|
| symlink →
~/.config/codex| 3 → 73 helpers in 27 seconds, app threads 71 → 138 || real directory (
.config/codexinverted to point at it) | 1 helper, flat for 903 seconds, threads declining 67 → 60 |33× the time-to-storm on the same binary. Then migrated a second machine the same way and moved
both to 26.818.22352 with Computer History enabled (runtime
26.819.1000816) — 1 helper,flat, zero crashes since. Your decompilation of the missing timeout/cap in
requestComputerUseWorkermatches everything I measured.Two additional data points that may help others find themselves in this:
1. It explains the "two machines, one account" reports. Several of us (myself included)
suspected account-level or server-supplied state, because the storm followed one login across
multiple Macs. But my machines also share dotfiles — both had the identical
~/.codex -> ~/.config/codexlayout, created at different times. Two machines, one *dotfilelayout*. That's a much duller explanation and it fits the evidence better.
2. The condition is visible statically, before running anything. On my machine:
hooks.jsonwas registered twice inconfig.toml, once under each spelling —identical
trusted_hashvalues, because it's one file with two namesstate_5.sqliteheld 4,865 thread rows under/Users/mg/.codex/...versus 4 under/Users/mg/.config/codex/...SKY_CUA_SERVICE_PATHused one spelling while thenotifyhook used the otherAnyone can check for the duplicate-hook tell with a quick grep of
config.toml, or comparereadlink ~/.codexagainstCODEX_HOME.One migration caveat worth flagging for anyone following your fix: the majority of stored
paths are usually in the symlink spelling (4,865 vs 4 for me), so inverting the symlink —
making
~/.codexthe real directory — leaves nearly all existing state canonically correct,rather than orphaning it. Whereas setting
CODEX_HOMEto the resolved.configpathcanonicalizes toward the spelling that only a handful of records use. Both stop the storm; the
inversion needs far less rewriting afterward.
Thanks for the decompilation and the clean A/B — that was the thing that broke this open after
several of us spent days on permissions, config flags, and account theories.
Fuller write-up if useful: https://gist.github.com/galligan/c8a64ec15b89e11ac0421b1b117056c0
Independent confirmation on 26.818.31338, with direct native matcher evidence
I independently confirmed this issue on the same Mac using two application builds:
26.803.81509(build6415), Computer Use26.804.100063326.818.31338(build6892), Computer Use26.819.1000816~/.codex -> /Users/<user>/projects/_agents/codexThe affected build produced a hang report containing:
SkyComputerUseServiceprocessescomputer-useProcesses reached dispatch thread soft limit (64)I also directly called
computerUseServiceProcessMatchesExecutablePathfrom the bundled
sky.nodenative addons using a live service PID.Both the old and new native addons returned:
~/.codex:falsetruelsofreported the running executable using the resolved path. This directlyconfirms that the service identity check compares the lexical path without
canonicalizing the symlink.
I then inverted the layout:
~/.codexis now the real directory/Users/<user>/projects/_agents/codex -> ~/.codexThe crash loop stopped without disabling Computer Use.
The matcher behavior also exists in the older stable addon, so the path mismatch
predates the regression. The newer desktop lifecycle/retry path appears to turn
that mismatch into the unbounded spawn loop.
This supports both proposed fixes:
circuit breaker regardless of the initial handshake failure.