Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 5 included reviews per hour; 0 remain after this review. WalkthroughThe MySQL request path now passes ChangesMySQL query ownership
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Full details: Description checkExplanation The description explains the problem, implementation, ownership model, re-entrancy behavior, and verification results. It provides the information required by the repository template, although it uses Problem and Fix headings instead of the exact template headings. Comment |
There was a problem hiding this comment.
LGTM — mechanical *mut → NonNull tightening with no behavioral change.
What was reviewed:
deref_nnis exactlyunsafe { deref(p.as_ptr()) }(ref_count.rs:637), so all 5 release sites are byte-identical to before.impl_field_parent!split: thenonnullarm returnsNonNullfrom the samefrom_mut(self)provenance the oldget_js_connectionused; both call sites (advance,upgrade_to_tls) updated, no others exist.NonNull::from(this)indo_runcarries the same shared provenance as the oldas_ctx_ptr(); the queue only formsParentRef(&T) andderef_nnfrom it.peek_item(0) == requeststill compares by address (NonNullPartialEq).
Extended reasoning...
Overview
Changes the MySQLRequestQueue element type from *mut JSMySQLQuery to NonNull<JSMySQLQuery> and threads that through add/current/advance and the two enqueue_request wrappers. The 5 release sites switch from unsafe { JSMySQLQuery::deref(p) } to the safe deref_nn(p) trait method, and the 6 NonNull::new(p).expect(...) re-checks disappear. get_js_connection is regenerated via the nonnull arm of impl_field_parent! so advance and upgrade_to_tls receive a NonNull<JSMySQLConnection> directly.
Security risks
None. No I/O, protocol, or auth logic touched — pure pointer-type plumbing inside the request queue.
Level of scrutiny
Low-to-medium. This is native code with intrusive refcounting, so I verified each substitution is semantics-preserving: deref_nn at ref_count.rs:637 is literally unsafe { Self::deref(this.as_ptr()) }; the nonnull macro arm at bun_core/lib.rs:783 wraps the same from_field_ptr!(.., from_mut(self)) the previous two-arm form used, so provenance is unchanged; NonNull::from(&Self) in do_run yields the same shared-provenance address as the prior as_ctx_ptr() (from_ref(self).cast_mut()); NonNull<T> has identical size/repr to *mut T so the LinearFifo layout is unchanged; and NonNull's PartialEq compares addresses, so the peek_item(0) == request head-check in advance behaves identically.
Other factors
Grepped for other get_js_connection callers — only the two updated in this diff exist. The pattern (two impl_field_parent! invocations for ref + nonnull forms) already appears elsewhere (FileReader, Execution). Net -5 unsafe blocks, -6 expect, +0 unsafe. Existing MySQL tests pass per the PR description; this change has no observable behavior to add a new test for.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Do we need to call deref_nn? Why isn't this an RAII type?
|
Agreed. The reason it is not one today is that bun_ptr has no owning RAII handle for intrusive counts: RefPtr/IntrusiveRc deliberately has no Drop, and ScopedRef is a scope guard without Deref. I will add one (an owned ref that holds exactly one count, derefs to &T, clones by taking another count, releases on Drop, with from_raw/into_raw for the FFI hand-offs) in a small base PR, and then make this queue a LinearFifo of those so popping or clearing releases the query without any deref_nn. Will update this PR on top of that. |
32eca06 to
84cb1ad
Compare
|
Updated 1:10 AM PT - Aug 26th, 2026
✅ @robobun, your commit 0fd5bba2555c83194ef750a0a49f9bab711a94e5 passed in 🧪 To try this PR locally: bunx bun-pr 37618That installs a local version of the PR into your bun-37618 --bun |
|
Reworked as promised, now that the owning handle exists (#37665, which this PR is stacked on): the fifo holds |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it reworks intrusive-refcount ownership in native code (the queue now relies on LinearFifo's bag-of-bits move semantics interacting correctly with OwnedRef::Drop on every removal path), a human look would still be worthwhile.
What was reviewed
- Refcount balance: every removed
derefmaps to exactly oneread_item()+ drop;add()no longer double-refs; OOM inwrite_itemdrops the moved-inOwnedRefso no leak. LinearFifonever drops elements — verified no remainingdiscard()calls on theOwnedReffifo;realign/ensure_total_capacitybit-move +MaybeUninitdrop of the old buffer don't double-free;clean()andDropstill drain fully.- Re-entrancy in
advance(): the run-failure identity check now usescurrent() == Some(request)(address compare, no deref of a possibly-freed pointer);reqis not touched afteron_errorreturns. peek_item_refis the previouspeek_itembody minus the copy;peek_itemdelegates to it, so existing callers are unchanged.
Extended reasoning...
Overview
This PR converts MySQLRequestQueue's element type from raw *mut JSMySQLQuery to OwnedRef<JSMySQLQuery>, so the queue's intrusive ref on each request is released by RAII rather than by five hand-paired deref() calls. It adds LinearFifo::peek_item_ref (a &T-returning peek for move-only element types), a one-line JSMySQLQuery::owned_ref() helper, and threads NonNull<JSMySQLConnection> through advance()/get_js_connection() in place of *mut. The enqueue_request signature on both the JS wrapper and the protocol struct changes to take the owned ref by value.
Security risks
None identified. This is a type-level refactor of an existing ownership pattern; no new attack surface, no protocol/parsing changes, no user-controlled input handling touched.
Level of scrutiny
High. Per REVIEW.md, native memory safety is the most-blocked category, and this PR sits squarely in it: it changes who releases each intrusive ref and when, and it stores a Drop type inside LinearFifo, which explicitly does not run destructors on its contents. The correctness argument depends on the invariant that every element leaves the fifo via read_item() (never discard(), never a silent buffer drop). I checked that this holds on all paths in MySQLRequestQueue.rs, but the invariant is documented only in a comment on the type Queue alias — a future discard() or ordered_remove_item() call would silently leak.
Other factors
- No new automated test ships in this PR. It's a behavior-preserving refactor covered by the existing
sql-mysql*.test.tssuite, and the description reports ASAN-clean smoke runs exercising each removal path — but a maintainer should confirm that's acceptable for a refcount-balance change. - The
comment-copgithub-actions bot has left eight inline flags on multi-line comments in the diff. Most look like doc/SAFETY comments the bot fires on indiscriminately, but a maintainer may want to triage them. - The
current()return type changed fromOption<*mut>toOption<NonNull<>>; I checked the two external callers (is_idle,can_flush) and they only use.is_none()/current_ref(), so no behavior change. - Drop ordering in
advance()is preserved:with_mut(|q| q.read_item())returns theOwnedRefafter the fifo borrow ends, then the explicitdrop()releases the ref (which may run the query's destructor and re-enter). This matches the olddiscard(1)-then-derefsequence.
a07d452 to
bca06d7
Compare
84cb1ad to
282ba33
Compare
There was a problem hiding this comment.
I reviewed this and didn't find any bugs. Since it reworks intrusive-refcount ownership on a re-entrant JS-facing path, a human pass on the drop ordering would still be worthwhile.
What was reviewed:
- Refcount balance:
owned_ref()takes the same incrementadd()used to; every removal is nowread_item()whose returnedOwnedRefdrops after the fifo borrow ends — matches the olddiscard(1)+dereforder at all five sites. - Run-failure path in
advance():current() == Some(request)subsumes the old length + peek check;requestis a copiedNonNullused only for identity comparison afteron_error, so re-entrantclean()can't cause a UAF or double-release there. LinearFifodoesn't drop elements — confirmedMySQLRequestQueue::Dropandclean()still drain viaread_item(), andwrite_item's OOM early-return drops the moved-inOwnedRefso no leak.peek_item_refis the oldpeek_itembody minus the copy;get_js_connection()→NonNullandcurrent()→Option<NonNull>have no other callers.
Extended reasoning...
Overview
Converts MySQLRequestQueue's element type from raw *mut JSMySQLQuery to OwnedRef<JSMySQLQuery> (the RAII intrusive-ref handle added in #37665), so removing an element from the fifo and dropping it is what releases the ref. Removes five hand-paired unsafe deref calls and the unsafe ThisPtr::new in current_ref. Adds LinearFifo::peek_item_ref (same body as peek_item, returns &T), tightens MySQLRequestQueue::advance's param from *mut to NonNull, and splits impl_field_parent! so get_js_connection() returns NonNull. Signature-only changes to enqueue_request in JSMySQLConnection / MySQLConnection.
Security risks
None. No untrusted-input parsing, auth, or crypto is touched; this is an internal ownership refactor.
Level of scrutiny
High. This is native intrusive-refcount management on a JS-cell payload with re-entrant callbacks (on_error, reject) that can synchronously mutate or drain the same queue — the exact class REVIEW.md calls out as most-blocked. I traced the drop order at each removal site (with_mut(|q| q.read_item()) returns the OwnedRef after the fifo borrow ends, so the destructor can't re-enter under a live borrow), the run-failure re-entry case (only pointer identity is compared after on_error; no deref of a possibly-freed request), and the OOM path in write_item (the moved OwnedRef is dropped by the early return, so no leak). The LinearFifo no-drop caveat is handled: Drop and clean() drain with read_item, and no discard() calls remain on this element type. peek_item_ref only accesses slots at offset < count, so the pre-existing assume_init_slice UB note in linear_fifo.rs is not made worse (OwnedRef is NonNull-bearing, but so were several existing element types the file already documents).
Other factors
The base PR (#37665, OwnedRef) is merged. Verification was ASAN + local MariaDB smoke over the four queue paths plus the existing mysql test files, but no new automated test in this PR (it's a behavior-preserving type change). The comment-cop bot flags are all resolved; the added comments are short doc/ordering notes, not workaround justifications. I'm deferring rather than approving because refcount lifetime changes across re-entrant JS boundaries are the category where a second pair of eyes on drop ordering has the highest payoff, even when the mechanical trace checks out.
…able items (#39570) Replaces #39545. The problem The Valkey client keeps two queues: the offline queue of serialized commands and the in-flight queue of promises. Both were LinearFifo rings. LinearFifo never drops its items. Every consumer of that ring except these two holds bytes, raw pointers or Copy structs, so that was fine there. Here each item owns a JS promise handle and a boxed byte buffer. That leaks on main today. reject_all_pending_commands moves both queues into locals and rejects each item with `?`. When a reject throws, for example during a worker teardown, the function returns early and both locals are dropped with items still inside. Those promises and boxes are never freed. The ASAN tests in #39543 observe exactly this leak. There was a second bug. Two places read the queue with `readable_slice(0)`, which only returns the first contiguous half of a wrapped ring. The auto-pipeline count and the memory estimate both under-counted once the ring had wrapped. What changed The two queue aliases are now std::collections::VecDeque. Every call site is a mechanical rename: init to new, readable_length to len or is_empty, readable_slice(0)[0] to front, write_item to push_back, read_item to pop_front, the two whole-queue scans to iter. Control flow is unchanged. VecDeque drops what is left inside it, so any early return now frees the remaining items. #39543 still fixes the drain loop itself so every promise gets rejected; this PR only makes the early return leak-free. LinearFifo now requires `T: Copy` on all of its impl blocks. The ring never runs item destructors, and the bound states that where cargo check and rust-analyzer see it, before monomorphization. Two consumers needed a derive: FillItem in the lockfile tree builder and RefDataValue in the test runner. Both hold only integers, raw pointers and Copy structs. Every other consumer was already Copy. The per-method `T: Copy` clauses that read, write, unget and peek_item carried are gone with the impl-level bound, and the memmove helper is now slice::copy_within. The header comment states the contract. Visible changes Two, both fixes. Before, the flush wrote the pre-wrap segment of the ring, stayed registered, and wrote the rest only when the event loop woke again. Nothing about the pending flush shortens the poll, so that wake was whatever else happened to fire: a reply, a timer, other I/O. Measured with nothing else live, the tail of a burst left about 80 ms after its head. Now every pipelineable command goes out in one write. estimateShallowMemoryUsageOf counts every queued command's bytes. Before, it counted only the pre-wrap segment. Tests Three new tests in test/js/valkey/reliability/connection-failures.test.ts. One drains and refills the queue so the old ring wrapped, then checks the memory estimate covers all queued bytes. It fails on main (2244 bytes reported for 5000 queued). The other queues 40 commands against a stub that never finishes the handshake, closes, and checks all 40 reject. That one passes on main too and is there to pin the behaviour. The third runs the client in a child process with nothing else live and has a stub count the GETs in the first read after the ring wrapped: main writes 2 of 5 there and the other 3 on a later wake, this branch writes all 5. Not in this PR The drain loop in reject_all_pending_commands still stops at the first throwing reject. #39543 fixes that. The DeferredFailure path when the VM is already stopping still does not settle its promises; that is a gap for the state machine rewrite. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/valkey/reliability/connection-failures.test.ts <!-- robobun:evidence:end --> Two follow-ups outside this PR. #37618 puts an OwnedRef into the MySQL request queue's LinearFifo; with the Copy bound that no longer compiles, and the queue should become a VecDeque the same way, which also removes its manual Drop drain. The Postgres request queue holds raw pointers today and is the next candidate for the same change. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
282ba33 to
102db1a
Compare
|
Retargeted onto main. The base PR #37665 ( One more change since the last revision: |
The MySQL request queue holds one intrusive ref on every queued JSMySQLQuery. The elements were raw pointers: add() called ref_() and each of the five places an element left the queue paired its removal with an unsafe JSMySQLQuery::deref call. The element type is now the ref: the queue is a VecDeque<RefPtr<JSMySQLQuery>>, and popping an element drops it, which is the release. LinearFifo requires Copy elements because it never runs item destructors, so the container changes to VecDeque, which does. do_run passes ref_guard() into enqueue_request, which is the one place a ref is taken. The file has no unsafe block left.
102db1a to
6d5cf34
Compare
|
Addressed the comment-cop findings in 474f68b. |
0fd5bba to
6d5cf34
Compare
|
Closing as superseded by #40516. That PR (opened today, base For the record, this branch was rebased onto main and tested before the overlap was found. The queue tests ( |
Problem
src/sql_jsc/mysql/MySQLRequestQueue.rs) holds one intrusive ref per queuedJSMySQLQuery, but stored raw pointers:add()calledref_(), and each of the five removal sites paired it with anunsafe { JSMySQLQuery::deref(request) }.Fix
type Queue = VecDeque<RefPtr<JSMySQLQuery>>.do_runpassesthis.ref_guard()intoenqueue_request, the one place a ref is taken. Each removal is apop_front()whose result drops, which is the release.RefPtrowns one count and releases it onDrop, so the queue holds as many refs as it has elements. Re-entrancy handling is unchanged: no borrow of the deque spans a call that can re-enter the queue.LinearFifonever runs item destructors and requiresT: Copysince valkey: use VecDeque for the command queues; LinearFifo rejects droppable items #39570, so the container becomes aVecDeque.test/js/sql/sql-mysql-clean-reentry.test.tsdrives the queue release paths (five rounds of 10 queued requests rejected byclean(), then GC finalizes the connection). Alsobun bd test test/js/sql/sql-mysql.test.ts(99 pass, 7 MariaDB-specific failures identical on unmodified main), 17 othersql-mysql*files, and a smoke script under ASAN (Notes). The change preserves behavior, so the new test passes on main too. It pins the release-exactly-once behavior on both sides.Background
JSMySQLQueryis the Rust side of aMySQLQueryJS object, intrusively refcounted: the JS wrapper holds one ref, the queue one per element.RefPtr<T>(src/ptr/ref_count.rs) is the owning handle for such a count:Dropreleases,Clonetakes another ref.ref_guard()returns a new one taken on&self.ThisPtr<T>is a non-owning pointer to a live pointee.current_ref()returns one, so callers hold no borrow into the deque.Notes
On tests: this is the ownership rework requested in review, not a bug fix. The manual ref/deref pairs on main are balanced, so no test can fail on main and pass here. The new test in
sql-mysql-clean-reentry.test.tspins the release-exactly-once behavior on both sides instead: it drivesclean()and the GC finalizer five rounds under ASAN, where a double release crashes and a missed release hangs the round.This PR was stacked on #37665 (
OwnedRef). #40478 madeRefPtrthe RAII owning handle, so the PR is rebased onto main and usesRefPtrin that role. The earlier version keptLinearFifo<OwnedRef<..>>and addedLinearFifo::peek_item_ref. Since #39570,LinearFiforejects droppable element types (T: Copyon every impl), so the queue is aVecDequenow, the same move that PR made for the Valkey queues.The file had 6
unsafeblocks. It has 0 now.cargo check,cargo clippyandrustfmtare clean forbun_sql_jsc, andcargo check --target x86_64-pc-windows-msvc -p bun_sql_jscpasses.Smoke script (not committed), run on the debug ASAN build against the container's MariaDB 11.8, output identical to the release build of main:
add,advancereleases completed heads).SLEEP(0.2)holds the connection, then 120 queued requests of which 60 have an out-of-range BigInt bind. Theirrun()fails insideadvance()and the run-failure path releases them (60ERR_OUT_OF_RANGE, the rest succeed or reject at bind time).close({ timeout: 0 })with 50 queued requests:clean()rejects all 50 withERR_MYSQL_CONNECTION_CLOSED.close()whileclean()drains.Bun.gc(true), so connections are finalized with work still queued (Drop).The 7 failures in
sql-mysql.test.tsare MariaDB vs MySQL differences (CAST(.. AS JSON), error wording,FROM_UNIXTIME(0)before the epoch,repeat()returning a blob) and two debug-build timeouts (10,000 inserts in a 10 s budget). They fail the same way on unmodified main in this container.sql-mysql.transactions.test.tshas 2 error-wording failures of the same kind.Unrelated observation, same on the release build of main: pipelining
SELEC nope(a syntax error) behind aSLEEP(0.2)on one connection hangs the queued requests. Not touched here.