Repository navigation
fix(usb): stop finalizer from dereferencing tsfn-owned callbackRef during shutdown (#790) - #791
Merged
Merged
Conversation
) The user-shared-buffer finalizer read finalizeData->callbackRef via napi_get_reference_value during env/process teardown. That napi_ref's ownership was transferred to the listener's threadsafe function in addListener (the tsfn finalizer deletes it), so once the listener was torn down at shutdown (removeListener / removeListenersByOwner / removeListenersByEnv) the finalizer dereferenced a freed ref -- a use-after-free that SIGSEGV'd Harper on ctrl-c shutdown (macOS). Remove the listener by identity instead: addListener now returns the ListenerCallback shared_ptr, the finalize data holds a weak_ptr to it, and a new env-free EventEmitter::removeListener(key, target) overload releases the tsfn and erases the entry without ever touching the napi_ref. A live weak_ptr means the listener is still registered; if it was already removed (close runs removeListenersByOwner before descriptor.reset()), the lock fails and there is nothing to do. Adds a macOS Guard-Malloc-gated child-process repro that SIGSEGVs before the fix and passes after. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-model review follow-ups: remove the generated RocksDB directories after each shutdown-repro iteration (honoring KEEP_FILES) so runs don't accumulate, and drop the overbroad "live weak_ptr means still in the map" claim from the finalizer comment (the ownership invariant lives on UserSharedBufferFinalizeData; removeListener is a safe no-op if gone). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Code Review
This pull request resolves a shutdown use-after-free (UAF) vulnerability on macOS related to user-shared buffers by replacing raw napi_ref handles with std::shared_ptr<ListenerCallback> and std::weak_ptr<ListenerCallback> references. This allows the finalizer to safely remove listeners by identity without dereferencing deleted references. Additionally, a regression test suite has been introduced to verify the fix. The review feedback suggests improving the implementation by using std::find instead of a manual loop in EventEmitter::removeListener and enforcing const-correctness by passing the key parameter as const std::string& in DBDescriptor::removeListener.
Contributor
📊 Benchmark Resultsget-sync.bench.tsgetSync() > random keys - small key size (100 records)
getSync() > sequential keys - small key size (100 records)
ranges.bench.tsgetRange() > small range (100 records, 50 range)
realistic-load.bench.tsRealistic write load with workers > write variable records with transaction log
transaction-log.bench.tsTransaction log > read 100 iterators while write log with 100 byte records
Transaction log > read one entry from random position from log with 1000 100 byte records
worker-put-sync.bench.tsputSync() > random keys - small key size (100 records, 10 workers)
worker-transaction-log.bench.tsTransaction log with workers > write log with 100 byte records
Results from commit 9d268f8 |
…ener Address Gemini review comments on #791: replace the manual find-and-erase loop in EventEmitter::removeListener(key, target) with std::find, and take the key by const std::string& (matching EventEmitter's signature; the key is never mutated). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #790.
Problem
Sending
ctrl-cto quit Harper crashed during shutdown (SIGSEGV) on macOS. The faulting frame:getUserSharedBuffer(key, buf, { callback })registers a listener whosenapi_refownership is transferred to the listener's threadsafe function inaddListener(the tsfn finalizernapi_delete_references it). The buffer'sUserSharedBufferFinalizeDataalso stashed that same raw ref as a borrowed pointer. At shutdown the listener is torn down (removeListenersByOwnerondb.close(), orremoveListenersByEnvon env teardown) → the tsfn finalizer deletes the ref → thenuserSharedBufferFinalizecallsnapi_get_reference_valueon the freed ref: a use-after-free.On a plain build the freed slot usually reads back benign, so it only bit intermittently in production. It needs no worker or GC — a plain main-thread
db.close()+ natural process exit reproduces it, because the ref lifetime belongs to the tsfn, not the finalizer.Fix
Remove the listener by identity instead of re-resolving a ref the finalizer doesn't own:
EventEmitter::addListenernow returns thestd::shared_ptr<ListenerCallback>it already allocates (wasnapi_ref).UserSharedBufferFinalizeDataholds astd::weak_ptr<ListenerCallback>instead of the rawnapi_ref.EventEmitter::removeListener(key, target)releases the tsfn (which queues the ref deletion on the JS thread, as before) and erases the map entry — without touching the napi_ref.The finalizer locks the weak_ptr and, only if the listener is still registered, removes it by identity. If it was already torn down (
db.close()runsremoveListenersByOwnerbeforedescriptor.reset()), the lock fails and there's nothing to do. The map holds the sole strong ref to theListenerCallback, and close/env-teardown/finalizer all run on the same env thread, so there's no cross-thread lifetime race.The
buffer.cancel()/db.removeListener(key, fn)path (removal by function value) and the "cleanup callbacks on GC" behavior are unchanged.Testing
test/user-shared-buffer-shutdown.test.ts+ fixture): opens a DB, gets user-shared buffers with callbacks,db.close(), keeps them referenced, and exits naturally soRunCleanupfinalizes them. SIGSEGVs before this change, passes after (verified 20+ runs under Guard Malloc across three repro variants). Gated to macOS because a plain build reads benign freed memory and can't fault deterministically.pnpm check(type-check, lint, format) is clean.Reviews
Deep review (two independent passes) and cross-model review (Codex + Gemini): no crash-safety, concurrency, lifetime, or security blockers. Follow-ups addressed here: repro DB dirs are now cleaned up (honoring
KEEP_FILES), and an overbroad finalizer comment was tightened. Two Gemini "majors" were false positives (thestore.tslayer unwrapsoptions.callbackbefore native;.mtstype-stripping is unflagged at the repo's engines floor and existing fixtures spawn identically — the test runs green).Pre-existing follow-ups (out of scope, noted for tracking)
getUserSharedBufferallocatesfinalizeDatabeforenapi_create_external_arraybuffer; on that (rare) failure it leaks. RAII cleanup would tidy it.defaultBufferisn't an ArrayBuffer, the listener is registered before the validation throw, so it lingers untilclose().🤖 Generated with Claude Code