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

Open 💬 4 comments Opened Aug 20, 2026 by TahitianBlue
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

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.

View original on GitHub ↗

4 Comments

github-actions[bot] contributor · 7 days ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #39420
  • #39403
  • #38813
  • #39110
  • #38769

Powered by Codex Action

TahitianBlue · 7 days ago

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.

galligan · 7 days ago

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.41047 as 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/codex inverted 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
requestComputerUseWorker matches 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/codex layout, created at different times. Two machines, one *dotfile
layout*. That's a much duller explanation and it fits the evidence better.

2. The condition is visible statically, before running anything. On my machine:

  • the same hooks.json was registered twice in config.toml, once under each spelling —

identical trusted_hash values, because it's one file with two names

  • state_5.sqlite held 4,865 thread rows under /Users/mg/.codex/... versus 4 under

/Users/mg/.config/codex/...

  • SKY_CUA_SERVICE_PATH used one spelling while the notify hook used the other

Anyone can check for the duplicate-hook tell with a quick grep of config.toml, or compare
readlink ~/.codex against CODEX_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 ~/.codex the real directory — leaves nearly all existing state canonically correct,
rather than orphaning it. Whereas setting CODEX_HOME to the resolved .config path
canonicalizes 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

saigyouyou · 4 days ago

Independent confirmation on 26.818.31338, with direct native matcher evidence

I independently confirmed this issue on the same Mac using two application builds:

  • Stable control: ChatGPT 26.803.81509 (build 6415), Computer Use 26.804.1000633
  • Affected: ChatGPT 26.818.31338 (build 6892), Computer Use 26.819.1000816
  • Original layout: ~/.codex -> /Users/<user>/projects/_agents/codex

The affected build produced a hang report containing:

  • 302 SkyComputerUseService processes
  • 120 main-process threads named computer-use
  • main-process footprint around 3.9 GB
  • approximately 6.6 GB across the helper processes
  • Processes reached dispatch thread soft limit (64)

I also directly called computerUseServiceProcessMatchesExecutablePath
from the bundled sky.node native addons using a live service PID.

Both the old and new native addons returned:

  • expected path through ~/.codex: false
  • resolved real executable path: true

lsof reported the running executable using the resolved path. This directly
confirms that the service identity check compares the lexical path without
canonicalizing the symlink.

I then inverted the layout:

  • ~/.codex is now the real directory
  • /Users/<user>/projects/_agents/codex -> ~/.codex

The 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:

  1. Canonicalize paths before service identity comparison.
  2. Add single-flight spawning, bounded retries/backoff, helper cleanup, and a

circuit breaker regardless of the initial handshake failure.