ConPTY/TSFN exit callback aborts the process during environment teardown — fixable with NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS (same root cause as #904)
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 76/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Quiet
- Domain
- backend, operating-systems
Research direction
Start with binding.gyp and node-addon-api's except.gypi and napi-inl.h guard, then inspect the exit callback at src/win/conpty.cc:94. Confirm the macro is compiled into node-pty and validate a Windows/ConPTY build during environment teardown; done means the callback no longer aborts the process.
Written by the indexing model from the issue text.
Description
Summary
A ThreadSafeFunction exit callback that fires while the Node environment is terminating aborts the process. On Windows this is 0xc0000409 / FAST_FAIL_FATAL_APP_EXIT; on macOS it is the SIGABRT in #904.
The fix is one line in binding.gyp: define NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS. node-addon-api already contains the guard for exactly this situation, and node-pty does not compile it in.
I believe this is the same defect as #904 (macOS, Environment::RunCleanup) and plausibly the mechanism behind #938. I have Windows/ConPTY evidence for it below.
Evidence
Downstream: amirlehmam/wmux#150 — 13 crashes across 0.10 → 1.1.0, one signature, over 13 months. Reported and analysed by @Ray0483, who did the dump forensics; the version-independence is theirs, not mine.
Exception code: 0xc0000409 (STATUS_STACK_BUFFER_OVERRUN)
Additional parameter: 0x7 (FAST_FAIL_FATAL_APP_EXIT -> abort())
Faulting module: wmux.exe (throw originates in conpty.node)
The C++ EH record decodes to a real throw, not an SEH fault (ExceptionInformation[0] == 0x19930520, NumberParameters=4), and ExceptionInformation[3] — the throw's image base — equals conpty.node's load address in every dump. Walking _ThrowInfo -> _CatchableTypeArray -> TypeDescriptor:
.?AVError@Napi@@ <- Napi::Error
.?AVObjectReference@Napi@@
.?AV?$Reference@VObject@Napi@@@Napi@@
.?AVexception@std@@
conpty.dll loaded and winpty absent in all of them, so every occurrence is the useConptyDll: true path.
A full-memory dump then gave the decisive fact — the thrown Napi::Error's napi_ref resolves to a live V8 heap object whose message is:
'An exception is pending'
That string is not JS-authored. It is napi_extended_error_info.error_message for status napi_pending_exception.
Why that string is a proof rather than a hint
Against node-addon-api 7.1.1, the version node-pty@1.1.0 resolves.
1. Error::New(napi_env) only produces that message on one branch (napi-inl.h:2822):
status = napi_is_exception_pending(env, &is_exception_pending);
if (is_exception_pending) {
status = napi_get_and_clear_last_exception(env, &error); // adopts the real JS error
} else {
const char* error_message = last_error_info_copy.error_message ...; // <- 'An exception is pending'
...
}
So observing it proves napi_is_exception_pending returned false, while the immediately preceding N-API call had failed with napi_pending_exception.
2. Only one state satisfies both. In Node's js_native_api_v8.cc, NAPI_PREAMBLE returns napi_pending_exception when !(env->last_exception.IsEmpty() && env->can_call_into_js()), whereas napi_is_exception_pending reports only !last_exception.IsEmpty(). A false from the second with a napi_pending_exception from the first therefore means can_call_into_js() == false — the environment is stopping. (This step is from Node's source rather than from a header in my tree; everything else here I read directly.)
3. It is unhandleable by design, and node-addon-api says so (napi-inl.h:3039):
inline void Error::ThrowAsJavaScriptException() const {
#ifdef NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS
...
if (status == napi_pending_exception) {
// The environment must be terminating as we checked earlier and there
// was no pending exception. In this case continuing will result
// in a fatal error and there is nothing the author has done incorrectly
// in their code that is worth flagging through a fatal error
return; // <- the guard
}
#else
napi_status status = napi_throw(_env, Value());
#endif
#ifdef NAPI_CPP_EXCEPTIONS
if (status != napi_ok) {
throw Error::New(_env); // <- uncaught, from a frame with nothing above it
}
#endif
4. node-pty does not define the macro. binding.gyp depends on node_addon_api_except, whose except.gypi defines NAPI_CPP_EXCEPTIONS and nothing else. NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS appears nowhere in the package. So the #else compiles in, and the guard written for this exact scenario is absent from the shipped prebuilds.
The full sequence
src/win/conpty.cc:94:
auto callback = [](Napi::Env env, Napi::Function cb, ExitEvent *exit_event) {
cb.Call({Napi::Number::New(env, exit_event->exit_code)});
delete exit_event;
};
- A PTY child exits; the TSFN dispatcher runs this on the main thread.
- node-addon-api wraps it in
WrapVoidCallback(napi-inl.h:95), which catchesNapi::Errorand callsThrowAsJavaScriptException(). - The environment is terminating, so
Napi::Number::New/cb.Callfail withnapi_pending_exceptionand throw aNapi::Errorcarrying the synthesized'An exception is pending'. <- throw #1 WrapVoidCallbackcatches it and callsThrowAsJavaScriptException().napi_throwcannot throw into a dying isolate. Without the swallow guard,throw Error::New(_env). <- throw #2, uncaught- Nothing above that frame ->
UnhandledExceptionFilter->abort()->__fastfail(7)->0xc0000409.
This also accounts for an observation that was previously unexplained: the faulting thread's stack carries exactly two live C++ throw records, at byte-identical offsets across dumps taken months and five releases apart. Steps 3 and 5.
Napi::Number::New being an argument matters — it is evaluated before cb.Call is entered, so this can begin before control ever reaches the JS callback. A downstream try/catch around the JS function cannot see it. We shipped one and it changed nothing, which is consistent.
Suggested fix
'target_defaults': {
'defines': [ 'NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS' ],
That turns step 5 into a return, which is what the macro exists for, and makes teardown a no-op instead of an abort. It changes nothing on any path where the environment is alive.
Belt and braces, and independently worthwhile: guard the callback body itself, so a failure during teardown does not proceed to cb.Call and does not leak the ExitEvent (which I think is #938):
auto callback = [](Napi::Env env, Napi::Function cb, ExitEvent *exit_event) {
std::unique_ptr<ExitEvent> owned(exit_event);
try {
cb.Call({Napi::Number::New(env, owned->exit_code)});
} catch (const Napi::Error&) {
// Environment teardown. Nothing to report to, and nowhere to report it.
}
};
Reproduction
Not deterministic — it is a race with environment teardown. Downstream it ran about one crash every 2–4 days under normal use, and both crashes we have context for happened within seconds of an OS-driven session end (one a Windows Update restart, 57 seconds later). #904 reproduces it reliably by having Playwright close Electron windows with live PTYs, which is the same race deliberately provoked.
Happy to help
@Ray0483 has nine dumps and the parser to read them, and has offered to answer targeted questions about specific structures without publishing the files (they carry the process environment block — please do not ask anyone for these casually). I can test a patched build on the Windows side.
I would also be glad to send the binding.gyp one-liner as a PR if you would take it.
- Dominant language
- TypeScript
- Stars
- 2k
- Forks
- 337
- Avg merge
- 21h 58m
- Merged PRs (30d)
- 3
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from microsoft/node-pty
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
All issues in microsoft/node-pty
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
copse-dev/agent-pane#2953 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Eynzof/Hermes-CN-Desktop#610 ·
-
bug clawsweeper:linked-pr-open clawsweeper:needs-live-repro clawsweeper:no-new-fix-pr impact:message-loss issue-rating: 🐚 platinum hermit P2 regression
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
-
calcite-components needs triage refactor
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Esri/calcite-design-system#15203 ·