Repository navigation
Make io_uring cancellation submission exception-safe #228
Description
Problem
Cancellation can unwind a waiting fiber before its original kernel operation has completed if obtaining the cancellation SQE raises.
This was found while reviewing #210, but the shared cancellation implementation already exists on main (fb8c691); it is not introduced by Futex support.
Cause
Completion_cancel_asyncsetscancellation_pending = truebefore callingio_get_sqe.- If the submission queue is full,
io_get_sqecallsio_uring_submit_now. This can yield or raise, including whenio_uring_submitreturns an error. Waiting_cancel_and_waitinvokesCompletion_cancel_asyncoutside its protected draining loop.
If cancellation submission raises, cleanup can unwind without observing the original operation's completion. Stack-backed waiting state and, depending on the caller, buffer locks/references can be released while the operation is still outstanding. The completion also remains marked as having a pending cancellation even though no cancellation SQE was prepared.
The existing read cleanup, write cleanup, and waitid cleanup use this shared helper. These paths are implicated by code inspection; the fault-injected reproduction below used the Futex branch.
Reproduction observed
Reproduced for both single and vector Futex waits on #210 at 9944c6d, using Linux, Ruby head, and liburing 2.6:
- Submit an original wait with
selector.select(0). - Fill the 64-entry submission queue with other waits without calling
select. - Use an isolated
LD_PRELOADshim to makeio_uring_submitreturn-EIO. - Raise into the original waiting fiber, triggering cancellation cleanup.
- Observe that the fiber exits with
Errno::EIOand its Futex can be closed. - A separate Futex referencing the same address can still wake one kernel waiter.
Thus the Ruby wait has finished and released its reference while the kernel wait remains pending. The subprocess disabled GC and used Process.exit! without processing further completions to avoid dereferencing the abandoned wait state. This demonstrates premature cleanup under injected submission failure; it is not a naturally occurring crash reproduction.
Proposed direction
Make requesting cancellation non-raising and non-yielding:
- Try to acquire an SQE directly, without flushing.
- If the queue is full, retain the completion on a selector-owned pending-cancellation list without allocating. Track the deferred request explicitly and prevent completion reuse.
- Have the event loop prepare and submit deferred cancellations before blocking.
- Retain the waiting frame and any kernel-visible memory until the original operation's CQE has been observed. Track the cancellation CQE separately before recycling the completion.
Simply moving the call inside rb_protect is insufficient: a failed submission must not leave an untracked cancellation that later calls silently skip.
Unrecoverable ring errors also need an explicit selector-level shutdown policy. A failed cancellation submission is not proof that the original operation has stopped accessing memory.
Regression coverage
Cover cancellation with a full submission queue, injected submission errors, and interruption while submission yields. Verify that outstanding operations retain their wait state and memory, cancellation is not silently lost, and completion records are not reused prematurely. Keep this fix separate from #210.
Activity