Skip to content

collections: LinearFifo drops its live items and iterates both ring halves - #39545

Closed
alii wants to merge 1 commit into
mainfrom
ali/linear-fifo-iter-and-drop
Closed

alii wants to merge 1 commit into
mainfrom
ali/linear-fifo-iter-and-drop

Conversation

@alii

@alii alii commented Aug 18, 2026

Copy link
Copy Markdown
Member

The problem

LinearFifo stores its items in MaybeUninit slots. Rust does not drop MaybeUninit contents. The comment in linear_fifo.rs said that field drop glue covers deinit. That is only true for types with no drop glue. If a fifo of Box or of JS promise handles is dropped with items still queued, the storage is freed but the items are not dropped. They leak.

Both RedisClient queues hold such items. The offline queue holds Entry (a Box<[u8]> and a promise Strong). The in flight queue holds PromisePair (a promise Strong). Today shutdown() and reject_all_pending_commands() drain both queues by hand with read_item() before the queues are dropped, so I did not find a live leak in the Redis client. The leak is one missed drain away, and any new fifo of a droppable type gets it for free. discard() and ordered_remove_item() had the same gap. They moved head past the items or memmoved over them without a drop.

readable_slice(0) returns only the first contiguous run of the ring. Once the ring wraps, the second run is not in the slice. Two RedisClient sites used it as a whole queue scan. The auto pipeline flush counted pipelineable commands from it, so a wrapped queue was flushed in two socket writes instead of one. The memoryCost estimate summed serialized bytes from it, so it under reported a wrapped queue. Both were found while reviewing #34829 and #39193.

What changed

LinearFifo now implements Drop. It drops the live items in ring order, both runs. discard(n) drops the first n items before it moves head. read_item() moves the item out and then advances head without a drop, so nothing is dropped twice. ordered_remove_item() drops the removed item before the shift. Types without drop glue take the same fast path as before.

New accessors: as_slices(), as_mut_slices(), iter() and iter_mut(). They cover all live items across both runs in FIFO order. The two scan sites in valkey.rs and js_valkey.rs now use iter(). The head peeks at send_next_command() and drain() only read element 0 of readable_slice(0). That element is always in the first run, so they are unchanged.

Tests

Unit tests in linear_fifo.rs use a type that counts its drops and owns a Box. They cover drop of a partially read fifo, drop of a wrapped ring, drop of a static ring, discard across the wrap, read_item not dropping, ordered_remove_item on both runs, iter() and iter_mut() on empty, full, wrapped and grown rings, and iter() matching repeated read_item(). The three drop tests fail on main.

Not in this PR

The MaybeUninit accessor rework noted at the top of the file. writable_slice() still hands out &mut [T] over unwritten slots. That needs a signature change in other crates.

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 39 minutes

Limit details: You’ve used all 1 included review currently available under your plan. You completed 78 included PR reviews in the past 7 days; at that activity level, included reviews refill at 1 review per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6b385d83-e686-40d1-9352-801f9189bb33

📥 Commits

Reviewing files that changed from the base of the PR and between 4c68990 and ceb7bfd.

📒 Files selected for processing (3)
  • src/collections/linear_fifo.rs
  • src/runtime/valkey_jsc/js_valkey.rs
  • src/runtime/valkey_jsc/valkey.rs

Comment @coderabbitai help to get the list of available commands.

Comment on lines +691 to +693
// SAFETY: `offset < count`, so the slot holds a live item; the shifts
// below overwrite it without reading it again.
unsafe { ptr::drop_in_place(self.peek_item_mut(offset)) };

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.

🟡 ordered_remove_item runs drop_in_place before the shift and before self.count -= 1, so a panicking T::drop unwinds through a fifo whose live range still contains the already-dropped slot — LinearFifo::drop → discard(self.count) then drops it again. discard() explicitly guards against this ('Move head first so a panicking T::drop leaves the fifo consistent'); this sibling path should get the same treatment — e.g. ptr::read the item out, do the shift + self.count -= 1, then drop the local. (Bun ships panic = "abort", so this only bites under cargo test's unwind — flagging for consistency, not blocking.)

Extended reasoning...

What the bug is

In the offset != 0 branch of ordered_remove_item, this PR adds:

unsafe { ptr::drop_in_place(self.peek_item_mut(offset)) };
// ... shift_down_one(...) ...
self.count -= 1;

The drop_in_place runs before the shift and before self.count -= 1. If T::drop panics and the panic unwinds, self.count is unchanged and the slot at offset — now containing already-dropped bits — is still inside the live range [head, head+count). Unwinding then runs LinearFifo::drop (also new in this PR), which calls self.discard(self.count), which drop_in_place's the entire live range including that slot again. That's a double drop; for the PR's own Counted test type (which owns a Box<u32>) it would be a double free.

Why this is inconsistent with the same PR's discard()

The PR explicitly designed discard() to be panic-safe on exactly this hazard, with a comment stating the intent:

// Move `head` first so a panicking `T::drop` leaves the fifo
// consistent (the rest of the range leaks, is not dropped twice).
self.move_head(count);
unsafe { ptr::drop_in_place(a); ptr::drop_in_place(b); ... }

ordered_remove_item was given the same drop responsibility in this PR but not the same panic guard. Per REVIEW.md "Fix the whole class in the same PR" — the two drop paths added here should be consistent.

Step-by-step proof

Take a LinearFifo<Counted, DynamicBuffer<_>> with head=0, count=3, and suppose Counted::drop for the item at offset 1 panics:

  1. ordered_remove_item(1) — offset != 0, so drop_in_place(self.peek_item_mut(1)) runs.
  2. Counted::drop panics. At the panic point, self.count == 3 and slot 1 holds a dropped Counted (its Box<u32> freed).
  3. Unwinding drops the LinearFifo. Drop::drop sees self.count == 3 and calls self.discard(3).
  4. discard(3) computes (a, b) = as_mut_slices() covering slots [0, 3), moves head, then ptr::drop_in_place(a) — which drops slot 1 a second time. UB / double free of the Box.

Contrast with discard(): it moves head past the range before dropping, so on a panic during the drop loop the fifo's own Drop sees count == 0 and does nothing.

Impact

Low in practice:

  • Cargo.toml sets panic = "abort" for both dev and release (lines 144, 147), so in the shipped binary a panicking T::drop aborts the process — there is no unwind and no double drop.
  • The sole non-test caller of ordered_remove_item (the dev-server weak_refs fifo in source_map_store.rs) stores a POD WeakRef {u32, u32, i64} with no drop glue, so drop_in_place on it is a no-op that cannot panic.
  • Only cargo test builds unwind (the test harness forces it), and none of the new tests use a panicking Drop.

So this cannot cause a concrete failure today. It's flagged as a nit because (a) the author clearly intended panic-safety here — they wrote the comment in discard() — and (b) the two sibling drop paths added in the same PR should carry the same guard.

How to fix

Match the discard() pattern — make the fifo consistent before running T::drop:

// SAFETY: `offset < count`, so the slot holds a live item; the shifts
// below overwrite it and `count -= 1` drops it from the live range,
// so a panicking `T::drop` unwinds through a consistent fifo.
let removed = unsafe { ptr::read(self.peek_item_mut(offset)) };
// ... existing shift_down_one(...) branches ...
self.count -= 1;
drop(removed);

If drop(removed) panics, the fifo has already shifted and decremented count, so LinearFifo::drop drops only the remaining live items — the removed one leaks instead of being dropped twice, mirroring discard()'s guarantee.

@alii

alii commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

@robobun adopt

@robobun

robobun commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator

Closed as superseded by #39570. Nothing was pushed to this branch.

@alii

alii commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Superseded by #39570. The review found a simpler shape: the two Redis queues are the only LinearFifo users with droppable items, and they only need push, pop, front, len and iter, so they become VecDeque. LinearFifo gets a compile-time assert that its item type has no drop glue. No new unsafe.

@alii alii closed this Aug 18, 2026
@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Standing down here. I did not push to this branch.

Two notes from the verification of this PR, for the record. Both are moot in the shape of #39570.

  1. The discard() in this PR failed under Stacked Borrows Miri for a StaticBuffer (the mode that test/internal/linear-fifo.test.ts runs the unit tests in). It kept pointers to the items across the move_head() call, and the items of a StaticBuffer live inline in the fifo, so the &mut self retag invalidated them. Tree Borrows, which bun run rust:miri uses, accepts it. valkey: use VecDeque for the command queues; LinearFifo rejects droppable items #39570 removes that path.
  2. I checked every LinearFifo item type in the tree. The two valkey queues are the only ones with drop glue, so the const assert in valkey: use VecDeque for the command queues; LinearFifo rejects droppable items #39570 holds on current main.

Jarred-Sumner pushed a commit that referenced this pull request Aug 18, 2026
…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>
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