Skip to content

shuttle-tokio: Implement the mpsc reservation APIs - #340

Merged
sarsko merged 1 commit into
mainfrom
impl-mpsc-reserve
Sep 5, 2026
Merged

shuttle-tokio: Implement the mpsc reservation APIs#340
sarsko merged 1 commit into
mainfrom
impl-mpsc-reserve

Conversation

@sarsko

@sarsko sarsko commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #339.

Sender::reserve and Sender::reserve_owned panicked with unimplemented!(), try_reserve/try_reserve_owned were missing, and Permit/OwnedPermit had no methods at all, so there was no way to use a reservation under Shuttle.

Approach

The permit accounting this needs already existed. send acquires one permit from send_semaphore before pushing a message, and the receiver releases one back once it has popped a message. A Permit is exactly that acquire without the push, so:

  • reserve is the first half of send, with the acquired capacity parked in the Permit
  • Permit::send is the second half
  • Permit::drop returns the capacity — the "returning capacity when an unused permit is dropped" the issue asks for

There is no double-release risk, because the receiver only releases capacity for messages it actually popped, so capacity that never became a message is solely the permit's to return.

OwnedPermit additionally holds the moved-in sender's slot in ChannelState::known_senders. reserve_owned can't just move the Arc out of the SenderInternal, since SenderInternal::drop would then decrement that count and could close the channel out from under the permit. Instead it claims a second slot up front and lets the consumed sender give its own slot back, which keeps the count from dipping to zero across the await. OwnedPermit::{send, release} pass the slot on to the Sender they return, and OwnedPermit::drop returns the capacity and then gives the slot back.

Also factors the send-capacity accounting into Channel::{acquire_capacity, try_acquire_capacity, release_capacity}, so the rule that capacity is released exactly once by whoever consumed it lives in one place rather than being spelled out at each is_bounded() call site.

API

Signatures match tokio 1.x exactly:

Sender::reserve async fn(&self) -> Result<Permit<'_, T>, SendError<()>>
Sender::try_reserve fn(&self) -> Result<Permit<'_, T>, TrySendError<()>>
Sender::reserve_owned async fn(self) -> Result<OwnedPermit<T>, SendError<()>>
Sender::try_reserve_owned fn(self) -> Result<OwnedPermit<T>, TrySendError<Self>>
Permit::send fn(self, value: T)
OwnedPermit::send fn(self, value: T) -> Sender<T>
OwnedPermit::release fn(self) -> Sender<T>
OwnedPermit::same_channel fn(&self, other: &Self) -> bool
OwnedPermit::same_channel_as_sender fn(&self, sender: &Sender<T>) -> bool

Permit gains a lifetime parameter (Permit<T>Permit<'a, T>) to match tokio's Permit<'a, T>. That's a breaking change in principle, but the only way to obtain a Permit was reserve, which panicked, so nothing can be relying on the old shape. Doing it now is what makes the type drop-in compatible; SemaphorePermit<'a> in the same crate already borrows this way.

reserve_many/try_reserve_many and PermitIterator are still unimplemented — out of scope for #339.

Tests

15 new tests in tests/mpsc.rs, all check_dfs except the two that fan out over many senders. They cover: reserve→send delivery; capacity held while a permit lives and restored on drop; a dropped permit unblocking a send that was waiting on a full channel (this deadlocks without the Drop impl); reserve blocking when full; try_reserve full/closed; reserve after the receiver dropped; Permit::send after the receiver dropped not panicking; reserve_owned send/release round-tripping the Sender; an OwnedPermit keeping the channel open and closing it exactly once on drop, including waking a waiting receiver; try_reserve_owned handing the Sender back on failure; reservations and plain sends contending for the same capacity; and same_channel/same_channel_as_sender.

Verified locally: full tests/mpsc.rs suite (40 tests) passes, cargo clippy --all-targets -- -D clippy::all clean, cargo fmt --check clean, cargo doc --no-deps adds no new warnings, and shuttle-tokio builds with --features shuttle,full.

@sarsko sarsko changed the title Implement the mpsc reservation APIs shuttle-tokio: Implement the mpsc reservation APIs Sep 5, 2026
`Sender::reserve` and `Sender::reserve_owned` panicked with `unimplemented!()`,
`try_reserve`/`try_reserve_owned` were missing, and `Permit`/`OwnedPermit` had
no methods at all, so there was no way to use a reservation.

The permit accounting this needs already existed: `send` acquires one permit
from `send_semaphore` before pushing a message, and the receiver releases one
back once it has popped a message. A `Permit` is just that acquire without the
push, so `reserve` is the first half of `send`, `Permit::send` is the second
half, and dropping an unused permit returns the capacity. Because the receiver
only releases capacity for messages it actually popped, there is no risk of
double-releasing.

`OwnedPermit` additionally holds the moved-in sender's slot in `known_senders`.
`reserve_owned` cannot simply move the `Arc` out of the `SenderInternal`, since
`SenderInternal::drop` would then decrement that count and could close the
channel out from under the permit; instead it claims a second slot up front and
lets the consumed sender give its own slot back, which keeps the count from
dipping to zero across the `await`. `OwnedPermit::{send, release}` pass the slot
on to the `Sender` they return, and its `Drop` gives the slot back to the
channel after returning the capacity.

`Permit` gains a lifetime parameter to match tokio's `Permit<'a, T>`. That is a
breaking change in principle, but the only way to obtain a `Permit` used to
panic, so nothing can be relying on the old shape.

Also factors the send-capacity accounting into `Channel::{acquire_capacity,
try_acquire_capacity, release_capacity}` so the rule that capacity is released
exactly once, by whoever consumed it, lives in one place.

`reserve_many`/`try_reserve_many` and `PermitIterator` are still unimplemented.

Fixes #339
@sarsko

sarsko commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main to resolve conflicts with #319 (poll_recv), which landed in the same file.

Both conflicts were in code #319 rewrote, and both resolved in favour of #319's structure:

No change to the reservation implementation itself. Re-verified after the rebase: all 44 tests in tests/mpsc.rs pass (including #319's poll_recv_then_recv_no_leak, which is the one most likely to interact with the capacity accounting this PR touches), cargo clippy --all-targets -- -D clippy::all clean, cargo fmt --check clean, cargo doc --no-deps adds no new warnings, and shuttle-tokio builds with --features shuttle,full.

@sarsko
sarsko merged commit f8ee860 into main Sep 5, 2026
9 checks passed
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.

shuttle-tokio: implement mpsc reservation APIs

1 participant