fix: close pending transactions with their owning DBHandle; napi-free TransactionHandle::close (#741) - #780
fix: close pending transactions with their owning DBHandle; napi-free TransactionHandle::close (#741)#780cb1kenobi wants to merge 11 commits into
Conversation
…#741) Worker envs that exit with a PENDING transaction leak their TransactionHandle: transactionAdd stores a strong shared_ptr in the process-global DBDescriptor, and the JS finalizer only drops the JS-side ref -- only commit/abort ever call transactionRemove. The leaked handle (dangling env, weak napi refs) then crashes env teardown in Node's second-pass napi finalizer drain. Measured on macOS/Node 24.16 under Guard Malloc, 10 leakers: main @ 3ea9a0f: 4/4 SIGSEGV (0 leakers: 4/4 clean) PR #745 @ 1fc79f5: 5/6 SIGSEGV -- the #745 guards do not cover this Repro test stays it.skip until the leak is fixed; the 0-leaker control runs in CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ctionHandle::close napi-free Two changes toward #741: 1. DBDescriptor::releaseByOwner(DBHandle*) — one entry point releasing everything a handle registered on the shared descriptor: listeners, pending transactions (new closeTransactionsByOwner), and locks. Pending transactions were only removed from the registry by commit/abort, so a handle closing with one still open (explicit db.close(), GC, worker-env teardown) leaked the TransactionHandle -- and its live RocksDB transaction + snapshot -- into the process-global descriptor until process-wide shutdown. 2. TransactionHandle no longer holds env/jsDatabaseRef/envThreadId. The JS database is passed to UseLog per-call by the TS layer (its only consumer), so close() is now napi-free and safe from any thread or teardown phase. NOT fixed: the lingering-txn-shutdown repro still SIGSEGVs under Guard Malloc (identical stack before/after every addon-side change tried): a worker exiting with a pending transaction crashes Node's second-pass napi finalizer drain (EnqueueFinalizer lambda) during that worker's own RunCleanup. The trigger is 'pending transaction existed at worker exit'; committed transactions are clean. Suspected Node-core teardown ordering bug; needs a from-source Node or upstream minimal repro to confirm. The repro test stays it.skip; the no-leak control runs in CI. Full vitest suite: 753 passed; the 4 failures (db-options x2, shutdown fork, readonly child-process) are pre-existing timeouts reproduced on pristine main in the same environment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…he fix Linux/glibc (node:24 arm64, podman): main aborts 10/10 with the #741 production signatures (corrupted size vs. prev_size, corrupted double-linked list, free(): invalid pointer, malloc_consolidate()); with releaseByOwner + napi-free close: 10/10 clean incl. MALLOC_PERTURB_. macOS keeps skipIf(darwin): a Guard-Malloc-only fault in Node's second-pass napi finalizer drain persists even with the fix and never reproduces natively or on glibc — tracked as a separate artifact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…741) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request addresses a critical native heap corruption issue (#741) caused by pending transactions outliving their owning DBHandle during worker environment teardown. The fix introduces DBDescriptor::releaseByOwner to clean up all registered resources in one sweep and refactors TransactionHandle to be napi-free by passing the JS database per-call to UseLog. Feedback on these changes highlights a potential null pointer dereference in Transaction::UseLog when called on a closed transaction, which should be guarded against. Additionally, the jsDatabase parameter in NativeDatabase::useLog should be made optional to preserve backward compatibility for existing TypeScript callers.
📊 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 125de3e |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ot DBHandle::close Rework of the releaseByOwner approach: reaping at DBHandle::close() broke the legitimate close-then-commit overlap — db.transaction() awaits its callback, so a commit is routinely one microtask behind db.close() and must still run. Reaping there rejected those commits with 'Database not open' and, under Deno's scheduling, stranded the caller's commit promise (txn-close-commit-uaf timed out on all three Deno CI platforms). The reap now lives in DBRegistry::CloseTransactionsByEnv -> DBDescriptor::closeTransactionsByEnv, wired into the module's per-env cleanup hook: it runs on the dying env's own thread while the env is valid, where no commit continuation can exist. DBHandle::close() is back to main's shape with a comment explaining why it must not reap. Validated: vitest-deno txn-close-commit-uaf back to main-parity (12.5s pass x2, was 60s timeout); node suite green locally; AGENTS.md item 12 documents the microtask hazard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A closed transaction has txn/dbHandle reset, and a transaction whose database handle was closed keeps dbHandle but loses its descriptor; UseLog dereferenced both unguarded (pre-existing on main, flagged by review on #780). Throws 'Transaction is closed' instead of crashing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kriszyp
left a comment
There was a problem hiding this comment.
Review outcome: changes needed before merge
#780 is the right narrow replacement for the dominant reproduced #741 failure: it reaps pending transactions at environment exit and removes the transaction-owned N-API reference. At head 630a3efb, two correctness problems still block that fix:
- Environment cleanup can destroy a transaction while native work is active. The cleanup path calls
close(), which mutatesstatebefore waiting and, after a five-second timeout, releases log/VT state and deletestxneven if RocksDB still uses it. The inline comment now states the required ordering: prevent new acquisition, drain already-acquired native work definitively, then mutate or destroy state. #784 tracks extending that invariant across every entry point. useLog()can bind through a foreign database object. Because the new argument is checked only as an object, a transaction from database B can be paired with database A; descriptor-local transaction IDs commonly collide, so a log entry can silently attach to an unrelated transaction. The binding must validate exact DBHandle ownership before changing log state.
The native type declarations, committed-worker control, and unreferenced VT churn fixtures also need the inline corrections so the API contract and regression evidence match the implementation.
The GC-retained pending-transaction problem is real but can remain outside this PR now that it is tracked in #785. The omitted #745 lifecycle scope is durable under #741:
- #783 — parked coordinated-retry TSFNs outliving their environment
- #784 — complete
TransactionHandleclose/work synchronization - #785 — GC-abandoned pending transactions
- #786 — remaining
DBHandleN-API references reachable from shutdown - related: #746 — Deno/macOS commit-teardown mutex crash
Accordingly, #780 should continue to use Refs #741: merging it must not close #741 or be described as fully superseding #745. #745 can stay closed as the obsolete implementation vehicle.
Reviewed head: 630a3efbc67d7705638204dfdafffc02cfca9d78. git diff --check passes.
— KrAIs (Codex)
…st controls - UseLog now unwraps the supplied NativeDatabase and requires it to own the transaction (exact DBHandle match), checked BEFORE the log-store bind so a mismatch cannot leave pendingTransactionCount incremented. Transaction ids are descriptor-local and collide across databases, so binding through a foreign database could silently attach entries to an unrelated transaction — a regression introduced when the transaction stopped holding its own napi_ref to the database. - load-binding.ts: the jsDatabase argument was required on NativeDatabase and optional on NativeTransaction — inverted relative to runtime. Swapped. - lingering-txn-shutdown: the '0 leakers' control spawned only anchor+final and exercised no transaction churn. Replaced with 10 sequential 'committer' workers: identical topology and NativeTransaction lifecycle, differing only in that commit removes each transaction from the registry. - Removed test/fixtures/fork-vt-lock-tracker-churn.mts and test/workers/vt-lock-tracker-churn-worker.mts: unreferenced by any test or script on this branch, and their docs cited tryRegisterAsyncWork(), which does not exist here. - AGENTS.md item 12: rewrapped the two paragraphs whose inline code spans straddled a line break. Indenting those continuations (as suggested) would have inserted spaces inside the code spans and oxfmt strips it; rewrapping gives uniform indentation and correct rendering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Another thing I will note: I don't see any info in the PR description about which models were used to review this before pushing (should be at least two outside models). Just a note for future PRs. |
…l running Addresses the #780 review blocker (corroborated independently by the cross-model Codex leg, which also scoped it: narrow timeout hardening here, full admission-and-drain contract in #784). TransactionHandle::close() set state=Aborted BEFORE waiting, then after waitForAsyncWorkCompletion()'s 5s timeout released log/VT state and deleted txn regardless — so a worker env torn down during a slow commit could free a transaction RocksDB was still using, and could mark the transaction log aborted while the data commit went on to succeed. The env-cleanup reap this PR adds is a new caller of that path, firing when in-flight commit work is far more likely than at final shutdown. waitForAsyncWorkCompletion() now reports whether it actually drained. close() drains first and, on timeout, returns without mutating state, releasing log/VT state, or deleting txn: the in-flight commit owns its own cleanup, and a leaked transaction is recoverable where a use-after-free and a log/data disagreement are not. Also fixes a leak both outside models flagged: close() can only self-remove from DBDescriptor::transactions while it can still reach the descriptor through its DBHandle, and a handle the user closed earlier has already reset that pointer — exactly the case the env reap exists to catch. closeTransactionsByEnv now removes the entry itself. Regression test: new ROCKSDB_JS_COMMIT_EXECUTE_DELAY_MS seam stalls a commit immediately before rocksdb::Transaction::Commit(), with the work still registered, and a slowcommit worker role kills the env there. The existing COMMIT_DELAY_MS seam cannot exercise this — it fires after execute completes. Verified red/green: 3/3 SIGSEGV before the hardening (the commit thread commits through a destroyed txn; plain dereference, so it reproduces natively on macOS with no allocator instrumentation), clean after. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Good call on the model coverage — I'd skipped it, and running it was worth the detour. Ran Two outcomes, both in 1. Your close-during-active-work blocker — fixed, and Codex reached it independently. It also scoped it exactly as you did: narrow timeout hardening here, the full admission-and-drain contract in #784. The regression test took two attempts and the first one was worthless — worth recording. 2. A leak both outside models flagged that neither of us caught. The other six inline comments are addressed in Also filed separately: — |
There was a problem hiding this comment.
Also filed separately: prepush-review.mjs's domain leg is currently broken against the installed claude CLI. (--permission-mode dontAsk is no longer valid for the installed claude CLI),
What version of the CLI are you using? I think the latest should support dontAsk. But I posted https://github.com/HarperFast/skills-internal/pull/126 to tolerate this. And did you say you filed an issue (that I should link)?
Anyway, codex thinks there is still a pointer to fix.
🤖 Reviewed with Codex
| // create shared_ptr on heap so it persists after function returns | ||
| std::shared_ptr<TransactionHandle>* txnHandle = new std::shared_ptr<TransactionHandle>( | ||
| std::make_shared<TransactionHandle>(*dbHandle, env, jsDatabaseRef, disableSnapshot) | ||
| std::make_shared<TransactionHandle>(*dbHandle, disableSnapshot) |
There was a problem hiding this comment.
Making TransactionHandle::close() N-API-free solves the teardown constraint, but the unreachable-transaction root remains: the NativeTransaction finalizer only resets the wrapper’s shared pointer, while DBDescriptor::transactions retains a strong pointer. If an uncommitted Transaction is garbage-collected while its database remains open, its snapshot, locks/VT intents, log pending count, and DB handle remain alive until database close. After resolving the in-flight-close synchronization above, please close/abort from the finalizer or change the registry ownership model, and add a forced-GC regression against a long-lived database.
— KrAIs (Codex)
Root cause: pending transactions leak past their env and get walked at shutdown
A worker env that exits while a transaction is still pending leaks its
TransactionHandle:Transaction::Constructor→transactionAddstores a strongshared_ptr<TransactionHandle>in the process-globalDBDescriptor::transactions.transactionRemove.envthat dangles once the worker dies — survives in the shared descriptor until the last env'sDBRegistry::Shutdown → finishClose → close()walks it. That is exactly the Worker-env teardown destroys transactions on the shared DBDescriptor, corrupting the heap under concurrent commits #741 production stack (Shutdown → finishClose → TransactionHandle::close → ~OptimisticTransaction → ~TransactionBaseImpl → _int_free).Besides the crash, this is a real resource leak: every worker recycled mid-request leaks an open RocksDB transaction and snapshot until process-wide shutdown.
The fix
Env-cleanup-hook reap —
DBRegistry::CloseTransactionsByEnv→DBDescriptor::closeTransactionsByEnv, wired into the module's per-env cleanup hook: every transaction owned by the dying env's handles is closed on that env's own thread while the env is still valid. The invariant (AGENTS.md item 12): an env's pending transactions are reaped by its cleanup hook.Deliberately NOT in
DBHandle::close(). A first iteration reaped there and it broke the legitimate close-then-commit overlap:db.transaction()awaits its callback before committing, so a commit is routinely one microtask behinddb.close()and must still run — reaping rejected those commits with "Database not open", and under Deno's scheduling stranded the caller's commit promise (txn-close-commit-uaftimed out on all three Deno CI platforms; back to main-parity after the rework). At env teardown no such continuation can exist, so the hook is the only safe reap point. Between a userdb.close()and env death, an open transaction's handle intentionally lingers (bounded, reaped at env exit).TransactionHandle::close()is now napi-free —env/jsDatabaseRef/envThreadIdare gone. The JS database is passed toUseLogper-call by the TS layer (its only consumer), so close is safe from any thread and any teardown phase, removing the cross-threadnapi_delete_referencehazard class entirely.Evidence
Repro:
test/lingering-txn-shutdown.test.ts+test/fixtures/fork-lingering-txn-shutdown.mts— N workers exit with a pending transaction while an anchor pins the descriptor; the last worker's shutdown walks the leaked handles.Linux/glibc (node:24 arm64, Debian bookworm, native — no sanitizers), 10 leakers per run:
main(3ea9a0f)corrupted size vs. prev_size,corrupted double-linked list,free(): invalid pointer,malloc_consolidate(): invalid chunk size, 1× SIGSEGVmain+MALLOC_PERTURB_MALLOC_PERTURB_)Those abort strings are the #741 family verbatim (incl. the exact
corrupted size vs. prev_sizefrom the still-skippedvt-lock-tracker-churnresidual).Which change carries the fix?
Isolation arms (same container and method;
mainplus exactly one change; 8 leaker runs each):releaseByOwneronlyclose()onlyEach change is independently sufficient on Linux (arms measured on the first iteration; the leaker choreography exercises the same teardown reap as the final env-hook shape), which pins the corrupting write precisely:
Shutdownwalks the leaked handles of dead envs,close()'s thread-identity guard passes when the closing thread has recycled a dead worker's pthread (std::thread::idcollision), andnapi_delete_reference(dead env, dead ref)corrupts the heap. Removing the napi call breaks the chain at the write; reaping the env's transactions at env death breaks it at the source. Both are kept: the reap also fixes the resource leak (a live RocksDB transaction + snapshot per abandoned worker transaction), and napi-freeclose()removes the hazard class structurally.The repro test runs on Linux (red on main, green here). On macOS it is
skipIf(darwin): a Guard-Malloc-only fault in Node's second-pass napi finalizer drain persists even with the fix — it never reproduces natively or on glibc, and looks like a Node-core teardown artifact worth a separate minimal repro (no rocksdb involved).Full Vitest suite (final tree, release build): 757 passed / 2 skipped / 0 failures locally. Deno
txn-close-commit-uafverified back to main-parity locally (12.5s pass, was a 60s timeout on all three Deno platforms with the first iteration); the new repro test is gated to Linux+Node per the repo's existing teardown-repro convention (notify-teardown-uaf).Relationship to #745
The dominant #741 crash reproduces on
mainwithout any of #745's guarded paths engaging (verified: zeronapi_delete_referencecalls, zeroTransactionHandle::closecalls at fault time on the pre-fix repro), and #745's head does not prevent this repro (5/6 crash under the macOS harness). This PR fixes the lifecycle hole those guards work around; #745'sstateMutexremains relevant for the same-env close-vs-commit-thread TOCTOU (AGENTS item 12a gaps untouched here).Review coverage
Cross-model pre-push review (
prepush-review.mjs,--author claude, risk=high/scope=full) at675bfd5e:agy)--permission-mode dontAskrejected by the installed CLI)Two outside families produced coverage; the Harper domain leg failed, so the findings below were never adjudicated (no false-positive filter) — each was verified by hand against the code instead. Receipt published
Human-Review-Need: 4partly for that degradation. Cursor Grok/Composer were pruned by auto-policy.What it changed, all in
e9345cce:TransactionHandle::close()now drains before mutating anything and, on drain timeout, returns without touchingstate, log/VT state, ortxn.close()can only self-remove fromDBDescriptor::transactionswhile it can still reach the descriptor through itsDBHandle, and a user-closed handle has already reset that pointer — precisely the case this reap exists to catch, so the entry leaked for the process lifetime.closeTransactionsByEnvnow removes it directly.Follow-up worth noting for anyone re-running this: the
prepush-review.mjsdomain leg is currently broken against the installedclaudeCLI (dontAskis no longer a valid--permission-mode).Refs #741
🤖 Generated with Claude Code