Skip to content

fix: N-API use-after-free during worker-env teardown, plus two VT/transaction lifecycle races (#741) - #745

Closed
kriszyp wants to merge 15 commits into
mainfrom
fix/txn-handle-cross-env-close-race
Closed

fix: N-API use-after-free during worker-env teardown, plus two VT/transaction lifecycle races (#741)#745
kriszyp wants to merge 15 commits into
mainfrom
fix/txn-handle-cross-env-close-race

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 1, 2026

Copy link
Copy Markdown
Member

Human-Review-Need: 4 @ c740d2e

Root cause found: N-API calls are unsafe inside napi_add_env_cleanup_hook

Node's Environment::RunCleanup() runs principal_realm_->RunCleanup(); cleanup_queue_.Drain();
in that order. So by the time our module's env-cleanup hook runs, Realm::RunCleanup() has
already destroyed the env's BaseObjects and freed N-API per-env state.

TransactionHandle::close() guarded its napi_delete_reference with "am I on the owning JS
thread?"
— but env teardown runs on exactly that thread, so the guard passed and the call
wrote through a freed env (napi_delete_referencenapi_clear_last_error writes last_error).
That corrupts glibc heap metadata; the abort then surfaces later and somewhere unrelated — usually
inside RocksDB's own allocators (~Arena / ~WriteBatchWithIndex / ~TransactionBaseImpl), which
is why this read as a RocksDB/VT bug for so long.

Confirmed with ThreadSanitizer against a from-source TSan Node — three heap-use-after-free writes
in napi_clear_last_error, reached from TransactionHandle::close()DBDescriptor::finishClose()
DBRegistry::Shutdown() ← our cleanup hook.

Fix: src/binding/napi/env_teardown.h — a thread-local EnvTeardownScope set for the duration
of the cleanup hook; close paths check isEnvTearingDown() before making N-API calls. During
teardown the reference is reclaimed with the env anyway, so skipping is correct.

Measured effect

repro-crossthread.mjs (GRACEFUL=1 RECYCLE_MS=2000, 4 workers, 15s, ~1M transactions/run), all in
one worktree with runs verified to have actually done work:

build result
baseline ea83ff46 9/12 crashed
before this commit 8/12 crashed
with the fix 0/12 crashed

Still clean 8/8 at a harsher 800ms recycle. TSan goes from 3 races to 0.

Also included: two VT/transaction lifecycle races

  1. TOCTOU on cross-env close() vs. in-flight async commitclose() could delete txn while
    a commit that registered after waitForAsyncWorkCompletion() saw zero work was still using it.
    Fixed with a stateMutex, with close() flipping closed under it before draining.
  2. A parked coordinated-retry wake-callback's TSFN outliving its env — fixed with
    ParkedFlagRegistry, mirroring the existing commitCompletions/ReleaseCommitCompletionsByEnv
    per-env teardown pattern.

Both were confirmed against gdb backtraces matching the issue's signatures. Note these two do not
move the repro's crash rate on their own (bisected) — the UAF above was what that repro was hitting.

Known remaining (not root-caused)

test/vt-lock-tracker-churn.test.ts still fails ~25% (1/4 reps at 4000ms, 1/6 at 1500ms) with the
same corrupted size vs. prev_size, so it stays it.skip. It reproduces only through that
fixture's tsx-transpiled-worker path, never through the plain-.mjs repro, and TSan does not catch
it. Also still open: getSync/putSync/removeSync/CommitSync/getCount remain outside
tryRegisterAsyncWork() (AGENTS.md item 10a).

Because of that residual, I'd suggest keeping #741 open after this merges, scoped to what's left.

Notes for reviewers

  • db_handle.cpp has further napi_delete_reference sites reachable from
    DBRegistry::Shutdown()
    that this repro didn't exercise. They likely need the same
    isEnvTearingDown() guard — I left them alone rather than change code I couldn't test.
  • AGENTS.md item 12 documents the root cause, the working TSan-Node recipe (needs
    -DTHREAD_SANITIZER; V8_IS_TSAN must stay off or V8 won't compile under
    v8_enable_sandbox=0), why ASan is structurally blind here, and a methodology warning about
    false-negative baseline runs that cost me significant time this session.
  • binding.gyp gains a ROCKSDB_TSAN=1 toggle alongside the existing ROCKSDB_ASAN.

Test coverage

Full Vitest suite 710 passed / 2 skipped; native GoogleTest 104/104.

Refs #741

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses critical race conditions and use-after-free (UAF) vulnerabilities under worker-environment churn (#741). It introduces TransactionHandle::stateMutex to serialize transaction and verification table (VT) lock states, implements tryRegisterAsyncWork to safely track active operations, and adds a ParkedFlagRegistry to prevent wake-callback thread-safe functions (TSFNs) from outliving their parent environments. The review feedback highlights a potential null-pointer dereference in TransactionHandle::getCount if called on a closed transaction with an active snapshot, and suggests refactoring the manual erase-remove idiom in ParkedFlagRegistry to use C++20's cleaner std::erase_if helper.

Comment thread src/binding/transaction/transaction_handle.cpp
Comment thread src/binding/transaction/transaction.cpp
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

get-sync.bench.ts

getSync() > random keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 24.29K ops/sec 41.16 39.84 524.603 0.110 121,465
🥈 rocksdb 2 10.90K ops/sec 91.78 87.63 31,565.725 1.25 54,481

getSync() > sequential keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.59K ops/sec 34.97 33.90 291.961 0.098 142,969
🥈 rocksdb 2 11.41K ops/sec 87.68 84.97 665.966 0.050 57,028

ranges.bench.ts

getRange() > small range (100 records, 50 range)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 25.51K ops/sec 39.20 36.09 2,132.183 0.301 127,549
🥈 rocksdb 2 16.26K ops/sec 61.50 51.78 1,111.341 0.112 81,300

realistic-load.bench.ts

Realistic write load with workers > write variable records with transaction log

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 381.59 ops/sec 2,620.612 120.671 26,521.227 9.98 764
🥈 lmdb 2 26.21 ops/sec 38,159.973 448.844 1,221,599.44 137.387 64.00

transaction-log.bench.ts

Transaction log > read 100 iterators while write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 37.36K ops/sec 26.76 12.21 13,921.566 0.603 186,814
🥈 lmdb 2 441.53 ops/sec 2,264.861 175.953 25,103.375 1.51 2,208

Transaction log > read one entry from random position from log with 1000 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 763.59K ops/sec 1.31 1.13 472.468 0.062 3,817,965
🥈 lmdb 2 440.85K ops/sec 2.27 1.18 786.417 0.279 2,204,234

worker-put-sync.bench.ts

putSync() > random keys - small key size (100 records, 10 workers)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 843.75 ops/sec 1,185.183 1,016.032 2,327.873 0.369 1,688
🥈 lmdb 2 1.18 ops/sec 845,601.932 783,971.211 919,774.835 3.40 10.00

worker-transaction-log.bench.ts

Transaction log with workers > write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 23.55K ops/sec 42.46 29.22 626.937 0.590 47,103
🥈 lmdb 2 805.57 ops/sec 1,241.353 72.87 14,739.884 5.67 1,614

Results from commit 85676cd

@kriszyp

kriszyp commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Update on the blocking finding: spent another round investigating per Kris's request, ruling out three hypotheses and confirming ThreadSanitizer isn't practical here without a from-source Node build. Full detail in AGENTS.md item 12 (substantially rewritten in c86e78e).

Ruled out this round:

  1. VT's writerMutex_ held across N-API wake-callback calls — moving wake() outside the lock didn't help.
  2. Un-closed/leaked TransactionHandles inflating finishClose()'s teardown backlog — this leak is real (confirmed via debug-log correlation: completeCommitWork's non-IsBusy rejection path never calls close()), but patching the repro script to always abort() on rejection didn't reduce the crash rate.
  3. Raw TransactionHandle::close() latency — tested decisively by injecting an artificial delay into close() on unmodified baseline code via the existing ROCKSDB_JS_TXN_CLOSE_DELAY_MS seam. Clean 20/20 at both 10ms and 100ms delays. This rules out "the fix just makes close() slower, widening some pre-existing window" as the explanation.

ThreadSanitizer: wired up (ROCKSDB_TSAN=1 in binding.gyp), but running the repro under it produces 100-150+ races per run that all resolve to pure V8-internal GC/JIT machinery (confirmed via addr2line) — a known limitation of running vanilla Node under TSan without Node's own build-time instrumentation. A real attempt needs Node built from source with -fsanitize=thread.

Net: the regression traces to something in this fix's actual new logic (the stateMutex critical sections or ParkedFlagRegistry), not simply added latency in the teardown path. Still unresolved — still recommend not merging until it is.

🤖 Generated with Claude Code

@kriszyp

kriszyp commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Retraction of my earlier comment on this PR.

The comment above claiming this fix converts the repro from "15/15 clean" to "14/15 crashing" is
wrong, and I've rewritten the PR description accordingly.

The baseline arm in that comparison was a false negative. It ran in a separate worktree where
pnpm install had run but pnpm build:bundle had not, so dist/index.mjs didn't exist. Every
worker failed with Cannot find module, did zero work, and the harness still printed
RESULT: no stuck commit and exited 0. All ~35 "clean baseline" data points — including the
10ms/100ms ROCKSDB_JS_TXN_CLOSE_DELAY_MS runs I used to "decisively rule out close() latency" —
were no-op runs.

Re-measured in a single worktree (identical RocksDB prebuild, node_modules and build config; only
src/ differing; competing build paused; each run verified to have actually issued ~580k
transactions):

  • baseline ea83ff46: 9/12 crashed
  • this PR: 8/12 crashed

So the fix neither causes nor fixes that crash, and the crash matches the 7/12 rate #741 itself
reports. The real conclusion is less dramatic but more useful: this PR closes two genuine races
but does not resolve #741's headline repro
, so #741 should stay open.

Lesson recorded in AGENTS.md item 12: a green result from these repro scripts is only meaningful
if the run actually did work — check per-worker issued= counts and grep for Cannot find module
before trusting any before/after comparison, and compare revisions within one worktree rather than
across worktrees.

Apologies for the noise from the incorrect earlier analysis.

🤖 Generated with Claude Code

@kriszyp kriszyp changed the title fix(transaction): close TOCTOU and parked-TSFN races on cross-env teardown (#741) fix: N-API use-after-free during worker-env teardown, plus two VT/transaction lifecycle races (#741) Aug 2, 2026
@kriszyp

kriszyp commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Pre-push review round (DLC step 10) — outcome and what changed.

independent-review: FAILED — codex leg timed out (rc=-1 after 973s); same-family fallback skipped for budget. Gemini + Grok legs did produce findings and were acted on manually, but per the skill's rules this run does NOT count as independent coverage.

Both non-Claude legs independently flagged one thing I had only noted rather than fixed, so I fixed it:

  • DBHandle::close() had the same teardown UAF (Grok, major). It releases transaction-log JS refs via napi_delete_reference and is reachable from DBRegistry::Shutdown() inside the env cleanup hook — the same shape as the TransactionHandle path. Now guarded with isEnvTearingDown() (b3e8130d). Only the close() loop is guarded; unrefLog()/useLog() are called from live JS, not teardown.
  • Comment verbosity (both legs, nit): trimmed the comments added for this fix down to the invariant they encode, per repo convention.

Findings I did not act on, with reasons:

  • CommitSync/getSync/putSync/removeSync/getCount bypass tryRegisterAsyncWork() (Gemini: blocker; Grok: major). Real, but pre-existing on main — not introduced here — and documented as a deliberately deferred gap in AGENTS.md item 10a. An earlier attempt to close it on CommitSync reproduced a separate heap-corruption regression that wasn't root-caused, so it needs its own change with its own verification rather than being appended to this one.
  • waitForAsyncWorkCompletion()'s 5s timeout (both legs, major). Also pre-existing and unchanged by this PR (item 10b). Removing it trades a UAF risk for a shutdown-hang risk; that's a design call worth making deliberately, not here.
  • lockVTSlot now takes stateMutex on the VT write path (Gemini major / Grok minor, perf). Accepted, deliberate: it is the lock that makes the intent vectors safe against a concurrent cross-env close(). The hottest paths (getSync/putSync/removeSync themselves) were explicitly left unlocked for exactly this cost reason.
  • The churn test is it.skip (both legs, major). Fair criticism and I agree it's a real coverage gap — but it still fails ~25% on a residual crash this PR doesn't fix (unchanged at 2/6 after the db_handle guard), and shipping it enabled would make CI flaky for an unrelated reason.

Post-fix verification: repro-crossthread.mjs 0/10, full suite 709 passed / 3 skipped, native 104/104.

🤖 Generated with Claude Code

kriszyp and others added 9 commits August 4, 2026 17:04
…rdown

DBDescriptor::finishClose() (worker-env teardown, e.g. via
DBRegistry::Shutdown()) closes every TransactionHandle registered on a
shared descriptor -- including ones owned by a different, still-live env
that may be mid-commit, mid-put, or mid-abort at that exact moment. Two
independent races there produced the reported heap corruption:

1. close()'s waitForAsyncWorkCompletion() only waited for async work
   already registered on activeAsyncWorkCount -- a TOCTOU let a racing
   Commit()/CommitSync/Abort register (or start touching txn) after
   close() had already observed zero in-flight work and moved on to
   delete this->txn and releaseIntent()'s vector mutation. Fixed by
   serializing txn/lockedVTSlots/heldTrackers access with a new
   TransactionHandle::stateMutex: close() flips `closed` under the
   mutex before waiting, so no new work can register once it starts
   waiting for what already did.

2. A coordinated-retry commit that loses (IsBusy) parks its RETRY_NOW
   resolution on the winning holder's LockTracker via a wake callback
   that captures a napi_threadsafe_function by value. LockTracker is
   process-global VT state, so that callback can fire on any thread an
   arbitrary time later -- including after the parking transaction's
   own env has torn down and Node has reclaimed the tsfn, making the
   callback's napi_release_threadsafe_function call a use-after-free
   (confirmed via gdb: uv_mutex_lock aborting inside it, matching the
   issue's crash trace exactly). Fixed with a ParkedFlagRegistry,
   mirroring DBDescriptor's per-env commitCompletions pattern: env
   cleanup invalidates that env's outstanding parked flags before Node
   frees its tsfns, and the wake callback checks the same flag under a
   shared mutex before touching the tsfn.

Also adds holders/refcount underflow assertions in
VerificationTable::releaseWriteIntent/unrefTracker as a hard invariant
check, and a worker_threads churn regression test (VT materialized,
coordinatedRetry, graceful env recycling) adapted from the investigation's
proven repro scripts.

Verified against the original repro-vt-stress.mjs / repro-crossthread.mjs
scripts (graceful and abrupt teardown, up to 300k+ operations) and under
AddressSanitizer: no crash, no ASan report, all settled.

Fixes #741

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Findings from the Gemini + Grok legs of the pre-push review:

- Upgrade the holders/refcount underflow checks from assert() (a no-op in
  NDEBUG/release builds -- exactly where the invariant matters most) to an
  always-on vtInvariant() that logs and aborts. A silent wraparound would
  strand a locked VT slot and mask the corruption this fix targets.
- tryRegisterAsyncWork() now also atomically gates TransactionHandle::get()
  (async), closing the same TOCTOU already fixed for Commit/CommitSync/Abort.
- Fix a leaked activeAsyncWorkCount registration when napi_queue_async_work
  fails after tryRegisterAsyncWork() succeeds in the legacy commit path --
  would have hung close()'s waitForAsyncWorkCompletion() indefinitely.
- close() no longer early-returns on a null txn before running
  waitForAsyncWorkCompletion()/releaseIntent() -- every step now self-guards
  instead of being skipped as a block, in case async work is still
  registered against a handle whose txn happens to be transiently null.
- Amortize ParkedFlagRegistry::registerFlag()'s prune sweep (only run it
  when the per-env vector is about to reallocate) instead of an O(n) scan
  under the process-global lock on every single park.
- Trim repeated inline "(#741)" citations down to the
  handful of canonical doc comments; the rest now explain the same
  constraints without repeating the issue number as narration.
- Exercise CommitSync in the churn regression test (previously only
  Commit/Abort were covered).
- Document two deliberately-deferred gaps in AGENTS.md: putSync/getSync/
  removeSync still don't route through tryRegisterAsyncWork() (hot-path
  cost not accepted without dedicated review) and the pre-existing 5s
  waitForAsyncWorkCompletion() timeout can still let close() proceed past
  a genuinely slow (not parked) commit.

Re-verified: native GoogleTest suite (104/104), full Vitest suite
(710/710), and the original repro-vt-stress.mjs script (20s/4 workers)
under both a plain build and AddressSanitizer -- clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…est, document critical unresolved teardown regression

CommitSync() and getCount() are reverted to their pre-review-round-2
state (no tryRegisterAsyncWork()/AsyncWorkGuard): adding that
registration reproduced a separate heap-corruption regression under
worker-churn testing that was not root-caused in time; both are
documented in AGENTS.md item 10a as deliberately deferred.

The vt-lock-tracker-churn Vitest regression test is skipped by
default: even tuned to a low-churn cadence it intermittently hits a
severe, unrelated crash (see AGENTS.md item 12) and would make CI
flaky for a reason outside this fix's scope. The fixture and worker
script are kept for manual reproduction.

AGENTS.md item 12 documents a CRITICAL, UNRESOLVED finding from this
round's testing: comparing the pre-fix commit against every tested
version of this fix on the project's own repro-crossthread.mjs
(GRACEFUL=1, same settings) shows 15/15 clean at baseline vs. 14/15
crashing with the fix applied. Two gdb backtraces land in the
pre-existing (unmodified by this fix) DBDescriptor::finishClose()
closables-teardown loop, not in this fix's own new code paths. This
needs dedicated follow-up (ThreadSanitizer is the likely next tool)
before this fix should be merged.

Refs #741

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…up with ruled-out hypotheses

Adds a ThreadSanitizer build toggle to binding.gyp (ROCKSDB_TSAN=1,
mirroring the existing ROCKSDB_ASAN one) as diagnostic infrastructure
for investigating AGENTS.md item 12.

Updates item 12 with this round's findings: three hypotheses were
tested and ruled out (writerMutex_ held during VT wake callbacks;
un-closed/leaked transactions inflating the finishClose() closables
backlog -- real, but not the cause; and raw TransactionHandle::close()
latency, tested decisively via the existing ROCKSDB_JS_TXN_CLOSE_DELAY_MS
seam against unmodified baseline code at 10ms and 100ms, both clean
20/20). Running the repro under TSan was attempted but is not
currently practical: every race across several runs resolves to pure
V8-internal GC/JIT machinery with zero rocksdb_js:: frames, a known
limitation of running vanilla Node under TSan without Node's own
build-time instrumentation.

Refs #741

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…false-negative baseline

The earlier conclusion that this fix converts repro-crossthread.mjs from
15/15 clean to 14/15 crashing was wrong. The baseline arm ran in a separate
worktree where pnpm install had run but pnpm build:bundle had not, so
dist/index.mjs did not exist: every worker died on "Cannot find module", did
zero work, and the harness still printed "RESULT: no stuck commit" and exited
0. All ~35 "clean baseline" data points -- including the
ROCKSDB_JS_TXN_CLOSE_DELAY_MS runs used to rule out close() latency -- were
no-op runs.

Re-measured within one worktree (identical RocksDB prebuild, node_modules and
build config, competing build paused, each run verified to have issued ~580k
transactions): baseline ea83ff4 9/12 crashed, this fix 8/12 crashed. The fix
neither causes nor fixes that crash, and the rate matches the 7/12 reported in
the issue.

Rewrites AGENTS.md item 12 accordingly: the real finding is that this fix
closes two genuine races but does NOT resolve #741's headline repro. Also
records the methodology warning (verify a repro run actually did work before
trusting a green result; compare revisions in one worktree), the ASan blind
spot from the non-instrumented prebuilt librocksdb.a, and that a from-source
TSan Node needs -DTHREAD_SANITIZER but must leave V8_IS_TSAN off.

Refs #741

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…scripts

The repo's own test/fixtures/*.mts already fail correctly on a worker
startup error (worker.once('error', reject)); only the ad-hoc scripts
under ~/dev/tmp/harper-2001-repro/ swallow it and still exit 0.

Refs #741

Co-Authored-By: Claude Opus <noreply@anthropic.com>
… dominant crash

Node's Environment::RunCleanup() runs principal_realm_->RunCleanup()
*before* cleanup_queue_.Drain(), so by the time our module's env-cleanup
hook runs, Realm cleanup has already destroyed the env's BaseObjects and
freed N-API per-env state.

TransactionHandle::close() guarded its napi_delete_reference call with
"am I on the owning JS thread?" -- but env teardown runs on exactly that
thread, so the guard passed and the call wrote through a freed env
(napi_delete_reference -> napi_clear_last_error writes last_error).
That corrupts glibc heap metadata; the abort then surfaces later and
elsewhere, usually inside RocksDB's own allocators (~Arena /
~WriteBatchWithIndex / ~TransactionBaseImpl), which is why this read as
a RocksDB/VT bug.

Confirmed with ThreadSanitizer against a from-source TSan Node: three
heap-use-after-free writes in napi_clear_last_error, reached from
TransactionHandle::close() <- DBDescriptor::finishClose() <-
DBRegistry::Shutdown() <- our cleanup hook.

Adds napi/env_teardown.h (thread-local EnvTeardownScope +
isEnvTearingDown()), scopes the cleanup hook with it, and makes the
close path consult it. During teardown the reference is reclaimed with
the env anyway, so skipping is correct.

Effect on repro-crossthread.mjs (GRACEFUL=1 RECYCLE_MS=2000, 4 workers,
15s, ~1M transactions/run): 9/12 crashing -> 0/12. Still clean 8/8 at a
harsher 800ms recycle. TSan goes from 3 races to 0.

test/vt-lock-tracker-churn.test.ts stays skipped: it still fails ~25%
via its tsx-transpiled-worker path with the same signature, which is a
separate, not-yet-root-caused issue (documented in AGENTS.md item 12
along with the TSan-Node recipe and a methodology warning about
false-negative baseline runs).

Full suite 710 passed / 2 skipped; native 104/104.

Refs #741

Co-Authored-By: Claude Opus <noreply@anthropic.com>
… teardown

Second instance of the bug class fixed in the previous commit, flagged
independently by the Grok review leg: DBHandle::close() releases its
transaction-log JS references with napi_delete_reference, and is
reachable from DBRegistry::Shutdown() inside the env cleanup hook -- so
on last-env exit (or a worker unloading the addon) with open
transaction-log handles it writes into freed N-API env state, the same
delayed-heap-abort shape as the TransactionHandle path.

Guarded with isEnvTearingDown(); the refs are reclaimed with the env.
Only the close() loop is guarded -- unrefLog()/useLog() are called from
live JS, not from teardown.

Also trims the comments added in the previous commit down to the
invariant they encode, per repo convention (no narration of mechanics
or history) -- raised by both review legs.

repro-crossthread.mjs still 0/10; full suite 709 passed / 3 skipped;
native 104/104.

Refs #741

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
@kriszyp
kriszyp force-pushed the fix/txn-handle-cross-env-close-race branch from b3e8130 to c740d2e Compare August 4, 2026 23:27
@kriszyp
kriszyp marked this pull request as ready for review August 13, 2026 18:09
@kriszyp
kriszyp requested a review from cb1kenobi as a code owner August 13, 2026 18:09
@cb1kenobi

Copy link
Copy Markdown
Member

I have fixed the merge conflicts, but I have some reservations about this PR.

The description states "N-API calls are unsafe inside", but that's false. This PR adds the env-teardown guard and mutexes, and that's cool. But the friction is this is a 947-line three-race fix with an unresolved residual crash, a skipped test, and its only real evidence living outside the repo.

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Comment thread src/binding/transaction/transaction_handle.cpp Outdated
// (0 races over 3 aggressive runs -- TSan's ~15x slowdown likely closes the
// window). It is NOT root-caused, so enabling this would make CI flaky for
// a reason this PR does not address. See AGENTS.md item 12.
it.skip(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: three teardown fixes ship with no runnable regression coverage

This is the only test the PR adds and it's it.skip, because the ~25% residual crash it exercises is not root-caused. The evidence cited for the dominant fix (repro-crossthread.mjs, "9/12 → 0/12") is not committed to the repo, so neither a reviewer nor CI can reproduce it. The stateMutex, ParkedFlag, and env-teardown fixes have no passing automated test — nothing here catches a regression.

Suggested fix: commit the repro-crossthread.mjs scenario as an opt-in/long-form spawned-child test. The existing ROCKSDB_JS_TXN_CLOSE_DELAY_MS / ROCKSDB_JS_COMMIT_DELAY_MS seams make the close-vs-commit race deterministic without relying on the flaky fixture — at minimum, don't leave all three fixes uncovered.


Generated by Barber AI

/**
* True while the calling thread is inside its env's cleanup hook.
*
* Node runs `Realm::RunCleanup()` — which frees N-API per-env state — before

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: the documented root cause doesn't match Node's teardown ordering

Checked against Node source: Realm::RunCleanup() only resets binding-data and BaseObjects — it does not free the napi_env struct where last_error lives. The napi_env's own teardown (UnrefDeleteMe) is itself a cleanup-queue hook registered at env creation, and the queue drains in reverse (LIFO) order, so it runs after this module's hook. For the env being torn down, last_error is still valid memory when the hook runs.

A use-after-free write to last_error therefore implies the reference belongs to a different, already-destroyed worker env — i.e. the cross-env lifetime problem the stateMutex/ParkedFlag changes address, not "the realm freed my own env's N-API state." The isEnvTearingDown() guard is cheap and safe, so keep it — but please reconcile this explanation against the actual TSan/gdb trace (is the crashing env the current thread's, or foreign?) before enshrining it in AGENTS.md item 14, and don't treat #741 as closed on this basis.


Generated by Barber AI

// Skip during env teardown: this is reachable from DBRegistry::Shutdown()
// inside the env cleanup hook, by which point Node has already freed the
// env's N-API state and the refs go with it (napi/env_teardown.h).
if (!isEnvTearingDown()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: sibling napi_delete_reference sites left unguarded by this fix's own logic

This guard is correct, but napi_delete_reference is still called unconditionally in DBHandle::unrefLog() (line 371) and DBHandle::useLog() (line 392). The PR description itself notes db_handle.cpp "has further napi_delete_reference sites reachable from DBRegistry::Shutdown()" that were left alone — by this fix's own reasoning those are latent heap corruption during teardown.

Suggested fix: either wrap those sites in the same isEnvTearingDown() check, or confirm and document that they are unreachable from DBRegistry::Shutdown(). Shipping a self-documented reachable UAF defers the crash rather than fixing it.


Generated by Barber AI

@cb1kenobi

Copy link
Copy Markdown
Member

Closing this PR in favor of #780.

@cb1kenobi cb1kenobi closed this Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants