Skip to content

Commit fa8c294

Browse files
committed
CABI: tighten subtask.cancel behavior, add tests
1 parent a53b241 commit fa8c294

11 files changed

Lines changed: 635 additions & 207 deletions

‎design/mvp/CanonicalABI.md‎

Lines changed: 49 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,8 @@ time before or after the callee returns. If the callee returns and the
584584
`OnResolve` callback has *not* yet been called, the caller may invoke the
585585
returned `OnCancel` callback *at most once* to cooperatively request that the
586586
callee "hurry up" and call `OnResolve` (possibly, but not necessarily, passing
587-
`None` and/or skipping the call to `OnStart`).
587+
`None` and/or skipping the call to `OnStart`). The `OnCancel` may transitively
588+
execute arbitrary guest code but must not block.
588589

589590
When `FuncInst` is implemented by wasm guest code (as opposed to the host), each
590591
call creates a `Task` object to track the state of the call and ensure that the
@@ -730,12 +731,20 @@ returned a value to its caller.
730731
```
731732

732733
The `Task.request_cancellation` method implements the `OnCancel` callback
733-
described above that allows a caller to indicate that they are no longer
734+
described above and allows a task's caller to indicate that they are no longer
734735
interested in the return value. If a task's implicit thread is waiting to start
735736
(in `Task.enter_implicit_thread`, defined above) due to backpressure, then it is
736-
immediately cancelled without running any guest code. Otherwise, the pending
737-
request is stored in the task state so that it can be picked up from the
738-
`async callback` event loop via `Task.deliver_pending_cancel`.
737+
immediately cancelled without running any guest code. Otherwise, if any of the
738+
threads in the callee's component instance are ready to run, one is resumed
739+
(chosen nondeterministically if there are multiple). Furthermore, the host may
740+
nondeterministically continue resuming ready threads in the same component
741+
instance until either the subtask resolves or the host declares that
742+
cancellation has blocked. Note that setting `Task.state` to `PENDING_CANCEL`
743+
makes any implicit `callback` thread contained by the task that is waiting in
744+
its event loop `ready` (as long as the component instance's `exclusive_thread`
745+
lock is not held by some other implicit thread). Thus, in the best case,
746+
`subtask.cancel` directly calls the subtask's `callback` function, passing
747+
`TASK_CANCELLED`.
739748
```python
740749
def request_cancellation(self):
741750
if self.state == Task.State.INITIAL:
@@ -745,7 +754,18 @@ request is stored in the task state so that it can be picked up from the
745754
else:
746755
assert(self.state == Task.State.STARTED)
747756
self.state = Task.State.PENDING_CANCEL
757+
while self.state != Task.State.RESOLVED:
758+
candidates = { t for t in self.inst.threads if t.ready() }
759+
if candidates:
760+
random.choice(list(candidates)).resume()
761+
if not candidates or DETERMINISTIC_PROFILE or random.randint(0,1):
762+
break
763+
```
748764

765+
If the pending cancellation request is not delivered to the subtask during
766+
`Task.request_cancellation`, it may still be delivered in the future to a
767+
`callback` via `Task.deliver_pending_cancel`:
768+
```python
749769
def has_pending_cancel(self):
750770
return self.state == Task.State.PENDING_CANCEL
751771

@@ -4089,24 +4109,8 @@ validation specifies:
40894109
* `$f` is given type `(func (param i32) (result i32))`
40904110
* 🚝 - `async` is allowed (otherwise it must be absent)
40914111

4092-
Calling `$f` sends a request to a nondeterministically-chosen thread of the
4093-
subtask at the given index to cancel the subtask ASAP. This request is
4094-
cooperative and the subtask may take arbitrarily long to receive and confirm
4095-
the request. If the subtask doesn't immediately confirm the cancellation
4096-
request, `subtask.cancel` returns `BLOCKED` and the caller must wait for a
4097-
`SUBTASK` progress update using `waitable-set` methods as usual.
4098-
4099-
When cancellation is confirmed the supertask will receive the final state of
4100-
the subtask which is one of:
4101-
* `RETURNED`, if the subtask successfully returned a value via `task.return`;
4102-
* `CANCELLED_BEFORE_STARTED`, if the subtask was cancelled before receiving its
4103-
arguments (and thus no `own` handles were transferred); or
4104-
* `CANCELLED_BEFORE_RETURNED`, if the subtask called `task.cancel` instead of
4105-
`task.return`.
4106-
4107-
This state is either returned by `subtask.cancel`, if the subtask resolved
4108-
without blocking, or, if `subtask.cancel` returns `BLOCKED`, then as part of
4109-
the event payload of a future `SUBTASK` event.
4112+
Calling `$f` invokes the following function which cooperatively requests
4113+
cancellation of the given subtask:
41104114
```python
41114115
BLOCKED = 0xffff_ffff
41124116

@@ -4122,11 +4126,8 @@ def canon_subtask_cancel(async_, i):
41224126
subtask.cancellation_requested = True
41234127
subtask.has_sync_waiter = True
41244128
subtask.on_cancel()
4125-
if not subtask.resolved():
4126-
if not async_:
4127-
thread.wait_until(subtask.resolved)
4128-
else:
4129-
thread.yield_()
4129+
if not async_ and not subtask.resolved():
4130+
thread.wait_until(subtask.resolved)
41304131
subtask.has_sync_waiter = False
41314132
if not subtask.resolved():
41324133
return [BLOCKED]
@@ -4148,26 +4149,28 @@ trapping on entry to `subtask.cancel`.
41484149

41494150
Otherwise, if the subtask is *not* already resolved, the subtask's `OnCancel`
41504151
callback is called to officially request cancellation. If the callee is another
4151-
component, `OnCancel` is implemented by `Task.request_cancellation` which, as
4152-
defined above, will immediately resolve a task that is blocked waiting to enter
4153-
due to backpressure. Otherwise, the second `if not subtask.resolved()` handles
4154-
the general case where core wasm code needs to run in the callee to receive and
4155-
explicitly resolve the task (by calling `task.cancel` or `task.return`).
4156-
4157-
If `subtask.cancel` is called synchronously, the call blocks until the subtask
4158-
resolves. If `subtask.cancel` is called asynchronously, rather than *blocking*,
4159-
`Thread.yield_` is called to allow the host to nondeterministically schedule
4160-
whatever thread it heuristically chooses in the hopes that a subtask thread will
4161-
get to execute and quickly resolve the subtask, allowing `subtask.cancel` to
4162-
complete eagerly. Otherwise, `subtask.cancel` returns `BLOCKED` (`-1`) and the
4163-
caller must wait for a `SUBTASK` event using a waitable set.
4152+
component, `OnCancel` is implemented by `Task.request_cancellation` (defined
4153+
above); which may execute arbitrary guest code in the callee before returning,
4154+
but may not block.
4155+
4156+
If the subtask is *not* eagerly resolved before `OnCancel` returns and
4157+
`subtask.cancel` is called with the `async` immediate, then `subtask.cancel`
4158+
immediately returns the sentinel `BLOCKED` (`-1`) value. Otherwise, if called
4159+
without `async`, `subtask.cancel` blocks until the subtask *is* resolved. Once
4160+
the subtask is resolved, the return value is the subtask's final `State` value
4161+
which is one of:
4162+
* `RETURNED`, if the subtask successfully returned a value via `task.return`;
4163+
* `CANCELLED_BEFORE_STARTED`, if the subtask was cancelled before receiving its
4164+
arguments (and thus no `own` handles were transferred); or
4165+
* `CANCELLED_BEFORE_RETURNED`, if the subtask called `task.cancel` instead of
4166+
`task.return`.
41644167

41654168
Lastly, a subtle race condition guarded by the above code is that during the
4166-
calls to `on_cancel`, `yield_` and `wait_until`, arbitrary code may run which
4167-
can reenter the caller's component instance. By setting `has_sync_waiter` to
4168-
true for the duration of these calls, `subtask.cancel` prevents other threads in
4169-
the same component instance from "stealing" an event or otherwise invalidating
4170-
the conditions guarded at the beginning of `subtask.cancel`.
4169+
calls to `on_cancel` and `wait_until`, arbitrary code may run which can reenter
4170+
the caller's component instance. By setting `has_sync_waiter` to true for the
4171+
duration of these calls, `subtask.cancel` prevents other threads in the same
4172+
component instance from "stealing" an event or otherwise invalidating the
4173+
conditions guarded at the beginning of `subtask.cancel`.
41714174

41724175

41734176
### 🔀 `canon subtask.drop`

‎design/mvp/Concurrency.md‎

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -711,10 +711,11 @@ achieve the highest degree of (cooperative) concurrency.
711711

712712
Since non-`async` functions are not allowed to block (including due to
713713
backpressure) and also don't pile up like `async` functions, non-`async`
714-
functions ignore backpressure (explicit and implicit) entirely. If a
715-
component exports a mix of `async` and non-`async` functions, code generation
716-
must therefore be prepared to handle non-`async` functions executing at
717-
any cooperative yield point, even in the middle of a `callback`.
714+
functions ignore backpressure (explicit and implicit) entirely. If a component
715+
exports a mix of `async` and non-`async` functions, code generation must
716+
therefore be prepared to handle non-`async` functions executing at any core wasm
717+
import call to a `canon` definition (with the exception of select non-reentrant
718+
built-ins like `resource.get`).
718719

719720
Once a task is allowed to start according to these backpressure rules, its
720721
arguments are lowered into the callee's linear memory and the task is in
@@ -796,31 +797,29 @@ allowing the subtask to continue executing for an arbitrary amount of time
796797
to call [`task.cancel`] to confirm the cancellation or, for whatever reason,
797798
call `task.return` as-if there had been no cancellation. `task.cancel` enforces
798799
the same "all borrowed handles dropped" rule as `task.return`, so that once a
799-
subtask is resolved, the caller knows its lent handles have been returned. If
800-
the subtask was waiting to start due to backpressure, the subtask is immediately
801-
aborted without running the callee at all.
802-
803-
When `subtask.cancel` is called, it first records a "pending cancellation
804-
request" on the callee's task. If the callee's task was waiting to start due to
805-
[backpressure](#backpressure), `subtask.cancel` returns "cancelled before
806-
started" immediately. Otherwise, `subtask.cancel` performs a nonblocking,
807-
cooperative `thread.yield` that allows the host to nondeterministically schedule
808-
any cooperative threads it wants in the hopes of delivering the cancellation
809-
request to the callee. Currently, cancellation requests can only be delivered
810-
to `async` functions using the stackless `callback` ABI. In this case, a pending
811-
cancellation request is delivered as a "task cancelled" event to the `callback`
812-
function, following the standard run-to-completion rules mentioned above. In
813-
the [future](#TODO), other cancellation delivery mechanisms will be added for
814-
the `async` functions using the stackful ABI.
815-
816-
`subtask.cancel` can be called synchronously or asynchronously. If called
817-
synchronously, `subtask.cancel` will block until the subtask has resolved (i.e.,
818-
the subtask returns a value or calls `task.cancel`), returning whether the
819-
callee "returned" or "cancelled before returning". If called *asynchronously*
820-
and the callee has *still* not resolved after the host decides the cooperative
821-
yield has completed, `subtask.cancel` will return "blocked" instead and the
822-
caller must [wait](#waitables-and-waitable-sets) via waitable set until the
823-
subtask reaches a resolved state.
800+
subtask is resolved, the caller knows its lent handles have been returned.
801+
802+
If the subtask was waiting to start due to [backpressure](#backpressure), the
803+
subtask is immediately aborted without running the callee at all, returning
804+
cancelled-before-started. Otherwise, `subtask.cancel` records the "pending
805+
cancellation request" in the subtask and attempts to resume execution in the
806+
subtask's component instance in the hopes that the subtask will quickly resolve
807+
itself. However, after at least one thread resumption that blocks, the host is
808+
free to declare that cancellation has blocked. In this case, asynchronous calls
809+
to `subtask.cancel` will immediately return a "blocked" code and synchronous
810+
calls to `subtask.cancel` will themselves block.
811+
812+
The ready threads resumed by cancellation can be ready for all the normal
813+
reasons described above (yielding, I/O progress, etc). However, implicit threads
814+
using the `callback` ABI will *additionally* become ready due to the pending
815+
cancellation request itself (as long as the run-to-completion rules mentioned
816+
above are satisfied). If resumed, the `callback` will be passed a "task
817+
cancelled" event code to indicate the cancellation has been requested and that
818+
`task.cancel` may be called (instead of `task.return`). In the [future](#TODO),
819+
before the stackful ABI is released, other cancellation delivery mechanisms will
820+
be added so that threads via `waitable-set.wait` can also become ready and
821+
receive "task cancelled". Until then, `waitable-set.wait` will never return
822+
"task cancelled".
824823

825824
The Component Model does not provide a mechanism to force prompt termination of
826825
threads as this can lead to leaks and corrupt state in a still-live component
@@ -870,8 +869,9 @@ defined by the Component Model:
870869
disabled, the order in which these pending tasks start, along with how
871870
they interleave with new tasks, is nondeterministic.
872871
* When `subtask.cancel` is called for a task that has started (i.e., passed the
873-
backpressure gate), the host may nondeterministically resume as many or as few
874-
threads as it wants before `subtask.cancel` returns.
872+
backpressure gate), the choice of which ready thread to resume (if there are
873+
multiple) and how many times to resume (if the first resumption blocks without
874+
resolving the subtask) is nondeterministic.
875875

876876
Despite the above, the following scenarios do behave deterministically:
877877
* If a component `a` asynchronously calls the export of another component `b`,

‎design/mvp/Explainer.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1914,8 +1914,8 @@ resolved to (which is one of `returned`, `cancelled-before-started` or
19141914
`cancelled-before-returned`).
19151915

19161916
The `async` immediate is gated on 🚝. Without `async`, the `none` case is not
1917-
possible and `subtask.cancel` synchronously waits until the callee is
1918-
resolved.
1917+
possible and `subtask.cancel` synchronously waits, if necessary, until the
1918+
callee is resolved.
19191919

19201920
For details, see [Cancellation] in the concurrency explainer and
19211921
[`canon_subtask_cancel`] in the Canonical ABI explainer.

‎design/mvp/canonical-abi/definitions.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,12 @@ def request_cancellation(self):
467467
else:
468468
assert(self.state == Task.State.STARTED)
469469
self.state = Task.State.PENDING_CANCEL
470+
while self.state != Task.State.RESOLVED:
471+
candidates = { t for t in self.inst.threads if t.ready() }
472+
if candidates:
473+
random.choice(list(candidates)).resume()
474+
if not candidates or DETERMINISTIC_PROFILE or random.randint(0,1):
475+
break
470476

471477
def has_pending_cancel(self):
472478
return self.state == Task.State.PENDING_CANCEL
@@ -2367,11 +2373,8 @@ def canon_subtask_cancel(async_, i):
23672373
subtask.cancellation_requested = True
23682374
subtask.has_sync_waiter = True
23692375
subtask.on_cancel()
2370-
if not subtask.resolved():
2371-
if not async_:
2372-
thread.wait_until(subtask.resolved)
2373-
else:
2374-
thread.yield_()
2376+
if not async_ and not subtask.resolved():
2377+
thread.wait_until(subtask.resolved)
23752378
subtask.has_sync_waiter = False
23762379
if not subtask.resolved():
23772380
return [BLOCKED]

‎design/mvp/canonical-abi/run_tests.py‎

Lines changed: 22 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2518,28 +2518,19 @@ def core_caller(args):
25182518

25192519
caller_heap.memory[0] = 13
25202520
[ret] = canon_subtask_cancel(True, subi2)
2521-
if ret == definitions.BLOCKED:
2522-
canon_waitable_join(subi2, seti)
2523-
[ret] = canon_waitable_set_wait(caller_mem, seti, retp)
2524-
assert(ret == EventCode.SUBTASK)
2525-
assert(caller_heap.memory[retp+0] == subi2)
2526-
assert(caller_heap.memory[retp+4] == Subtask.State.CANCELLED_BEFORE_RETURNED)
2527-
else:
2528-
assert(ret == Subtask.State.CANCELLED_BEFORE_RETURNED)
2521+
assert(ret == Subtask.State.CANCELLED_BEFORE_RETURNED)
25292522
assert(caller_heap.memory[0] == 13)
25302523
[] = canon_subtask_drop(subi2)
25312524

25322525
caller_heap.memory[0] = 13
25332526
[ret] = canon_subtask_cancel(True, subi3)
2534-
if ret == definitions.BLOCKED:
2535-
assert(caller_heap.memory[0] == 13)
2536-
[] = canon_waitable_join(subi3, seti)
2537-
[ret] = canon_waitable_set_wait(caller_mem, seti, retp)
2538-
assert(ret == EventCode.SUBTASK)
2539-
assert(caller_heap.memory[retp+0] == subi3)
2540-
assert(caller_heap.memory[retp+4] == Subtask.State.RETURNED)
2541-
else:
2542-
assert(ret == Subtask.State.RETURNED)
2527+
assert(ret == definitions.BLOCKED)
2528+
assert(caller_heap.memory[0] == 13)
2529+
[] = canon_waitable_join(subi3, seti)
2530+
[ret] = canon_waitable_set_wait(caller_mem, seti, retp)
2531+
assert(ret == EventCode.SUBTASK)
2532+
assert(caller_heap.memory[retp+0] == subi3)
2533+
assert(caller_heap.memory[retp+4] == Subtask.State.RETURNED)
25432534
assert(caller_heap.memory[0] == 43)
25442535
[] = canon_subtask_drop(subi3)
25452536

@@ -2551,15 +2542,13 @@ def core_caller(args):
25512542

25522543
caller_heap.memory[0] = 13
25532544
[ret] = canon_subtask_cancel(True, subi4)
2545+
assert(ret == definitions.BLOCKED)
25542546
assert(caller_heap.memory[0] == 13)
2555-
if ret == definitions.BLOCKED:
2556-
[] = canon_waitable_join(subi4, seti)
2557-
[ret] = canon_waitable_set_wait(caller_mem, seti, retp)
2558-
assert(ret == EventCode.SUBTASK)
2559-
assert(caller_heap.memory[retp+0] == subi4)
2560-
assert(caller_heap.memory[retp+4] == Subtask.State.CANCELLED_BEFORE_RETURNED)
2561-
else:
2562-
assert(ret == Subtask.State.CANCELLED_BEFORE_RETURNED)
2547+
[] = canon_waitable_join(subi4, seti)
2548+
[ret] = canon_waitable_set_wait(caller_mem, seti, retp)
2549+
assert(ret == EventCode.SUBTASK)
2550+
assert(caller_heap.memory[retp+0] == subi4)
2551+
assert(caller_heap.memory[retp+4] == Subtask.State.CANCELLED_BEFORE_RETURNED)
25632552
[] = canon_subtask_drop(subi4)
25642553

25652554
caller_heap.memory[0] = 13
@@ -2587,14 +2576,7 @@ def core_caller(args):
25872576
state,subi = unpack_result(ret)
25882577
assert(state == Subtask.State.STARTED)
25892578
[ret] = canon_subtask_cancel(True, subi)
2590-
if ret == definitions.BLOCKED:
2591-
canon_waitable_join(subi, seti)
2592-
[ret] = canon_waitable_set_wait(caller_mem, seti, retp)
2593-
assert(ret == EventCode.SUBTASK)
2594-
assert(caller_heap.memory[retp+0] == subi)
2595-
assert(caller_heap.memory[retp+4] == Subtask.State.CANCELLED_BEFORE_RETURNED)
2596-
else:
2597-
assert(ret == Subtask.State.CANCELLED_BEFORE_RETURNED)
2579+
assert(ret == Subtask.State.CANCELLED_BEFORE_RETURNED)
25982580
assert(caller_heap.memory[0] == 13)
25992581
[] = canon_subtask_drop(subi)
26002582

@@ -2613,15 +2595,13 @@ def core_caller(args):
26132595
state,subi_hog = unpack_result(ret)
26142596
assert(state == Subtask.State.STARTED)
26152597
[ret] = canon_subtask_cancel(True, subi)
2616-
if ret == definitions.BLOCKED:
2617-
assert(caller_heap.memory[0] == 13)
2618-
[] = canon_waitable_join(subi, seti)
2619-
[ret] = canon_waitable_set_wait(caller_mem, seti, retp)
2620-
assert(ret == EventCode.SUBTASK)
2621-
assert(caller_heap.memory[retp+0] == subi)
2622-
assert(caller_heap.memory[retp+4] == cancelled_state)
2623-
else:
2624-
assert(ret == cancelled_state)
2598+
assert(ret == definitions.BLOCKED)
2599+
assert(caller_heap.memory[0] == 13)
2600+
[] = canon_waitable_join(subi, seti)
2601+
[ret] = canon_waitable_set_wait(caller_mem, seti, retp)
2602+
assert(ret == EventCode.SUBTASK)
2603+
assert(caller_heap.memory[retp+0] == subi)
2604+
assert(caller_heap.memory[retp+4] == cancelled_state)
26252605
assert(caller_heap.memory[0] == expected)
26262606
assert(caller_heap.memory[4] == 45)
26272607
[] = canon_subtask_drop(subi)

0 commit comments

Comments
 (0)