{"slug": "retro-six-days-chasing-a-chatgpt-desktop-crash-that-turned-out-to-be-a-symlink", "title": "Retro: six days chasing a ChatGPT desktop crash that turned out to be a symlink (openai/codex#38455, #39732)", "summary": "A developer traced a recurring ChatGPT for macOS crash to an unbounded retry loop in the app's Computer Use helper spawning, which aborts the process once roughly 600 concurrent helpers exhaust V8 isolate allocation. The investigation found the loop requires two conditions — the uncapped retry behavior in OpenAI's Electron main process and a symlinked codex home directory on the user's machine — and removing either stops the crashes. The bug was reported to OpenAI as codex issues #38455 and #39732, with some users reporting kernel panics when the helper storm exhausted launchservicesd's dispatch-thread limit.", "body_md": "**TL;DR** — ChatGPT for macOS `26.810.x`+ spawns Computer Use helper processes in an\nunbounded retry loop and dies at ~600 helpers. It needs *two* ingredients: an uncapped retry\nloop in the app (OpenAI's bug), and a **symlinked codex home** (mine). Removing either one\nstops it. I spent five days on the first and fixed it in twenty minutes once I understood the\nsecond.\n\nInvestigation ran 2026-08-14 → 2026-08-20 across two Macs. This is the retrospective: what I believed, what was wrong, and what actually found it.\n\nRelated: [openai/codex#38455](https://github.com/openai/codex/issues/38455) ·\n[#39732](https://github.com/openai/codex/issues/39732) (the symlink finding) ·\n[field report with full measurements](https://gist.github.com/galligan/7fdeeed33b3e282dc3afa9fcde7be470)\n\nChatGPT hard-crashed seven times in one afternoon, roughly four minutes after each launch.\nEach crash was `EXC_BREAKPOINT (SIGTRAP)` on a thread named `computer-use`, bottoming out in\n`node::worker::Worker::Run() → node::NewIsolate() → v8::Isolate::Initialize()`.\n\nThe mechanism: from launch, while idle, the app spawned `SkyComputerUseService` helpers at\n~2/sec accelerating to ~6/sec, each accompanied by a Node worker thread and a V8 isolate inside\nthe main process. At ~600–682 concurrent helpers, V8 could no longer allocate an isolate and\naborted the process. The threshold was remarkably consistent — 7 of 7 crashes landed in that band.\n\nOther users independently hit the same thing hard enough to **kernel-panic their Macs**, when\nthe storm exhausted `launchservicesd`'s 512-dispatch-thread limit and WindowServer missed its\nwatchdog check-ins.\n\nThe timing was seductive: the crashes began the day after OpenAI shipped **Computer History**,\na feature that records interaction events through the macOS Accessibility API. One of my Macs\nhad shown the \"add ChatGPT Computer History to Accessibility\" prompt; the other never did. A\nstale TCC record after reinstall is a well-documented macOS failure mode, and it fit perfectly.\n\nI ran the full `tccutil reset` dance, got the prompt to appear, granted it, re-added the helper\nto Screen Recording. **Zero effect on the spawn rate.**\n\n*Lesson: a hypothesis that explains the timing and the asymmetry can still be completely wrong.\nFitting the narrative is not evidence.*\n\nToggled Computer Use off. Toggled Computer History off. Set `[features] computer_use = false`\nin `config.toml` — which the app visibly *honored*, stripping its own\n`[mcp_servers.computer-use]` and `[plugins.\"computer-use@openai-bundled\"]` entries on the next\nlaunch — **and kept spawning anyway.** Tried the `computerUseAlwaysHidePictureInPicture` flag\nthat had fixed an earlier variant. Deleted the runtime directory; the app re-provisioned it\nwithin seconds and resumed.\n\n*Lesson: \"the setting was accepted\" and \"the behavior changed\" are different claims. I checked\nthe first and assumed the second.*\n\nKilled every codex-family process system-wide, unloaded unrelated launchd agents, verified a\ntrue zero, relaunched: **4 → 101 helpers in 60 seconds.** The loop needed nothing outside the\napp binary.\n\nThis was the most useful of the failed experiments, because a clean negative closed off an entire category.\n\nHaving established that the bundled Computer Use runtime was byte-identical at\n`26.812.1000717` across three consecutive builds, I concluded that runtime version was the\nthing to watch, and told my collaborator so.\n\nThen `26.814.x` shipped with runtime `26.817.1000761` — a real bump — **and still stormed**,\nper two independent reporters. The defect was in the Electron main process, not the helper.\n\n*Lesson: I built a decision rule out of a correlation observed across three data points, then\nhanded it over as guidance. A heuristic that hasn't been falsified once isn't a signal yet.*\n\nBy day six there was still no fix, no OpenAI acknowledgement, and no press coverage. That\nabsence of noise felt diagnostic: *if this were general, everyone would be screaming.* A\nplausible theory followed — maybe an account-level feature flag, or corrupted server-side state\ntied to the account, since both my Macs shared one login.\n\nTwo things were wrong with this.\n\nFirst, **the rarity wasn't real.** Counting properly: 15 issues, 67 comments, **49 distinct\nGitHub logins**, inflow steady at 6–8 interactions/day and not decaying. Filing a GitHub issue\nagainst a consumer desktop app is a fraction-of-a-percent behavior; 49 filers implies a large\naffected population.\n\nSecond, my explanation for the rarity was *also* wrong. I reasoned that Computer History is\nPro/Business-gated, opt-in, off by default, macOS-only, and unavailable in the EEA/UK — so of\ncourse few people hit it. But reporters were storming with the feature **disabled and never\nused**, and **Plus-tier** users were affected despite sitting below that gate. The feature\ngating was real and explained nothing.\n\n*Lesson: I reached for \"we're special\" precisely when I was most tired of the problem. Rarity\nis a claim that needs a count, and I never counted until someone made me.*\n\nA second analyst proposed the account/server-state hypothesis and cited a specific report: a\nuser with a clean profile stayed stable while signed out, then began spawning workers after\nadding their real `auth.json`. That's a strong-sounding experiment.\n\nThe report was real. It also didn't hold up:\n\n- **n=1** , never replicated.\n- **Directly contradicted** by another user who pointed`CODEX_HOME` at an empty directory with\nno auth at all and still crashed at 89 seconds.\n- **The manipulated variable was mislabeled.** Removing`auth.json` from`CODEX_HOME` removes\nthe*Codex CLI* credential — not the desktop app's Electron session. The test never signed\nthe app out. And nobody anywhere had tested a*second account* , which is what the hypothesis\nactually required.\n\n*Lesson: verify the load-bearing citation before building on it. \"A reporter found X\" is a\nclaim about a claim; both need checking, and the second-hand version had quietly relabeled the\nindependent variable.*\n\nAnother user posted a genuinely excellent piece of work\n([#39732](https://github.com/openai/codex/issues/39732)): they decompiled `app.asar` and found\n`requestComputerUseWorker` awaiting `t.requestFromHost(e)` with **no timeout and no cap**, its\ndisposal `finally` block only running after that promise settles. A request that never settles\nleaks a worker and an isolate, permanently. Then they isolated a trigger with a clean,\norder-independent A/B: `CODEX_HOME` reached **through a symlink** → 434 IPC connections in 90\nseconds; the **resolved real path to the same inode** → 4.\n\nI checked my own machines:\n\n``` php\n/Users/mg/.codex -> /Users/mg/.config/codex     (created 2025-08-25)\nCODEX_HOME: unset\n```\n\nBoth Macs. Identical layout, because they share dotfiles — which explained \"two machines, one\naccount\" without any account theory at all. The evidence was sitting in my config the whole\ntime: five hooks registered **twice**, once under each spelling of the same file, and 4,865\nthread records under `/Users/mg/.codex/...` against 4 under `/Users/mg/.config/codex/...`.\nThe app compares path *strings*, not inodes. Register under one spelling, look up under the\nother, find nothing, spawn another.\n\nKnown-bad build `26.810.41047`, one machine, one variable, everything else identical:\n\n| `~/.codex` | Result | \n|---|---|\n| **symlink** →`~/.config/codex` | 3 → **73 helpers in 27 seconds** , threads 71 → 138 | \n| **real directory** | **1 helper, flat for 903 seconds** , threads*declining* 67 → 60 | \n\nSame binary. Same machine. Same script. 33× the time-to-storm.\n\nInvert the symlink so the canonical path is the one the app derives. It's a same-volume rename — instant, no data movement:\n\n```\n# quit ChatGPT and all codex/Sky helpers first\nrm ~/.codex\nmv ~/.config/codex ~/.codex\nln -s /Users/mg/.codex /Users/mg/.config/codex   # compat shim; optional\n```\n\nVerify with `stat -f '%i %N'` on both paths (same inode) and\n`python3 -c \"import os;print(os.path.realpath(os.path.expanduser('~/.codex')))\"`.\n\nThen canonicalize the *operative* path fields — and **only** those:\n\n- `config.toml` : project entries, marketplace`source` , the Computer Use`notify` path\n- `.codex-global-state.json`\n- `state_5.sqlite` :`threads.rollout_path` and`threads.cwd`\n\n**Do not blanket-rewrite.** Most occurrences of the old path live in `threads.title`,\n`first_user_message`, `preview`, `logs_2.sqlite`, and `archived_sessions/*.jsonl` — those are\nrecords of what you actually typed and what actually happened. Rewriting them falsifies your\nhistory and risks corrupting transcripts, for zero effect on the bug. On my machine that was\n205 rows and 2,352 log entries left deliberately untouched.\n\nOne landmine: if hooks are registered under both spellings, a naive `sed` collapses them into\n**duplicate TOML table keys** and breaks the config. Merge them instead, keeping whichever\nblock carries extra state (mine had `enabled = true` on one side only). I caught this by\nrehearsing the transform against a *copy* and diffing the parsed structure — which is also how\nI caught my deletion logic swallowing a tool-managed section marker.\n\nBoth Macs migrated. The main one now runs **26.818.22352** — the newest build, which others\nreport storming on — with **Computer History enabled** and runtime `26.819.1000816`:\n1 helper, 65 threads, flat, zero crashes.\n\nBacking up before the migration, the NAS refused SSH: connection accepted, then reset at key exchange. Synology auto-block, triggered by 5 failed logins in 5 minutes.\n\nThe cause had nothing to do with this investigation. **Time Machine had been failing to mount\nthat NAS since April 10th**, retrying on a schedule, each attempt authenticating against a\ncredential set that couldn't work: the keychain entry for the exact server string Time Machine\ntargets (`TARDIS._smb._tcp.local.`, *with* trailing dot) had account **\"No user account\"**,\nwhile the only entry with a real account was filed under the same hostname *without* the\ntrailing dot.\n\nTwo spellings of one thing that never compare equal — the same failure mode as the bug we were chasing, in a completely unrelated system, discovered by accident. And a four-month-old silent backup failure nobody knew about.\n\n1. **A known-bad control is worth more than any amount of reasoning.** Everything turned on\nhaving a build that reliably stormed in 27 seconds. Without it, \"it seems fine now\" would\nhave been unfalsifiable.\n2. **Count before you conclude something is rare.** \"It must be us\" arrived as a feeling and\nsurvived a week because nobody made it produce a number. The number was 49.\n3. **Verify the load-bearing citation.** The strongest-sounding evidence for the wrong theory\nwas a real report whose independent variable had been quietly relabeled in the retelling.\n4. **Distinguish \"the setting was accepted\" from \"the behavior changed.\"** The app honored a\nconfig flag by editing its own config, and kept doing the thing anyway.\n5. **Don't promote a correlation to a decision rule.** My \"watch the runtime version\" heuristic\nwas three data points wearing a lab coat.\n6. **Rehearse destructive transforms against a copy and diff the parsed result.** Two real bugs\nin my own migration script died there instead of in production.\n7. **Operative state and historical state deserve opposite treatment.** One should be\ncanonicalized; the other is a record and should be left alone.\n8. **When two systems disagree about the name of one thing, expect trouble.** This bug, and the\nunrelated NAS bug found alongside it, were the same shape: string identity standing in for\nreal identity.", "url": "https://wpnews.pro/news/retro-six-days-chasing-a-chatgpt-desktop-crash-that-turned-out-to-be-a-symlink", "canonical_source": "https://gist.github.com/galligan/c8a64ec15b89e11ac0421b1b117056c0", "published_at": "2026-08-20 18:21:41+00:00", "updated_at": "2026-09-15 12:42:15.076331+00:00", "lang": "en", "topics": ["ai-products", "ai-tools", "developer-tools"], "entities": ["OpenAI", "ChatGPT", "macOS", "codex", "SkyComputerUseService", "V8", "Node.js", "Computer History"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/retro-six-days-chasing-a-chatgpt-desktop-crash-that-turned-out-to-be-a-symlink", "markdown": "https://wpnews.pro/news/retro-six-days-chasing-a-chatgpt-desktop-crash-that-turned-out-to-be-a-symlink.md", "text": "https://wpnews.pro/news/retro-six-days-chasing-a-chatgpt-desktop-crash-that-turned-out-to-be-a-symlink.txt", "jsonld": "https://wpnews.pro/news/retro-six-days-chasing-a-chatgpt-desktop-crash-that-turned-out-to-be-a-symlink.jsonld"}}