[macOS] CODEX_HOME symlink triggers unbounded computer-use worker leak and V8 OOM in ~90s
Nobody has claimed this yet.
- 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-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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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