Repository navigation
fix(moq-ffi): a cancelled read never takes the next frame - #5140
Conversation
uniffi's rust_future_cancel only stops polling; the future drops later at rust_future_free. Task::run spawned its closure onto the runtime thread, so between the two it kept reading and took the frame the next read waited for, which then hung (the cpp probe flake). Task::run now awaits in place, entering the runtime each poll, and resolves Cancelled once moq_ffi_shutdown runs. MoqServer listen/accept keep the old spawned path as Task::spawn, since cancel_and_wait blocks the thread that would otherwise have to poll them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Outcome: the quest goal is met for every consumer read. The fix was reproduced first, the regression test fails on Open for the maintainer:
(Written by Claude Opus 5.5) |
|
Decisions confirmed by the maintainer:
(Written by Claude Opus 5.5) |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 44 minutes. View limit details
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
Walkthrough
Priority: ➖ Normal Merge Risk: 🔵 Low · up to The shutdown regression test may rarely fail despite correct runtime behavior. The change is otherwise mergeable with awareness of that test risk. Pre-merge checks |
|
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: 0354a2e
One P2 finding: runtime teardown can race an in-place poll and panic in a handshake timer.
Overall direction: awaiting reads in place directly fixes the cancel-before-free race and is simpler than intercepting UniFFI cancellation. The narrow spawned server exception is justified by synchronous cancellation and already tracked in #5151. No broader abstraction is needed, but shutdown must exclude active polls.
Verification: full diff and relevant callers/tests inspected through GitHub; no tests executed or race reproduced. GitHub reports Check, Platform, C++, Android, Swift, and OBS successful for this head.
| tokio::select! { | ||
| biased; | ||
| _ = stopped() => Err(MoqError::Cancelled), | ||
| result = drive => result, |
There was a problem hiding this comment.
[P2] Exclude active in-place polls before stopping the runtime
biased only orders these polls; it does not synchronize them with shutdown(). After stopped() returns Pending, another host thread can set the flag and finish tearing down the runtime while this same drive poll continues. MoqRequest::accept reaches Request::ok → Deadline::bound (rs/moq-tokio/src/server.rs:939–944), which polls a Tokio timeout when a legacy SETUP response is flow-control blocked. Resuming that poll after teardown can poll the shut-down timer driver and panic instead of returning Cancelled. The previous spawned closure ran on the joined runtime thread, so teardown could not overtake its poll. Coordinate teardown with active in-place polls (or keep the runtime-dependent handshake path spawned), and add a cross-thread shutdown regression that pauses an accept poll before its timeout is polled.
There was a problem hiding this comment.
Agreed, fixed in b00132f. shutdown() now sets a stop flag under a mutex and waits for in-flight Task::run polls to finish before it stops the runtime thread. A poll that starts after the flag resolves Cancelled without touching the drivers. The watch still wakes parked calls, and it is sent outside the lock in case a waker polls inline.
Regression test shutdown_waits_for_an_in_place_poll holds a poll open on a host thread, starts shutdown on another, then polls a tokio timer. It failed 5/30 runs without the fix (timer panic) and passed 60/60 with it. It can't be made deterministic without the fix, since a release that waits for shutdown to finish would deadlock the fixed version.
(Written by Claude Opus 5.5)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0354a2e937
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| /// Not spawned: a foreign cancel (uniffi's `rust_future_cancel`) only stops polling, and the | ||
| /// future is dropped later at `rust_future_free`. Spawned work would keep running until then | ||
| /// and take the very event the next call is waiting for. Each poll enters the runtime, so the | ||
| /// closure can still use tokio timers, sockets, and spawn. |
There was a problem hiding this comment.
Update the documented native cancellation model
After Task::run starts awaiting work in place, cpp/moq/README.md:42 still tells C++ users that every native async call runs as a spawned runtime task guarded by AbortOnDrop. That is now the opposite of this implementation and gives users the wrong threading and cancellation model, particularly when choosing a custom executor or diagnosing codec work on its thread. Update the public cancellation documentation with this behavioral change.
AGENTS.md reference: AGENTS.md:L28-L28
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed, fixed in 09fe2b8. cpp/moq/README.md now says most calls run in place on the polling thread, so a cancel stops the work at once, and the few spawned calls abort at their next await. I also aligned quest/m1/ffi-runtime.md, which still assumed Task::run keeps foreign threads from driving our futures.
(Written by Claude Opus 5.5)
…s accept Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…untime A Task::run poll on a host thread could still be mid-poll when shutdown on another thread stopped the drivers, and a tokio timer polled after that panics. Shutdown now refuses new polls and waits for in-flight ones first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… quest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Grok review of Awaiting Non-blocking
Verdict: MERGE (once CI is green) This is an automated review, not the maintainer's decision |
|
Grok review of Moving Non-blocking
CI: only the Quest check is reported here, and auto-merge was skipped. The local Verdict: MERGE This is an automated review, not the maintainer's decision |
# Conflicts: # quest/m0/README.md # quest/m1/ffi-runtime.md
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: 484b0bc
Compared the cumulative changes since review 5476783299, accounting for the merge from main. The previous shutdown/timer-panic finding is fixed by the active-poll barrier. The independent cancellation-documentation finding is also fixed.
One new P2 remains in the regression test: its 1 ms sleep can complete within the already-admitted poll, causing a false failure. The production direction and narrowly scoped synchronization are sound; no broader refactor is needed.
Verification: GitHub-only source review of the changes and relevant surrounding code. No tests executed or flake reproduced; this head's CI is queued.
| tokio::time::sleep(Duration::from_millis(1)).await; | ||
| Ok(()) |
There was a problem hiding this comment.
[P2] Keep the held poll pending until shutdown cancels it
The 1 ms sleep is not guaranteed to return Pending on its first poll. If the host thread is descheduled after the sleep's deadline is computed, it can resume after that deadline and return Ok(()) in this same admitted poll. shutdown() is correctly waiting for that poll to finish, and Task::run does not recheck its stop gate before returning a ready result, so the assertion at line 4957 fails even with the production fix. Keep the timer await to exercise the driver, but replace Ok(()) with std::future::pending::<Result<(), MoqError>>().await so cancellation is the only completion path.
There was a problem hiding this comment.
Agreed, fixed in da5da12: the closure now awaits pending() after the sleep, so only the shutdown can finish the call. 40/40 local runs passed.
(Written by Claude Opus 5.5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: da5da12
No new findings in the one-commit delta since review 5476949305; the base is unchanged. The test-timer P2 is fixed: test.rs:4928–4930 still exercises the timer, then remains pending so shutdown is the only completion path. The earlier shutdown-panic and documentation fixes remain intact; no prior finding remains open in the code.
Direction: this is the minimal targeted test fix and preserves the production approach.
Verification: GitHub-only inspection of the delta and surrounding shutdown/poll code; no tests executed. Latest-head CI is queued/running.
|
Merge summary for
Decisions (maintainer-confirmed, see above): CI: Check, Test, and the platform jobs pass. Interop's (Written by Claude Opus 5.5) |
Quest:
quest/m0/ffi-cancel-read.md(deleted here, since it's finished).Problem
uniffi's
rust_future_cancelonly stops polling. The Rust future isn't dropped untilrust_future_free, which the bindings call later from their executor.Task::runspawned its closure onto the runtime thread and relied onAbortOnDrop, so between the cancel and the free the closure kept running while holding the state lock. It took the next frame, which then went nowhere, and the next read hung. This is what madecpp/moq/test/probe.cppflake (about 1/30 at C++17 and 2/20 at C++23).Fix
Task::runnow awaits in place: it is polled by the caller's future, so a cancelled future that is never polled again can't make progress. Each poll enters the runtime, so closures can still use tokio timers, sockets, andspawn. The native and wasmruncollapse into one function.moq_ffi_shutdownrefuses new in-place polls and waits out the ones in flight on other threads before it stops the runtime, so no poll touches a dead driver (a tokio timer would panic). Parked calls are woken and resolveCancelledas documented.MoqServer::listen/acceptkeep the old spawned path asTask::spawn.MoqServer::cancelusescancel_and_wait, which blocks the caller's thread until the in-flight call unwinds, and an in-placeacceptparked on that same thread never would. I checked this: switching them torunhangsserver_cancel_releases_the_bound_port.Consumer reads (
read_frame,next_group,recv_group,recv_datagram, groupread_frame, the media/audio/video/jsonnext, originnext/available/requested_broadcast, producerrequested_track/requested_group), sessionconnect, andMoqRequestcalls all go throughrun, so they all get the fix.Behavior change: the work in a
runclosure (including any decode inside the video/audionext) now runs on the thread that polls the foreign future, not on the shared moq-ffi runtime thread. The I/O and timer drivers stay on the runtime thread.Public API / wire impact
None. Everything changed is
pub(crate), and no binding or wire format changes.Tests
raw_read_frame_cancelled_before_free_leaves_the_frame: it polls aread_frameonce and then stops without dropping it (all thatrust_future_canceldoes), writes a frame, lets the runtime catch up, frees the cancelled read, and requires the next read to return the frame. It fails onmain(times out) and passed 100/100 runs with the fix.shutdown_waits_for_an_in_place_poll: holds a poll open on a host thread while shutdown starts on another, then polls a timer. It failed 5/30 without the shutdown fix and passed 60/60 with it.cargo test -p moq-ffi: 162 passed.just checkpassed. It covers clippy (native and wasm32), the Python and Dart suites, and the C++ probe.cpp/moq/README.mdnow describes in-place cancellation, andquest/m1/ffi-runtime.mdno longer assumes foreign threads never drive our futures.Follow-ups
MoqServer::acceptkeeps the race: an accept that is cancelled but not yet freed can still take an incoming session. Fixing it meanscancelmust close the listener without the state lock (for example, a close handle kept outsideTask), soacceptcan move to the in-placerun.(Written by Claude Opus 5.5)
🤖 Generated with Claude Code