Skip to content

Reject in-flight async gets when an orphaned transaction is GC'd - #789

Open
cb1kenobi wants to merge 2 commits into
kris/txn-registry-weakptrfrom
chris/txn-orphan-gc-inflight-get
Open

Reject in-flight async gets when an orphaned transaction is GC'd#789
cb1kenobi wants to merge 2 commits into
kris/txn-registry-weakptrfrom
chris/txn-orphan-gc-inflight-get

Conversation

@cb1kenobi

@cb1kenobi cb1kenobi commented Aug 20, 2026

Copy link
Copy Markdown
Member

Closes the GC-time UAF on an in-flight transactional async get that #768's orphan-release path newly made reachable: onWrapperCollected no longer calls close() while async work is live (the wait can time out, then delete this->txn races the worker). Pending gets are cancelled and reject with "Transaction is closed"; AsyncGetState pins the handle with a shared_ptr and each get complete callback re-enters onWrapperCollected so only the last in-flight get closes. In-flight commit is unchanged (still deferred to completeCommitWork).

Gemini's suggested delete state then close-if-count-0 is not used: unregisterAsyncWork already runs in execute via signalExecuteCompleted(), so the count is already decremented when complete runs. Re-entering onWrapperCollected reuses the existing count>0 gate.

Unresolved from the follow-up review (not blocking this Gemini thread): the two-get test locks both-reject + snapshot cleanup, not a >5s stalled sibling that would have timed out the old close(); the execute-delay seam remains on the production cache-miss get path (same as the first commit).

For the human reviewer

  1. Reject vs complete a still-awaited read. Dropping the wrapper while holding only the promise now rejects (GC-timed) instead of resolving. Completing the read is memory-safe now that AsyncGetState holds a shared_ptr; rejection is the stated intent and what the tests lock in. Alternative: let execute finish Get and still close in complete. A "no" on rejection is a small behavior change for getBinary callers that don't retain the Transaction.

Verification

Extended test/transaction-orphan-gc.test.ts (should reject an in-flight async get when the transaction is dropped, should reject two in-flight async gets when the transaction is dropped). Ran node --expose-gc ./node_modules/vitest/vitest.mjs test/transaction-orphan-gc.test.ts — 7 passed. pnpm type-check clean via pre-commit.

Review coverage

Authored by Cursor Grok 4.6. Cross-model review of 2ee75bdd via prepush-review.mjs vs origin/kris/txn-registry-weakptr: gemini via agy (default model) ✓, gpt-5.6-sol (codex graded) ✓, Harper domain ✗ (claude not on PATH — findings unadjudicated), cursor-grok disabled as authoring family. Receipt @ 2ee75bd.

Complexity: complicated

Review-Coverage: authored=claude; ran=codex; declined=gemini,cursor-grok,cursor-composer,domain; rounds=2 @ 2ee75bd

Human-Review-Need: 4 @ 2ee75bd

@cb1kenobi
cb1kenobi requested a review from kriszyp August 20, 2026 03:27

@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 a critical issue where transactions dropped without explicit commit or abort were not being released, causing them to pin RocksDB snapshots and prevent the reclamation of obsolete versions. The changes introduce an onWrapperCollected finalizer mechanism to properly close orphaned transactions, ensure that in-flight asynchronous work is respected, and add diagnostic tools (such as rocksdb.num-snapshots and transaction age tracking) to monitor and debug these scenarios. I have reviewed the implementation and included a high-severity security recommendation regarding a potential Use-After-Free hazard in the asynchronous completion callback, which requires adjustment to ensure close() is only invoked when all concurrent operations have finished.

Comment thread src/binding/transaction/transaction_handle.cpp
…lected

close() from the NativeTransaction finalizer can time out waiting for
async work and then delete txn under a still-running get worker. Cancel
and defer close until the get's complete callback instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cb1kenobi
cb1kenobi force-pushed the chris/txn-orphan-gc-inflight-get branch from 651977f to 0d53344 Compare August 20, 2026 03:45
@cb1kenobi
cb1kenobi marked this pull request as ready for review August 20, 2026 04:00
… get

The get complete callback used to close() on the first finished get, which
could free txn under a sibling still in Get. Re-enter onWrapperCollected so
close waits until activeAsyncWorkCount is 0.

Co-authored-by: Cursor <cursoragent@cursor.com>

@kriszyp kriszyp left a comment

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.

Could we strong reference (napi_ref) this (jsThis), which should entrain the transaction, to pin the transaction in memory, using the natural/native mechanism, rather than creating a separate mechanism for tracking?

🤖 Reviewed with Codex

await db.put('baz', 'qux');
await db.flush();

setTxnGetExecuteDelayMsForTesting(250);

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.

This case does not appear to fail on the pre-fix implementation. Both gets receive the same 250 ms delay, while close() waits up to five seconds: either both workers finish before the first completion runs, or the second finishes safely while the first completion waits. In both cases the old code still rejects both promises and removes the registry entry. Could we use a deterministic stagger or barrier so the first completion runs while the second worker remains blocked, assert that the transaction stays registered without blocking the event loop, then release the second worker and assert cleanup? That would specifically guard the new last-active-get invariant.

— KrAIs (Codex)

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