Skip to content

fix: prevent SIGSEGV on worker exit when wrappers survive into final GC#1477

Open
tstone-1 wants to merge 1 commit into
WiseLibs:masterfrom
tstone-1:fix-worker-exit-segfault-1476
Open

fix: prevent SIGSEGV on worker exit when wrappers survive into final GC#1477
tstone-1 wants to merge 1 commit into
WiseLibs:masterfrom
tstone-1:fix-worker-exit-segfault-1476

Conversation

@tstone-1

@tstone-1 tstone-1 commented May 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fixes Segmentation fault on worker exit #1476: workers that opened a Database and exited abruptly (uncaught throw or process.exit) would SIGSEGV during isolate teardown when better-sqlite3 was loaded only in the worker thread.
  • Root cause: every wrapper (Database, Statement, Backup, StatementIterator) inherits from node::ObjectWrap, whose V8 weak callback (ObjectWrap::WeakCallback from node_object_wrap.h) is inlined into the addon's own .node binary. When the worker isolate is the only loader, Node unloads the binary between AddEnvironmentCleanupHook firing and the final GC inside Isolate::DeinitCppHeap::StartDetachingIsolate, leaving V8 to invoke a function pointer into now-unmapped memory. The macOS crash report confirms better_sqlite3.node is absent from the loaded-image list and the faulting address sits in the unmapped gap just below libnode.
  • Fix:
    1. Track every live ObjectWrap-derived instance in a new Addon::wrappers set. Each wrapper class registers on construction and removes itself on destruction. Statement, Backup, and StatementIterator now also store an Addon* directly so their destructors can erase without dereferencing a possibly-freed parent.
    2. In Addon::Cleanup, close sqlite resources of any open databases (Database::CloseHandles() cascades into child Statement and Backup handles), then manually ClearWeak() + Reset() every wrapper still alive in Addon::wrappers. Because the set contains every constructed-but-not-destructed wrapper, this disarms every armed callback in the addon — reachable or not.

After Cleanup returns, no bsqlite weak callback is armed, so V8's later teardown GC has nothing to invoke into the about-to-be-unloaded binary.

Test plan

  • Reproduce against the issue's exact repro on Node v26, macOS arm64, better-sqlite3@12.10.0, typeorm@1.0.0: 5/5 SIGSEGV before patch, 30/30 clean worker exits after across the variants below (5 iterations × 6 scenarios).
  • Variants verified clean after patch:
    • Unreachable new Database(':memory:') followed by sync throw.
    • Unreachable new Database(':memory:') followed by process.exit(0).
    • Globally-rooted Database then throw.
    • Globally-rooted Database + Statement then throw.
    • Orphaned Statement (db.close() then throw, statement still rooted on globalThis).
    • Natural worker completion (no throw, no exit).
  • npm test → 311/311 passing on macOS arm64.

Workers that opened a Database and exited abruptly (uncaught throw or
process.exit) could segfault during isolate teardown when the addon
was loaded only in the worker thread. The crash happened inside
v8::internal::GlobalHandles::InvokeFirstPassWeakCallbacks during the
final GC that runs as part of Isolate::Deinit -> CppHeap::
StartDetachingIsolate, jumping into unmapped memory below libnode.

Root cause: every wrapper (Database, Statement, Backup,
StatementIterator) inherits from node::ObjectWrap, which arms a V8
weak callback whose function pointer is inlined into the addon's own
code segment (ObjectWrap::WeakCallback in node_object_wrap.h). When a
worker isolate is the only loader of the addon, Node may unload
(dlclose) the .node file between AddEnvironmentCleanupHook firing and
the final teardown GC. If any wrapper is still weak-armed at that
point, V8 invokes a function pointer into an already-unmapped page.

The crash report confirms this: better_sqlite3.node is absent from the
loaded-image list at crash time, and the faulting address lies in
unmapped memory just below libnode.

Fix:

1. Track every live ObjectWrap-derived instance in Addon::wrappers.
   Each wrapper class registers itself on construction and removes
   itself on destruction. Statement, Backup, and StatementIterator
   now also store an Addon* directly so their destructors can erase
   from the set without dereferencing a possibly-freed parent.

2. In Addon::Cleanup (the env cleanup hook):
   - Close sqlite resources of any open databases via CloseHandles(),
     which cascades into child Statement and Backup handles.
   - For every wrapper still alive in Addon::wrappers, manually call
     ClearWeak() + Reset() on its persistent handle. Because every
     constructed-but-not-destructed wrapper is in this set, this
     disarms every armed callback in the addon - reachable or not.

After Cleanup returns no bsqlite weak callback is armed, so V8's
later teardown GC has nothing to invoke into the about-to-be-unloaded
binary.

Verified on macOS arm64, Node v26, against the issue's exact repro:
5/5 SIGSEGV before patch, 30/30 clean exits after across 6 scenarios
(unreachable throw, unreachable process.exit, rooted Database,
rooted Statement, orphaned Statement after db.close(), natural
completion). Existing test suite still passes (311/311).

Fixes WiseLibs#1476
@tstone-1 tstone-1 force-pushed the fix-worker-exit-segfault-1476 branch from 33b149e to 7f63854 Compare May 26, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Segmentation fault on worker exit

1 participant