diff --git a/design/mvp/CanonicalABI.md b/design/mvp/CanonicalABI.md index 0c222b79..7a980624 100644 --- a/design/mvp/CanonicalABI.md +++ b/design/mvp/CanonicalABI.md @@ -121,22 +121,17 @@ into the `handles` or `threads` fields of `ComponentInstance`. ```python class ComponentInstance: store: Store - parent: Optional[ComponentInstance] handles: Table[ResourceHandle | Waitable | WaitableSet | ErrorContext] threads: Table[Thread] - may_enter: bool may_leave: bool backpressure: int num_waiting_to_enter: int exclusive_thread: Optional[Thread] - def __init__(self, store, parent = None): - assert(parent is None or parent.store is store) + def __init__(self, store): self.store = store - self.parent = parent self.handles = Table() self.threads = Table() - self.may_enter = True self.may_leave = True self.backpressure = 0 self.num_waiting_to_enter = 0 @@ -150,127 +145,6 @@ that can interact with each other. For example, in a browser, all component instances in the same tab that were created via `WebAssembly.instantiate` or [ESM-integration] would go into the same store. -When one component uses an [`instance` definition] to `instantiate` another -component, the component containing the `instance` definition is called the -*parent* and the component that gets `instantiate`d is called the *child*. -Components immutably store their parent component or, if instantiated directly -by the host, `None`, in the `parent` field. Thus, the set of component instances -in a store forms a forest rooted by the component instances that were -instantiated directly by the host. - -The `ComponentInstance.may_enter_from`, `enter_from` and `leave_to` methods -defined here are used to guard and record execution entering and exiting a -component instance. These methods are used by the `Store` methods and -`Task.request_cancellation`, defined below, to ensure [Component Invariant] #2. -```python - def may_enter_from(self, caller: Optional[ComponentInstance]): - for inst in self.entering_set(caller): - if not inst.may_enter: - return False - return True - - def enter_from(self, caller: Optional[ComponentInstance]): - for inst in self.entering_set(caller): - assert(inst.may_enter) - inst.may_enter = False - - def leave_to(self, caller: Optional[ComponentInstance]): - for inst in self.entering_set(caller): - assert(not inst.may_enter) - inst.may_enter = True - - def entering_set(self, caller: Optional[ComponentInstance]) -> set[ComponentInstance]: - if caller: - return self.self_and_ancestors() - caller.self_and_ancestors() - else: - return self.self_and_ancestors() - - def self_and_ancestors(self) -> set[ComponentInstance]: - s = { self } - ancestor = self.parent - while ancestor is not None: - s.add(ancestor) - ancestor = ancestor.parent - return s -``` -In `may_enter_from`, `enter_from` and `leave_to`, the `caller` parameter is -either the caller's `ComponentInstance` in a component-to-component call or -`None` for a host-to-component call. This `caller` is used to avoid trapping in -the case of a parent component [donut wrapping] a child component and being -reentered by a child component import call which by definition does not violate -[Component Invariant] #2. - -To distinguish and allow donut-wrapping-reentrance, we say that entering a -component instance C also implicitly enters all of C's transitive parents -("ancestors") but when calling from one component into another, any component -instance *already* entered by the caller (including itself) is *subtracted* from -the set of component instances being entered by the callee because execution is -not "entering" but rather "staying inside" those instances held in common. - -For example, given a parent component instance `P` which contains core module -instances `M1` and `M2` and child component instances `C1` and `C2`, -`may_enter_from` allows every call in this callstack to succeed: -``` - +-------------------------------------------------+ - | P | - | +-----------+ +----+ +----+ +-----------+ | -host-->| M1 (in P) |-->| C1 |-->| C2 |-->| M2 (in P) | | - | +-----------+ +----+ +----+ +-----------+ | - +-------------------------------------------------+ -``` -In particular, when the host first calls into `P` (via `lift`ed `M1`), -`P.entering_set(None)` is `{ P }`, so `P.may_enter` is tested and then set to -`False`. When `P` calls into `C1`, `C1.entering_set(P)` is `{ C1 }` (since -`P.self_and_ancestors() = { P }` is subtracted from `C1.self_and_ancestors() = -{ C1, P }`) and thus `C1.may_enter` is tested and set to `False`. When `C1` -calls `C2`, `C2.entering_set(C1)` is `{ C2 }`, so `C2.may_enter` is also set to -`False`. And then finally when `C2` calls back into `P` (via `lift`ed `M2`), -`P.entering_set(C2)` is empty (because `C2.self_and_ancestors() = { C2, P }` is -subtracted from `P.self_and_ancestors() = { P }`) and thus there is no -`trap_if(not P.may_enter)` (which would have otherwise failed). - -If now `P` tries to call from `M2` back into `C1` (using the power of -`call_indirect`), there *would* be a trap, since `C1.entering_set(P)` is -`{ C1 }` and `C1.may_enter` is already `False`: -``` - +-----------------------------------------------------------+ - | P | - | +-----------+ +----+ +----+ +-----------+ +----+ | -host-->| M1 (in P) |-->| C1 |-->| C2 |-->| M2 (in P) |-X->| C1 | | - | +-----------+ +----+ +----+ +-----------+ +----+ | - +-----------------------------------------------------------+ -``` - -Alternatively, let's say `P` also contains a third child `C3` whose exports are -re-exported by `P` so that they can be called directly by the host. Then if `M2` -calls back out into the host and the host tries to call `C3` directly, it also -traps since `C3.entering_set(None)` is `{ C3, P }` and `P.may_enter` is already -set to `False`: -``` - +-------------------------------------------------+ +--------+ - | P | | P | - | +-----------+ +----+ +----+ +-----------+ | | +----+ | -host-->| M1 (in P) |-->| C1 |-->| C2 |-->| M2 (in P) |-->host-X->| C3 | | - | +-----------+ +----+ +----+ +-----------+ | | +----+ | - +-------------------------------------------------+ +--------+ -``` - -From an optimizing compiler's perspective, the `set[ComponentInstance]` returned -by `entering_set` is known *statically* when compiling a component-to-component -trampoline and thus the compiler can fully unroll the `for` loops in -`may_enter_from`, `enter_from` and `leave_to` into fixed sequences of branches -and stores with fixed memory locations for the `may_enter` flags. Furthermore, -because component-to-component reentrance is only possible via [donut wrapping] -and donut wrapping is only possible when a parent component contains a `canon -lower` definition, whenever the compiler sees a component with no `canon lower` -definitions, it can mark the `may_enter` flags of all its direct children as -*optimized-out* and then completely ignore them. Since donut wrapping is rare, -this means that, in practice, only root component instances' `may_enter` flags -will be tested and only for host-to-component or component-to-component calls -between different root components (linked by the host). Thus, the overall cost -of reentrance should be very low, in exchange for allowing the producer -toolchain to not have to safely handle reentrance at every single import call. - The other fields of `ComponentInstance` are described below as they are used. @@ -750,15 +624,14 @@ spec-level function type, where the host can be the caller, the callee or even ```python OnStart = Callable[[], list[any]] OnResolve = Callable[[Optional[list[any]]], None] -OnCancel = Callable[[Optional[ComponentInstance]], None] -FuncInst = Callable[[OnStart, OnResolve, Optional[ComponentInstance]], OnCancel] +OnCancel = Callable[[], None] +FuncInst = Callable[[OnStart, OnResolve], OnCancel] ``` -The three parameters of `FuncInst` are: +The parameters of `FuncInst` are: * an `OnStart` callback that is called by the callee when it is ready to receive its arguments after waiting for any [backpressure] to subside; * an `OnResolve` callback that is called by the callee when it is ready to return its value or, if cancellation has been requested, `None`. -* the caller's `ComponentInstance`, if the caller is not the host Critically, if the callee [blocks] at the wasm level, the spec-level `FuncInst` returns immediately to the caller while continuing to execute the callee in a @@ -767,8 +640,7 @@ time before or after the callee returns. If the callee returns and the `OnResolve` callback has *not* yet been called, the caller may invoke the returned `OnCancel` callback *at most once* to cooperatively request that the callee "hurry up" and call `OnResolve` (possibly, but not necessarily, passing -`None` and/or skipping the call to `OnStart`). The optional parameter is, like -`FuncInst`, the caller's component instance, or, if called from the host, `None`. +`None` and/or skipping the call to `OnStart`). When `FuncInst` is implemented by wasm guest code (as opposed to the host), each call creates a `Task` object to track the state of the call and ensure that the @@ -808,7 +680,7 @@ class Task: The `Task.needs_exclusive` predicate returns whether this task's implicit thread (`Task.implicit_thread`) has *not* opted in to multiple concurrent linear memory shadow stacks (via "stackful" lift) and thus, according to [Component Invariant] -#3, requires serialization with all the other implicit threads in the component +#2, requires serialization with all the other implicit threads in the component instance that have similarly not opted in. This question only applies to `async`-typed functions, since synchronous functions can't block and thus can always execute in a LIFO fashion using a single linear memory shadow stack. When @@ -926,18 +798,16 @@ considers resuming that thread (picking one nondeterministically if there are multiple), giving the thread the chance to handle cancellation promptly so that `subtask.cancel` completes without blocking. ```python - def request_cancellation(self, caller: Optional[ComponentInstance]): + def request_cancellation(self): if self.state == Task.State.INITIAL: self.state = Task.State.CANCEL_DELIVERED self.implicit_thread.resume(Cancelled.TRUE) else: assert(self.state == Task.State.STARTED) candidates = { t for t in self.threads if t.cancellable() } - if candidates and self.inst.may_enter_from(caller): + if candidates: self.state = Task.State.CANCEL_DELIVERED - self.inst.enter_from(caller) random.choice(list(candidates)).resume(Cancelled.TRUE) - self.inst.leave_to(caller) else: self.state = Task.State.PENDING_CANCEL ``` @@ -946,7 +816,7 @@ flag, so that whether a `Thread` is cancellable or not can vary dynamically. Concretely, `cancellable` only varies dynamically in one situation: in `canon_lift`, when a `callback`-lifted `async` function is waiting in its event loop and must not be resumed if some other thread holds the `exclusive_thread` -lock (to preserve [Component Invariant] #3). +lock (to preserve [Component Invariant] #2). If cancellation cannot be immediately delivered by `Task.request_cancellation`, the request is remembered in `Task.state` and delivered at the next opportunity @@ -1032,25 +902,28 @@ class Store: ``` The `waiting` field is populated by `Thread` methods, as defined above, and the `nesting_depth` field is purely a specification device used by `Store` methods -below to define the valid host call interleavings. +below to define the valid host call interleavings (and, in particular, when it +is valid to call `Store.tick`). The `Store.invoke` method is analogous to Core WebAssembly's [`func_invoke`] and takes a `FuncInst` (analogous to a Core WebAssembly [`funcinst`]) along with its runtime `OnStart` and `OnResolve` arguments (which are described above alongside -their definitions). The `Store.nesting_depth` field tracks whether there are any -active `Store.invoke` calls for the benefit of `Store.tick`, defined below. +their definitions). ```python def invoke(self, f: FuncInst, on_start: OnStart, on_resolve: OnResolve) -> OnCancel: self.nesting_depth += 1 - on_cancel = f(on_start, on_resolve, caller = None) + request_cancellation = f(on_start, on_resolve) self.nesting_depth -= 1 + def on_cancel(): + self.nesting_depth += 1 + request_cancellation() + self.nesting_depth -= 1 return on_cancel ``` The `FuncInst` passed to `Store.invoke` can be either a guest function (produced by `Store.lift`, defined next) or (in the special case of component re-exports) a host function. Symmetrically, `FuncInst`s can be called either from the host -(via `Store.invoke`) or core wasm code (via `Store.lower`). `Store.invoke` -passes a `None` `caller` to signal that the host is the caller. +(via `Store.invoke`) or core wasm code (via `Store.lower`). The `Store.lift` method is called for each `canon lift` definition in a component to wrap a core wasm `CoreFuncInst` into a component-level `FuncInst`, @@ -1061,50 +934,29 @@ in a component to wrap a component-level `FuncInst` into a core wasm would be replaced by a single, higher-level `Store.instantiate` method of type `Component -> ComponentInstance`, analogous to the Core WebAssembly's [`module_instantiate`]. But for the Canonical ABI, just `lift` and `lower` are -sufficient to define relevant ABI behavior.) +sufficient to define relevant ABI behavior.) `canon_lift` and `canon_lower` are +defined below, combining all the intervening supporting definitions to specify +the full runtime behavior of calls into and out of Core WebAssembly code. ```python CoreFuncInst = Callable[[list[CoreValType]], list[CoreValType]] def lift(self, f: CoreFuncInst, ft: FuncType, opts: CanonicalOptions, inst: ComponentInstance) -> FuncInst: - def func_inst(on_start: OnStart, on_resolve: OnResolve, caller: Optional[ComponentInstance]) -> OnCancel: - assert(not caller or caller is current_instance()) - trap_if(not inst.may_enter_from(caller)) - inst.enter_from(caller) + def func_inst(on_start: OnStart, on_resolve: OnResolve) -> OnCancel: + assert(self.nesting_depth > 0) on_cancel = canon_lift(f, ft, opts, inst, on_start, on_resolve) - inst.leave_to(caller) + assert(self.nesting_depth > 0) return on_cancel return func_inst def lower(self, f: FuncInst, ft: FuncType, opts: CanonicalOptions, inst: ComponentInstance) -> CoreFuncInst: def core_func_inst(args: list[CoreValType]) -> list[CoreValType]: assert(inst is current_instance()) - assert(all(not i.may_enter for i in inst.self_and_ancestors())) - results = canon_lower(f, ft, opts, args) - assert(all(not i.may_enter for i in inst.self_and_ancestors())) - return results + assert(self.nesting_depth > 0) + flat_results = canon_lower(f, ft, opts, args) + assert(self.nesting_depth > 0) + return flat_results return core_func_inst ``` -Before entering a component via core wasm export call, the `FuncInst` wrapper -produced by `Store.lift` traps if entering the component would violate -[Component Invariant] #2, and then records that the instance was entered by -calling `ComponentInstance.enter_from`. The rest of the trampoline is defined by -`canon_lift` below. Importantly though, `canon_lift` will return immediately if -it [blocks], thereby calling `ComponentInstance.leave_to` and allowing -reentrance (via `Store.invoke` or `Store.tick`) without trapping. - -Before temporarily leaving a component via core wasm import call, the -`CoreFuncInst` wrapper produced by `Store.lower` asserts that the `may_enter` -flags of the current component instance and all its ancestors are already -`False` (as set by `ComponentInstance.enter_from` in `Store.lift`). Thus, -by default, reentrance is disallowed. *However*, if the lowered `FuncInst` -callee [blocks] before returning a value and the `canon lower` definition didn't -specify the `async` ABI option (which opts in to the non-blocking async ABI), -`canon_lower` will *block* until the callee returns (via `Thread.wait_until`, -defined above) which will suspend the current thread and return from -`canon_lift` to `Store.lift` which then calls `ComponentInstance.leave_to` to -enable reentrance for as long as `Thread.wait_until` stays blocked. Thus, -in accordance with [Component Invariant] #2, synchronous (blocking) calls to -`async`-typed function imports *may* be reentered during `canon_lower`. Lastly, the `Store.tick` method does not have an analogue in Core WebAssembly but is necessary to enable native concurrency support in the Component Model. @@ -1118,23 +970,17 @@ while new tasks are being started. ```python def tick(self): assert(self.nesting_depth == 0) - assert(all(thread.task.inst.may_enter_from(None) for thread in self.waiting)) self.nesting_depth += 1 candidates = { thread for thread in self.waiting if thread.ready() } if candidates: thread = random.choice(list(candidates)) - thread.task.inst.enter_from(None) thread.resume() - thread.task.inst.leave_to(None) self.nesting_depth -= 1 ``` As shown above, `Store.nesting_depth` is greater than zero while calling -`Store.invoke` and thus the first `assert` prohibits the host from calling -`Store.tick` during an active `Store.invoke`. This prohibition ensures that the -second `assert` holds, which is that all component instances in the store can be -(re)entered. If this were *not* the case, a random thread might be resumed while -one of its imports' component instances was on the stack and not reenterable, -leading to a spurious trap when it was called. +`Store.invoke` or cancelling via the `OnCancel` callback and thus the `assert` +prohibits the host from scheduling arbitrary store-wide cooperative threads +until all core wasm calls on the stack have [blocked] or returned. ## Canonical ABI Options @@ -1239,34 +1085,27 @@ The `borrow_scope` field may be `None` if the types being lifted/lowered are known to not contain `borrow`. The `LiftLowerContext.reallocate` and `LiftLowerContext.allocate` methods define -how to call a Core WebAssembly `LiftLowerOptions.realloc` function when lowering -a value that requires dynamic allocation. When one component imports and calls -another component which synchronously returns a value that requires dynamic -allocation, the callstack at the point where the `realloc` `canonopt` needs to -be called has the form: -``` -... -> |component A| --calls-import--> |component B| --returns-value--> |component A realloc| -``` -Thus, in general, `realloc` must be called reentrantly and so the call to -`realloc` is specified as if `realloc` were an exported function called without -the usual `ComponentInstance.may_enter_from` reentrance checks enforced by -`Store.lift`: +how to dynamically allocate linear memory using the `realloc` `canonopt` when +lowering non-flattened values. The definition below specifies this call as-if +`realloc` were a component export being (potentially recursively) invoked by the +host: ```python def reallocate(self, old, old_byte_length, alignment, new_byte_length): - assert(self.inst.may_leave) - self.inst.may_leave = False ptrt = U32Type() if self.opts.memory.ptr_type() == 'i32' else U64Type() ft = FuncType([ptrt, ptrt, ptrt, ptrt], [ptrt], async_ = False) opts = CanonicalOptions(async_ = False) + realloc = self.inst.store.lift(self.opts.realloc, ft, opts, self.inst) def on_start(): return [old, old_byte_length, alignment, new_byte_length] ptr = None def on_resolve(result): nonlocal ptr [ptr] = result - canon_lift(self.opts.realloc, ft, opts, self.inst, on_start, on_resolve) - assert(ptr is not None) + assert(self.inst.may_leave) + self.inst.may_leave = False + self.inst.store.invoke(realloc, on_start, on_resolve) self.inst.may_leave = True + assert(ptr is not None) return ptr def allocate(self, alignment, byte_length): @@ -1385,10 +1224,8 @@ The `own` field indicates whether this element was created from an `own` type (or, if false, a `borrow` type). The `borrow_scope` field stores the `Task` that lowered the borrowed handle as a -parameter. When a component only uses sync-lifted exports, due to lack of -reentrance, there is at most one `Task` alive in a component instance at any -time and thus an optimizing implementation doesn't need to store the `Task` -per `ResourceHandle`. +parameter since there can be multiple tasks live in a component instance at a +time (even when only synchronous functions are used, due to reentrance). The `num_lends` field maintains a conservative approximation of the number of live handles that were lent from this handle (by calls to `borrow`-taking @@ -3766,7 +3603,7 @@ The event loop releases `ComponentInstance.exclusive_thread` (which was acquired by `Task.enter_implicit_thread`) before potentially blocking the thread to allow other `needs_exclusive` tasks to execute in the interim. However, the `exclusive_thread` lock is held throughout each core wasm invocation from the -event loop to maintain [Component Invariant] #3. Thus, `async callback`-lifted +event loop to maintain [Component Invariant] #2. Thus, `async callback`-lifted tasks allow *more* concurrency than synchronously-lifted tasks (which only release the `exclusive_thread` lock after they've returned) but *less* concurrency than (stackful) non-`callback` `async`-lifted tasks, which entirely @@ -3794,15 +3631,11 @@ return value of `FuncInst`. thread.resume() if not ft.async_: while task.state != Task.State.RESOLVED: - candidates = { t for t in inst.threads if t.ready() and t is not inst.exclusive_thread } + candidates = { t for t in inst.threads if t.ready() } trap_if(not candidates) random.choice(list(candidates)).resume() return task.request_cancellation ``` -The special case that excludes any thread (created by a previous blocked `async` -call) holding the instance's `exclusive_thread` lock is necessary to preserve -[Component Invariant] #3, which might otherwise be violated if the current -synchronous call is using the single global linear memory shadow stack. Note that, because non-`async`-typed functions can't block, they do not actually require a separate thread/fiber/stack to implement the above specified behavior @@ -3930,7 +3763,7 @@ caller. flat_results = lower_flat_values(cx, max_flat_results, result, ft.result_type(), flat_args) subtask.resolve(Subtask.State.RETURNED, flat_results) - subtask.on_cancel = callee(on_start, on_resolve, caller = thread.task.inst) + subtask.on_cancel = callee(on_start, on_resolve) assert(ft.async_ or subtask.state == Subtask.State.RETURNED) ``` According to the `FuncInst` calling contract, if `callee` [blocks], it must @@ -3939,10 +3772,6 @@ immediately return an `OnCancel` callback which the code above stores in the the `callee`'s function type does not declare the `async` effect, `callee` must not block before returning a value. -Note that, for component-to-component calls, the `caller` of the `FuncInst` is -the current component instance. This information is used by `may_enter_from` to -determine when to trap because `callee` is being synchronously reentered. - In the synchronous case (when the `async` `canonopt` is not set), if the `callee` blocked before calling `on_resolve`, the synchronous caller's thread is non-cancellably suspended until the callee calls `on_resolve` to return a @@ -4066,18 +3895,10 @@ def canon_resource_drop(rt, i): ``` The call to a resource's destructor passes the `i32` representation value that was previously supplied to `resource.new`. The call works like a normal -non-`async` cross-component call, using the same `canon_lift` and `canon_lower` -rules to, for example, catch reentrance. Because the type, lifting and -lowering are all non-`async`, the destructor may not block. However, the -destructor may spawn a cooperative thread that does. - -In particular, `Store.lift` may trap (if `rt.impl.may_enter_from(inst)` is -`False`) if the call to the destructor would reenter the destructor's instance -in a way that violates [Component Invariant] #2. In the special case where the -`current_instance` is the *same* as the destructor's instance, `may_enter_from` -will always return `True` (because the set of instances being freshly entered is -empty) and so, as one might expect, component instances can `resource.drop` the -owned handles of the resources they implement. +non-`async` cross-component call which means that destructors may not block. +However, the destructor may spawn an explicit thread that blocks as long as +this explicit thread doesn't transitively block the destructor's implicit thread +from returning. ### `canon resource.rep` @@ -4463,7 +4284,7 @@ def canon_subtask_cancel(async_, i): assert(subtask.has_pending_event()) else: subtask.cancellation_requested = True - subtask.on_cancel(thread.task.inst) + subtask.on_cancel() if not subtask.resolved(): if not async_: subtask.wait_for_pending_event() @@ -5353,9 +5174,7 @@ def canon_thread_available_parallelism(): [`canonopt`]: Explainer.md#canonical-definitions [`canon`]: Explainer.md#canonical-definitions [Type Definitions]: Explainer.md#type-definitions -[`instance` definition]: Explainer.md#instance-definitions [Component Invariant]: Explainer.md#component-invariants -[Donut Wrapping]: Linking.md#higher-order-shared-nothing-linking-aka-donut-wrapping [JavaScript Embedding]: Explainer.md#JavaScript-embedding [ESM-integration]: Explainer.md#esm-integration [Adapter Functions]: FutureFeatures.md#custom-abis-via-adapter-functions @@ -5365,7 +5184,6 @@ def canon_thread_available_parallelism(): [Thread Index]: Concurrency.md#thread-built-ins [Async Call Stack]: Concurrency.md#subtasks-and-supertasks [Structured Concurrency]: Concurrency.md#subtasks-and-supertasks -[Recursive Reentrance]: Concurrency.md#subtasks-and-supertasks [Backpressure]: Concurrency.md#backpressure [Thread]: Concurrency.md#threads-and-tasks [Current Thread]: Concurrency.md#current-thread-and-task diff --git a/design/mvp/Concurrency.md b/design/mvp/Concurrency.md index 2419a08d..b0ad6609 100644 --- a/design/mvp/Concurrency.md +++ b/design/mvp/Concurrency.md @@ -22,7 +22,7 @@ gated by the 🔀 (async) and 🧵 (threading) emojis. * [Borrows](#borrows) * [Cancellation](#cancellation) * [Nondeterminism](#nondeterminism) - * [Asynchronous Recursion](#asynchronous-recursion) + * [Reentrance](#reentrance) * [Interaction with the start function](#interaction-with-the-start-function) * [Async ABI](#async-abi) * [Async Import ABI](#async-import-abi) @@ -58,9 +58,8 @@ use cases are added, refining the Component Model's high-level * Allow runtimes to maintain meaningful cross-language call stacks (for the benefit of debugging, logging, tracing and profiling). * Consider backpressure and cancellation as part of the design. -* Allow non-reentrant synchronous and event-loop-driven core wasm code that - assumes a single global linear memory stack to not have to worry about - additional reentrancy. +* Provide optional run-to-completion semantics for async code as described + by [Component Invariant] #2. ## Summary @@ -202,11 +201,8 @@ Note that because functions without `async` in their type are not allowed to block, non-`async` functions do not attempt to acquire the "exclusive" lock; they just barge in. Components exporting a mix of `async` and non-`async` functions (which again mostly only arises in the more advanced virtualization -scenarios) must therefore take care to handle the "barge-in" case gracefully. -Because this nested non-`async` call will complete synchronously without -blocking, this behavior does not break [Component Invariant] #3: a single -global shadow stack can still be (re)used in a LIFO manner, much like a -traditional signal handler. +scenarios) must therefore take care to handle the "barge-in" case gracefully +by, for example, allocating a fresh linear memory shadow stack. Lastly, WIT is extended with two new type constructors—`future` and `stream`—to allow new WIT interfaces to explicitly represent concurrency in @@ -506,10 +502,11 @@ There are several ways for a task to potentially "block": Since Component Model concurrency is [specified in terms of] the Core WebAssembly [stack-switching] proposal, each of the above represents a point where the [current thread](#current-thread-and-task) may suspend with the `$block` effect. -Each of these points also serves as a **cooperative yield point** where -[Component Invariant] #2 allows reentrance. However, just because the current -thread *suspends* doesn't mean that the *task* has officially "blocked": what -happens next depends on the state of the task and the declared function type: +Each of these points also serves as a **cooperative yield point** where the host +may nondeterministically switch to another thread. However, just because the +current thread *suspends* doesn't mean that the *task* has officially "blocked": +what happens next depends on the state of the task and the declared function +type: If the task has already [returned](#returning) a value to the caller, then control flow returns to the caller and, from the caller's perspective, the call @@ -703,15 +700,14 @@ instead of a boolean flag, unrelated pieces of code can report backpressure for distinct limited resources without prior coordination. In addition to *explicit* backpressure set by wasm code, there is also an -*implicit* source of backpressure to ensure [Component Invariant] #3 and protect -non-reentrant core wasm code. In particular, when an `async`-typed export is -lifted with the sync ABI or the stackless async ABI, a component-instance-wide -lock is implicitly acquired every time core wasm is executed. By returning to -the event loop after every event (instead of once at the end of the task), -stackless async exports release the lock between every event, allowing a higher -degree of concurrency than synchronous exports. Stackful async exports ignore -the lock entirely and thus achieve the highest degree of (cooperative) -concurrency. +*implicit* source of backpressure to ensure [Component Invariant] #2. In +particular, when an `async`-typed export is lifted with the sync ABI or the +stackless async ABI, a component-instance-wide lock is implicitly acquired every +time core wasm is executed. By returning to the event loop after every event +(instead of once at the end of the task), stackless async exports release the +lock between every event, allowing a higher degree of concurrency than +synchronous exports. Stackful async exports ignore the lock entirely and thus +achieve the highest degree of (cooperative) concurrency. Since non-`async` functions are not allowed to block (including due to backpressure) and also don't pile up like `async` functions, non-`async` @@ -891,104 +887,76 @@ Despite the above, the following scenarios do behave deterministically: (modulo any nondeterministic execution that determines the ordering in which the operations are performed). -### Asynchronous Recursion +### Reentrance -Even without concurrency support, it is possible to reenter a component instance -by recursively calling the component's export from a function called by the -component's import. For example, given a component importing `imp` and exporting -`exp`, using the [JS API], JS code could write: +In general, whenever a component calls an imported function, the component must +assume that before the import call returns, the callee may **reenter** the +caller's component instance by recursively calling an exported function. For +example, if `component.wasm` has type: +```wat +(component + (import "imp" (func)) + (export "exp" (func)) +) +``` +using the [JS API], reentrance is possible via: ```js -import source component from './component.wasm'; -var instance; function imp() { instance.exports.exp(); } -instance = WebAssembly.instantiate(component, { imp }); -instance.exports.exp(); // exp ~~> imp ~~> exp +var { instance } = await WebAssembly.instantiateStreaming(fetch('component.wasm'), { imp }); +instance.exports.exp(); +``` +Pure component-to-component reentrance is also possible via [donut wrapping], +using `call_indirect` and the parent's mutable function table to close the loop. + +If `component.wasm` is changed to have type: +```wat +(component + (import "imp" (func async)) + (export "exp" (func async)) +) ``` -To relieve generic bindings generators and component authors from having to -conservatively assume that *every* import call might reenter in this manner, -the Component Model has [Component Invariant] #2. This is enforced by the -[Canonical ABI](CanonicalABI.md#embedding) using strategically placed traps and -boolean flags on component instances. - -With native concurrency support, what we'd naturally expect is that if our -component imports `imp` and exports `exp` as `async` functions, then the -following JS code could run the two `exp` calls concurrently, as if they were JS -`async` functions: +and the above JS code is modified to use `async`/`await`: ```js -import source component from './component.wasm'; async function imp() { - await ... some Web API I/O + await instance.exports.exp(); } -instance = WebAssembly.instantiate(component, { imp }); -await Promise.all([ - instance.exports.exp(), - instance.exports.exp() -]); +var { instance } = await WebAssembly.instantiateStreaming(fetch('component.wasm'), { imp }); +instance.exports.exp(); ``` -In particular, if `exp` transitively awaits `imp`, then when `imp` blocks (via -`await`), control flow returns to the top-level JS script with `instance` in a -reenterable state, so that `exp` can be concurrently invoked a second time. - -However, this also means that if we slightly change our original recursive -example to use `async` and then `await` before attempting to reenter `instance`, -there is no trap. The first `await` in `imp` returns to top-level, leaving -`instance` in a reenterable state, so when `imp` is later resumed from the event -loop, it is allowed to reenter `exp`. +this call will deadlock if `component.wasm` implements `exp` using the sync or +stackless async ABI and calls `imp` synchronously. This is due to the [automatic +backpressure](#backpressure) mentioned above that is necessary to preserve the +run-to-completion semantics of [Component Invariant] #2. However, if `imp` is +called asynchronously and `exp` doesn't wait for the result, there is no +deadlock (the outer task completes, backpressure is disabled, and the inner task +can start). Alternatively, if `exp` is implemented using the *stackful* async +ABI, there is no automatic backpressure and it's up to the guest to handle the +recursion without deadlock (as with the preceding synchronous case). + +Setting aside these recursive cases, with concurrency support, a new kind of +*non-recursive* reentrance is also possible, which is that `exp` can +[block](#blocking), allowing the caller to then start a second `exp` call that +can run concurrently with the first. From JS code this looks like: ```js -import source component from './component.wasm'; -var instance; async function imp() { - await Promise.resolve(); - await instance.exports.exp(); + await ... some I/O ... } -instance = WebAssembly.instantiate(component, { imp }); -await instance.exports.exp(); // exp ~~> imp ~~> exp +var { instance } = await WebAssembly.instantiateStreaming(fetch('component.wasm'), { imp }); +let results = await Promise.all([ + instance.exports.exp(), + instance.exports.exp() +]); ``` -The hazard with this example is that if the outer call to `exp` internally grabs -and holds a lock while awaiting the call to `imp`, and if the recursive call to -`exp` waits to acquire the same lock, there will be a deadlock. In the preceding -`async` example, since there is no circular dependency between the two calls to -`exp`, the second call can simply wait for the first to release any lock it -holds. - -A concrete example of this hazard is the implicit per-component-instance lock -taken and released by [backpressure](#backpressure). E.g., if `component` lifts -`exp` synchronously (which triggers implicit backpressure while a call to `exp` -is running), the recursive call to `exp` will immediately deadlock. - -Unfortunately, it's not possible to reliably discriminate the two cases so that -the second example traps (as it did in the synchronous case) while the first -example succeeds. Given the Component Model's well-defined [async call -stack](#subtasks-and-supertasks), it might seem possible to tell the cases apart -by checking whether `instance` is already *on the call stack* when attempting to -enter `exp`. However, this doesn't work for two reasons: - -First, to properly detect asynchronous recursion, the host embedding would have -to maintain something analogous to the Component Model's async call stack, which -some hosts (including, currently, browsers) simply do not have a well-defined -way to do. - -Second, the async call stack is neither necessary nor sufficient to catch these -kinds of asynchronous recursive deadlocks. The async call stack tracks the -*causality* leading up to a call, which is useful for debugging, tracing, -profiling, etc., but the async call stack doesn't imply that every call on the -stack is blocking on the result of the next call in the chain (unlike with a -synchronous call stack, which does imply this). Moreover, the async call stack -can arbitrarily reset through indirect forms of asynchronous calls (e.g., host -APIs with callbacks like, in a browser, `setTimeout`), so the absence of -recursion on the async call stack does not guarantee the absence of a circular -asynchronous dependency. - -Thus, the Canonical ABI rules don't attempt to distinguish the different kinds -of asynchronous reentrance. It is thus the responsibility of component clients -to avoid async recursion. Fortunately, in component-to-component compositions, -this kind of recursion is only possible when doing advanced higher-order linking -(aka [donut wrapping]). And unlike [Component Invariant] #2, which directly -impacts bindings generators, async recursion only arises when there's -[blocking](#blocking) and so it's already necessary to support (non-recursive) -reentrance. +Unlike recursive reentrance, this non-recursive reentrance is expected to be +commonplace and enabling it is one of the primary goals of having native +concurrency support in the Component Model. Moreover, because the two `exp` +tasks above are non-recursive siblings, unless there is implicit synchronization +between them (e.g., through their shared linear memory), there shouldn't be any +deadlock hazards. Thus, while reentrance is possible both with and without +`async`, `async` "dials up" the expected degree of reentrance but also "dials +down" the hazards associated with reentrance. ## Interaction with the start function diff --git a/design/mvp/Explainer.md b/design/mvp/Explainer.md index 3cab3bb2..7e47ee2f 100644 --- a/design/mvp/Explainer.md +++ b/design/mvp/Explainer.md @@ -3009,21 +3009,15 @@ In particular, the Component Model maintains the following invariants: after a trap, it's no longer possible to observe the internal state of a component instance. -2. Components can only be reentered (via component export or thread resumption) - when they explicitly [block] or call a [donut wrapped] child component. Calls - to non-`async` functions do *not* count as "blocking" nor do non-blocking - (`async`-lowered) calls to `async` functions. Thus, bindings generators and - component authors do not need to always safely handle reentrance at all - import call sites. (In the [future](Concurrency.md#TODO), support for - first-class functions (as parameter and result values) would loosen this - restriction in an explicit opt-in manner.) - -3. To ease adoption, unless a component opts in (via "stackful" lift 🚟 or - cooperative threads 🧵), all core wasm execution inside a component instance - is locally serialized (via automatic backpressure applied at export calls) so - that producer toolchains can continue to use a single global linear memory - shadow stack that is pushed and popped in LIFO order. - +2. When components implement `async` functions using the 0.3.0 sync or + async-callback ABIs, core wasm execution is "run to completion" within the + scope of a single component instance: the runtime automatically exerts + backpressure to prevent there from being multiple core wasm stacks live at + the same time. Multiple stacks may, however, be live across *multiple* + component instances, or within a *single* component instance if the component + uses the "stackful" async ABI 🚟, cooperative thread built-ins 🧵, or exposes + synchronously-typed functions (since non-`async` functions cannot exert + backpressure when called). ## JavaScript Embedding diff --git a/design/mvp/Linking.md b/design/mvp/Linking.md index 3d134cdf..866f85b7 100644 --- a/design/mvp/Linking.md +++ b/design/mvp/Linking.md @@ -240,9 +240,12 @@ Once `M1` and `M2` share linear memory and table state, `M2` can import the so that `M1` can call `C`'s exports via `call_indirect`. This provides `Q` the flexibility to put *all* its core wasm code in `M1` (using `M2` to only do `funcref`-plumbing), which is convenient. But this also allows `M1` to attempt -to reenter `C` while `C` is calling an import of `M1`, which would violate -[Component Invariant] #2. To prevent this, the Canonical ABI must place runtime -guards in `lift` that trap if `M1` tries to recursively reenter `C`. +to recursively reenter `C` while `C` is calling an import of `M1`. This kind of +[recursive reentrance](Concurrency.md#reentrance) is already possible to perform +from the host, so donut wrapping just gives parent components the same +expressive capability. In both cases, the host/parent must be careful not to +create circular dependencies between child component tasks that create +deadlocks. Similarly, donut wrapping allows `Q` to both define resource types that are imported by `C` and consume resource types that are defined by `C`. This allows @@ -319,7 +322,6 @@ future features of WIT and the Component Model.) [Binary Format]: Binary.md [WIT]: WIT.md [`external-id`]: Explainer.md#import-and-export-definitions -[Component Invariant]: Explainer.md#component-invariants [ESM-integration]: Explainer.md#esm-integration [WebAssembly/tool-conventions]: https://github.com/WebAssembly/tool-conventions diff --git a/design/mvp/canonical-abi/definitions.py b/design/mvp/canonical-abi/definitions.py index 99fb6fc6..d234b42e 100644 --- a/design/mvp/canonical-abi/definitions.py +++ b/design/mvp/canonical-abi/definitions.py @@ -190,57 +190,22 @@ class FutureType(ValType): class ComponentInstance: store: Store - parent: Optional[ComponentInstance] handles: Table[ResourceHandle | Waitable | WaitableSet | ErrorContext] threads: Table[Thread] - may_enter: bool may_leave: bool backpressure: int num_waiting_to_enter: int exclusive_thread: Optional[Thread] - def __init__(self, store, parent = None): - assert(parent is None or parent.store is store) + def __init__(self, store): self.store = store - self.parent = parent self.handles = Table() self.threads = Table() - self.may_enter = True self.may_leave = True self.backpressure = 0 self.num_waiting_to_enter = 0 self.exclusive_thread = None - def may_enter_from(self, caller: Optional[ComponentInstance]): - for inst in self.entering_set(caller): - if not inst.may_enter: - return False - return True - - def enter_from(self, caller: Optional[ComponentInstance]): - for inst in self.entering_set(caller): - assert(inst.may_enter) - inst.may_enter = False - - def leave_to(self, caller: Optional[ComponentInstance]): - for inst in self.entering_set(caller): - assert(not inst.may_enter) - inst.may_enter = True - - def entering_set(self, caller: Optional[ComponentInstance]) -> set[ComponentInstance]: - if caller: - return self.self_and_ancestors() - caller.self_and_ancestors() - else: - return self.self_and_ancestors() - - def self_and_ancestors(self) -> set[ComponentInstance]: - s = { self } - ancestor = self.parent - while ancestor is not None: - s.add(ancestor) - ancestor = ancestor.parent - return s - ## Concurrency ### Stack Switching @@ -447,8 +412,8 @@ def yield_then_promote(self, cancellable, other: Thread) -> Cancelled: OnStart = Callable[[], list[any]] OnResolve = Callable[[Optional[list[any]]], None] -OnCancel = Callable[[Optional[ComponentInstance]], None] -FuncInst = Callable[[OnStart, OnResolve, Optional[ComponentInstance]], OnCancel] +OnCancel = Callable[[], None] +FuncInst = Callable[[OnStart, OnResolve], OnCancel] class Task: class State(Enum): @@ -526,18 +491,16 @@ def unregister_thread(self, thread): assert(thread.index is not None) self.inst.threads.remove(thread.index) - def request_cancellation(self, caller: Optional[ComponentInstance]): + def request_cancellation(self): if self.state == Task.State.INITIAL: self.state = Task.State.CANCEL_DELIVERED self.implicit_thread.resume(Cancelled.TRUE) else: assert(self.state == Task.State.STARTED) candidates = { t for t in self.threads if t.cancellable() } - if candidates and self.inst.may_enter_from(caller): + if candidates: self.state = Task.State.CANCEL_DELIVERED - self.inst.enter_from(caller) random.choice(list(candidates)).resume(Cancelled.TRUE) - self.inst.leave_to(caller) else: self.state = Task.State.PENDING_CANCEL @@ -580,41 +543,40 @@ def __init__(self): def invoke(self, f: FuncInst, on_start: OnStart, on_resolve: OnResolve) -> OnCancel: self.nesting_depth += 1 - on_cancel = f(on_start, on_resolve, caller = None) + request_cancellation = f(on_start, on_resolve) self.nesting_depth -= 1 + def on_cancel(): + self.nesting_depth += 1 + request_cancellation() + self.nesting_depth -= 1 return on_cancel CoreFuncInst = Callable[[list[CoreValType]], list[CoreValType]] def lift(self, f: CoreFuncInst, ft: FuncType, opts: CanonicalOptions, inst: ComponentInstance) -> FuncInst: - def func_inst(on_start: OnStart, on_resolve: OnResolve, caller: Optional[ComponentInstance]) -> OnCancel: - assert(not caller or caller is current_instance()) - trap_if(not inst.may_enter_from(caller)) - inst.enter_from(caller) + def func_inst(on_start: OnStart, on_resolve: OnResolve) -> OnCancel: + assert(self.nesting_depth > 0) on_cancel = canon_lift(f, ft, opts, inst, on_start, on_resolve) - inst.leave_to(caller) + assert(self.nesting_depth > 0) return on_cancel return func_inst def lower(self, f: FuncInst, ft: FuncType, opts: CanonicalOptions, inst: ComponentInstance) -> CoreFuncInst: def core_func_inst(args: list[CoreValType]) -> list[CoreValType]: assert(inst is current_instance()) - assert(all(not i.may_enter for i in inst.self_and_ancestors())) - results = canon_lower(f, ft, opts, args) - assert(all(not i.may_enter for i in inst.self_and_ancestors())) - return results + assert(self.nesting_depth > 0) + flat_results = canon_lower(f, ft, opts, args) + assert(self.nesting_depth > 0) + return flat_results return core_func_inst def tick(self): assert(self.nesting_depth == 0) - assert(all(thread.task.inst.may_enter_from(None) for thread in self.waiting)) self.nesting_depth += 1 candidates = { thread for thread in self.waiting if thread.ready() } if candidates: thread = random.choice(list(candidates)) - thread.task.inst.enter_from(None) thread.resume() - thread.task.inst.leave_to(None) self.nesting_depth -= 1 ## Canonical ABI Options @@ -678,20 +640,21 @@ def __init__(self, opts, inst, borrow_scope = None): self.borrow_scope = borrow_scope def reallocate(self, old, old_byte_length, alignment, new_byte_length): - assert(self.inst.may_leave) - self.inst.may_leave = False ptrt = U32Type() if self.opts.memory.ptr_type() == 'i32' else U64Type() ft = FuncType([ptrt, ptrt, ptrt, ptrt], [ptrt], async_ = False) opts = CanonicalOptions(async_ = False) + realloc = self.inst.store.lift(self.opts.realloc, ft, opts, self.inst) def on_start(): return [old, old_byte_length, alignment, new_byte_length] ptr = None def on_resolve(result): nonlocal ptr [ptr] = result - canon_lift(self.opts.realloc, ft, opts, self.inst, on_start, on_resolve) - assert(ptr is not None) + assert(self.inst.may_leave) + self.inst.may_leave = False + self.inst.store.invoke(realloc, on_start, on_resolve) self.inst.may_leave = True + assert(ptr is not None) return ptr def allocate(self, alignment, byte_length): @@ -2225,7 +2188,7 @@ def lock_available(): thread.resume() if not ft.async_: while task.state != Task.State.RESOLVED: - candidates = { t for t in inst.threads if t.ready() and t is not inst.exclusive_thread } + candidates = { t for t in inst.threads if t.ready() } trap_if(not candidates) random.choice(list(candidates)).resume() return task.request_cancellation @@ -2291,7 +2254,7 @@ def on_resolve(result): flat_results = lower_flat_values(cx, max_flat_results, result, ft.result_type(), flat_args) subtask.resolve(Subtask.State.RETURNED, flat_results) - subtask.on_cancel = callee(on_start, on_resolve, caller = thread.task.inst) + subtask.on_cancel = callee(on_start, on_resolve) assert(ft.async_ or subtask.state == Subtask.State.RETURNED) if not opts.async_: @@ -2491,7 +2454,7 @@ def canon_subtask_cancel(async_, i): assert(subtask.has_pending_event()) else: subtask.cancellation_requested = True - subtask.on_cancel(thread.task.inst) + subtask.on_cancel() if not subtask.resolved(): if not async_: subtask.wait_for_pending_event() diff --git a/design/mvp/canonical-abi/run_tests.py b/design/mvp/canonical-abi/run_tests.py index b4bd0c34..d512f94a 100644 --- a/design/mvp/canonical-abi/run_tests.py +++ b/design/mvp/canonical-abi/run_tests.py @@ -59,15 +59,15 @@ def lift_and_run(opts, inst, ft, callee, on_start, on_resolve): inst.store.tick() def mk_host_func(store, host_func, ft): - def func_inst(on_start, on_resume, caller) -> OnCancel: + def func_inst(on_start, on_resume) -> OnCancel: def thread_func(): wait_until = lambda rf: host_thread.wait_until(rf, cancellable = lambda: True) - host_func(caller, on_start, on_resume, wait_until) + host_func(on_start, on_resume, wait_until) inst = ComponentInstance(store) task = Task(ft, CanonicalOptions(), inst, on_start, on_resume) host_thread = Thread(task, thread_func) host_thread.resume() - def on_cancel(caller_inst): + def on_cancel(): host_thread.resume(Cancelled.TRUE) return on_cancel return func_inst @@ -473,11 +473,10 @@ def on_resolve(result): def test_cross_component_realloc(): store = Store() - root_inst = ComponentInstance(store) producer_heap = Heap(16) producer_opts = mk_opts(MemInst(producer_heap.memory, 'i32')) - producer_inst = ComponentInstance(store, root_inst) + producer_inst = ComponentInstance(store) ft = FuncType([], [ListType(U8Type())]) def core_producer(args): @@ -492,7 +491,7 @@ def core_producer(args): producer = store.lift(core_producer, ft, producer_opts, producer_inst) consumer_heap = Heap(24) - consumer_inst = ComponentInstance(store, root_inst) + consumer_inst = ComponentInstance(store) consumer_thread = None num_realloc_calls = 0 @@ -504,7 +503,6 @@ def core_consumer_realloc(args): assert(thread.task is not consumer_thread.task) assert(thread.task.inst is consumer_inst) assert(current_instance() is consumer_inst) - assert(not consumer_inst.may_enter) assert(not consumer_inst.may_leave) assert(canon_context_get('i32', 0) == [0]) assert(canon_context_get('i32', 1) == [0]) @@ -553,9 +551,9 @@ def dtor(args): return [] store = Store() - root_inst = ComponentInstance(store) - rt = ResourceType(ComponentInstance(store, root_inst), dtor) # usable in imports and exports - inst = ComponentInstance(store, root_inst) + producer_inst = ComponentInstance(store) + rt = ResourceType(producer_inst, dtor) # usable in imports and exports + inst = ComponentInstance(store) rt2 = ResourceType(inst, dtor) # only usable in exports opts = mk_opts() @@ -565,7 +563,7 @@ def dtor(args): ],[ OwnType(rt) ]) - def host_func(caller, on_start, on_return, wait_until): + def host_func(on_start, on_return, wait_until): args = on_start() assert(len(args) == 2) assert(args[0] == 42) @@ -655,8 +653,7 @@ def test_async_to_async(): producer_opts.async_ = True store = Store() - root_inst = ComponentInstance(store) - producer_inst = ComponentInstance(store, root_inst) + producer_inst = ComponentInstance(store) eager_ft = FuncType([], [U8Type()], async_=True) def core_eager_producer(args): @@ -695,7 +692,7 @@ def core_blocking_producer(args): consumer_heap = Heap(20) consumer_opts = mk_opts(MemInst(consumer_heap.memory, 'i32')) consumer_opts.async_ = True - consumer_inst = ComponentInstance(store, root_inst) + consumer_inst = ComponentInstance(store) def consumer(args): [b] = args @@ -764,8 +761,7 @@ def on_resolve(results): def test_async_callback(): store = Store() - root_inst = ComponentInstance(store) - producer_inst = ComponentInstance(store, root_inst) + producer_inst = ComponentInstance(store) producer_opts = mk_opts() producer_opts.async_ = True producer_ft = FuncType([], [], async_ = True) @@ -782,7 +778,7 @@ def core_producer_pre(fut, args): core_producer2 = partial(core_producer_pre, fut2) producer2 = store.lift(core_producer2, producer_ft, producer_opts, producer_inst) - consumer_inst = ComponentInstance(store, root_inst) + consumer_inst = ComponentInstance(store) consumer_ft = FuncType([],[U32Type()], async_ = True) consumer_inst = ComponentInstance(store) seti = 0 @@ -856,8 +852,7 @@ def on_resolve(results): def test_callback_interleaving(): store = Store() - root_inst = ComponentInstance(store) - producer_inst = ComponentInstance(store, root_inst) + producer_inst = ComponentInstance(store) producer_ft = FuncType([U32Type(), FutureType(None),FutureType(None),FutureType(None)],[U32Type()], async_ = True) fut3s = [None,None] def core_producer(args): @@ -905,7 +900,7 @@ def core_sync_callee(args): sync_callee_opts = mk_opts() sync_callee = store.lift(core_sync_callee, sync_callee_ft, sync_callee_opts, producer_inst) - consumer_inst = ComponentInstance(store, root_inst) + consumer_inst = ComponentInstance(store) consumer_ft = FuncType([], [], async_ = True) consumer_mem = bytearray(24) consumer_opts = mk_opts(MemInst(consumer_mem, 'i32'), async_ = True) @@ -1028,11 +1023,10 @@ def core_consumer(args): def test_sync_ignores_backpressure(): store = Store() - root_inst = ComponentInstance(store) sync_opts = mk_opts(async_ = False) async_opts = mk_opts(async_ = True) - callee_inst = ComponentInstance(store, root_inst) + callee_inst = ComponentInstance(store) async_ft = FuncType([U32Type(), FutureType(None)],[U32Type()], async_ = True) def core_callee1(args): @@ -1048,7 +1042,7 @@ def core_callee2(args): return [84 + i] sync_callee = store.lift(core_callee2, sync_ft, sync_opts, callee_inst) - caller_inst = ComponentInstance(store, root_inst) + caller_inst = ComponentInstance(store) caller_ft = FuncType([], [], async_ = True) caller_mem = bytearray(24) caller_opts = mk_opts(memory = MemInst(caller_mem, 'i32'), async_ = True) @@ -1086,9 +1080,8 @@ def core_caller(args): def test_async_to_sync(): store = Store() - root_inst = ComponentInstance(store) producer_opts = CanonicalOptions() - producer_inst = ComponentInstance(store, root_inst) + producer_inst = ComponentInstance(store) producer_ft = FuncType([],[], async_ = True) fut = RacyBool(False) @@ -1114,7 +1107,7 @@ def producer2_core(args): consumer_heap = Heap(20) consumer_opts = mk_opts(MemInst(consumer_heap.memory, 'i32')) consumer_opts.async_ = True - consumer_inst = ComponentInstance(store, root_inst) + consumer_inst = ComponentInstance(store) consumer_ft = FuncType([],[U8Type()], async_ = True) def consumer(args): assert(len(args) == 0) @@ -1172,10 +1165,9 @@ def on_resolve(results): def test_async_backpressure(): store = Store() - root_inst = ComponentInstance(store) producer_opts = CanonicalOptions() producer_opts.async_ = True - producer_inst = ComponentInstance(store, root_inst) + producer_inst = ComponentInstance(store) producer_ft = FuncType([],[], async_ = True) fut = RacyBool(False) @@ -1202,7 +1194,7 @@ def producer2_core(args): consumer_heap = Heap(20) consumer_opts = mk_opts(MemInst(consumer_heap.memory, 'i32'), async_ = True) - consumer_inst = ComponentInstance(store, root_inst) + consumer_inst = ComponentInstance(store) consumer_ft = FuncType([],[U8Type()], async_ = True) def consumer(args): assert(len(args) == 0) @@ -1256,10 +1248,9 @@ def on_resolve(results): def test_sync_using_wait(): store = Store() - root_inst = ComponentInstance(store) producer_opts = mk_opts() producer_opts.async_ = True - producer_inst = ComponentInstance(store, root_inst) + producer_inst = ComponentInstance(store) ft = FuncType([], [], async_ = True) def core_producer_pre(fut, args): @@ -1276,7 +1267,7 @@ def core_producer_pre(fut, args): consumer_heap = Heap(20) consumer_opts = mk_opts(MemInst(consumer_heap.memory, 'i32')) consumer_opts.async_ = True - consumer_inst = ComponentInstance(store, root_inst) + consumer_inst = ComponentInstance(store) def core_func(args): [ret] = store.lower(producer1, ft, consumer_opts, consumer_inst)([]) @@ -1482,7 +1473,7 @@ def test_eager_stream_completion(): sync_opts = mk_opts(memory=MemInst(mem, 'i32'), async_=False) ft = FuncType([StreamType(U8Type())], [StreamType(U8Type())]) - def host_func(caller, on_start, on_resolve, wait_until): + def host_func(on_start, on_resolve, wait_until): args = on_start() assert(len(args) == 1) assert(isinstance(args[0], ReadableStream)) @@ -1567,7 +1558,7 @@ def test_async_stream_ops(): host_import_incoming = None host_import_outgoing = None ft = FuncType([StreamType(U8Type())], [StreamType(U8Type())], async_ = True) - def host_func(caller, on_start, on_resolve, wait_until): + def host_func(on_start, on_resolve, wait_until): nonlocal host_import_incoming, host_import_outgoing args = on_start() assert(len(args) == 1) @@ -1714,7 +1705,7 @@ def test_receive_own_stream(): opts = mk_opts(memory=MemInst(mem, 'i32'), async_=True) host_ft = FuncType([StreamType(U8Type())], [StreamType(U8Type())]) - def host_func(caller, on_start, on_resolve, wait_until): + def host_func(on_start, on_resolve, wait_until): args = on_start() assert(len(args) == 1) assert(isinstance(args[0], ReadableStream)) @@ -1754,14 +1745,14 @@ def test_host_partial_reads_writes(): src = HostSource(U8Type(), [1,2,3,4], chunk=2, destroy_if_empty = False) source_ft = FuncType([], [StreamType(U8Type())]) - def host_source_func(caller, on_start, on_resolve, wait_until): + def host_source_func(on_start, on_resolve, wait_until): [] = on_start() on_resolve([src]) host_source_func_inst = mk_host_func(store, host_source_func, source_ft) dst = None sink_ft = FuncType([StreamType(U8Type())], []) - def host_sink_func(caller, on_start, on_resolve, wait_until): + def host_sink_func(on_start, on_resolve, wait_until): nonlocal dst [s] = on_start() dst = HostSink(s, chunk=1, remain=2) @@ -1831,10 +1822,9 @@ def on_resolve(results): assert(len(results) == 0) def test_wasm_to_wasm_stream(): store = Store() - root_inst = ComponentInstance(store) fut1, fut2, fut3, fut4 = RacyBool(False), RacyBool(False), RacyBool(False), RacyBool(False) - inst1 = ComponentInstance(store, root_inst) + inst1 = ComponentInstance(store) mem1 = bytearray(24) opts1 = mk_opts(memory=MemInst(mem1, 'i32'), async_=True) ft1 = FuncType([], [StreamType(U8Type())]) @@ -1899,7 +1889,7 @@ def core_func1(args): func1 = store.lift(core_func1, ft1, opts1, inst1) - inst2 = ComponentInstance(store, root_inst) + inst2 = ComponentInstance(store) heap2 = Heap(24) mem2 = heap2.memory opts2 = mk_opts(memory=MemInst(heap2.memory, 'i32'), realloc=heap2.realloc, async_=True) @@ -1965,10 +1955,9 @@ def core_func2(args): def test_wasm_to_wasm_stream_empty(): store = Store() - root_inst = ComponentInstance(store) fut1, fut2, fut3, fut4 = RacyBool(False), RacyBool(False), RacyBool(False), RacyBool(False) - inst1 = ComponentInstance(store, root_inst) + inst1 = ComponentInstance(store) mem1 = bytearray(24) opts1 = mk_opts(memory=MemInst(mem1, 'i32'), async_=True) ft1 = FuncType([], [StreamType(None)]) @@ -2013,7 +2002,7 @@ def core_func1(args): func1 = store.lift(core_func1, ft1, opts1, inst1) - inst2 = ComponentInstance(store, root_inst) + inst2 = ComponentInstance(store) heap2 = Heap(10) mem2 = heap2.memory opts2 = mk_opts(memory=MemInst(heap2.memory, 'i32'), realloc=heap2.realloc, async_=True) @@ -2071,7 +2060,7 @@ def test_cancel_copy(): host_ft1 = FuncType([StreamType(U8Type())],[]) host_sink = None - def host_func1(caller, on_start, on_resolve, wait_until): + def host_func1(on_start, on_resolve, wait_until): nonlocal host_sink [stream] = on_start() host_sink = HostSink(stream, 2, remain = 0) @@ -2080,7 +2069,7 @@ def host_func1(caller, on_start, on_resolve, wait_until): host_ft2 = FuncType([], [StreamType(U8Type())]) host_source = None - def host_func2(caller, on_start, on_resolve, wait_until): + def host_func2(on_start, on_resolve, wait_until): nonlocal host_source [] = on_start() host_source = HostSource(U8Type(), [], chunk=2, destroy_if_empty = False) @@ -2225,7 +2214,7 @@ def test_futures(): lower_opts = mk_opts(memory=MemInst(mem, 'i32'), async_=True) host_ft1 = FuncType([FutureType(U8Type())],[FutureType(U8Type())], async_ = True) - def host_func(caller, on_start, on_resolve, wait_until): + def host_func(on_start, on_resolve, wait_until): [future] = on_start() outgoing = HostFutureSource(U8Type()) on_resolve([outgoing]) @@ -2310,13 +2299,12 @@ def core_func(args): def test_cancel_subtask(): store = Store() - root_inst = ComponentInstance(store) ft = FuncType([U8Type()], [U8Type()], async_ = True) callee_heap = Heap(10) callee_opts = mk_opts(MemInst(callee_heap.memory, 'i32'), async_ = True) sync_callee_opts = mk_opts(MemInst(callee_heap.memory, 'i32'), async_ = False) - callee_inst = ComponentInstance(store, root_inst) + callee_inst = ComponentInstance(store) def core_callee1(args): assert(False) @@ -2354,7 +2342,7 @@ def core_callee3(args): callee3 = store.lift(core_callee3, ft, callee_opts, callee_inst) host_fut4 = RacyBool(False) - def host_func4(caller, on_start, on_resolve, wait_until): + def host_func4(on_start, on_resolve, wait_until): args = on_start() assert(len(args) == 1) assert(args[0] == 42) @@ -2380,7 +2368,7 @@ def core_callee4(args): callee4 = store.lift(core_callee4, ft, callee_opts, callee_inst) host_fut5 = RacyBool(False) - def host_func5(caller, on_start, on_resolve, wait_until): + def host_func5(on_start, on_resolve, wait_until): args = on_start() assert(len(args) == 1) assert(args[0] == 42) @@ -2448,7 +2436,7 @@ def core_callee6(args): caller_heap = Heap(20) caller_opts = mk_opts(MemInst(caller_heap.memory, 'i32'), async_ = True) - caller_inst = ComponentInstance(store, root_inst) + caller_inst = ComponentInstance(store) def core_caller(args): [x] = args @@ -2686,7 +2674,7 @@ def test_async_flat_params(): inst = ComponentInstance(store) ft1 = FuncType([F32Type(), F64Type(), U32Type(), S64Type()],[]) - def f1(caller, on_start, on_resolve, wait_until): + def f1(on_start, on_resolve, wait_until): args = on_start() assert(len(args) == 4) assert(args[0] == 1.1) @@ -2697,7 +2685,7 @@ def f1(caller, on_start, on_resolve, wait_until): f1_inst = mk_host_func(store, f1, ft1) ft2 = FuncType([U32Type(),U8Type(),U8Type(),U8Type()],[]) - def f2(caller, on_start, on_resolve, wait_until): + def f2(on_start, on_resolve, wait_until): args = on_start() assert(len(args) == 4) assert(args == [1,2,3,4]) @@ -2705,7 +2693,7 @@ def f2(caller, on_start, on_resolve, wait_until): f2_inst = mk_host_func(store, f2, ft2) ft3 = FuncType([U32Type(),U8Type(),U8Type(),U8Type(),U8Type()],[]) - def f3(caller, on_start, on_resolve, wait_until): + def f3(on_start, on_resolve, wait_until): args = on_start() assert(len(args) == 5) assert(args == [1,2,3,4,5]) @@ -2922,11 +2910,12 @@ def core_func(args): return [] ready_bit = RacyBool(False) - ok_to_run_async = False + # once ready_bit is set, this thread may be resumed either by canon_lift's + # completion loop (while core_func's task is still executing) or by a + # top-level tick (after it resolves) def async_task_thread(args): assert(not args) current_thread().wait_until(ready_bit.is_set) - assert(ok_to_run_async) return [43] async_ft = FuncType([], [U32Type()], async_ = True) other_result = None @@ -2940,9 +2929,8 @@ def on_async_resolve(v): result = None def on_resolve(v): - nonlocal result, ok_to_run_async + nonlocal result [result] = v - ok_to_run_async = True caller_ft = FuncType([], [U8Type()]) lift_and_run(opts, inst, caller_ft, core_func, lambda:[], on_resolve) @@ -2951,8 +2939,7 @@ def on_resolve(v): def test_thread_cancel_callback(): store = Store() - root_inst = ComponentInstance(store) - producer_inst = ComponentInstance(store, root_inst) + producer_inst = ComponentInstance(store) producer_ft = FuncType([], [U32Type()], async_ = True) producer_opts1 = mk_opts(async_ = True) @@ -2979,7 +2966,7 @@ def core_producer_callback2(args): producer_opts2.callback = core_producer_callback2 producer_callee2 = store.lift(core_producer2, producer_ft, producer_opts2, producer_inst) - consumer_inst = ComponentInstance(store, root_inst) + consumer_inst = ComponentInstance(store) consumer_ft = FuncType([], [], async_ = True) consumer_mem = bytearray(24) consumer_opts = mk_opts(MemInst(consumer_mem, 'i32'), async_ = True) diff --git a/test/async/dont-block-start.wast b/test/async/dont-block-start.wast index 12f137f3..344ba93e 100644 --- a/test/async/dont-block-start.wast +++ b/test/async/dont-block-start.wast @@ -24,11 +24,23 @@ (assert_trap (component (component $C + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) (core module $M - (func (export "f") (result i32) unreachable) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (func (export "f") (result i32) + (drop (call $waitable-set.wait (call $waitable-set.new) (i32.const 0))) + unreachable + ) (func (export "f_cb") (param i32 i32 i32) (result i32) unreachable) ) - (core instance $i (instantiate $M)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + (core instance $i (instantiate $M (with "" (instance + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.wait" (func $waitable-set.wait)) + )))) (func (export "f") async (canon lift (core func $i "f") async (callback (core func $i "f_cb")))) ) (component $D diff --git a/test/async/during-sync-call-exclusive-resume.wast b/test/async/during-sync-call-exclusive-resume.wast new file mode 100644 index 00000000..40d2b8e2 --- /dev/null +++ b/test/async/during-sync-call-exclusive-resume.wast @@ -0,0 +1,103 @@ +;; While a non-async-typed export call is in progress, the runtime may resume +;; *any* ready thread of the same component instance, including the implicit +;; threads of async-typed tasks using the sync or callback ABIs (which take +;; the instance's exclusive lock while they execute core wasm). To test this +;; deterministically, a sync call suspends its implicit thread in a state +;; where the *only* ready thread in the instance is the parked event-loop +;; thread of an async callback task, which must therefore be resumed (running +;; its callback in the middle of the sync call) to unblock the sync call. +(component + (core module $Core + (import "" "task.return" (func $task.return (param i32))) + (import "" "thread.index" (func $thread.index (result i32))) + (import "" "thread.suspend" (func $thread.suspend (result i32))) + (import "" "thread.resume-later" (func $thread.resume-later (param i32))) + + (global $sync-thread-index (mut i32) (i32.const 0xdead)) + (global $pinged (mut i32) (i32.const 0)) + + ;; async callback task resolves, then parks its implicit thread, ready, in + ;; its event loop by returning YIELD + (func (export "arm") (result i32) + (call $task.return (i32.const 1)) + (i32.const 1 (; YIELD ;))) + + ;; Since arm's YIELD may nondeterministically complete without suspending, + ;; 'ping-cb' may be called (with a NONE event) while no sync call is in + ;; progress; it then just parks the thread again. When resumed during + ;; 'sync-block', it wakes the suspended sync thread and exits. + (func (export "ping-cb") (param i32 i32 i32) (result i32) + (if (i32.eq (global.get $sync-thread-index) (i32.const 0xdead)) + (then (return (i32.const 1 (; YIELD ;))))) + (global.set $pinged (i32.const 1)) + (call $thread.resume-later (global.get $sync-thread-index)) + (i32.const 0 (; EXIT ;))) + + ;; non-async-typed: suspend; only ping-cb can wake us + (func (export "sync-block") (result i32) + (global.set $sync-thread-index (call $thread.index)) + (drop (call $thread.suspend)) + (if (i32.eqz (global.get $pinged)) + (then unreachable)) + (i32.const 42)) + ) + (canon task.return (result u32) (core func $task.return)) + (canon thread.index (core func $thread.index)) + (canon thread.suspend (core func $thread.suspend)) + (canon thread.resume-later (core func $thread.resume-later)) + (core instance $core (instantiate $Core (with "" (instance + (export "task.return" (func $task.return)) + (export "thread.index" (func $thread.index)) + (export "thread.suspend" (func $thread.suspend)) + (export "thread.resume-later" (func $thread.resume-later)) + )))) + (func (export "arm") async (result u32) + (canon lift (core func $core "arm") async (callback (core func $core "ping-cb")))) + (func (export "sync-block") (result u32) + (canon lift (core func $core "sync-block"))) +) +(assert_return (invoke "arm") (u32.const 1)) +(assert_return (invoke "sync-block") (u32.const 42)) + +;; A ready needs-exclusive thread allows the sync call to block, but if that +;; thread exits without waking the sync call's own thread, the runtime traps +;; once no ready threads remain in the instance. +(component + (core module $Core + (import "" "task.return" (func $task.return (param i32))) + (import "" "thread.suspend" (func $thread.suspend (result i32))) + + (global $in-sync-call (mut i32) (i32.const 0)) + + ;; async callback task resolves, then parks its implicit thread, ready, in + ;; its event loop by returning YIELD + (func (export "arm") (result i32) + (call $task.return (i32.const 1)) + (i32.const 1 (; YIELD ;))) + + ;; Park again on a spurious wake-up outside the sync call; exit (without + ;; waking anyone) when resumed during it. + (func (export "exit-cb") (param i32 i32 i32) (result i32) + (if (i32.eqz (global.get $in-sync-call)) + (then (return (i32.const 1 (; YIELD ;))))) + (i32.const 0 (; EXIT ;))) + + ;; non-async-typed: suspend with nothing left to wake us + (func (export "sync-block") + (global.set $in-sync-call (i32.const 1)) + (drop (call $thread.suspend)) + unreachable) + ) + (canon task.return (result u32) (core func $task.return)) + (canon thread.suspend (core func $thread.suspend)) + (core instance $core (instantiate $Core (with "" (instance + (export "task.return" (func $task.return)) + (export "thread.suspend" (func $thread.suspend)) + )))) + (func (export "arm") async (result u32) + (canon lift (core func $core "arm") async (callback (core func $core "exit-cb")))) + (func (export "sync-block") + (canon lift (core func $core "sync-block"))) +) +(assert_return (invoke "arm") (u32.const 1)) +(assert_trap (invoke "sync-block") "cannot block a synchronous task before returning") diff --git a/test/async/during-sync-call-may-block-if-other-ready-threads.wast b/test/async/during-sync-call-may-block-if-other-ready-threads.wast index d9e5ec4b..e7422fd9 100644 --- a/test/async/during-sync-call-may-block-if-other-ready-threads.wast +++ b/test/async/during-sync-call-may-block-if-other-ready-threads.wast @@ -2,7 +2,7 @@ ;; depend on the thread's task's function type (since threads can arbitrarily ;; switch to any other thread running in the same component instance). Rather, ;; it depends on whether, at the point of blocking, there are any other -;; threads that are ready to run and able to use the stack. +;; threads that are ready to run. ;; ;; To test this, the tester below has two non-async-typed exports, so both ;; run their bodies inside a dynamic scope where the "cannot block before diff --git a/test/async/during-sync-call-no-exclusive-resume.wast b/test/async/during-sync-call-no-exclusive-resume.wast deleted file mode 100644 index 1298fe8e..00000000 --- a/test/async/during-sync-call-no-exclusive-resume.wast +++ /dev/null @@ -1,163 +0,0 @@ -;; While a non-async-typed export call is in progress, the runtime must only -;; ever resume threads that can use the stack: in particular, the implicit -;; threads of async-typed tasks that need exclusive use of the stack must never -;; be resumed, even when they are ready and in the same component instance. To -;; test this, a number of ready-but-excluded threads are created, all of which -;; trap if ever resumed, and the sync call must instead make progress through -;; one valid resumable thread. -(component - (component $Inner - (core module $Table - (table (export "__indirect_function_table") 1 funcref)) - (core instance $table (instantiate $Table)) - (core module $Core - (import "" "task.return" (func $task.return (param i32))) - (import "" "thread.new-indirect" (func $thread.new-indirect (param i32 i32) (result i32))) - (import "" "thread.suspend-then-resume" (func $thread.suspend-then-resume (param i32) (result i32))) - (import "" "thread.resume-later" (func $thread.resume-later (param i32))) - (import "" "thread.suspend" (func $thread.suspend (result i32))) - (import "" "thread.index" (func $thread.index (result i32))) - (import "" "__indirect_function_table" (table $indirect-function-table 1 funcref)) - - (global $setup-thread-index (mut i32) (i32.const 0xdead)) - (global $implicit-thread-index (mut i32) (i32.const 0xdead)) - (global $in-sync-call (mut i32) (i32.const 0)) - - (func $thread-start (param i32) - (local $r i32) - (loop $rounds - (call $thread.resume-later (global.get $implicit-thread-index)) - (drop (call $thread.suspend)) - (local.set $r (i32.add (local.get $r) (i32.const 1))) - (br_if $rounds (i32.lt_u (local.get $r) (i32.const 4)))) - unreachable) - (elem (table $indirect-function-table) (i32.const 0) func $thread-start) - - (func (export "setup") (result i32) - (global.set $setup-thread-index (call $thread.new-indirect (i32.const 0) (i32.const 0))) - (call $task.return (i32.const 1)) - (i32.const 0 (; EXIT ;))) - - ;; async callback task resolves, then parks its implicit thread, ready, - ;; in its event loop by returning YIELD. Since a YIELD may - ;; nondeterministically complete without suspending, 'never-cb' may be - ;; called (with a NONE event) while no non-async-typed call is in - ;; progress and parks the thread again; it must never be called during - ;; 'sync-block'. - (func (export "arm") (result i32) - (call $task.return (i32.const 1)) - (i32.const 1 (; YIELD ;))) - - (func (export "never-cb") (param i32 i32 i32) (result i32) - (if (global.get $in-sync-call) - (then unreachable)) - (i32.const 1 (; YIELD ;))) - - ;; non-async-typed: 4 rounds of switching to $thread-start and being made ready - ;; again by it - (func (export "sync-block") (result i32) - (local $r i32) - (global.set $in-sync-call (i32.const 1)) - (global.set $implicit-thread-index (call $thread.index)) - (loop $rounds - (drop (call $thread.suspend-then-resume (global.get $setup-thread-index))) - (local.set $r (i32.add (local.get $r) (i32.const 1))) - (br_if $rounds (i32.lt_u (local.get $r) (i32.const 4)))) - (global.set $in-sync-call (i32.const 0)) - (i32.const 42)) - ) - (core type $start-func-ty (func (param i32))) - (alias core export $table "__indirect_function_table" (core table $indirect-function-table)) - (core func $thread.new-indirect - (canon thread.new-indirect $start-func-ty (core table $indirect-function-table))) - (canon task.return (result u32) (core func $task.return)) - (canon thread.suspend-then-resume (core func $thread.suspend-then-resume)) - (canon thread.resume-later (core func $thread.resume-later)) - (canon thread.suspend (core func $thread.suspend)) - (canon thread.index (core func $thread.index)) - (core instance $core (instantiate $Core (with "" (instance - (export "task.return" (func $task.return)) - (export "thread.new-indirect" (func $thread.new-indirect)) - (export "thread.suspend-then-resume" (func $thread.suspend-then-resume)) - (export "thread.resume-later" (func $thread.resume-later)) - (export "thread.suspend" (func $thread.suspend)) - (export "thread.index" (func $thread.index)) - (export "__indirect_function_table" (table $indirect-function-table)) - )))) - (func (export "setup") async (result u32) - (canon lift (core func $core "setup") async (callback (core func $core "never-cb")))) - (func (export "arm") async (result u32) - (canon lift (core func $core "arm") async (callback (core func $core "never-cb")))) - (func (export "sync-block") (result u32) - (canon lift (core func $core "sync-block"))) - ) - (component $Driver - (import "inner" (instance $inner - (export "sync-block" (func (result u32))) - )) - (core module $Core - (import "" "sync-block" (func $sync-block (result i32))) - (func (export "run") (result i32) - (call $sync-block))) - (canon lower (func $inner "sync-block") (core func $sync-block')) - (core instance $core (instantiate $Core (with "" (instance - (export "sync-block" (func $sync-block')) - )))) - (func (export "run") (result u32) - (canon lift (core func $core "run"))) - ) - (instance $inner (instantiate $Inner)) - (instance $driver (instantiate $Driver (with "inner" (instance $inner)))) - (func (export "setup") (alias export $inner "setup")) - (func (export "arm") (alias export $inner "arm")) - (func (export "run") (alias export $driver "run")) -) -(assert_return (invoke "setup") (u32.const 1)) -(assert_return (invoke "arm") (u32.const 1)) -(assert_return (invoke "arm") (u32.const 1)) -(assert_return (invoke "arm") (u32.const 1)) -(assert_return (invoke "run") (u32.const 42)) - -;; A ready-but-excluded thread does not allow the sync call to block: when -;; the only other thread in the instance needs exclusive use of the stack, -;; blocking must trap immediately rather than resume that thread. -(component - (core module $Core - (import "" "task.return" (func $task.return (param i32))) - (import "" "thread.suspend" (func $thread.suspend (result i32))) - - (global $in-sync-call (mut i32) (i32.const 0)) - - ;; async callback task resolves, then parks its implicit thread, ready, - ;; in its event loop by returning YIELD. Since a YIELD may - ;; nondeterministically complete without suspending, 'never-cb' may be - ;; called (with a NONE event) while no non-async-typed call is in progress - ;; and parks the thread again; it must never be called during 'sync-block'. - (func (export "arm") (result i32) - (call $task.return (i32.const 1)) - (i32.const 1 (; YIELD ;))) - - (func (export "never-cb") (param i32 i32 i32) (result i32) - (if (global.get $in-sync-call) - (then unreachable)) - (i32.const 1 (; YIELD ;))) - - ;; non-async-typed: suspend with no valid thread to switch to - (func (export "sync-block") - (global.set $in-sync-call (i32.const 1)) - (drop (call $thread.suspend)) - unreachable) - ) - (canon task.return (result u32) (core func $task.return)) - (canon thread.suspend (core func $thread.suspend)) - (core instance $core (instantiate $Core (with "" (instance - (export "task.return" (func $task.return)) - (export "thread.suspend" (func $thread.suspend)) - )))) - (func (export "arm") async (result u32) - (canon lift (core func $core "arm") async (callback (core func $core "never-cb")))) - (func (export "sync-block") - (canon lift (core func $core "sync-block"))) -) -(assert_return (invoke "arm") (u32.const 1)) -(assert_trap (invoke "sync-block") "deadlock detected: event loop cannot make further progress") diff --git a/test/async/during-sync-call-no-sibling-resume.wast b/test/async/during-sync-call-no-sibling-resume.wast index d3ff9e05..26a3328f 100644 --- a/test/async/during-sync-call-no-sibling-resume.wast +++ b/test/async/during-sync-call-no-sibling-resume.wast @@ -1,9 +1,9 @@ -;; While a non-async-typed export call is in progress, the runtime must only -;; ever resume threads of that call's own component instance (which is necessary -;; to prevent accidental and unexpected reentrance). To test this behavior, the -;; following test creates a bunch of ready-but-excluded threads which sit in a -;; sibling instance and must not be resumed when a sync call in the primary -;; component instance blocks. +;; While a non-async-typed export call is in progress, the runtime only ever +;; resumes threads of that call's own component instance; ready threads of +;; *other* component instances run only once control returns to the top-level +;; event loop. To test this behavior, the following test creates a bunch of +;; ready-but-excluded threads which sit in a sibling instance and must not be +;; resumed when a sync call in the primary component instance blocks. ;; ;; In particular: ;; 1. "setup" spawns thread X inside $Inner (X belongs to a resolved @@ -212,4 +212,4 @@ (func (export "sync-block") (alias export $inner "sync-block")) ) (assert_return (invoke "arm")) -(assert_trap (invoke "sync-block") "deadlock detected: event loop cannot make further progress") +(assert_trap (invoke "sync-block") "cannot block a synchronous task before returning") diff --git a/test/async/during-sync-scheduling-candidates.wast b/test/async/during-sync-scheduling-candidates.wast new file mode 100644 index 00000000..0e10af2e --- /dev/null +++ b/test/async/during-sync-scheduling-candidates.wast @@ -0,0 +1,484 @@ +;; Test which threads may be resumed while a non-async-typed export call is in +;; progress, in two contexts: +;; - implicitly, as part of sync-call scheduling: when the implicit thread of +;; a non-async-typed call blocks, the runtime resumes (nondeterministically) +;; ready threads of the same component instance until the call resolves +;; - explicitly, via the thread.{suspend,yield}-then-promote built-ins, which +;; switch directly to the target thread when it is ready +;; +;; Every ready thread of the same component instance is a valid candidate, +;; including the implicit threads of async-typed tasks using the sync or +;; callback ABIs (which take the instance's exclusive lock while they execute +;; core wasm). Each component below tests one kind of thread in both contexts, +;; side by side: 'run' blocks with plain thread.yield, so resumption happens +;; implicitly as part of sync-call scheduling, while 'run-promote' targets the +;; thread in question explicitly with thread.yield-then-promote. In each case, +;; 'run' and 'run-promote' can only complete if the target thread is resumed. + +;; Explicit threads (including of the sync task itself) +(component + (core module $Table (table (export "__indirect_function_table") 1 funcref)) + (core instance $table (instantiate $Table)) + (core module $Core + (import "" "thread.new-indirect" (func $thread.new-indirect (param i32 i32) (result i32))) + (import "" "thread.resume-later" (func $thread.resume-later (param i32))) + (import "" "thread.yield" (func $thread.yield (result i32))) + (import "" "thread.yield-then-promote" (func $thread.yield-then-promote (param i32) (result i32))) + (import "" "__indirect_function_table" (table $tbl 1 funcref)) + + (global $worker-ran (mut i32) (i32.const 0)) + (global $worker-thread (mut i32) (i32.const 0)) + + (func $worker (param i32) + (global.set $worker-ran (i32.const 1))) + (elem (table $tbl) (i32.const 0) func $worker) + + (func $spawn-worker + (global.set $worker-ran (i32.const 0)) + (global.set $worker-thread (call $thread.new-indirect (i32.const 0) (i32.const 0))) + (call $thread.resume-later (global.get $worker-thread))) + + (func (export "run") (result i32) + (call $spawn-worker) + (loop $again + (drop (call $thread.yield)) + (br_if $again (i32.eqz (global.get $worker-ran)))) + (i32.const 42)) + + (func (export "run-promote") (result i32) + (call $spawn-worker) + (drop (call $thread.yield-then-promote (global.get $worker-thread))) + (if (i32.eqz (global.get $worker-ran)) + (then unreachable)) + (i32.const 42)) + ) + (core type $start-func-ty (func (param i32))) + (alias core export $table "__indirect_function_table" (core table $indirect-function-table)) + (core func $thread.new-indirect + (canon thread.new-indirect $start-func-ty (core table $indirect-function-table))) + (canon thread.resume-later (core func $thread.resume-later)) + (canon thread.yield (core func $thread.yield)) + (canon thread.yield-then-promote (core func $thread.yield-then-promote)) + (core instance $core (instantiate $Core (with "" (instance + (export "thread.new-indirect" (func $thread.new-indirect)) + (export "thread.resume-later" (func $thread.resume-later)) + (export "thread.yield" (func $thread.yield)) + (export "thread.yield-then-promote" (func $thread.yield-then-promote)) + (export "__indirect_function_table" (table $indirect-function-table)) + )))) + (func (export "run") (result u32) + (canon lift (core func $core "run"))) + (func (export "run-promote") (result u32) + (canon lift (core func $core "run-promote"))) +) +(assert_return (invoke "run") (u32.const 42)) +(assert_return (invoke "run-promote") (u32.const 42)) + +;; Implicit thread of a stackful async task +(component + (core module $Core + (import "" "task.return" (func $task.return (param i32))) + (import "" "thread.index" (func $thread.index (result i32))) + (import "" "thread.yield" (func $thread.yield (result i32))) + (import "" "thread.yield-then-promote" (func $thread.yield-then-promote (param i32) (result i32))) + + (global $finished (mut i32) (i32.const 0)) + (global $may-finish (mut i32) (i32.const 0)) + (global $setup-thread (mut i32) (i32.const 0)) + + (func (export "setup") + (global.set $finished (i32.const 0)) + (global.set $may-finish (i32.const 0)) + (global.set $setup-thread (call $thread.index)) + (call $task.return (i32.const 1)) + ;; keep yielding (and thus keep $setup-thread valid) until 'run' or + ;; 'run-promote' says it's ok to finish + (loop $again + (drop (call $thread.yield)) + (br_if $again (i32.eqz (global.get $may-finish)))) + (global.set $finished (i32.const 1))) + + (func (export "run") (result i32) + (global.set $may-finish (i32.const 1)) + (loop $again + (drop (call $thread.yield)) + (br_if $again (i32.eqz (global.get $finished)))) + (i32.const 42)) + + (func (export "run-promote") (result i32) + (global.set $may-finish (i32.const 1)) + (drop (call $thread.yield-then-promote (global.get $setup-thread))) + (if (i32.eqz (global.get $finished)) + (then unreachable)) + (i32.const 42)) + ) + (canon task.return (result u32) (core func $task.return)) + (canon thread.index (core func $thread.index)) + (canon thread.yield (core func $thread.yield)) + (canon thread.yield-then-promote (core func $thread.yield-then-promote)) + (core instance $core (instantiate $Core (with "" (instance + (export "task.return" (func $task.return)) + (export "thread.index" (func $thread.index)) + (export "thread.yield" (func $thread.yield)) + (export "thread.yield-then-promote" (func $thread.yield-then-promote)) + )))) + (func (export "setup") async (result u32) + (canon lift (core func $core "setup") async)) + (func (export "run") (result u32) + (canon lift (core func $core "run"))) + (func (export "run-promote") (result u32) + (canon lift (core func $core "run-promote"))) +) +(assert_return (invoke "setup") (u32.const 1)) +(assert_return (invoke "run") (u32.const 42)) +(assert_return (invoke "setup") (u32.const 1)) +(assert_return (invoke "run-promote") (u32.const 42)) + +;; Implicit thread of an async callback task waiting in an event loop: it is +;; ready while parked, so it may be resumed during a non-async-typed call, +;; running its callback in the middle of the sync call. Here, 'run' and +;; 'run-promote' cannot complete until 'setup-cb' observes the sync call in +;; progress. 'run' is additionally called from an async callback task in a +;; separate driver component instance to check that this scheduling is +;; unaffected by the caller's task kind. +(component + (component $C + (core module $Core + (import "" "task.return" (func $task.return (param i32))) + (import "" "thread.index" (func $thread.index (result i32))) + (import "" "thread.yield" (func $thread.yield (result i32))) + (import "" "thread.yield-then-promote" (func $thread.yield-then-promote (param i32) (result i32))) + + (global $setup-thread (mut i32) (i32.const 0)) + (global $in-sync-call (mut i32) (i32.const 0)) + (global $cb-ran (mut i32) (i32.const 0)) + + (func (export "setup") (result i32) + (global.set $setup-thread (call $thread.index)) + (call $task.return (i32.const 1)) + (i32.const 1 (; YIELD ;))) + + ;; Since setup's YIELD may nondeterministically complete without + ;; suspending, 'setup-cb' may also be called (with a NONE event) while no + ;; non-async-typed call is in progress; either way it parks the thread + ;; again so it stays available for the next 'run*'. + (func (export "setup-cb") (param i32 i32 i32) (result i32) + (if (global.get $in-sync-call) + (then (global.set $cb-ran (i32.const 1)))) + (i32.const 1 (; YIELD ;))) + + (func (export "run") (result i32) + (global.set $cb-ran (i32.const 0)) + (global.set $in-sync-call (i32.const 1)) + (loop $again + (drop (call $thread.yield)) + (br_if $again (i32.eqz (global.get $cb-ran)))) + (global.set $in-sync-call (i32.const 0)) + (i32.const 42)) + + (func (export "run-promote") (result i32) + (global.set $cb-ran (i32.const 0)) + (global.set $in-sync-call (i32.const 1)) + (drop (call $thread.yield-then-promote (global.get $setup-thread))) + (if (i32.eqz (global.get $cb-ran)) + (then unreachable)) + (global.set $in-sync-call (i32.const 0)) + (i32.const 42)) + ) + (canon task.return (result u32) (core func $task.return)) + (canon thread.index (core func $thread.index)) + (canon thread.yield (core func $thread.yield)) + (canon thread.yield-then-promote (core func $thread.yield-then-promote)) + (core instance $core (instantiate $Core (with "" (instance + (export "task.return" (func $task.return)) + (export "thread.index" (func $thread.index)) + (export "thread.yield" (func $thread.yield)) + (export "thread.yield-then-promote" (func $thread.yield-then-promote)) + )))) + (func (export "setup") async (result u32) + (canon lift (core func $core "setup") async (callback (core func $core "setup-cb")))) + (func (export "run") (result u32) + (canon lift (core func $core "run"))) + (func (export "run-promote") (result u32) + (canon lift (core func $core "run-promote"))) + ) + (component $D + (import "run" (func $run (result u32))) + (core module $Core + (import "" "task.return" (func $task.return (param i32))) + (import "" "run" (func $run (result i32))) + + (func (export "driver") (result i32) + (call $task.return (call $run)) + (i32.const 0 (; EXIT ;))) + + (func (export "driver-cb") (param i32 i32 i32) (result i32) + unreachable) + ) + (canon task.return (result u32) (core func $task.return)) + (canon lower (func $run) (core func $run')) + (core instance $core (instantiate $Core (with "" (instance + (export "task.return" (func $task.return)) + (export "run" (func $run')) + )))) + (func (export "driver") async (result u32) + (canon lift (core func $core "driver") async (callback (core func $core "driver-cb")))) + ) + (instance $c (instantiate $C)) + (instance $d (instantiate $D (with "run" (func $c "run")))) + (func (export "setup") (alias export $c "setup")) + (func (export "run") (alias export $c "run")) + (func (export "run-promote") (alias export $c "run-promote")) + (func (export "driver") (alias export $d "driver")) +) +(assert_return (invoke "setup") (u32.const 1)) +(assert_return (invoke "run") (u32.const 42)) +(assert_return (invoke "driver") (u32.const 42)) +(assert_return (invoke "run-promote") (u32.const 42)) + +;; Implicit thread of an async callback task blocked not in the event loop +;; (suspended mid-frame, still holding the instance's exclusive lock): once +;; made ready, it may be resumed during a non-async-typed call; here its +;; resumption is required for 'run' and 'run-promote' to complete. Since 'run' +;; and 'run-promote' each make setup's suspended thread ready and then consume +;; it, each runs in a fresh instance. +(component definition $BlockedCallbackTester + (core module $Core + (import "" "task.return" (func $task.return (param i32))) + (import "" "thread.index" (func $thread.index (result i32))) + (import "" "thread.suspend" (func $thread.suspend (result i32))) + (import "" "thread.resume-later" (func $thread.resume-later (param i32))) + (import "" "thread.yield" (func $thread.yield (result i32))) + (import "" "thread.yield-then-promote" (func $thread.yield-then-promote (param i32) (result i32))) + + (global $setup-thread (mut i32) (i32.const 0)) + (global $resumed (mut i32) (i32.const 0)) + + (func (export "setup") (result i32) + (global.set $setup-thread (call $thread.index)) + (call $task.return (i32.const 1)) + (drop (call $thread.suspend)) + (global.set $resumed (i32.const 1)) + (i32.const 0 (; EXIT ;))) + + (func (export "setup-cb") (param i32 i32 i32) (result i32) + unreachable) + + (func (export "run") (result i32) + (call $thread.resume-later (global.get $setup-thread)) + (loop $again + (drop (call $thread.yield)) + (br_if $again (i32.eqz (global.get $resumed)))) + (i32.const 42)) + + (func (export "run-promote") (result i32) + (call $thread.resume-later (global.get $setup-thread)) + (drop (call $thread.yield-then-promote (global.get $setup-thread))) + (if (i32.eqz (global.get $resumed)) + (then unreachable)) + (i32.const 42)) + ) + (canon task.return (result u32) (core func $task.return)) + (canon thread.index (core func $thread.index)) + (canon thread.suspend (core func $thread.suspend)) + (canon thread.resume-later (core func $thread.resume-later)) + (canon thread.yield (core func $thread.yield)) + (canon thread.yield-then-promote (core func $thread.yield-then-promote)) + (core instance $core (instantiate $Core (with "" (instance + (export "task.return" (func $task.return)) + (export "thread.index" (func $thread.index)) + (export "thread.suspend" (func $thread.suspend)) + (export "thread.resume-later" (func $thread.resume-later)) + (export "thread.yield" (func $thread.yield)) + (export "thread.yield-then-promote" (func $thread.yield-then-promote)) + )))) + (func (export "setup") async (result u32) + (canon lift (core func $core "setup") async (callback (core func $core "setup-cb")))) + (func (export "run") (result u32) + (canon lift (core func $core "run"))) + (func (export "run-promote") (result u32) + (canon lift (core func $core "run-promote"))) +) + +(component instance $i $BlockedCallbackTester) +(assert_return (invoke "setup") (u32.const 1)) +(assert_return (invoke "run") (u32.const 42)) + +(component instance $i $BlockedCallbackTester) +(assert_return (invoke "setup") (u32.const 1)) +(assert_return (invoke "run-promote") (u32.const 42)) + +;; Implicit thread of a synchronously-lifted async-typed function (blocked +;; mid-frame, holding the instance's exclusive lock): once made ready, it may +;; be resumed during a non-async-typed call; here its resumption is required +;; for 'run' and 'run-promote' to complete (and also resolves the async task). +(component definition $SyncLiftedTester + (component $I + (core module $Core + (import "" "thread.index" (func $thread.index (result i32))) + (import "" "thread.suspend" (func $thread.suspend (result i32))) + (import "" "thread.resume-later" (func $thread.resume-later (param i32))) + (import "" "thread.yield" (func $thread.yield (result i32))) + (import "" "thread.yield-then-promote" (func $thread.yield-then-promote (param i32) (result i32))) + + (global $f-started (mut i32) (i32.const 0)) + (global $f-thread (mut i32) (i32.const 0)) + (global $resumed (mut i32) (i32.const 0)) + + (func (export "f") + (global.set $f-started (i32.const 1)) + (global.set $f-thread (call $thread.index)) + (drop (call $thread.suspend)) + (global.set $resumed (i32.const 1))) + + (func (export "run") (result i32) + (if (i32.eqz (global.get $f-started)) + (then unreachable)) + (call $thread.resume-later (global.get $f-thread)) + (loop $again + (drop (call $thread.yield)) + (br_if $again (i32.eqz (global.get $resumed)))) + (i32.const 42)) + + (func (export "run-promote") (result i32) + (if (i32.eqz (global.get $f-started)) + (then unreachable)) + (call $thread.resume-later (global.get $f-thread)) + (drop (call $thread.yield-then-promote (global.get $f-thread))) + (if (i32.eqz (global.get $resumed)) + (then unreachable)) + (i32.const 42)) + ) + (canon thread.index (core func $thread.index)) + (canon thread.suspend (core func $thread.suspend)) + (canon thread.resume-later (core func $thread.resume-later)) + (canon thread.yield (core func $thread.yield)) + (canon thread.yield-then-promote (core func $thread.yield-then-promote)) + (core instance $core (instantiate $Core (with "" (instance + (export "thread.index" (func $thread.index)) + (export "thread.suspend" (func $thread.suspend)) + (export "thread.resume-later" (func $thread.resume-later)) + (export "thread.yield" (func $thread.yield)) + (export "thread.yield-then-promote" (func $thread.yield-then-promote)) + )))) + (func (export "f") async + (canon lift (core func $core "f"))) + (func (export "run") (result u32) + (canon lift (core func $core "run"))) + (func (export "run-promote") (result u32) + (canon lift (core func $core "run-promote"))) + ) + (component $D + (import "f" (func $f async)) + (core module $Core + (import "" "task.return" (func $task.return (param i32))) + (import "" "f" (func $f (result i32))) + + (func (export "setup") (result i32) + ;; The async-lowered call must come back blocked in the STARTED state. + (if (i32.ne (i32.and (call $f) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (call $task.return (i32.const 1)) + (i32.const 0 (; EXIT ;))) + + (func (export "setup-cb") (param i32 i32 i32) (result i32) + unreachable) + ) + (canon task.return (result u32) (core func $task.return)) + (canon lower (func $f) async (core func $f')) + (core instance $core (instantiate $Core (with "" (instance + (export "task.return" (func $task.return)) + (export "f" (func $f')) + )))) + (func (export "setup") async (result u32) + (canon lift (core func $core "setup") async (callback (core func $core "setup-cb")))) + ) + (instance $i (instantiate $I)) + (instance $d (instantiate $D (with "f" (func $i "f")))) + (func (export "setup") (alias export $d "setup")) + (func (export "run") (alias export $i "run")) + (func (export "run-promote") (alias export $i "run-promote")) +) + +(component instance $i $SyncLiftedTester) +(assert_return (invoke "setup") (u32.const 1)) +(assert_return (invoke "run") (u32.const 42)) + +(component instance $i $SyncLiftedTester) +(assert_return (invoke "setup") (u32.const 1)) +(assert_return (invoke "run-promote") (u32.const 42)) + +;; Explicit threads may be promoted when their task is an async callback task +;; whose own implicit thread is parked in its event loop (the parked implicit +;; thread may also be resumed during the sync call; it just parks again). +(component + (core module $Table (table (export "__indirect_function_table") 2 funcref)) + (core instance $table (instantiate $Table)) + (core module $Core + (import "" "task.return" (func $task.return (param i32))) + (import "" "thread.new-indirect" (func $thread.new-indirect (param i32 i32) (result i32))) + (import "" "thread.resume-later" (func $thread.resume-later (param i32))) + (import "" "thread.yield" (func $thread.yield (result i32))) + (import "" "thread.yield-then-promote" (func $thread.yield-then-promote (param i32) (result i32))) + (import "" "__indirect_function_table" (table $tbl 2 funcref)) + + (global $worker0-ran (mut i32) (i32.const 0)) + (global $worker0-thread (mut i32) (i32.const 0)) + (global $worker1-ran (mut i32) (i32.const 0)) + (global $worker1-thread (mut i32) (i32.const 0)) + + (func $worker0 (param i32) + (global.set $worker0-ran (i32.const 1))) + (func $worker1 (param i32) + (global.set $worker1-ran (i32.const 1))) + (elem (table $tbl) (i32.const 0) func $worker0 $worker1) + + (func (export "setup") (result i32) + (global.set $worker0-thread (call $thread.new-indirect (i32.const 0) (i32.const 0))) + (global.set $worker1-thread (call $thread.new-indirect (i32.const 1) (i32.const 0))) + (call $task.return (i32.const 1)) + (i32.const 1 (; YIELD ;))) + + (func (export "setup-cb") (param i32 i32 i32) (result i32) + (i32.const 1 (; YIELD ;))) + + (func (export "run") (result i32) + (call $thread.resume-later (global.get $worker0-thread)) + (loop $again + (drop (call $thread.yield)) + (br_if $again (i32.eqz (global.get $worker0-ran)))) + (i32.const 42)) + + (func (export "run-promote") (result i32) + (call $thread.resume-later (global.get $worker1-thread)) + (loop $again + (drop (call $thread.yield-then-promote (global.get $worker1-thread))) + (br_if $again (i32.eqz (global.get $worker1-ran)))) + (i32.const 42)) + ) + (core type $start-func-ty (func (param i32))) + (alias core export $table "__indirect_function_table" (core table $indirect-function-table)) + (core func $thread.new-indirect + (canon thread.new-indirect $start-func-ty (core table $indirect-function-table))) + (canon task.return (result u32) (core func $task.return)) + (canon thread.resume-later (core func $thread.resume-later)) + (canon thread.yield (core func $thread.yield)) + (canon thread.yield-then-promote (core func $thread.yield-then-promote)) + (core instance $core (instantiate $Core (with "" (instance + (export "task.return" (func $task.return)) + (export "thread.new-indirect" (func $thread.new-indirect)) + (export "thread.resume-later" (func $thread.resume-later)) + (export "thread.yield" (func $thread.yield)) + (export "thread.yield-then-promote" (func $thread.yield-then-promote)) + (export "__indirect_function_table" (table $indirect-function-table)) + )))) + (func (export "setup") async (result u32) + (canon lift (core func $core "setup") async (callback (core func $core "setup-cb")))) + (func (export "run") (result u32) + (canon lift (core func $core "run"))) + (func (export "run-promote") (result u32) + (canon lift (core func $core "run-promote"))) +) +(assert_return (invoke "setup") (u32.const 1)) +(assert_return (invoke "run") (u32.const 42)) +(assert_return (invoke "run-promote") (u32.const 42)) diff --git a/test/async/reentrance.wast b/test/async/reentrance.wast new file mode 100644 index 00000000..688edcaa --- /dev/null +++ b/test/async/reentrance.wast @@ -0,0 +1,517 @@ +;; Test various case of reentrance + +;; Child-to-parent reentrance +(component + (core module $M1 + (global $entered (export "entered") (mut i32) (i32.const 0)) + (func (export "back") (result i32) + (global.set $entered (i32.add (global.get $entered) (i32.const 1))) + (i32.const 5)) + ) + (core instance $m1 (instantiate $M1)) + (func $back (export "back") (result u32) (canon lift (core func $m1 "back"))) + + (component $C + (import "back" (func $back (result u32))) + (canon lower (func $back) (core func $back')) + (core module $MC + (import "" "back" (func $back (result i32))) + (func (export "go") (result i32) (i32.add (call $back) (i32.const 1))) + ) + (core instance $mc (instantiate $MC (with "" (instance (export "back" (func $back')))))) + (func (export "go") (result u32) (canon lift (core func $mc "go"))) + ) + (instance $c (instantiate $C (with "back" (func $back)))) + (canon lower (func $c "go") (core func $go')) + + (core module $M2 + (import "" "go" (func $go (result i32))) + (import "" "entered" (global $entered (mut i32))) + (func (export "run") (result i32) + (local $r i32) + (local.set $r (call $go)) + (if (i32.ne (global.get $entered) (i32.const 1)) + (then unreachable)) + (local.get $r)) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "go" (func $go')) + (export "entered" (global $m1 "entered")))))) + (func (export "run") (result u32) (canon lift (core func $m2 "run"))) +) +(assert_return (invoke "run") (u32.const 6)) + +;; Parent-to-child +(component + (component $Child + (core module $CoreChild (func (export "f") (result i32) (i32.const 11))) + (core instance $core_child (instantiate $CoreChild)) + (func (export "f") (result u32) (canon lift (core func $core_child "f"))) + ) + (instance $child (instantiate $Child)) + (canon lower (func $child "f") (core func $f')) + (core module $CoreOuter + (import "" "f" (func $f (result i32))) + (func (export "g") (result i32) (i32.add (call $f) (i32.const 1))) + ) + (core instance $core_outer (instantiate $CoreOuter (with "" (instance (export "f" (func $f')))))) + (func (export "g") (result u32) (canon lift (core func $core_outer "g"))) +) +(assert_return (invoke "g") (u32.const 12)) + +;; Mutual recursion between a parent and its child via donut wrapping +(component + (core module $M1 + (type $ft (func (param i32) (result i32))) + (table (export "tbl") 1 1 funcref) + (func (export "f") (param i32) (result i32) + (if (result i32) (i32.eqz (local.get 0)) + (then (i32.const 100)) + (else (i32.add + (call_indirect (type $ft) (i32.sub (local.get 0) (i32.const 1)) (i32.const 0)) + (i32.const 1))))) + ) + (core instance $m1 (instantiate $M1)) + (func $f (export "f") (param "n" u32) (result u32) (canon lift (core func $m1 "f"))) + + (component $B + (import "f" (func $f (param "n" u32) (result u32))) + (canon lower (func $f) (core func $f')) + (core module $MB + (import "" "f" (func $f (param i32) (result i32))) + (func (export "g") (param i32) (result i32) (call $f (local.get 0))) + ) + (core instance $mb (instantiate $MB (with "" (instance (export "f" (func $f')))))) + (func (export "g") (param "n" u32) (result u32) (canon lift (core func $mb "g"))) + ) + (instance $b (instantiate $B (with "f" (func $f)))) + (canon lower (func $b "g") (core func $g')) + (core module $M2 + (import "" "tbl" (table 1 1 funcref)) + (import "" "g" (func $g (param i32) (result i32))) + (elem (i32.const 0) func $g) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "tbl" (table $m1 "tbl")) + (export "g" (func $g')))))) +) +;; f(3) -> g(2) -> f(2) -> g(1) -> f(1) -> g(0) -> f(0) = 100, +1 per level +(assert_return (invoke "f" (u32.const 3)) (u32.const 103)) +(assert_return (invoke "f" (u32.const 0)) (u32.const 100)) + +;; Reentrance through a sibling instance +(component + (core module $M1 + (type $ft (func (param i32) (result i32))) + (table (export "tbl") 1 1 funcref) + (func (export "fwd") (param i32) (result i32) + (call_indirect (type $ft) (local.get 0) (i32.const 0))) + ) + (core instance $m1 (instantiate $M1)) + (func $fwd (export "fwd") (param "n" u32) (result u32) (canon lift (core func $m1 "fwd"))) + + (component $C2 + (import "fwd" (func $fwd (param "n" u32) (result u32))) + (canon lower (func $fwd) (core func $fwd')) + (core module $M + (import "" "fwd" (func $fwd (param i32) (result i32))) + (func (export "g") (param i32) (result i32) (call $fwd (local.get 0))) + ) + (core instance $m (instantiate $M (with "" (instance (export "fwd" (func $fwd')))))) + (func (export "g") (param "n" u32) (result u32) (canon lift (core func $m "g"))) + ) + (instance $c2 (instantiate $C2 (with "fwd" (func $fwd)))) + + (component $C1 + (import "g" (func $g (param "n" u32) (result u32))) + (canon lower (func $g) (core func $g')) + (core module $M + (import "" "g" (func $g (param i32) (result i32))) + (func (export "f") (param i32) (result i32) + (if (result i32) (i32.eqz (local.get 0)) + (then (i32.const 100)) + (else (i32.add (call $g (i32.sub (local.get 0) (i32.const 1))) (i32.const 1)))))) + (core instance $m (instantiate $M (with "" (instance (export "g" (func $g')))))) + (func (export "f") (param "n" u32) (result u32) (canon lift (core func $m "f"))) + ) + (instance $c1 (instantiate $C1 (with "g" (func $c2 "g")))) + (canon lower (func $c1 "f") (core func $f')) + + (core module $M2 + (import "" "tbl" (table 1 1 funcref)) + (import "" "f" (func $f (param i32) (result i32))) + (elem (i32.const 0) func $f) + (func (export "run") (param i32) (result i32) (call $f (local.get 0))) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "tbl" (table $m1 "tbl")) + (export "f" (func $f')))))) + (func (export "run") (param "n" u32) (result u32) (canon lift (core func $m2 "run"))) +) +(assert_return (invoke "run" (u32.const 2)) (u32.const 102)) + +;; A destructor is called reentrantly +(component + (core module $Indirect + (table (export "ftbl") 1 funcref) + (type $FT (func (param i32))) + (func (export "R-dtor") (param i32) + (call_indirect (type $FT) (local.get 0) (i32.const 0))) + ) + (core instance $indirect (instantiate $Indirect)) + (type $R (resource (rep i32) (dtor (core func $indirect "R-dtor")))) + (canon resource.new $R (core func $resource.new)) + + (component $D + (import "r" (type $R (sub resource))) + (canon resource.drop $R (core func $resource.drop)) + (core module $DM + (import "" "resource.drop" (func $resource.drop (param i32))) + (func (export "drop-it") (param i32) (call $resource.drop (local.get 0))) + ) + (core instance $dm (instantiate $DM (with "" (instance + (export "resource.drop" (func $resource.drop)))))) + (func (export "drop-it") (param "r" (own $R)) (canon lift (core func $dm "drop-it"))) + ) + (instance $d (instantiate $D (with "r" (type $R)))) + (canon lower (func $d "drop-it") (core func $drop-it')) + + (core module $CM + (import "" "ftbl" (table 1 funcref)) + (import "" "resource.new" (func $resource.new (param i32) (result i32))) + (import "" "drop-it" (func $drop-it (param i32))) + (global $dropped (mut i32) (i32.const 0)) + (func $dtor (param $rep i32) + (if (i32.ne (local.get $rep) (i32.const 7)) (then unreachable)) + (global.set $dropped (i32.add (global.get $dropped) (i32.const 1)))) + (elem (i32.const 0) $dtor) + (func (export "run") (result i32) + (call $drop-it (call $resource.new (i32.const 7))) + (global.get $dropped)) + ) + (core instance $cm (instantiate $CM (with "" (instance + (export "ftbl" (table $indirect "ftbl")) + (export "resource.new" (func $resource.new)) + (export "drop-it" (func $drop-it')))))) + (func (export "run") (result u32) (canon lift (core func $cm "run"))) +) +(assert_return (invoke "run") (u32.const 1)) + +;; A non-async-typed export can be reentered even while the instance's +;; exclusive lock is held +(component + (canon task.return (result u32) (core func $task.return)) + (core module $M1 + (func (export "s") (result i32) (i32.const 7)) + ) + (core instance $m1 (instantiate $M1)) + (func $s (export "s") (result u32) (canon lift (core func $m1 "s"))) + + (component $C + (import "s" (func $s (result u32))) + (canon lower (func $s) (core func $s')) + (core module $MC + (import "" "s" (func $s (result i32))) + (func (export "mid") (result i32) (i32.add (call $s) (i32.const 1))) + ) + (core instance $mc (instantiate $MC (with "" (instance (export "s" (func $s')))))) + (func (export "mid") (result u32) (canon lift (core func $mc "mid"))) + ) + (instance $c (instantiate $C (with "s" (func $s)))) + (canon lower (func $c "mid") (core func $mid')) + + (core module $M2 + (import "" "mid" (func $mid (result i32))) + (import "" "task.return" (func $task.return (param i32))) + (func (export "a") (result i32) + (call $task.return (call $mid)) + (i32.const 0 (; EXIT ;))) + (func (export "a-cb") (param i32 i32 i32) (result i32) unreachable) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "mid" (func $mid')) + (export "task.return" (func $task.return)))))) + (func (export "a") async (result u32) + (canon lift (core func $m2 "a") async (callback (core func $m2 "a-cb")))) +) +(assert_return (invoke "a") (u32.const 8)) + +;; Reentering an async-typed export lifted with the callback ABI blocks on +;; automatic backpressure instead of trapping, and completes once the +;; outstanding task releases the instance's exclusive lock +(component + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (canon task.return (result u32) (core func $task.return)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable.join (core func $waitable.join)) + (canon subtask.drop (core func $subtask.drop)) + + (core module $M1 + (import "" "task.return" (func $task.return (param i32))) + (func (export "b") (result i32) + (call $task.return (i32.const 42)) + (i32.const 0 (; EXIT ;))) + (func (export "b-cb") (param i32 i32 i32) (result i32) unreachable) + ) + (core instance $m1 (instantiate $M1 (with "" (instance + (export "task.return" (func $task.return)))))) + (func $b (export "b") async (result u32) + (canon lift (core func $m1 "b") async (callback (core func $m1 "b-cb")))) + + (component $C + (import "b" (func $b async (result u32))) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (canon lower (func $b) async (memory (core memory $memory "mem")) (core func $b')) + (canon task.return (result u32) (core func $task.return)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable.join (core func $waitable.join)) + (canon subtask.drop (core func $subtask.drop)) + (core module $MC + (import "" "mem" (memory 1)) + (import "" "b" (func $b (param i32) (result i32))) + (import "" "task.return" (func $task.return (param i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (global $ws (mut i32) (i32.const 0)) + (global $sub (mut i32) (i32.const 0)) + (func (export "c") (result i32) + (local $packed i32) + (global.set $ws (call $waitable-set.new)) + (local.set $packed (call $b (i32.const 0))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 0 (; STARTING ;))) + (then unreachable)) + (global.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + (call $waitable.join (global.get $sub) (global.get $ws)) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $ws) (i32.const 4)))) + (func (export "c-cb") (param i32 i32 i32) (result i32) + (if (i32.ne (local.get 0) (i32.const 1 (; SUBTASK ;))) (then unreachable)) + (if (i32.ne (local.get 1) (global.get $sub)) (then unreachable)) + (if (i32.ne (local.get 2) (i32.const 2 (; RETURNED ;))) (then unreachable)) + (call $subtask.drop (global.get $sub)) + (call $task.return (i32.load (i32.const 0))) + (i32.const 0 (; EXIT ;))) + ) + (core instance $mc (instantiate $MC (with "" (instance + (export "mem" (memory $memory "mem")) + (export "b" (func $b')) + (export "task.return" (func $task.return)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable.join" (func $waitable.join)) + (export "subtask.drop" (func $subtask.drop)))))) + (func (export "c") async (result u32) + (canon lift (core func $mc "c") async (callback (core func $mc "c-cb")))) + ) + (instance $c (instantiate $C (with "b" (func $b)))) + (canon lower (func $c "c") async (memory (core memory $memory "mem")) (core func $c')) + + (core module $M2 + (import "" "mem" (memory 1)) + (import "" "c" (func $c (param i32) (result i32))) + (import "" "task.return" (func $task.return (param i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (global $ws (mut i32) (i32.const 0)) + (global $sub (mut i32) (i32.const 0)) + (func (export "a") (result i32) + (local $packed i32) + (global.set $ws (call $waitable-set.new)) + (local.set $packed (call $c (i32.const 0))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (global.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + (call $waitable.join (global.get $sub) (global.get $ws)) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $ws) (i32.const 4)))) + (func (export "a-cb") (param i32 i32 i32) (result i32) + (if (i32.ne (local.get 0) (i32.const 1 (; SUBTASK ;))) (then unreachable)) + (if (i32.ne (local.get 1) (global.get $sub)) (then unreachable)) + (if (i32.ne (local.get 2) (i32.const 2 (; RETURNED ;))) (then unreachable)) + (call $subtask.drop (global.get $sub)) + (call $task.return (i32.load (i32.const 0))) + (i32.const 0 (; EXIT ;))) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "mem" (memory $memory "mem")) + (export "c" (func $c')) + (export "task.return" (func $task.return)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable.join" (func $waitable.join)) + (export "subtask.drop" (func $subtask.drop)))))) + (func (export "a") async (result u32) + (canon lift (core func $m2 "a") async (callback (core func $m2 "a-cb")))) +) +(assert_return (invoke "a") (u32.const 42)) + +;; The same shape as test 7, but with "a" lifted using the sync ABI, which +;; holds the exclusive lock for the whole call instead of releasing it at +;; each event-loop turn. Reentrance still doesn't trap, but "b" can now +;; never acquire the lock, so the cycle deadlocks. +(component + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (canon task.return (result u32) (core func $task.return)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + + (core module $M1 + (import "" "task.return" (func $task.return (param i32))) + (func (export "b") (result i32) + (call $task.return (i32.const 42)) + (i32.const 0 (; EXIT ;))) + (func (export "b-cb") (param i32 i32 i32) (result i32) unreachable) + ) + (core instance $m1 (instantiate $M1 (with "" (instance + (export "task.return" (func $task.return)))))) + (func $b (export "b") async (result u32) + (canon lift (core func $m1 "b") async (callback (core func $m1 "b-cb")))) + + (component $C + (import "b" (func $b async (result u32))) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (canon lower (func $b) async (memory (core memory $memory "mem")) (core func $b')) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable.join (core func $waitable.join)) + (core module $MC + (import "" "b" (func $b (param i32) (result i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (global $ws (mut i32) (i32.const 0)) + (global $sub (mut i32) (i32.const 0)) + (func (export "c") (result i32) + (local $packed i32) + (global.set $ws (call $waitable-set.new)) + (local.set $packed (call $b (i32.const 0))) + (global.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + (call $waitable.join (global.get $sub) (global.get $ws)) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $ws) (i32.const 4)))) + (func (export "c-cb") (param i32 i32 i32) (result i32) unreachable) + ) + (core instance $mc (instantiate $MC (with "" (instance + (export "b" (func $b')) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable.join" (func $waitable.join)))))) + (func (export "c") async (result u32) + (canon lift (core func $mc "c") async (callback (core func $mc "c-cb")))) + ) + (instance $c (instantiate $C (with "b" (func $b)))) + (canon lower (func $c "c") async (memory (core memory $memory "mem")) (core func $c')) + + (core module $M2 + (import "" "mem" (memory 1)) + (import "" "c" (func $c (param i32) (result i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (func (export "a") (result i32) + (local $packed i32) (local $ws i32) (local $sub i32) + (local.set $ws (call $waitable-set.new)) + (local.set $packed (call $c (i32.const 0))) + (local.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + (call $waitable.join (local.get $sub) (local.get $ws)) + ;; "c" is waiting on "b", "b" is waiting for this task to release the + ;; exclusive lock, and a sync-ABI lift only releases it once it returns + (drop (call $waitable-set.wait (local.get $ws) (i32.const 8))) + unreachable) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "mem" (memory $memory "mem")) + (export "c" (func $c')) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "a") async (result u32) (canon lift (core func $m2 "a"))) +) +(assert_trap (invoke "a") "deadlock detected: event loop cannot make further progress") + +;; 'subtask.cancel' delivers cancellation immediately even when the callee's +;; component instance is on the stack. +(component + (canon subtask.cancel (core func $subtask.cancel)) + (canon subtask.cancel async (core func $subtask.cancel-async)) + (canon subtask.drop (core func $subtask.drop)) + (core module $M1 + (import "" "subtask.cancel" (func $subtask.cancel (param i32) (result i32))) + (import "" "subtask.cancel-async" (func $subtask.cancel-async (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (global $s1 (export "s1") (mut i32) (i32.const 0)) + (global $s2 (export "s2") (mut i32) (i32.const 0)) + (func (export "h") (result i32) + (local $r1 i32) (local $r2 i32) + (local.set $r1 (call $subtask.cancel (global.get $s1))) + (local.set $r2 (call $subtask.cancel-async (global.get $s2))) + (call $subtask.drop (global.get $s1)) + (call $subtask.drop (global.get $s2)) + (i32.or (local.get $r1) (i32.shl (local.get $r2) (i32.const 8)))) + ) + (core instance $m1 (instantiate $M1 (with "" (instance + (export "subtask.cancel" (func $subtask.cancel)) + (export "subtask.cancel-async" (func $subtask.cancel-async)) + (export "subtask.drop" (func $subtask.drop)))))) + (func $h (result u32) (canon lift (core func $m1 "h"))) + + (component $B + (import "h" (func $h (result u32))) + (canon lower (func $h) (core func $h')) + (canon task.cancel (core func $task.cancel)) + (canon waitable-set.new (core func $waitable-set.new)) + (core module $BM + (import "" "h" (func $h (result i32))) + (import "" "task.cancel" (func $task.cancel)) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (global $ws (mut i32) (i32.const 0)) + (func $start (global.set $ws (call $waitable-set.new))) + (start $start) + ;; parks cancellably in its event loop on a set that never gets an event + (func (export "f") (result i32) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $ws) (i32.const 4)))) + (func (export "f-cb") (param i32 i32 i32) (result i32) + (if (i32.ne (local.get 0) (i32.const 6 (; TASK_CANCELLED ;))) (then unreachable)) + (call $task.cancel) + (i32.const 0 (; EXIT ;))) + ;; non-async-typed, so it doesn't take $B's exclusive lock + (func (export "g") (result i32) (call $h)) + ) + (core instance $bm (instantiate $BM (with "" (instance + (export "h" (func $h')) + (export "task.cancel" (func $task.cancel)) + (export "waitable-set.new" (func $waitable-set.new)))))) + (func (export "f") async + (canon lift (core func $bm "f") async (callback (core func $bm "f-cb")))) + (func (export "g") (result u32) (canon lift (core func $bm "g"))) + ) + (instance $b (instantiate $B (with "h" (func $h)))) + (canon lower (func $b "f") async (core func $f')) + (canon lower (func $b "g") (core func $g')) + + (core module $M2 + (import "" "f" (func $f (result i32))) + (import "" "g" (func $g (result i32))) + (import "" "s1" (global $s1 (mut i32))) + (import "" "s2" (global $s2 (mut i32))) + (func (export "run") (result i32) + (local $p i32) + (local.set $p (call $f)) + (if (i32.ne (i32.and (local.get $p) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (global.set $s1 (i32.shr_u (local.get $p) (i32.const 4))) + (local.set $p (call $f)) + (if (i32.ne (i32.and (local.get $p) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (global.set $s2 (i32.shr_u (local.get $p) (i32.const 4))) + ;; both cancels report CANCELLED_BEFORE_RETURNED (4), neither blocks + (if (i32.ne (call $g) (i32.const 0x404)) (then unreachable)) + (i32.const 42)) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "f" (func $f')) + (export "g" (func $g')) + (export "s1" (global $m1 "s1")) + (export "s2" (global $m1 "s2")))))) + (func (export "run") (result u32) (canon lift (core func $m2 "run"))) +) +(assert_return (invoke "run") (u32.const 42)) diff --git a/test/async/trap-if-block-and-sync.wast b/test/async/trap-if-block-and-sync.wast index 8b7b30ae..f8720ed7 100644 --- a/test/async/trap-if-block-and-sync.wast +++ b/test/async/trap-if-block-and-sync.wast @@ -10,9 +10,11 @@ (import "" "waitable-set.new" (func $waitable-set.new (result i32))) (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) (func (export "sync-async-func") + (call $waitable-set.wait (call $waitable-set.new) (i32.const 0xdeadbeef)) unreachable ) (func (export "async-async-func") (result i32) + (call $waitable-set.wait (call $waitable-set.new) (i32.const 0xdeadbeef)) unreachable ) (func (export "async-async-func-cb") (param i32 i32 i32) (result i32) @@ -60,16 +62,15 @@ (import "" "waitable-set.new" (func $waitable-set.new (result i32))) (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "stream.new" (func $stream.new (result i64))) (import "" "stream.read" (func $stream.read (param i32 i32 i32) (result i32))) (import "" "stream.write" (func $stream.write (param i32 i32 i32) (result i32))) + (import "" "future.new" (func $future.new (result i64))) (import "" "future.read" (func $future.read (param i32 i32) (result i32))) (import "" "future.write" (func $future.write (param i32 i32) (result i32))) - (import "" "stream.cancel-read" (func $stream.cancel-read (param i32) (result i32))) - (import "" "stream.cancel-write" (func $stream.cancel-write (param i32) (result i32))) - (import "" "future.cancel-read" (func $future.cancel-read (param i32) (result i32))) - (import "" "future.cancel-write" (func $future.cancel-write (param i32) (result i32))) (import "" "await-sync-async-func" (func $await-sync-async-func)) (import "" "await-async-async-func" (func $await-async-async-func)) + (import "" "start-async-async-func" (func $start-async-async-func (result i32))) (import "" "sync-blocks-and-traps" (func $sync-blocks-and-traps)) (import "" "__indirect_function_table" (table $indirect-function-table 2 funcref)) @@ -143,39 +144,35 @@ (i32.const 42) ) (func (export "trap-if-sync-cancel") - (call $subtask.cancel (i32.const 0xdeadbeef)) + (local $ret i32) + (local.set $ret (call $start-async-async-func)) + (if (i32.ne (i32.const 1 (; STARTED ;)) (i32.and (local.get $ret) (i32.const 0xf))) + (then unreachable)) + (call $subtask.cancel (i32.shr_u (local.get $ret) (i32.const 4))) unreachable ) (func (export "trap-if-sync-stream-read") - (call $stream.read (i32.const 0xdead) (i32.const 0xbeef) (i32.const 0xdead)) + (local $ret i64) + (local.set $ret (call $stream.new)) + (call $stream.read (i32.wrap_i64 (local.get $ret)) (i32.const 0) (i32.const 4)) unreachable ) (func (export "trap-if-sync-stream-write") - (call $stream.write (i32.const 0xdead) (i32.const 0xbeef) (i32.const 0xdead)) + (local $ret i64) + (local.set $ret (call $stream.new)) + (call $stream.write (i32.wrap_i64 (i64.shr_u (local.get $ret) (i64.const 32))) (i32.const 0) (i32.const 4)) unreachable ) (func (export "trap-if-sync-future-read") - (call $future.read (i32.const 0xdead) (i32.const 0xdeadbeef)) + (local $ret i64) + (local.set $ret (call $future.new)) + (call $future.read (i32.wrap_i64 (local.get $ret)) (i32.const 0)) unreachable ) (func (export "trap-if-sync-future-write") - (call $future.write (i32.const 0xdead) (i32.const 0xdeadbeef)) - unreachable - ) - (func (export "trap-if-sync-stream-cancel-read") - (call $stream.cancel-read (i32.const 0xdead)) - unreachable - ) - (func (export "trap-if-sync-stream-cancel-write") - (call $stream.cancel-write (i32.const 0xdead)) - unreachable - ) - (func (export "trap-if-sync-future-cancel-read") - (call $future.cancel-read (i32.const 0xdead) (i32.const 0xdeadbeef)) - unreachable - ) - (func (export "trap-if-sync-future-cancel-write") - (call $future.cancel-write (i32.const 0xdead) (i32.const 0xdeadbeef)) + (local $ret i64) + (local.set $ret (call $future.new)) + (call $future.write (i32.wrap_i64 (i64.shr_u (local.get $ret) (i64.const 32))) (i32.const 0)) unreachable ) ) @@ -197,16 +194,15 @@ (canon waitable-set.new (core func $waitable-set.new)) (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon stream.new $ST (core func $stream.new)) (canon stream.read $ST (memory (core memory $memory "mem")) (core func $stream.read)) (canon stream.write $ST (memory (core memory $memory "mem")) (core func $stream.write)) + (canon future.new $FT (core func $future.new)) (canon future.read $FT (memory (core memory $memory "mem")) (core func $future.read)) (canon future.write $FT (memory (core memory $memory "mem")) (core func $future.write)) - (canon stream.cancel-read $ST (core func $stream.cancel-read)) - (canon stream.cancel-write $ST (core func $stream.cancel-write)) - (canon future.cancel-read $FT (core func $future.cancel-read)) - (canon future.cancel-write $FT (core func $future.cancel-write)) (canon lower (func $c "sync-async-func") (core func $await-sync-async-func')) (canon lower (func $c "async-async-func") (core func $await-async-async-func')) + (canon lower (func $c "async-async-func") async (core func $start-async-async-func')) (canon lower (func $c "sync-blocks-and-traps") (core func $sync-blocks-and-traps')) (core instance $core (instantiate $Core (with "" (instance (export "mem" (memory $memory "mem")) @@ -223,16 +219,15 @@ (export "waitable-set.new" (func $waitable-set.new)) (export "waitable-set.wait" (func $waitable-set.wait)) (export "waitable-set.poll" (func $waitable-set.poll)) + (export "stream.new" (func $stream.new)) (export "stream.read" (func $stream.read)) (export "stream.write" (func $stream.write)) + (export "future.new" (func $future.new)) (export "future.read" (func $future.read)) (export "future.write" (func $future.write)) - (export "stream.cancel-read" (func $stream.cancel-read)) - (export "stream.cancel-write" (func $stream.cancel-write)) - (export "future.cancel-read" (func $future.cancel-read)) - (export "future.cancel-write" (func $future.cancel-write)) (export "await-sync-async-func" (func $await-sync-async-func')) (export "await-async-async-func" (func $await-async-async-func')) + (export "start-async-async-func" (func $start-async-async-func')) (export "sync-blocks-and-traps" (func $sync-blocks-and-traps')) (export "__indirect_function_table" (table $indirect-function-table)) )))) @@ -253,10 +248,6 @@ (func (export "trap-if-sync-stream-write") (canon lift (core func $core "trap-if-sync-stream-write"))) (func (export "trap-if-sync-future-read") (canon lift (core func $core "trap-if-sync-future-read"))) (func (export "trap-if-sync-future-write") (canon lift (core func $core "trap-if-sync-future-write"))) - (func (export "trap-if-sync-stream-cancel-read") (canon lift (core func $core "trap-if-sync-stream-cancel-read"))) - (func (export "trap-if-sync-stream-cancel-write") (canon lift (core func $core "trap-if-sync-stream-cancel-write"))) - (func (export "trap-if-sync-future-cancel-read") (canon lift (core func $core "trap-if-sync-future-cancel-read"))) - (func (export "trap-if-sync-future-cancel-write") (canon lift (core func $core "trap-if-sync-future-cancel-write"))) ) (instance $c (instantiate $C)) (instance $d (instantiate $D (with "c" (instance $c)))) @@ -277,10 +268,6 @@ (func (export "trap-if-sync-stream-write") (alias export $d "trap-if-sync-stream-write")) (func (export "trap-if-sync-future-read") (alias export $d "trap-if-sync-future-read")) (func (export "trap-if-sync-future-write") (alias export $d "trap-if-sync-future-write")) - (func (export "trap-if-sync-stream-cancel-read") (alias export $d "trap-if-sync-stream-cancel-read")) - (func (export "trap-if-sync-stream-cancel-write") (alias export $d "trap-if-sync-stream-cancel-write")) - (func (export "trap-if-sync-future-cancel-read") (alias export $d "trap-if-sync-future-cancel-read")) - (func (export "trap-if-sync-future-cancel-write") (alias export $d "trap-if-sync-future-cancel-write")) ) (component instance $i $Tester) @@ -322,10 +309,9 @@ (component instance $i $Tester) (assert_trap (invoke "trap-if-sync-future-write") "cannot block a synchronous task before returning") (component instance $i $Tester) -(assert_trap (invoke "trap-if-sync-stream-cancel-read") "cannot block a synchronous task before returning") -(component instance $i $Tester) -(assert_trap (invoke "trap-if-sync-stream-cancel-write") "cannot block a synchronous task before returning") -(component instance $i $Tester) -(assert_trap (invoke "trap-if-sync-future-cancel-read") "cannot block a synchronous task before returning") -(component instance $i $Tester) -(assert_trap (invoke "trap-if-sync-future-cancel-write") "cannot block a synchronous task before returning") + +;; TODO: also test sync calls to `{stream,future}.cancel-{read,write}`. As of +;; this writing, such calls can only ever block if the host has the other end, +;; meaning we can't currently test that case in pure WAST; we'd need custom host +;; code. In the future, additional intrinsics (e.g. `stream.read-complete`) may +;; enable us to test those cases in pure WAST. diff --git a/test/async/trap-on-reenter.wast b/test/async/trap-on-reenter.wast deleted file mode 100644 index dd63baa5..00000000 --- a/test/async/trap-on-reenter.wast +++ /dev/null @@ -1,110 +0,0 @@ -;; This test creates an asynchronous recursive call stack: -;; $Parent --> $Child --> $Parent -;; That should trap when $Child tries to call $Parent. -(component $Parent - (core module $CoreInner - (memory (export "mem") 1) - (func (export "a") (result i32) - unreachable - ) - (func (export "a-cb") (param i32 i32 i32) (result i32) - unreachable - ) - ) - (core instance $core_inner (instantiate $CoreInner)) - (func $a async (canon lift - (core func $core_inner "a") - async (callback (core func $core_inner "a-cb")) - )) - - (component $Child - (import "a" (func $a async)) - - (core module $Memory (memory (export "mem") 1)) - (core instance $memory (instantiate $Memory)) - - (core module $CoreChild - (import "" "a" (func $a (result i32))) - (func (export "b") (result i32) - (i32.const 1 (; YIELD ;)) - ) - (func (export "b-cb") (param i32 i32 i32) (result i32) - (call $a) - unreachable - ) - ) - (canon lower (func $a) async (memory (core memory $memory "mem")) (core func $a')) - (core instance $core_child (instantiate $CoreChild (with "" (instance - (export "a" (func $a')) - )))) - (func (export "b") async (canon lift - (core func $core_child "b") - async (callback (core func $core_child "b-cb")) - )) - ) - (instance $child (instantiate $Child (with "a" (func $a)))) - - (core module $CoreOuter - (import "" "b" (func $b (result i32))) - (func $c (export "c") (result i32) - (i32.const 1 (; YIELD ;)) - ) - (func $c-cb (export "c-cb") (param i32 i32 i32) (result i32) - (call $b) - ) - ) - (canon lower (func $child "b") async (memory (core memory $core_inner "mem")) (core func $b)) - (core instance $core_outer (instantiate $CoreOuter (with "" (instance - (export "b" (func $b)) - )))) - (func $c (export "c") async (canon lift - (core func $core_outer "c") - async (callback (core func $core_outer "c-cb")) - )) -) -(assert_trap (invoke "c") "wasm trap: cannot enter component instance") - -;; also, for now, trap on parent-to-child -(component $Parent - (component $Child - (core module $CoreChild - (func (export "f")) - ) - (core instance $core_child (instantiate $CoreChild)) - (func (export "f") (canon lift (core func $core_child "f"))) - ) - (instance $child (instantiate $Child)) - (canon lower (func $child "f") (core func $f)) - - (core module $CoreOuter - (import "" "f" (func $f)) - (func (export "g") (call $f)) - ) - (core instance $core_outer (instantiate $CoreOuter (with "" (instance (export "f" (func $f)))))) - (func $g (export "g") (canon lift (core func $core_outer "g"))) -) -(assert_trap (invoke "g") "wasm trap: cannot enter component instance") - -;; also, for now, trap on child-to-parent -(component $Parent - (core module $CoreInner - (func (export "f")) - ) - (core instance $core_inner (instantiate $CoreInner)) - (func $f (canon lift (core func $core_inner "f"))) - - (component $Child - (import "f" (func $f)) - (canon lower (func $f) (core func $f')) - (core module $CoreChild - (import "" "f" (func $f)) - (func (export "g") (call $f)) - ) - (core instance $core_child (instantiate $CoreChild (with "" (instance (export "f" (func $f')))))) - (func (export "g") (canon lift (core func $core_child "g"))) - ) - (instance $child (instantiate $Child (with "f" (func $f)))) - (alias export $child "g" (func $g)) - (export "g" (func $g)) -) -(assert_trap (invoke "g") "wasm trap: cannot enter component instance") diff --git a/test/nyi.txt b/test/nyi.txt index 8f3e4250..1b785695 100644 --- a/test/nyi.txt +++ b/test/nyi.txt @@ -1,5 +1,2 @@ # See README.md ./validation/max-value-size.wast -./async/during-sync-call-may-block-if-other-ready-threads.wast -./async/during-sync-call-no-exclusive-resume.wast -./async/during-sync-call-no-sibling-resume.wast