Skip to content

0.5.7: SIGABRT from node-pty exit-waiter ThreadSafeFunction teardown (quit-time throw and uv_cond_wait abort) #806

Open

Description

@sirsid2001

Summary

Munder Difflin 0.5.7 (macOS, Electron 32.3.3, bundled node-pty 1.1.0) aborts with SIGABRT in two different ways. Both involve node-pty's per-pty exit-waiter threads (SetupExitCallback, one std::thread per pty, each holding a Napi::ThreadSafeFunction into the JS onExit callback). We saw 4 crash reports in one day (macOS 26.6.2, Apple silicon) with 6 to 7 live pty sessions in each.

Type A: exception thrown from a ThreadSafeFunction callback while quitting (1 of 4)

Faulting thread is the main thread, during app.quit(). A C++ exception escapes node-pty's native callback and hits std::terminate:

abort
__abort_message / std::terminate / __cxa_throw
pty.node: Napi::Error::ThrowAsJavaScriptException()
pty.node: Napi::ThreadSafeFunction::CallJS(...)
Electron Framework: uv_async_send / uv_run
Electron Framework: node::FreeEnvironment

This is the same stack shape as microsoft/node-pty#904, which is open and unfixed. The app quit and the abort landed in the same second, and the app was relaunched 5 s later. I compared SetupExitCallback in node-pty v1.1.0 and v1.2.0-beta.17: the thread/TSFN section is unchanged, so upgrading does not help. Related: nodejs/node#49171 (TSFN finalization during environment teardown), closed as not planned.

Type B: worker thread aborts inside uv_cond_wait (3 of 4)

Faulting thread is a non-main thread started straight from _pthread_start:

abort
Electron Framework: uv_cond_wait+28
Electron Framework: uv_cancel+680
libsystem_pthread: _pthread_start+136

Frame names inside the stripped Electron Framework are nearest-exported-symbol guesses, so the real function may differ. libuv calls abort() when pthread_cond_wait returns an error, which points at a condition variable or mutex that was destroyed or invalid under a waiting thread. node-pty does not use uv_cond_t itself. The only candidate is the cond/mutex that backs a TSFN inside Node/Electron, which makes this plausibly the same teardown-lifetime defect as Type A seen from the other thread. That is a hypothesis; we have no symbols for Electron Framework 32.3.3 to confirm it.

Observations:

  • Uptime at crash: 10, 14 and 29 minutes.
  • In 1 of 3, several agent sessions had been closed (pty teardown) within the previous 3 minutes; in the other 2 nothing was spawned or closed in the 7 minutes before. So teardown looks like an accelerator at most, not a proven trigger.
  • Every dump shows 6 to 7 SetupExitCallback threads.
  • kill -9 restarts leave no crash report, so there may be more.

Version

Munder Difflin 0.5.7, Electron 32.3.3, node-pty 1.1.0, macOS 26.6.2 arm64.

Suggested mitigations

  1. Before app.quit(), close every pty and wait for its exit event, so no exit-waiter thread is still holding a TSFN when the environment is freed.
  2. Wrap the JS onExit / onData handlers passed to node-pty so they can never throw, which removes the Type A path.
  3. Ship a build with Electron symbols (or a symbolicated .ips workflow) so Type B can be confirmed or ruled out.
  4. Write app stderr to a rotated log rather than overwriting it on each start, so the last lines before a crash survive.

Activity

  1. sirsid2001 commented on Oct 30, 2026

    @sirsid2001
    Author

    Update: the Type B abort is libuv#4165, not a node-pty teardown defect

    Correction to the "Type B" section above, which guessed at a condition variable destroyed under a waiter. Further analysis found the actual cause.

    Cause: libuv/libuv#4165 ("darwin: uv_cond_wait abort due to apple bug", closed). On macOS, pthread_cond_wait can return EINVAL with errno == EBUSY spuriously. libuv 1.46.0 treats any non-zero return as fatal and calls abort(). libuv 1.47.0 and later ignore exactly that case in uv_cond_wait.

    Why this build is affected: Electron 32.3.3 bundles Node 20.18.1 and libuv 1.46.0 (version string 1.46.0 in the shipped Electron Framework). Disassembling uv_cond_wait in the shipped binary shows a bare pthread_cond_wait / cbnz / bl _abort, with no EBUSY handling. Node 20.x never received the workaround, and neither did Electron 33 or 34 (Node 20.18.x). Node 22 does (Electron 35 = Node 22.14.0, libuv 1.49.2).

    Evidence (two of the four crash dumps symbolicated with the official Electron 32.3.3 dSYM; the dSYM, the installed binary and the dumps share the same arm64 UUID; return addresses taken at address - 4):

    • Faulting thread: abort <- uv_cond_wait (thread.c:794) <- threadpool worker (threadpool.c:76).
    • Main thread in both: __psynch_mutexwait <- uv_mutex_lock <- uv__work_submit (threadpool.c:274) <- uv_fs_write (fs.c:2187) <- node::fs::WriteBuffer (node_file.cc:2275) <- JS. This is frame-for-frame the pair of stacks in libuv#4165 (uv_mutex_lock <- uv__work_submit <- uv_fs_write on the main thread, uv_cond_wait abort on a pool thread).
    • Correction to the earlier symbolication: a label of uv_cond_timedwait (thread.c:798) is an off-by-one on arm64 return addresses; the real site is uv_cond_wait.
    • Not established: a kernel log line ("same sequence already present") at the crash times. The log store retains no kernel lines for those windows, so this is neither confirmed nor ruled out. The dumps are on macOS 26.6.2, while the libuv workaround comment names OS X up to 13.6, so the same kernel behaviour on 26.x is inferred from the matching stacks.

    What the main thread was doing: in the dumps it was queueing an async fs.write onto the threadpool from node-pty's write queue. node-pty 1.1.0 (lib/unixTerminal.js, around lines 314 to 319) retries fs.write on EAGAIN via setImmediate(() => _processWriteQueue()) with no delay, counter or reader check. If a pty's slave side is not being read (the tty input queue fills, so the write returns EAGAIN), this becomes a tight loop of threadpool submissions. That does not cause the kernel bug, but every submission is another wake-up of the pool's condition variable, so it multiplies the chances of hitting it. We could not show that it changes the crash rate: the same spin ran for many hours on the same binary without an abort, and a repro (hundreds of millions of EAGAIN writes) did not abort either. Treat it as a risk amplifier, not the trigger.

    Requests:

    1. Move to Electron 35 or later (Node 22, libuv 1.49.2), which carries the libuv fix. No Electron 32 to 34 build has it. node-pty must be rebuilt for the new ABI.
    2. Bound the node-pty EAGAIN retry: replace the immediate retry with a growing setTimeout (for example 1 ms doubling up to 1 s), clear it in dispose(), and give up (drop the write queue) after a time limit. This removes the hot loop even before the Electron upgrade.
    3. Avoid writing into a pty whose reader is not consuming input (stop after N seconds of backlog).

    Type A (the quit-time exception from the ThreadSafeFunction callback, matching microsoft/node-pty#904) is a separate defect and still stands as reported above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions