fix(desktop): stop capturing crashes from processes spawned in terminals
Problem
The desktop main process initialises the Sentry Electron SDK, whose SentryMinidump integration starts Electron's crashReporter. On macOS that points the task Mach exception port at Crashpad's handler, and macOS inherits task exception ports across fork/exec — including into grandchildren, arbitrarily deep.
Superset is terminal-centric: every shell, coding agent, compiler and test runner a user or agent starts is a descendant of the app. So when an unrelated program crashes inside a Superset terminal, our Crashpad handler writes a minidump into our database and the SDK uploads it under our DSN as a fatal Superset crash.
Measured on the desktop project, last 7 days, error.mechanism:minidump:
event.process | events |
|---|---|
| unknown | 4960 |
| renderer | 53 |
| Utility | 50 |
| browser | 36 |
| GPU | 25 |
| gpu | 15 |
| node | 2 |
~96.5% of our native crash reports, roughly 700/day. Three harms, weighed separately:
- Data we never intended to ingest. This is the one that makes it worth code rather than an inbound filter. A minidump is a memory dump. We are uploading the memory, file paths and command lines of unrelated programs that users run on their own machines — one sampled event carries a compiler front-end's full command line including a path from the user's filesystem. That is user data leaving the user's machine to us, ~700 times a day, and no filter on our side un-sends it.
- Our own crash signal is buried — real Superset native crashes are a couple of hundred a week under ~5000 foreign ones.
- We pay quota for other programs' crashes.
Sampled evidence that the bucket is foreign: DESKTOP-107 carries the Swift compiler front-end's own annotation block; DESKTOP-101 is a Swift JSONEncoder/Encodable recursion running off the end of its stack into the guard page; DESKTOP-10J is a crash inside Swift runtime stack-management code. Superset ships no Swift on desktop.
event.process: unknown is not the discriminator, and this fix does not use it. DESKTOP-10Q is in the unknown bucket and is genuinely ours — an Electron node-mode child that died of a V8 OOM abort. Its crashpad annotations say process_type: node while the tag says unknown. Crashpad's _productName: "Superset" appears on the foreign events too, because those annotations come from the handler (ours), not from the crashing process. Any matcher over that tag would have silenced DESKTOP-10Q.
Root cause
Mach exception ports are task-level state inherited at spawn. Verified directly on macOS 26.6 rather than assumed — a probe binary reading task_get_exception_ports shows a port set by a parent is still present after two intervening shells and after a Node child_process hop, and clearing it in an intermediate process de-registers the whole subtree below.
== set in parent -> /bin/zsh -> /bin/sh -> grandchild ==
[grandchild-via-two-shells] kr=0 slots=1 | mask=0x400 port=0xffffffff (SET)
== set in parent -> clear -> /bin/zsh -> /bin/sh -> grandchild ==
[after-clear] kr=0 slots=1 | mask=0x400 port=0x0 (NULL)
Confirmed in a real Electron 41.10.3 process with the same crashReporter options the SDK passes: crashReporter.start() replaces the inherited system CRASH port with Crashpad's, and children spawned through node-pty inherit it.
Fix
The right boundary is process ancestry, not any tag or heuristic: everything launched into a pty is user/agent territory; everything else is ours. node-pty posix_spawns a small spawn-helper executable that sets up the controlling terminal and then execvps the real command — the one point that is already inside the pty child and before the user's program. A bun patch clears the inherited exception ports there.
#if defined(__APPLE__)
task_set_exception_ports(mach_task_self(),
EXC_MASK_CRASH | EXC_MASK_RESOURCE | EXC_MASK_GUARD,
MACH_PORT_NULL, EXCEPTION_DEFAULT, THREAD_STATE_NONE);
#endif
Two things worth reviewing closely:
- The masks are named explicitly.
EXC_MASK_ALLdeliberately excludesEXC_MASK_CRASH, which is the one that matters. Using it would compile, run, and silently do nothing. The three named masks are the set Crashpad registers. The guard test assertsEXC_MASK_ALLdoes not appear in the call. - Clearing to
MACH_PORT_NULLdoes not cost users their own crash logs. Measured: a program crashing with the port cleared still produces its usual report in~/Library/Logs/DiagnosticReports(delta 1 with the port set, delta 1 with it cleared). So nobootstrap_look_upofcom.apple.ReportCrashis needed.
This is not a beforeSend, an allowlist, or any Sentry-side suppression, and native crash reporting stays fully on. The report is never generated in the first place.
What still reports, unchanged: Electron's main, renderer, GPU and utility processes are not spawned through node-pty. Neither are the node children the app spawns with child_process.spawn — host-service and the pty daemon, which is where the renderer/node OOM family lives. Demonstrated below rather than asserted.
Known, accepted gap: a Superset binary a user runs themselves in a terminal (e.g. the bundled superset CLI) is on the detached side of the boundary. That is a user-invoked program, not an app child; calling it out rather than widening scope to chase it.
Why it reaches shipped builds — the failure mode for a native patch is landing in source and never reaching the binary, so this was traced end to end. node-pty's loader prefers build/Release over the bundled prebuilds/, and build/Release/spawn-helper is compiled from the patched source by bun run install:deps (@electron/rebuild) and by electron-builder's npmRebuild: true. .github/workflows/build-desktop.yml runs bun install --frozen --ignore-scripts (patches still apply) → bun run install:deps → bun run package. Verified on the real toolchain after bun install:
=== patched source present in installed package? === 1
=== rebuilt binary carries the call? === 1
The guard test also fails if the loader would fall through to an unpatched prebuild.
Per patches/README.md, every patch needs a guard test that fails when its markers vanish — patchedDependencies is version-pinned, so a node-pty bump would otherwise drop this silently while everything still builds. Added, plus a README section.
Verification
Reproduced and fixed end to end in a real Electron process using the SDK's crashReporter options, with crashDumps redirected to a scratch dir. A = a foreign program crashing inside a pty. B = our own Electron-as-node child aborting, i.e. the DESKTOP-10Q shape that must keep reporting.
===== BASELINE (unpatched) =====
RESULT A (foreign crash via pty): 1 minidump(s)
RESULT B (our node child abort): 1 new minidump(s)
===== FINAL (patch installed via bun install) =====
RESULT A (foreign crash via pty): 0 minidump(s)
RESULT B (our node child abort): 1 new minidump(s)
Terminals still spawn normally through the patched helper:
PTY SANITY: "pty-works\r\n/tmp" exit= 0
Required checks:
$ bunx biome check package.json apps/desktop/src/pty-crash-ports-patch.test.ts
Checked 2 files in 68ms. No fixes applied.
$ cd apps/desktop && bun run typecheck
$ tsc --noEmit # clean, no output
$ bun test apps/desktop/src/main
558 pass
0 fail
1118 expect() calls
Ran 558 tests across 40 files. [6.41s]
$ bun test apps/desktop/src/pty-crash-ports-patch.test.ts
5 pass
0 fail
The guard test was confirmed to fail before the patch was applied (3 fail / 1 vacuous pass, which was then tightened) and pass after.
packages/pty-daemon is the other node-pty consumer and spawns real PTYs, so I ran it too. It has pre-existing flakiness when the full suite runs concurrently — A/B'd by swapping the helper binary rather than trusting the numbers:
FULL pty-daemon suite, UNPATCHED helper: 81 pass, 11 fail, 8 errors
FULL pty-daemon suite, PATCHED helper: 81 pass, 10 fail, 8 errors
Same tests both ways (timed out waiting: marker READY, plus trustd-probe network timeouts). In isolation, packages/pty-daemon/src/process-tree.test.ts is 6 pass / 0 fail with both binaries. Not caused by this change; not fixed by it either.
Did not touch desktop git code, so no-main-process-blocking.test.ts is not implicated. Did not run repo-wide bun run lint.
What I could not verify locally: I did not build, sign and run a packaged .app, so the packaging chain above is traced through config and CI workflow plus a real @electron/rebuild run — not observed in a notarized build. I also could not verify the x64 slice; the patch is arch-independent C, but only arm64 was compiled and run here. And this only removes crashes that arrive through a pty — foreign processes started outside one (e.g. via the shell-env snapshot helper) are unaffected, which the measurement suggests is a small tail but I did not quantify it.
Refs DESKTOP-101
Refs DESKTOP-107
Refs DESKTOP-10J
https://claude.ai/code/session_01KdhqzDJZfk5bW3TmuCT9Ym
Summary by cubic
Stops capturing foreign crashes from programs launched in Superset terminals on macOS by clearing inherited Mach exception ports in node-pty’s spawn-helper. Previously, pty-launched children inherited our Crashpad handler and uploaded minidumps to our DSN; now those processes are detached while Superset’s own processes continue to report. Refs DESKTOP-101, DESKTOP-107, DESKTOP-10J.
- Clears
EXC_MASK_CRASH | EXC_MASK_RESOURCE | EXC_MASK_GUARDtoMACH_PORT_NULLinsrc/unix/spawn-helper.ccbeforeexecvp(macOS only); avoidsEXC_MASK_ALLwhich excludesEXC_MASK_CRASH. - Scope: only processes launched into a pty are detached. Electron main/renderer/GPU/utility and node children started via
child_process.spawncontinue to report unchanged. - Adds guard test
apps/desktop/src/pty-crash-ports-patch.test.tsto ensure the call exists, masks are explicit, and the built helper is patched when rebuilt. - Ships via
patchedDependenciesfornode-pty@1.2.0-beta.14; our build pipeline rebuilds the helper and prefersbuild/Releaseover prebuilds, so no manual action. - Known gap: user-invoked Superset binaries run inside a terminal remain detached by design.
Summary by CodeRabbit
-
Bug Fixes
- Improved macOS terminal process isolation by clearing inherited crash-reporting exception ports before launching terminal commands.
- Prevents terminal subprocess crashes from being incorrectly reported through the application’s crash-reporting system.
- Preserves crash reporting for application-managed processes.
- Non-macOS behavior remains unchanged.
-
Documentation
- Added documentation covering the macOS behavior, build requirements, validation checks, and patch maintenance.