Skip to content

runtime: keep io_uring op alive when submission fails after push - #8185

Merged
Darksonn merged 2 commits into
tokio-rs:tokio-1.53.xfrom
blasrodri:io-uring-keep-op-on-submit-failure
Oct 11, 2026
Merged

Darksonn merged 2 commits into
tokio-rs:tokio-1.53.xfrom
blasrodri:io-uring-keep-op-on-submit-failure

Conversation

@blasrodri

@blasrodri blasrodri commented Jun 2, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

In the io_uring backend, register_op pushes an SQE onto the submission queue
and then calls submit(). If submit() returns any error other than EBUSY,
the op is removed from the slab, which drops the operation and frees the buffer
the SQE points at.

This is unsound. Once the SQE is pushed into the submission queue, the kernel
may have already accepted it or will consume it on a subsequent submit(). In
that case the kernel can still write into the buffer we just freed
(use-after-free), and the eventual CQE refers to a slab slot that no longer
exists, which panics the driver or, if the slot has been reused, completes an
unrelated op.

Solution

Once the SQE has been pushed, cancel the op if submit() fails so the buffer
and fd stay alive in CancelData until the completion is observed, and the
error is still propagated to the caller. The pre-push submit (flushing a full
queue) is unchanged, since there the entry has not reached the submission ring
and removing it is correct.

Fixes: #8255

@mattiapitossi mattiapitossi added A-tokio Area: The main tokio crate M-runtime Module: tokio/runtime T-io-uring Topic: Linux io_uring labels Jun 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tokio does not use unit tests.
You should move this file (with a better name) to tokio/tests/...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tricky part is that this test has to force a submit failure after the SQE is pushed, then check the slab β€” and register_op, the slab, and the ring fd are all pub(crate), so a test in tokio/tests/ can't reach them.

I could expose a test-util hook like time::pause does, but it's messier here: time is already a public module, whereas runtime::io isn't, so I'd have to open up a public path through runtime just for this.

Felt like more plumbing than the test is worth, especially since mod tests; is already used in a few places (runtime, time, sync, fs::file).

I added tokio/tests/fs_uring_cancel_read.rs for the cancel path that's reachable from the public API. Happy to do the test-util version if you'd prefer it though.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned by Martin, tokio tries to avoid unit tests, so I would remove this file.

Also, could you clarify what fs_uring_cancel_read is intended to cover here? From what I see, it doesn't seem to find any leak in the current implementation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right about fs_uring_cancel_read: it cancels with handle.abort(), which goes through the normal State::Polled β†’ Op::drop β†’ cancel_op path. That path is unchanged by this PR, so the test doesn't cover the post-push failure this fixes.

The only way to trigger that branch is forcing a submit failure after the SQE is pushed, which needs crate-internal access (the BrokenRing fd trick) β€” so it can't be done from an integration test without either a unit test or fault-injection scaffolding in the driver.

Rather than add either, the fix routes the failure through the existing cancel_op/reclaim path, which is already exercised by the cancel tests.

Comment thread tokio/src/runtime/io/driver/uring.rs Resolved
Comment thread tokio/src/runtime/io/driver/uring.rs Resolved
Comment thread tokio/src/runtime/io/driver/uring.rs Outdated
// the buffer it points at. Keep the slot even if the submit fails: the
// operation may still complete, and removing it here would let the `Op`
// drop the buffer while it is in use. The slot is released once the
// completion is observed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slot is released once the completion is observed.

Could you please point me to the code where that would happen ?

I think we will always leak the op now.

  1. register_op() fails and leads to state = Complete
  2. Because the future is now complete, it is dropped, and cancel_op is never called.
  3. Since the slot was not removed from ctx.ops during register_op, it remains in the slab forever (either as Waiting or Completed once a completion event is dispatched), resulting in a permanent memory leak of the slab slot.

@blasrodri blasrodri Jun 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, that was leaking.

Keeping the slot as Waiting and returning the error meant the future went straight to State::Complete, so Drop never called cancel_op and nobody ever cleaned up the slot. Reworked it.

register_op now returns a RegisterResult so the caller knows where the submit failed. If it failed before the entry reached the kernel, we remove the slot and give the resources back, since the kernel never saw it. If it failed after the entry was pushed, it returns Pushed { index, error } and
keeps the slot.

In the Pushed case Op::poll calls cancel_op(index, data) before completing, which moves the slot to Lifecycle::Cancelled and parks the buffer/fd in CancelData. After that it's the same as a normally cancelled op: dispatch_completions sees the Cancelled state on the CQE, closes the fd if it was
an Open, and removes it from the slab. And if no completion ever shows up, Drop for UringContext drains what's left at shutdown and removes them.

So the slot goes away once the completion is observed (or at driver drop), and the buffer stays alive the whole time. No use-after-free and no leaked slot.

@blasrodri
blasrodri force-pushed the io-uring-keep-op-on-submit-failure branch 2 times, most recently from 09993a4 to d5e4b10 Compare June 4, 2026 14:45
@mattiapitossi
mattiapitossi self-requested a review June 4, 2026 15:24
@blasrodri
blasrodri requested a review from martin-g June 8, 2026 12:59
@blasrodri
blasrodri force-pushed the io-uring-keep-op-on-submit-failure branch 2 times, most recently from f369753 to f7e3b8e Compare June 8, 2026 17:47
// `entry` is now pushed, so the kernel may already own the buffer it
// points at. Keep the slot on a submit error: dropping it would let the
// `Op` free the buffer while it is still in use.
if let Err(e) = ctx.submit() {

@mattiapitossi mattiapitossi Jun 25, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, if I got this correctly this now means that for every error we are receiving from io_uring_enter (apart from EBUSY, because we checked it before) we don't remove entries from the slab? If so, I'm not totally sure this is the right approach. The Kernel could see the submission when interrupted but for most of errors this is not the case

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, thanks β€” keeping the slot on every error was too broad. I've narrowed it.

Looking at the man page, the only case where the kernel might have consumed our entry is EINTR (a signal interrupts io_uring_enter mid-submit). The other errnos io_uring_enter returns directly β€” EBADF, EINVAL, ENXIO, etc. β€” are whole-call failures about the ring/flags, not about a queued entry; per-SQE validation errors come back through a CQE, not the syscall. So for those the entry wasn't consumed and we can drop the slot and hand the resources back.

The reason we can't just check the consumed-count is that submit() discards it (returns io::Result<()>), so on EINTR we genuinely can't tell whether our entry made it. Keep-and-cancel is the safe choice there.

Updated so only EINTR returns Pushed; everything else is NotPushed now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even when io_uring_enter fails before the kernel consumes the SQE, submission().push(&entry) has already added the entry to the user-space submission ring (and io-uring has no way to un-push it). That means the entry stays in the ring and will still be submitted to the kernel on the next submit() call, so we have to keep the slab entry alive on all errors after push, not just EINTR.

@Darksonn Darksonn added C-bug Category: This is a bug. M-fs Module: tokio/fs M-io Module: tokio/io I-unsound πŸ’₯ A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/Soundness S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. labels Oct 11, 2026
@Darksonn
Darksonn changed the base branch from master to tokio-1.53.x October 11, 2026 07:58
@Darksonn
Darksonn force-pushed the io-uring-keep-op-on-submit-failure branch from f7e3b8e to 85a6c25 Compare October 11, 2026 08:03
@github-actions github-actions Bot added the R-loom-time-driver Run loom time driver tests on this PR label Oct 11, 2026
@Darksonn
Darksonn force-pushed the io-uring-keep-op-on-submit-failure branch from 85a6c25 to c65a65b Compare October 11, 2026 08:24

@Darksonn Darksonn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this PR has been dormant for a while (sorry!), I took the liberty of rebasing it and making a minor tweak. I think the fix is correct now. Thanks a lot for the PR!

blasrodri and others added 2 commits October 11, 2026 09:16
Route post-push io_uring submission failures through the cancellation path so the operation's buffer, fd, and path remain alive in CancelData until the driver observes the completion, preventing use-after-free. Add a unit test verifying that a post-push submit failure keeps the slab slot alive.

Fixes: tokio-rs#8255
When a previous submission fails after pushing an entry to the
submission ring, the next submit call can have multiple entries queued
and return Ok after only submitting a subset of them. Additionally,
signal interruptions during submission are not retried. Thus, loop until
the submission queue is empty and retry on EINTR, while still calling
submit() at least once when the submission queue is empty so that
Driver::turn can flush completion queue overflows.
@Darksonn
Darksonn force-pushed the io-uring-keep-op-on-submit-failure branch from 931460f to abfcf65 Compare October 11, 2026 09:33
@Darksonn
Darksonn merged commit c5e4d9e into tokio-rs:tokio-1.53.x Oct 11, 2026
86 checks passed
@Darksonn Darksonn removed the S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. label Oct 11, 2026
dadezzz pushed a commit to dadezzz/kube-dns-rs that referenced this pull request Oct 14, 2026
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [tokio](https://tokio.rs) ([source](https://github.com/tokio-rs/tokio)) | dependencies | patch | `1.53.2` β†’ `1.53.3` |

---

### Release Notes

<details>
<summary>tokio-rs/tokio (tokio)</summary>

### [`v1.53.3`](https://github.com/tokio-rs/tokio/releases/tag/tokio-1.53.3): Tokio v1.53.3

[Compare Source](tokio-rs/tokio@tokio-1.53.2...tokio-1.53.3)

### 1.53.3 (October 13th, 2026)

##### Added

- io: add `AsyncFd::register` and `AsyncFd::register_with_interest` ([#&#8203;8587])

##### Changed

- io: deprecate `AsyncFd::{new, with_interest, try_new, try_with_interest}` ([#&#8203;8587])

##### Fixed

- io: wake and drop `DuplexStream` wakers outside the lock ([#&#8203;8618])
- macros: avoid `unreachable_code` in `join!` and `try_join!` for `Infallible` ([#&#8203;8518])
- runtime: fix paused time auto-advancing when I/O wakes a task on `current_thread` ([#&#8203;8595])
- sync: wake waiters outside the lock when closing a semaphore ([#&#8203;8529])
- test: fix several flaky tests ([#&#8203;8196], [#&#8203;8427], [#&#8203;8609])

##### Fixed (unstable)

- rt: keep `io_uring` op alive when submission fails after push ([#&#8203;8185])

[#&#8203;8185]: tokio-rs/tokio#8185

[#&#8203;8196]: tokio-rs/tokio#8196

[#&#8203;8427]: tokio-rs/tokio#8427

[#&#8203;8518]: tokio-rs/tokio#8518

[#&#8203;8529]: tokio-rs/tokio#8529

[#&#8203;8587]: tokio-rs/tokio#8587

[#&#8203;8595]: tokio-rs/tokio#8595

[#&#8203;8609]: tokio-rs/tokio#8609

[#&#8203;8618]: tokio-rs/tokio#8618

</details>

---

### Configuration

πŸ“… **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

β™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

πŸ”• **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTUuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjE1NS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-tokio Area: The main tokio crate C-bug Category: This is a bug. I-unsound πŸ’₯ A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/Soundness M-fs Module: tokio/fs M-io Module: tokio/io M-runtime Module: tokio/runtime R-loom-time-driver Run loom time driver tests on this PR T-io-uring Topic: Linux io_uring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants