← reports

#1873 · Uncaught C++ exception from PtyFork crashes the whole app (SIGABRT, no JS error frame)

Bug Priority: High Effort: n/a desktop workspaces open on GitHub 2026-08-19 · base d81fee6f

Verdict: REPRODUCED (mechanism reproduced end to end: binary evidence on the exact macOS prebuilt addons bb ships, addon load order of the shipped daemon bundle, and a live Linux analog that terminates the process with the same uncaught Napi::Error; the macOS SIGABRT itself was not re-run because no macOS host was available) · Root-cause confidence: high

1. TL;DR

The bb host daemon (which on the packaged desktop runs as a separate Electron-as-Node process, hence the "Electron Framework" frames) loads two native addons into the same process: @parcel/watcher's watcher.node and node-pty's pty.node. Both are built against node-addon-api and both export the same weak C++ template instantiation Napi::details::CallbackData<...>::Wrapper — the N-API trampoline that is supposed to convert a thrown Napi::Error into a JavaScript exception. node-pty is compiled with NAPI_CPP_EXCEPTIONS (its Wrapper has a try/catch); @parcel/watcher is compiled with NAPI_DISABLE_CPP_EXCEPTIONS (its Wrapper has no try/catch). On macOS, dyld coalesces weak definitions across all loaded images, so whichever addon loads first owns the symbol. The shipped daemon bundle loads watcher.node first (esbuild hoists host-watcher's "lazy" import to a static top-level import), so PtyFork is invoked through watcher.node's exception-unaware Wrapper. When a PTY spawn fails (fd/process exhaustion, etc.), PtyFork does throw Napi::Error, nothing on the stack can catch it, std::terminate runs, and the daemon dies with SIGABRT — exactly the crash report in the issue (PtyFork directly above a watcher.node Wrapper frame). There is no JS error because the frame that would have produced one was never on the stack.

2. Claims vs findings

Claim from the issueStatusEvidence
PtyFork throws a C++ exception that reaches std::terminate; nothing catches it at the N-API boundary.Verifiedsrc/unix/pty.cc in node-pty 1.1.0 does throw Napi::Error::New(...) on spawn failure (lines 373/376/416). The catcher is Napi::details::CallbackData<...>::Wrapperdetails::WrapCallback (napi-inl.h L76-L89) — which only has a try/catch when compiled with NAPI_CPP_EXCEPTIONS. pty.node has that copy, but its GOT slot for the symbol is a dyld weak bind (see §5) and is rebound to watcher.node's copy, compiled with NAPI_DISABLE_CPP_EXCEPTIONS (no try/catch).
The crash stack shows watcher.node Napi::details::CallbackData<...>::Wrapper directly beneath pty.node PtyFork; "both native addons were live in the same callback path".Verified (mechanism refined)They are not both "in the callback path": pty.node's call to its own Wrapper was coalesced by dyld to watcher.node's identical-named weak symbol. The reporter's frame layout is precisely what this produces, and it is the key clue. Confirmed that the daemon bundle loads watcher.node before pty.node (log).
No JS error frame, no uncaughtException; "silent native death".VerifiedWith watcher.node's Wrapper on the stack there is no handler, so __cxa_throwfailed_throwterminate. Reproduced on Linux by forcing the same symbol binding: terminate called after throwing an instance of 'Napi::Error' and the process dies (log). With the normal binding the same spawn failure is an ordinary catchable JS error forkpty(3) failed. (log).
Likely trigger: fork/posix_spawn failing with EAGAIN/EMFILE/ENFILE under load.Plausible, unverifiedOn macOS node-pty 1.1.0 throws "posix_spawnp failed." whenever pty_posix_spawn leaves err != 0 — including posix_openpt failing (EMFILE/ENFILE), grantpt/unlockpt/ioctl failing, or posix_spawnp returning EAGAIN/ENOMEM. Any of these fits; the specific errno on the reporter's machine is not in the report. What is certain is that the failure is fatal only because of the symbol coalescing.
"Catch at the N-API boundary" (wrap PtyFork's body) is the correctness fix.Verified as a valid upstream fix; bb-side fix is differentA try/catch inside PtyFork (or -fvisibility=hidden / NAPI_CPP_CUSTOM_NAMESPACE for node-pty's build) would make pty.node immune to the binding. bb cannot change the prebuilt; the bb-side root cause is loading watcher.node into the daemon parent at all (§6).
bb 0.38.0 affected.Verifiedbb-app 0.38.0 (45145e51a) pins node-pty@1.1.0 and @parcel/watcher@2.5.6, same as base d81fee6f; host-watcher's lazy-import code is identical. Not fixed on origin/main as of 2026-08-19 (no commits touch these paths after the base).
Recovery clean, no corruption.Unverified (out of scope)Consistent with a daemon crash being restarted by the desktop supervisor; not investigated.
Adjacent note: bb.sdk.threads.rateLimitRecovery removed in 0.39.0.Not investigatedFiled separately per the reporter; unrelated to this crash.

3. Environment

4. Minimal reproduction

Three independent pieces of evidence. Repro files: 1873/repro/.

4a. The shipped macOS binaries export the same weak symbol and pty.node weak-binds to it

  1. From a bb checkout at d81fee6f (after pnpm install), fetch the macOS parcel prebuilt and run the Mach-O inspector (macho-weak-syms.py, no dependencies):
    mkdir /tmp/pack && cd /tmp/pack && npm pack @parcel/watcher-darwin-arm64@2.5.6 && tar xzf parcel-watcher-darwin-arm64-2.5.6.tgz
    python3 macho-weak-syms.py 'CallbackData.*Wrapper' \
      <repo>/apps/host-daemon/node_modules/node-pty/prebuilds/darwin-arm64/pty.node \
      package/watcher.node
    # or, to regenerate the full log incl. the node-pty beta prebuilt: bash run-macho-check.sh <repo> /tmp/pack
    Actual output (full log, regenerated with run-macho-check.sh; the addr= column is the symbol's address inside its own image):
    <repo>/apps/host-daemon/node_modules/node-pty/prebuilds/darwin-arm64/pty.node
      cputype=0x100000c filetype=8 flags=0x18085 MH_WEAK_DEFINES=True MH_BINDS_TO_WEAK=True
      DEF   ext=False weak_def=True weak_ref=False addr=0x4c98 __ZN4Napi7details12WrapCallbackIZNS0_12CallbackDataIPFNS_5ValueERKNS_12CallbackInfoEES3_E7WrapperEP10napi_env__P20napi_callback_info__EUlvE_EEP12napi_value__T_
      DEF   ext=True weak_def=True weak_ref=False addr=0x4c60 __ZN4Napi7details12CallbackDataIPFNS_5ValueERKNS_12CallbackInfoEES2_E7WrapperEP10napi_env__P20napi_callback_info__
    package/watcher.node
      cputype=0x100000c filetype=8 flags=0x18085 MH_WEAK_DEFINES=True MH_BINDS_TO_WEAK=True
      DEF   ext=True weak_def=True weak_ref=False addr=0x79a4 __ZN4Napi7details12CallbackDataIPFNS_5ValueERKNS_12CallbackInfoEES2_E7WrapperEP10napi_env__P20napi_callback_info__
    --- dyld bind / import entries ---
    <repo>/apps/host-daemon/node_modules/node-pty/prebuilds/darwin-arm64/pty.node
      weak_bind: __ZN4Napi7details12CallbackDataIPFNS_5ValueERKNS_12CallbackInfoEES2_E7WrapperEP10napi_env__P20napi_callback_info__ (flags=0x0)
    package/watcher.node
      weak_bind: __ZN4Napi7details12CallbackDataIPFNS_5ValueERKNS_12CallbackInfoEES2_E7WrapperEP10napi_env__P20napi_callback_info__ (flags=0x0)
    Expected (for a safe pair of addons): the symbol would be hidden/non-external in at least one of them, or pty.node would have no weak_bind for it. Actual: both export it as an external weak definition and pty.node's pointer to it is a dyld weak bind — dyld will point it at whichever image defined it first. The same is true for the newest node-pty@1.2.0-beta.15 prebuilt (bottom of the log), so upgrading node-pty alone does not help.
    Reading notes: pty.node's ext=False WrapCallback<...> line is the private lambda instantiation that the Wrapper calls, not a second Wrapper. An earlier version of this log also showed ext=False weak_def=False duplicates of both names — those were STABS N_FUN debug entries (n_type 0x24) aliasing the same address (0x4c60), not non-weak local copies; the inspector now skips N_STAB entries. Independently, the verifier dumped pty.node's weak-bind target: it is the GOT slot at 0x8020 whose initial value is 0x4c60, i.e. pty.node's own Wrapper — exactly the slot dyld rebinds to the first image that defines the symbol. watcher.node contains zero Napi::Error typeinfo strings (pty.node has 4), so watcher's Wrapper cannot catch a Napi::Error.

4b. The shipped daemon bundle loads watcher.node before pty.node, in the parent

  1. Build the daemon (pnpm exec turbo run build --filter=@bb/host-daemon) and run the real bundle with a preload that logs process.dlopen (log-dlopen.cjs); any bogus flag makes it exit right after module evaluation:
    cd apps/host-daemon && cp /path/to/log-dlopen.cjs .
    node --require ./log-dlopen.cjs dist/daemon-bundle.mjs --definitely-not-a-flag 2>&1 | grep -E '^\[dlopen|^\[exit'
    # or: bash run-load-order.sh <repo>
    Expected (per the comment in packages/host-watcher/src/real-parcel-watcher.ts: "leaving the parent parcel-free"): only pty.node. Actual (log):
    [dlopen #1] <repo>/node_modules/.pnpm/@parcel+watcher-linux-x64-glibc@2.5.6/node_modules/@parcel/watcher-linux-x64-glibc/watcher.node
    [dlopen #2] <repo>/node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty/build/Release/pty.node
    [exit] code=1 addons loaded=2
    Why: grep -o 'import [A-Za-z]* from"@parcel/watcher"' dist/daemon-bundle.mjs shows esbuild hoisted import VYr from "@parcel/watcher" to the top level of the ESM bundle (offset ~407 KB, well before the node-pty import at ~5.6 MB), because the "lazy" await import("./real-parcel-watcher.js") targets an internal module, which esbuild inlines, and the external static import inside it cannot stay lazy in ESM output. A vitest that bundles the host-watcher entry the same way and asserts no static import FAILS on the base commit: parcel-watcher-not-loaded-in-parent.test.ts (output):
    AssertionError: static top-level import(s) of @parcel/watcher found in the bundle; watcher.node would load at daemon startup:
    import parcelWatcher from "@parcel/watcher";: expected [ Array(1) ] to be null

4c. Live analog: force the same binding and a failing spawn → uncaught Napi::Error → terminate

  1. Linux does not coalesce across RTLD_LOCAL addons, so to observe exactly the binding dyld performs on macOS, load watcher.node with RTLD_GLOBAL first, then node-pty, then make the spawn fail by clamping RLIMIT_NOFILE to the already-open count (napi-wrapper-coalesce-linux.cjs; pty.node on Linux references the Wrapper through the GOT, R_X86_64_GLOB_DAT, so it is interposable):
    cd <repo>/apps/host-daemon && cp /path/to/napi-wrapper-coalesce-linux.cjs .
    node napi-wrapper-coalesce-linux.cjs --local    # control: default binding
    node napi-wrapper-coalesce-linux.cjs            # watcher.node first, RTLD_GLOBAL
    # or: bash run-linux-analog.sh <repo>   (does the same with NODE_PATH unset and rewrites the two logs)
    The script resolves the platform package (@parcel/watcher-linux-x64-glibc, which pnpm does not hoist) relative to @parcel/watcher via createRequire(require.resolve("@parcel/watcher")), so it works in a fresh clone with no NODE_PATH. It needs prlimit (util-linux) and a glibc x86_64/arm64 host.
    Control — expected and actual (log, NODE_PATH unset):
    loaded watcher.node RTLD_LOCAL: <repo>/node_modules/.pnpm/@parcel+watcher-linux-x64-glibc@2.5.6/node_modules/@parcel/watcher-linux-x64-glibc/watcher.node
    loaded node-pty <repo>/node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty/lib/index.js
    caught JS error: forkpty(3) failed.
    process still alive, exiting normally
    exit=0
    Coalesced binding — actual (log); expected would be the same catchable JS error:
    loaded watcher.node RTLD_GLOBAL: <repo>/node_modules/.pnpm/@parcel+watcher-linux-x64-glibc@2.5.6/node_modules/@parcel/watcher-linux-x64-glibc/watcher.node
    loaded node-pty <repo>/node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty/lib/index.js
    terminate called after throwing an instance of 'Napi::Error'
    run-linux-analog.sh: line 13: 3288463 Segmentation fault         (core dumped) node napi-wrapper-coalesce-linux.cjs
    exit=139
    Same spawn failure, same addons, only the symbol binding differs: the JS try/catch in terminal-manager.ts never gets a chance. (libstdc++'s verbose terminate handler ends in SIGSEGV here; on macOS libc++abi's demangling_terminate_handler calls abort(), giving the reporter's SIGABRT.)

5. Root cause

Mechanism. node-addon-api is header-only; every addon instantiates the trampoline Napi::details::CallbackData<Napi::Value(*)(const Napi::CallbackInfo&), Napi::Value>::Wrapper and passes its address to napi_create_function. The body of that trampoline depends on a compile-time macro (napi-inl.h, node-addon-api 7.1.1 L76-L89 / L108-L123):

template <typename Callable>
inline napi_value WrapCallback(Callable callback) {
#ifdef NAPI_CPP_EXCEPTIONS
  try {
    return callback();
  } catch (const Error& e) {
    e.ThrowAsJavaScriptException();
    return nullptr;
  }
#else   // NAPI_CPP_EXCEPTIONS
  // When C++ exceptions are disabled, errors are immediately thrown as JS
  // exceptions, so there is no need to catch and rethrow them here.
  return callback();
#endif  // NAPI_CPP_EXCEPTIONS
}

Two addons with different settings produce two functions with the same mangled name and different bodies (an ODR violation across images). node-pty builds with node_addon_api_except (binding.gyp target_defaults) and throws C++ exceptions: src/unix/pty.cc L373 throw Napi::Error::New(napiEnv, "posix_spawnp failed."), L416 "forkpty(3) failed.". @parcel/watcher builds with "defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"] (binding.gyp L5). Neither sets -fvisibility=hidden / GCC_SYMBOLS_PRIVATE_EXTERN (node-gyp's common.gypi does not either), so both export the symbol as a weak external definition and both images are flagged MH_WEAK_DEFINES | MH_BINDS_TO_WEAK (§4a).

On macOS dyld performs weak-definition coalescing across all loaded images (RTLD_LOCAL does not opt out), binding every weak reference to the first definition in load order. In the daemon process watcher.node loads first (§4b), so pty.node's Wrapper pointer is patched to watcher.node's copy — the one with no try/catch. That is literally the reporter's stack: pty.node PtyFork called from watcher.node Napi::details::CallbackData<...>::Wrapper, called from Electron/V8. When PtyFork throws, unwinding walks up through watcher.node's Wrapper (no handler), into V8/Electron (compiled -fno-exceptions) → __cxa_throw finds no handler → failed_throwstd::terminateabort() → SIGABRT. No JS exception is ever created, so no log line, no uncaughtException.

Why watcher.node is in the daemon at all (the bb-side bug). Since #250 the daemon is supposed to run @parcel/watcher only in a forked child (start-host-daemon.ts L220-L238). The in-process fallback is meant to be lazy (parcel-watcher-backend.ts L39-L48):

function createInProcessBackend(): ParcelWatcherBackend {
  return {
    async subscribe(dir, callback, opts) {
      // Lazy import keeps the native addon out of the parent unless we actually
      // watch in-process.
      const { realParcelWatcher } = await import("./real-parcel-watcher.js");
      return realParcelWatcher.subscribe(dir, callback, opts);
    },
  };
}

and real-parcel-watcher.ts does import parcelWatcher from "@parcel/watcher";. But the daemon is shipped as a single esbuild ESM bundle (build-bundles.mjs: bundle: true, format: "esm", natives external). esbuild inlines the internal dynamic import into a lazy __esm initializer, but the external static import inside it has to become a top-level import VYr from "@parcel/watcher" of the bundle — which runs at startup and executes @parcel/watcher's index.js, whose first statement is require('@parcel/watcher-darwin-arm64')watcher.node. So the "parcel-free parent" guarantee from #250 silently does not hold in production builds (it does hold in pnpm dev, which runs from source via tsx — one reason this was never noticed locally). The result is two node-addon-api addons with conflicting exception modes in one process, watcher first.

Why the symptom follows. bb's JS layer is correct: terminal-manager.ts openTerminal wraps this.ptyAdapter.spawn in try/catch and would send terminal_open_failed; provider-cli-health.ts also spawns PTYs. None of that runs because the exception never becomes a JS value. The trigger can be anything that makes pty_posix_spawn fail — with dozens of threads/worktrees/terminals, posix_openpt EMFILE or posix_spawnp EAGAIN are the obvious candidates, matching the reporter's hypothesis — but the bug is that a recoverable failure is fatal.

Deeper issue. This is a latent hazard for any pair of node-addon-api addons with mismatched exception settings in one process on macOS; the general cure lives upstream (build addons with -fvisibility=hidden/GCC_SYMBOLS_PRIVATE_EXTERN as node-addon-api's setup docs recommend, or NAPI_CPP_CUSTOM_NAMESPACE). bb ships prebuilts for both and cannot rebuild them, but it can keep them out of the same process, which was already the design intent.

6. Proposed fix (first principles)

  1. Make the in-process parcel fallback truly lazy (packages/host-watcher/src/parcel-watcher-backend.ts): dynamically import the external package, not an internal wrapper — const { default: parcelWatcher } = await import("@parcel/watcher");. esbuild keeps a dynamic import of an external module as a real import(), so nothing loads at startup. real-parcel-watcher.ts stays for the child entry. Prototype diff: proposed-fix-lazy-parcel-import.diff. Verified: after rebuilding, the daemon bundle loads only pty.node (log); the repro vitest passes (log); @bb/host-watcher typecheck + 45 tests pass (log). With pty.node alone (better-sqlite3 does not use node-addon-api — 0 Napi symbols), there is no competing Wrapper, so a failed spawn becomes the JS error terminal_open_failed / a rejected provider install as designed.
  2. Guard it: add the repro test (or an assertion in apps/host-daemon/scripts/check-bundles.mjs) that the daemon bundle has no static top-level import ... from "@parcel/watcher", so a future refactor cannot re-hoist it. A daemon startup smoke test that asserts process.dlopen was never called for watcher.node would be even stronger.
  3. Defense in depth (optional): load node-pty as the first native addon in the daemon entry so that, if some addon without exceptions ever does get loaded, pty.node's exception-aware Wrapper wins the coalescing (the reverse binding is harmless: watcher's callbacks never throw). Weaker than (1) because it depends on ordering.
  4. Upstream: file against microsoft/node-pty — either wrap each native entry point's body in try/catch + ThrowAsJavaScriptException() (the reporter's ask), or add GCC_SYMBOLS_PRIVATE_EXTERN: YES / -fvisibility=hidden to binding.gyp; and/or against parcel-bundler/watcher for hidden visibility. node-pty@1.2.0-beta.15 still has the issue.

What could go wrong: (1) changes nothing for the child process path the daemon actually uses; the in-process fallback is only used when setParcelWatcherBackend was not called (tests, embedders). The type import typeof import("@parcel/watcher") remains type-only. No wire-protocol change, so no HOST_DAEMON_PROTOCOL_VERSION bump needed.

7. PR review

No open pull requests are linked to this issue.

8. Related issues

9. Appendix

Commands run

gh issue view 1873 --repo get-bb/bb --json title,body,labels,state,comments
git checkout d81fee6f && pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build
grep -n "throw\|NAPI" apps/host-daemon/node_modules/node-pty/src/unix/pty.cc apps/host-daemon/node_modules/node-pty/binding.gyp
cat node_modules/.pnpm/@parcel+watcher@2.5.6/node_modules/@parcel/watcher/binding.gyp     # NAPI_DISABLE_CPP_EXCEPTIONS
grep -n "GCC_SYMBOLS_PRIVATE_EXTERN\|fvisibility" ~/.cache/node-gyp/24.18.0/include/node/common.gypi   # none
npm pack @parcel/watcher-darwin-arm64@2.5.6 ; npm pack node-pty@1.2.0-beta.15
python3 macho-weak-syms.py 'CallbackData.*Wrapper' pty.node watcher.node
nm -D .../pty.node | grep Wrapper ; objdump -R .../pty.node | grep Wrapper      # W symbol, R_X86_64_GLOB_DAT
grep -bo 'from"@parcel/watcher"\|from"node-pty"' apps/host-daemon/dist/daemon-bundle.mjs  # 406933 vs 5585398
node --require ./log-dlopen.cjs dist/daemon-bundle.mjs --definitely-not-a-flag     # or: bash run-load-order.sh <repo>
node napi-wrapper-coalesce-linux.cjs --local ; node napi-wrapper-coalesce-linux.cjs   # or: bash run-linux-analog.sh <repo>  (NODE_PATH unset)
strace -f -e trace=none -e signal=all node napi-wrapper-coalesce-linux.cjs
pnpm exec vitest run test/parcel-watcher-not-loaded-in-parent.test.ts   # fails on base, passes with diff
pnpm exec turbo run typecheck test --filter=@bb/host-watcher            # with diff: 45 passed
git fetch origin main && git log d81fee6f..origin/main --oneline -- packages/host-watcher apps/host-daemon/src/terminals apps/host-daemon/scripts   # nothing
git show 45145e51a:packages/bb-app/package.json | grep "node-pty\|parcel"   # 0.38.0 pins the same versions

Signal sequence of the Linux analog (strace)

terminate called after throwing an instance of 'Napi::Error'
[pid N] --- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_ACCERR, ...} ---
[pid N] --- SIGSEGV {si_signo=SIGSEGV, si_code=SI_TKILL, ...} ---
+++ killed by SIGSEGV (core dumped) +++

Extra evidence

Verification

An independent verifier followed all three repro legs in a separate worktree at d81fee6f (fresh pnpm install + turbo build):