Skip to content

kio: rework Producer::poll/wait to a read-only predicate that returns a Mut#1735

Merged
kixelated merged 5 commits into
mainfrom
claude/kio-mut-poll-footgun-t0k9zm
Jun 14, 2026
Merged

kio: rework Producer::poll/wait to a read-only predicate that returns a Mut#1735
kixelated merged 5 commits into
mainfrom
claude/kio-mut-poll-footgun-t0k9zm

Conversation

@kixelated

@kixelated kixelated commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Producer::poll / Producer::wait were a self-triggering footgun. This reworks them so the closure is a read-only predicate and a satisfied poll hands back a Mut.

The old versions handed the closure a &mut Mut<T> and tracked a modified flag via DerefMut. On a Poll::Pending, the poll woke the consumer waiters. The trap: merely reading through a mutable path trips DerefMut. The canonical case is requests.pop() on an empty Vecpop() takes &mut, so an empty (no-op) poll still flags the state modified, wakes a waiter in the very list the poller just registered itself into, re-polls, and spins into an infinite busy loop.

Approach

Producer::poll (and its async sibling Producer::wait) now evaluates a read-only predicate over a Ref. While the predicate returns Poll::Pending nothing is mutated, so it can't spuriously flag the state modified. On Poll::Ready it upgrades the held lock guard to a Mut and hands it back, so the caller mutates atomically — the lock is never dropped between the predicate and the mutation, so there's no TOCTOU even with multiple drainers.

let mut state = ready!(self.poll(waiter, |state| {
    if state.requests.is_empty() { Poll::Pending } else { Poll::Ready(()) }
}))?;
Poll::Ready(Ok(state.requests.pop().expect("predicate guaranteed a request")))

Naming: both channel sides now expose a poll()/wait() pair. They differ in contract by design — Consumer::poll's closure returns the value (Poll<R>), while Producer::poll's closure is a bare Poll<()> predicate that gates handing back a Mut.

Changes

  • kio: Producer::poll / Producer::wait reworked to the read-only-predicate-returns-Mut shape. Weak::poll_write / Weak::wait had no users and are removed. The read-only Consumer::poll / Consumer::wait are unchanged. Dropped test = false from the [lib] so unit tests run, and added a test for the new Producer::poll.
  • moq-net: the two callers, BroadcastDynamic::poll_requested_track and OriginConsumer::poll_announced, now check "queue non-empty" read-only, then pop/take through the returned Mut (the latter tidied to use ready!).

API note

This is a breaking change to the published kio crate's surface (Producer::poll/wait change signature; the Weak methods are removed). No other workspace crate used the removed/changed APIs, and moq-net's public surface is unchanged — the affected helpers are private. Targeting main per request.

Test plan

  • cargo test -p kio (2 passed, incl. the new poll_gates_on_predicate_then_writes)
  • cargo test -p kio -p moq-net (346 passed; incl. broadcast::test::requests, requested_unused, origin announce tests)
  • cargo clippy -p kio -p moq-net -p moq-native clean
  • cargo build for kio-dependent crates (moq-net, moq-native, hang, moq-mux, moq-cli, moq-bench)

(Written by Claude)

claude added 2 commits June 14, 2026 20:35
The Mut-closure poll APIs (Producer::poll/wait, Weak::poll_write/wait)
auto-notified consumers whenever the closure touched the value through
DerefMut. Since merely draining a queue (e.g. requests.pop() on an empty
Vec) trips DerefMut, a pending poll would wake the polling task's own
waiter and spin into an infinite loop, an easy-to-hit footgun.

Replace them with Producer::poll_drain / Weak::poll_drain, which hand the
closure a plain &mut T and never notify. Callers that need to
mutate-and-notify still use write(). The only two users, broadcast's
poll_requested_track and origin's poll_announced, drain producer-owned
queues where notification was unwanted anyway.
Reshape per review: rather than handing the poll closure a plain &mut T,
poll_write_when evaluates a read-only predicate over a Ref and, on
Poll::Ready, hands back a Mut with the lock still held. The caller decides
readiness without touching DerefMut (so an empty-queue poll can't spuriously
flag the state modified and wake its own waiter), then mutates atomically
through the returned Mut. Keeping the lock held across the upgrade avoids any
TOCTOU between the predicate and the mutation.

broadcast's poll_requested_track and origin's poll_announced now check
"queue non-empty" read-only, then pop/take through the returned Mut.
@kixelated kixelated changed the title kio: replace Mut-closure poll with non-notifying poll_drain kio: replace Mut-closure poll with read-only poll_write_when Jun 14, 2026
@kixelated kixelated changed the title kio: replace Mut-closure poll with read-only poll_write_when kio: replace Mut-closure poll with read-only poll_write Jun 14, 2026
@kixelated
kixelated changed the base branch from dev to main June 14, 2026 21:03
Restore the descriptive name and add the async sibling so the API matches
the poll_xxx()/xxx() convention (poll_closed/closed, etc.): poll_write_when
for the poll form, write_when for the async form. Both evaluate a read-only
predicate over a Ref and hand back a Mut once it holds.
@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The PR replaces the old Mut-closure-based polling API (Producer::poll, Producer::wait, Weak::poll_write, Weak::wait) in the kio crate with a predicate-based alternative: Producer::poll_write_when and its async counterpart Producer::write_when. The new API evaluates a predicate over a read-locked &Ref<'_, T> and upgrades to Mut<'_, T> only when the predicate returns Ready, registering the waiter on Pending. Weak<T> loses its two polling methods entirely. Callers in moq-netBroadcastDynamic::poll_requested_track and OriginConsumer::poll_announced — are updated to use the new predicate pattern. The breaking change is documented in rs/kio/CHANGELOG.md.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: reworking Producer::poll/wait to use a read-only predicate that returns a Mut instead of a mutable closure.
Description check ✅ Passed The description comprehensively explains the problem, solution, and changes made, all directly related to the changeset across multiple files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/kio-mut-poll-footgun-t0k9zm

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
rs/moq-net/src/model/origin.rs (1)

882-894: ⚡ Quick win

Use ready! in poll_announced poll plumbing for consistency with project rules.

This block manually matches Poll where the guideline expects ready!(...) composition.

Suggested refactor
 	pub fn poll_announced(&mut self, waiter: &kio::Waiter) -> Poll<Option<OriginAnnounce>> {
-		let res = self.state.poll_write_when(waiter, |state| {
+		let mut state = match ready!(self.state.poll_write_when(waiter, |state| {
 			if state.pending.is_empty() {
 				Poll::Pending
 			} else {
 				Poll::Ready(())
 			}
-		});
-		match res {
-			Poll::Ready(Ok(mut state)) => Poll::Ready(Some(state.take().expect("predicate guaranteed an update"))),
-			// Closed: discard the Ref so its MutexGuard doesn't escape this call.
-			Poll::Ready(Err(_)) => Poll::Ready(None),
-			Poll::Pending => Poll::Pending,
-		}
+		})) {
+			Ok(state) => state,
+			// Closed: discard the Ref so its MutexGuard doesn't escape this call.
+			Err(_) => return Poll::Ready(None),
+		};
+		Poll::Ready(Some(state.take().expect("predicate guaranteed an update")))
 	}

As per coding guidelines, in poll_* plumbing, use ready!(...) to collapse Poll matching.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/src/model/origin.rs` around lines 882 - 894, In the poll_announced
method, replace the manual match on res (which handles Poll::Ready and
Poll::Pending cases) with the ready! macro for consistency with project
guidelines. Use ready! to unwrap the Poll result, which will automatically
propagate Poll::Pending and return the state value for further processing. Then
simplify the remaining match statement to only handle the Ok and Err cases from
the inner Result, eliminating the explicit Poll::Pending and Poll::Ready
wrapping in the match arms.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rs/moq-net/src/model/origin.rs`:
- Around line 882-894: In the poll_announced method, replace the manual match on
res (which handles Poll::Ready and Poll::Pending cases) with the ready! macro
for consistency with project guidelines. Use ready! to unwrap the Poll result,
which will automatically propagate Poll::Pending and return the state value for
further processing. Then simplify the remaining match statement to only handle
the Ok and Err cases from the inner Result, eliminating the explicit
Poll::Pending and Poll::Ready wrapping in the match arms.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b1f2b83d-c0f8-4853-9363-45e29f78c61e

📥 Commits

Reviewing files that changed from the base of the PR and between 15e2e88 and 0b4a085.

📒 Files selected for processing (5)
  • rs/kio/CHANGELOG.md
  • rs/kio/src/producer.rs
  • rs/kio/src/weak.rs
  • rs/moq-net/src/model/broadcast.rs
  • rs/moq-net/src/model/origin.rs
💤 Files with no reviewable changes (1)
  • rs/kio/src/weak.rs

Producer's read-only predicate poll is now poll() / wait() (was
poll_write_when / write_when) so both sides of the channel expose a
poll()/wait() pair. They differ in contract by design: Consumer's closure
returns the value, while Producer's is a bare Poll<()> predicate that gates
handing back a Mut with the lock still held.

Also drop `test = false` from kio's [lib] and add a unit test for the new
Producer::poll, and tidy origin's poll_announced to use ready! per the
poll_* plumbing guideline.
@kixelated kixelated changed the title kio: replace Mut-closure poll with read-only poll_write kio: rework Producer::poll/wait to a read-only predicate that returns a Mut Jun 14, 2026
@kixelated
kixelated enabled auto-merge (squash) June 14, 2026 21:34
@kixelated
kixelated merged commit 520e79c into main Jun 14, 2026
1 check passed
@kixelated
kixelated deleted the claude/kio-mut-poll-footgun-t0k9zm branch June 14, 2026 21:43
@moq-bot moq-bot Bot mentioned this pull request Jun 14, 2026
kixelated added a commit that referenced this pull request Jun 14, 2026
Resolved conflicts from main's kio Producer::poll/wait rework (#1735), which
replaced the mutating poll predicate with a read-only one that returns a Mut:

- rs/moq-net broadcast.rs / origin.rs / track.rs: ported dev's poll call sites
  (poll_requested_track, AnnounceConsumer::poll_next, poll_subscription_changed,
  poll_requested_group) onto the new read-only-predicate API. Mutation now happens
  through the returned Mut after the predicate gates readiness, which also removes
  the spurious self-wake footgun #1735 targeted.
- js/*: kept dev's refactored APIs (TrackProducer/TrackSubscriber/TrackConsumer
  split, callable time-unit brands, TrackRequest class) and grafted in main's
  JSR doc comments where dev lacked them.
@moq-bot moq-bot Bot mentioned this pull request Jun 16, 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