How to Fix: async: cancellable thread.yield not directly resumed by subtask.cancel
When subtask.cancel does not wake a cancellable thread.yield, the scheduler loses a critical handoff: cancellation is recorded, but the blocked task is not directly resumed to observe it.
This issue shows up in the async Component Model tests because a task suspended at thread.yield remains parked even after its parent or related subtask is cancelled. The result is subtle but important: the runtime treats cancellation as state, not as a wake-up event, so the yielded execution does not immediately re-enter to process the cancellation path.
Understanding the Root Cause
At the heart of the bug is a mismatch between cancellation propagation and resumption semantics. In a correct async runtime, a cancellable suspension point like thread.yield must do two things:
- Register that the task is suspended and cancellable.
- Provide a path for external events, including subtask.cancel, to place that task back on the run queue.
What happens here instead is that subtask.cancel updates cancellation state for the subtask, but does not directly resume the task currently blocked in thread.yield. If the task is only checking cancellation when it runs again, and no scheduler event requeues it, the task can remain suspended longer than intended.
Technically, this means the runtime is treating yield as a passive suspension point rather than an interruptible one. The cancellation flag exists, but the suspended continuation is not signaled. In practice, the continuation should be transitioned from something like Yielded to Ready when cancellation is delivered.
This is why the test case matters even if it appears low priority: it validates whether the runtime honors the expected contract that a cancellable yield can be interrupted by external cancellation without depending on unrelated scheduler activity.
Step-by-Step Solution
The fix is to wire subtask.cancel into the scheduler so that it not only marks the target subtask as cancelled, but also directly wakes any task suspended in a cancellable thread.yield.
1. Identify the suspension record for cancellable yield
Your runtime needs a structured representation of a suspended task. If it already tracks waiters, yields, or continuations, ensure that thread.yield records whether the suspension is cancellable.
enum TaskState {
Ready,
Running,
Yielded { cancellable: bool },
Waiting,
Cancelled,
Completed,
}
struct Task {
id: TaskId,
state: TaskState,
cancelled: bool,
queued: bool,
}
2. Update cancellation logic to wake yielded cancellable tasks
The key change is here. When subtask.cancel is invoked, do not stop after setting a flag. If the task is suspended in a cancellable yield, enqueue it for execution immediately.
fn cancel_subtask(task: &mut Task, scheduler: &mut Scheduler) {
task.cancelled = true;
match task.state {
TaskState::Yielded { cancellable: true } => {
task.state = TaskState::Ready;
if !task.queued {
scheduler.enqueue(task.id);
task.queued = true;
}
}
TaskState::Waiting => {
// Optional: if waiting is also cancellation-aware,
// convert it into a resumable cancellation path.
}
_ => {}
}
}
3. Make thread.yield observe cancellation on resume
Once resumed, the yielded code path must check the cancellation state before continuing normal work.
fn thread_yield(task: &mut Task, scheduler: &mut Scheduler, cancellable: bool) -> Result<(), Trap> {
if task.cancelled {
return Err(Trap::Cancelled);
}
task.state = TaskState::Yielded { cancellable };
scheduler.suspend_current(task.id);
// Control returns here only after the task is resumed.
if task.cancelled {
return Err(Trap::Cancelled);
}
Ok(())
}
4. Prevent lost wake-ups
A common implementation bug is waking a task without guarding against double-queueing or races between scheduler transitions. Ensure the enqueue operation is idempotent relative to task state.
fn wake_task(task: &mut Task, scheduler: &mut Scheduler) {
if matches!(task.state, TaskState::Yielded { .. } | TaskState::Waiting) {
task.state = TaskState::Ready;
}
if !task.queued && !matches!(task.state, TaskState::Completed) {
scheduler.enqueue(task.id);
task.queued = true;
}
}
5. Align the runtime with the expected test behavior
The test in the Component Model repository expects cancellation to have observable scheduling consequences. In other words, subtask.cancel must act as both:
- a state transition to cancelled, and
- a resumption trigger for interruptible suspension points.
If your engine has separate code paths for cancellation and wake-up notifications, connect them explicitly for cancellable yield frames.
6. Add or update regression coverage
Add a focused test that verifies all three facts:
- The task enters thread.yield.
- subtask.cancel is invoked while it is suspended.
- The task is resumed quickly enough to observe the cancellation and exit through the expected trap or completion path.
;; Pseudocode test intent
(start subtask
(do
(thread.yield cancellable)
(unreachable-if-not-cancelled)))
(subtask.cancel subtask)
(assert task-resumed-and-cancelled)
7. If your runtime uses an event loop, schedule cancellation as a runnable event
Some implementations separate execution into microtasks, fibers, or host-driven polling. In those runtimes, direct stack resumption may not be possible. The correct equivalent is to push a ready event for the cancelled yielded task.
fn cancel_subtask(task_id: TaskId, runtime: &mut Runtime) {
let task = runtime.task_mut(task_id);
task.cancelled = true;
if matches!(task.state, TaskState::Yielded { cancellable: true }) {
task.state = TaskState::Ready;
runtime.ready_queue.push(task_id);
}
}
This preserves the same contract without requiring synchronous unwinding.
Common Edge Cases
Cancellation arrives before thread.yield fully registers
If cancellation can race with task suspension, you need an atomic or lock-protected transition so that the task cannot miss the cancel signal between “about to yield” and “officially yielded.”
Non-cancellable yields are resumed incorrectly
Do not wake every yielded task on cancellation. The behavior should apply only where the suspension point is explicitly marked cancellable, otherwise you may violate runtime semantics.
Double enqueue causes duplicate execution
If both the scheduler and cancellation path attempt to wake the same task, you can run the continuation twice unless your queueing logic tracks whether the task is already runnable.
Cancellation state is cleared too early
Some runtimes consume cancellation as a one-shot event. If that happens before resumed code checks it, the task may continue normally. The cancellation marker must remain visible until the continuation processes it.
Parent-child cancellation propagation is inconsistent
If subtask.cancel propagates through a tree of async tasks, make sure the wake-up behavior applies consistently to all descendants currently suspended in cancellable yield states.
Host callbacks or promises keep the task indirectly alive
If your task also waits on host-side signals, resuming it on cancellation may still require detaching or invalidating external wait registrations to avoid unexpected later wake-ups.
FAQ
Why is setting a cancellation flag alone not enough?
Because a task suspended in thread.yield may never run again until something requeues it. Without a wake-up, the cancellation stays latent and the task cannot observe it.
Should subtask.cancel always resume a suspended task immediately?
It should resume or schedule the task when the current suspension point is cancellation-aware. In synchronous runtimes that may mean direct wake-up; in event-loop runtimes it usually means placing the task back on the ready queue.
Is this a scheduler bug or a cancellation bug?
It is both at the integration boundary. The cancellation mechanism correctly marks intent, but the scheduler fails to deliver that intent to a task blocked in cancellable yield. The practical fix is in the handoff between them.
For reference, review the linked test in the Component Model async cancellable test and align your runtime so that subtask.cancel transitions a cancellable yielded task into a runnable state.