#1873 · Uncaught C++ exception from PtyFork crashes the whole app (SIGABRT, no JS error frame)
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 issue | Status | Evidence |
|---|---|---|
PtyFork throws a C++ exception that reaches std::terminate; nothing catches it at the N-API boundary. | Verified | src/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<...>::Wrapper → details::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". | Verified | With watcher.node's Wrapper on the stack there is no handler, so __cxa_throw → failed_throw → terminate. 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, unverified | On 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 different | A 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. | Verified | bb-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 investigated | Filed separately per the reporter; unrelated to this crash. |
3. Environment
- bb base commit
d81fee6f47178c75f6ecf23d80bb69c4a3e9e5c3;node-pty@1.1.0(bundles node-addon-api 7.1.1, built withnode_addon_api_except=NAPI_CPP_EXCEPTIONS);@parcel/watcher@2.5.6(built withNAPI_DISABLE_CPP_EXCEPTIONS). - Investigation host: Linux 7.0.0-29-generic x86_64, Node v24.18.0. No macOS host was available; the macOS-specific dyld behaviour is shown by inspecting the actual
darwin-arm64prebuilt binaries bb ships and by forcing the equivalent symbol binding on Linux. - Own dev instance (started only to confirm process layout; not needed for the repro): App :13621, Server :21621, Host daemon :29621, data dir
~/.bb-dev/projects-bb-.claude-worktrees-wf_d5c47f31-487-3-067348fd471c(stopped and deleted at the end). - Revision pass (after verification): all repro logs were regenerated in a fresh worktree (
wf_d5c47f31-487-11, checked out atd81fee6f, freshpnpm install+turbo build) withNODE_PATHunset; no dev instance was started for it. Paths in the logs are shown as<repo>.
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
- From a bb checkout at
d81fee6f(afterpnpm 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; theaddr=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 noweak_bindfor 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 newestnode-pty@1.2.0-beta.15prebuilt (bottom of the log), so upgrading node-pty alone does not help.
Reading notes: pty.node'sext=FalseWrapCallback<...>line is the private lambda instantiation that the Wrapper calls, not a second Wrapper. An earlier version of this log also showedext=False weak_def=Falseduplicates of both names — those were STABSN_FUNdebug entries (n_type 0x24) aliasing the same address (0x4c60), not non-weak local copies; the inspector now skipsN_STABentries. 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 zeroNapi::Errortypeinfo strings (pty.node has 4), so watcher's Wrapper cannot catch aNapi::Error.
4b. The shipped daemon bundle loads watcher.node before pty.node, in the parent
- Build the daemon (
pnpm exec turbo run build --filter=@bb/host-daemon) and run the real bundle with a preload that logsprocess.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 inpackages/host-watcher/src/real-parcel-watcher.ts: "leaving the parent parcel-free"): onlypty.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.mjsshows esbuild hoistedimport VYr from "@parcel/watcher"to the top level of the ESM bundle (offset ~407 KB, well before thenode-ptyimport 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
- Linux does not coalesce across
RTLD_LOCALaddons, so to observe exactly the binding dyld performs on macOS, load watcher.node withRTLD_GLOBALfirst, then node-pty, then make the spawn fail by clampingRLIMIT_NOFILEto 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/watcherviacreateRequire(require.resolve("@parcel/watcher")), so it works in a fresh clone with noNODE_PATH. It needsprlimit(util-linux) and a glibc x86_64/arm64 host.
Control — expected and actual (log,NODE_PATHunset):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 JStry/catchinterminal-manager.tsnever gets a chance. (libstdc++'s verbose terminate handler ends in SIGSEGV here; on macOS libc++abi'sdemangling_terminate_handlercallsabort(), 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_throw → std::terminate → abort() → 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)
- 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 realimport(), so nothing loads at startup.real-parcel-watcher.tsstays for the child entry. Prototype diff: proposed-fix-lazy-parcel-import.diff. Verified: after rebuilding, the daemon bundle loads onlypty.node(log); the repro vitest passes (log);@bb/host-watchertypecheck + 45 tests pass (log). With pty.node alone (better-sqlite3 does not use node-addon-api — 0Napisymbols), there is no competing Wrapper, so a failed spawn becomes the JS errorterminal_open_failed/ a rejected provider install as designed. - 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-levelimport ... from "@parcel/watcher", so a future refactor cannot re-hoist it. A daemon startup smoke test that assertsprocess.dlopenwas never called forwatcher.nodewould be even stronger. - Defense in depth (optional): load
node-ptyas 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. - Upstream: file against microsoft/node-pty — either wrap each native entry point's body in
try/catch+ThrowAsJavaScriptException()(the reporter's ask), or addGCC_SYMBOLS_PRIVATE_EXTERN: YES/-fvisibility=hiddentobinding.gyp; and/or against parcel-bundler/watcher for hidden visibility.node-pty@1.2.0-beta.15still 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
- #250 Run @parcel/watcher in a self-healing child process — established the "parent is parcel-free" intent that the bundle defeats.
- #1506 Survive a broken pipe to the watcher child — same subsystem.
- Upstream: node-addon-api
doc/setup.mdrecommends-fvisibility=hidden/GCC_SYMBOLS_PRIVATE_EXTERNfor macOS;NAPI_CPP_CUSTOM_NAMESPACEexists "to avoid symbol conflicts between different instances of node-addon-api". Apple developer forum thread "Two-level namespace and coalesced typeinfos" describes the same cross-image weak coalescing. - The reporter's adjacent note about
bb.sdk.threads.rateLimitRecoveryis a separate issue and was not investigated.
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
- Linux control without any symbol trick (forkpty-eagain-linux.cjs):
caught JS error: forkpty(3) failed.— i.e. the throw inPtyForkis designed to be catchable; only the wrong Wrapper makes it fatal. - Desktop process layout:
apps/desktop/src/bb-process.tsruns bb-app withELECTRON_RUN_AS_NODE=1and the bb-app launcher spawns the daemon bundle withprocess.execPath(the Electron binary) — this is why the crash report shows Electron Framework frames for what is the host daemon process. - Only two native addons load during daemon module evaluation (watcher.node, pty.node); better-sqlite3 loads later and has no node-addon-api symbols.
Verification
An independent verifier followed all three repro legs in a separate worktree at d81fee6f (fresh pnpm install + turbo build):
- 4a reproduced byte-for-byte (same weak symbol exported by both darwin-arm64 prebuilts, same
weak_bindin pty.node). The verifier additionally dumped the weak-bind target (GOT slot 0x8020, initial value 0x4c60 = pty.node's own Wrapper) and confirmed watcher.node carries noNapi::Errortypeinfo. Finding: the earlier full log listedext=False weak_def=Falseduplicates that looked like a non-weak second copy; they were STABS debug entries. Changed:macho-weak-syms.pynow skipsN_STABsymbols and printsaddr=;macho-weak-syms-output.logregenerated; explanatory note added under 4a. - 4b reproduced (watcher.node first, pty.node second,
addons loaded=2; staticimport VYr from"@parcel/watcher"at ~407 KB). Re-run in this pass withNODE_PATHunset viarun-load-order.sh; same result. The repro vitest fails on base with the same assertion and passes aftergit applyof the proposed diff (re-confirmed here); the rebuilt bundle then loads only pty.node (after-fix log, regenerated). - 4c reproduced (
caught JS error: forkpty(3) failed./ exit 0 vsterminate called after throwing an instance of 'Napi::Error'/ SIGSEGV exit 139) — but only because the verifier's shell, like the author's, had aNODE_PATHpointing at a.pnpm/node_modulesdir; withNODE_PATHunset the original script failed withCannot find module '@parcel/watcher-linux-x64-glibc/watcher.node'(major finding). Changed:napi-wrapper-coalesce-linux.cjsnow resolves the platform package throughcreateRequire(require.resolve("@parcel/watcher")); its usage comment now says "terminate (SIGSEGV on Linux, SIGABRT on macOS)" instead of "SIGABRT"; bothlinux-rtld-*.logfiles were regenerated from this worktree withNODE_PATHunset (run-linux-analog.sh) and still show the same two outcomes. - Source claims checked by the verifier and found accurate: node-pty 1.1.0
binding.gypusesnode_addon_api_except;pty.ccthrowsNapi::Errorat L373/376/416; @parcel/watcher 2.5.6 definesNAPI_DISABLE_CPP_EXCEPTIONS; napi-inl.h 7.1.1 L76-L89 only has the try/catch underNAPI_CPP_EXCEPTIONS; the host-watcher excerpts match;origin/main(4 commits past the base) does not touch host-watcher/host-daemon. No macOS host was available to either party, so the SIGABRT itself is still inferred from the binary evidence, the reporter's stack, and the Linux analog.