Skip to content

Commit 3a00c74

Browse files
authored
Refine recursion check to handle child import/export forwarding (#589)
* Refine reentrance check to handle import/export forwarding * Make the recursive check more conservative and optimizable * Fix grammar * Test the new trapping cases in test/async/trap-on-reenter.wast
1 parent 41f6ee9 commit 3a00c74

File tree

5 files changed

+279
-64
lines changed

5 files changed

+279
-64
lines changed

design/mvp/CanonicalABI.md

Lines changed: 154 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,11 @@ class Store:
130130
def __init__(self):
131131
self.pending = []
132132

133-
def invoke(self, f: FuncInst, caller, on_start, on_resolve) -> Call:
134-
return f(caller, on_start, on_resolve)
133+
def invoke(self, f: FuncInst, caller: Optional[Supertask], on_start, on_resolve) -> Call:
134+
host_caller = Supertask()
135+
host_caller.inst = None
136+
host_caller.supertask = caller
137+
return f(host_caller, on_start, on_resolve)
135138

136139
def tick(self):
137140
random.shuffle(self.pending)
@@ -167,7 +170,7 @@ OnStart = Callable[[], list[any]]
167170
OnResolve = Callable[[Optional[list[any]]], None]
168171

169172
class Supertask:
170-
inst: ComponentInstance
173+
inst: Optional[ComponentInstance]
171174
supertask: Optional[Supertask]
172175

173176
class Call:
@@ -190,6 +193,14 @@ However, as described in the [concurrency explainer], an async call's
190193
(currently) that the caller can know or do about it (hence there are
191194
currently no other methods on `Call`).
192195

196+
The optional `Supertask.inst` field either points to the `ComponentInstance`
197+
containing the supertask or, if `None`, indicates that the supertask is a host
198+
function. Because `Store.invoke` unconditionally appends a host `Supertask`,
199+
every callstack is rooted by a host `Supertask`. There is no prohibition on
200+
component-to-host-to-component calls (as long as the conditions checked by
201+
`call_might_be_recursive` are satisfied) and thus host `Supertask`s may also
202+
appear anywhere else in the callstack.
203+
193204

194205
## Supporting definitions
195206

@@ -280,24 +291,147 @@ behavior and enforce invariants.
280291
```python
281292
class ComponentInstance:
282293
store: Store
294+
parent: Optional[ComponentInstance]
283295
table: Table
284296
may_leave: bool
285297
backpressure: int
286298
exclusive: bool
287299
num_waiting_to_enter: int
288300

289-
def __init__(self, store):
301+
def __init__(self, store, parent = None):
302+
assert(parent is None or parent.store is store)
290303
self.store = store
304+
self.parent = parent
291305
self.table = Table()
292306
self.may_leave = True
293307
self.backpressure = 0
294308
self.exclusive = False
295309
self.num_waiting_to_enter = 0
296310
```
297311
Components are always instantiated in the context of a `Store` which is saved
298-
immutably in the `store` field. The other fields are described below as they
299-
are used.
312+
immutably in the `store` field.
313+
314+
If a component is instantiated by an `instantiate` expression in a "parent"
315+
component, the parent's `ComponentInstance` is immutably saved in the `parent`
316+
field of the child's `ComponentInstance`. If instead a component is
317+
instantiated directly by the host, the `parent` field is `None`. Thus, the set
318+
of component instances in a store forms a forest rooted by the component
319+
instances that were instantiated directly by the host.
320+
321+
Based on this, the "reflexive ancestors" of a component (i.e., all parent
322+
component instances up to the root component including the component itself) can
323+
be enumerated and tested via these two helper functions:
324+
```python
325+
def reflexive_ancestors(self) -> set[ComponentInstance]:
326+
s = set()
327+
inst = self
328+
while inst is not None:
329+
s.add(inst)
330+
inst = inst.parent
331+
return s
332+
333+
def is_reflexive_ancestor_of(self, other):
334+
while other is not None:
335+
if self is other:
336+
return True
337+
other = other.parent
338+
return False
339+
```
300340

341+
How the host instantiates and invokes root components is up to the host and not
342+
specified by the Component Model. Exports of previously-instantiated root
343+
components *may* be supplied as the imports of subsequently-instantiated root
344+
components. Due to the ordered nature of instantiation, root components cannot
345+
directly import each others' exports in cyclic manner. However, the host *may*
346+
attempt to perform cyclic component-to-host-to-component calls using host
347+
powers.
348+
349+
Because a child component is fully encapsulated by its parent component (with
350+
all child imports specified by the parent's `instantiate` expression and access
351+
to all child exports controlled by the parent through its private instance index
352+
space), the host does not have direct control over how a child component is
353+
instantiated or invoked. However, if a child's ancestors transitively forward
354+
the root component's host-supplied imports to the child, direct child-to-host
355+
calls are possible. Symmetrically, if a child's ancestors transitively
356+
re-export the child's exports from the root component, direct host-to-child
357+
calls are possible.
358+
359+
Recursive component calls are technically possible using either host powers (as
360+
mentioned above) or via a parent component lowering a child component's export
361+
to a `funcref` and then recursively calling this `funcref` from a lifted parent
362+
function passed as an import to the child. However, for the time being, both
363+
cases are prevented via trap for several reasons:
364+
* automatic [backpressure] would otherwise deadlock in unpredictable and
365+
surprising ways;
366+
* by default, most code does not expect [recursive reentrance] and will break
367+
in subtle and potentially security sensitive ways if allowed;
368+
* to properly handle recursive reentrance, an extra ABI parameter is required
369+
to link recursive calls and this requires opting in via some
370+
[TBD](Concurrency.md#TODO) function effect type or canonical ABI option.
371+
372+
The `call_might_be_recursive` predicate is used by `canon_lift` and
373+
`canon_resource_drop` (defined below) to conservatively detect recursive
374+
reentrance and subsequently trap.
375+
```python
376+
def call_might_be_recursive(caller: Supertask, callee_inst: ComponentInstance):
377+
if caller.inst is None:
378+
while caller is not None:
379+
if caller.inst and caller.inst.reflexive_ancestors() & callee_inst.reflexive_ancestors():
380+
return True
381+
caller = caller.supertask
382+
return False
383+
else:
384+
return (caller.inst.is_reflexive_ancestor_of(callee_inst) or
385+
callee_inst.is_reflexive_ancestor_of(caller.inst))
386+
```
387+
The first case (where `caller.inst` is `None`) covers host-to-component calls.
388+
By testing whether any of the callers' reflexive anecestor sets intersect the
389+
callee's ancestor set, the following case is considered recursive:
390+
```
391+
+-------+
392+
| A |<-.
393+
| +---+ | |
394+
host-->| B |-->host
395+
| +---+ |
396+
+-------+
397+
```
398+
Here, when attempting to recursively call back into `A`, `caller` points to the
399+
following stack:
400+
```
401+
|inst=None| --supertask--> |inst=B| --supertask--> |inst=None| --supertask--> None
402+
```
403+
while `A` does not appear as the `inst` of any `Supertask` on this stack,
404+
`B.reflexive_ancestors()` is `{ B, A }`, so the loop correctly determines that
405+
`A` is being reentered. This ensures that child components are kept an
406+
encapsulated detail of the parent.
407+
408+
The second case (where `caller.inst` is not `None`) covers component-to-
409+
component calls by conservatively rejecting any call from a component to its
410+
anecestor or descendant (thereby preventing any possible recursion via ancestor
411+
`funcref`). Thus, the following sibling-to-sibling component call is allowed:
412+
```
413+
+----------------+
414+
| P |
415+
| +----+ +----+ |
416+
host-->| C1 |->| C2 | |
417+
| +----+ +----+ |
418+
+----------------+
419+
```
420+
while the following child-to-parent and parent-to-child calls are disallowed:
421+
```
422+
+----------+ +----------+
423+
| +---+ | | +---+ |
424+
host-->| C |->P | host->| P->| C | |
425+
| +---+ | | +---+ |
426+
+----------+ +----------+
427+
```
428+
This conservative approximation allows `call_might_be_recursive` to be computed
429+
ahead-of-time when compiling a fused component-to-component adapter (where both
430+
caller and callee intances and their relationship are statically known). In the
431+
future this check will be relaxed and more sophisticated optimizations can be
432+
used to statically eliminate the check in common cases.
433+
434+
The other fields of `ComponentInstance` are described below as they are used.
301435

302436
#### Table State
303437

@@ -804,7 +938,7 @@ class Task(Call, Supertask):
804938
opts: CanonicalOptions
805939
inst: ComponentInstance
806940
ft: FuncType
807-
supertask: Optional[Task]
941+
supertask: Supertask
808942
on_resolve: OnResolve
809943
num_borrows: int
810944
threads: list[Thread]
@@ -838,37 +972,6 @@ called (by the `Task.return_` and `Task.cancel` methods, defined below).
838972
assert(self.num_borrows == 0)
839973
```
840974

841-
The `Task.trap_if_on_the_stack` method checks for unintended reentrance,
842-
enforcing a [component invariant]. This guard uses the `Supertask` defined by
843-
the [Embedding](#embedding) interface to walk up the async call tree defined as
844-
part of [structured concurrency]. The async call tree is necessary to
845-
distinguish between the deadlock-hazardous kind of reentrance (where the new
846-
task is a transitive subtask of a task already running in the same component
847-
instance) and the normal kind of async reentrance (where the new task is just a
848-
sibling of any existing tasks running in the component instance). Note that, in
849-
the [future](Concurrency.md#TODO), there will be a way for a function to opt in
850-
(via function type attribute) to the hazardous kind of reentrance, which will
851-
nuance this test.
852-
```python
853-
def trap_if_on_the_stack(self, inst):
854-
c = self.supertask
855-
while c is not None:
856-
trap_if(c.inst is inst)
857-
c = c.supertask
858-
```
859-
An optimizing implementation can avoid the O(n) loop in `trap_if_on_the_stack`
860-
in several ways:
861-
* Reentrance by a child component can (often) be statically ruled out when the
862-
parent component doesn't both lift and lower the child's imports and exports
863-
(i.e., "donut wrapping").
864-
* Reentrance of the root component by the host can either be asserted not to
865-
happen or be tracked in a per-root-component-instance flag.
866-
* When a potentially-reenterable child component only lifts and lowers
867-
synchronously, reentrance can be tracked in a per-component-instance flag.
868-
* For the remaining cases, the live instances on the stack can be maintained in
869-
a packed bit-vector (assigning each potentially-reenterable async component
870-
instance a static bit position) that is passed by copy from caller to callee.
871-
872975
The `Task.needs_exclusive` predicate returns whether the Canonical ABI options
873976
indicate that the core wasm being executed does not expect to be reentered
874977
(e.g., because the code is using a single global linear memory shadow stack).
@@ -3132,8 +3235,8 @@ Based on this, `canon_lift` is defined in chunks as follows, starting with how
31323235
a `lift`ed function starts executing:
31333236
```python
31343237
def canon_lift(opts, inst, ft, callee, caller, on_start, on_resolve) -> Call:
3238+
trap_if(call_might_be_recursive(caller, inst))
31353239
task = Task(opts, inst, ft, caller, on_resolve)
3136-
task.trap_if_on_the_stack(inst)
31373240
def thread_func(thread):
31383241
if not task.enter(thread):
31393242
return
@@ -3147,16 +3250,16 @@ def canon_lift(opts, inst, ft, callee, caller, on_start, on_resolve) -> Call:
31473250
flat_ft = flatten_functype(opts, ft, 'lift')
31483251
assert(types_match_values(flat_ft.params, flat_args))
31493252
```
3150-
Each call starts by immediately checking for unexpected reentrance using
3151-
`Task.trap_if_on_the_stack`.
3253+
Each lifted function call starts by immediately trapping on possible recursive
3254+
reentrance (as defined by `call_might_be_recursive` above).
31523255

31533256
The `thread_func` is immediately called from a new `Thread` created and resumed
3154-
at the end of `canon_lift` and so control flow proceeds directly from the
3155-
`trap_if_on_stack` to the `enter`. `Task.enter` (defined above) suspends the
3156-
newly-created `Thread` if there is backpressure until the backpressure is
3157-
resolved. If the caller cancels the new `Task` while the `Task` is still
3158-
waiting to `enter`, the call is aborted before the arguments are lowered (which
3159-
means that owned-handle arguments are not transferred).
3257+
at the end of `canon_lift` and so control flow proceeds directly to the `enter`.
3258+
`Task.enter` (defined above) suspends the newly-created `Thread` if there is
3259+
backpressure until the backpressure is resolved. If the caller cancels the new
3260+
`Task` while the `Task` is still waiting to `enter`, the call is aborted before
3261+
the arguments are lowered (which means that owned-handle arguments are not
3262+
transferred).
31603263

31613264
Once the backpressure gate is cleared, the `Thread` is added to the callee's
31623265
component instance's table (storing the index for later retrieval by the
@@ -3541,7 +3644,7 @@ def canon_resource_drop(rt, thread, i):
35413644
callee = partial(canon_lift, callee_opts, rt.impl, ft, rt.dtor)
35423645
[] = canon_lower(caller_opts, ft, callee, thread, [h.rep])
35433646
else:
3544-
thread.task.trap_if_on_the_stack(rt.impl)
3647+
trap_if(call_might_be_recursive(thread.task, rt.impl))
35453648
else:
35463649
h.borrow_scope.num_borrows -= 1
35473650
return []
@@ -3558,9 +3661,9 @@ reentrance guard of `Task.enter`, an exception is made when the resource type's
35583661
implementation-instance is the same as the current instance (which is
35593662
statically known for any given `canon resource.drop`).
35603663

3561-
When a destructor isn't present, the rules still perform a reentrance check
3664+
When a destructor isn't present, there is still a trap on recursive reentrance
35623665
since this is the caller's responsibility and the presence or absence of a
3563-
destructor is an encapsualted implementation detail of the resource type.
3666+
destructor is an encapsulated implementation detail of the resource type.
35643667

35653668

35663669
### `canon resource.rep`
@@ -4780,6 +4883,7 @@ def canon_thread_available_parallelism():
47804883
[Concurrency Explainer]: Concurrency.md
47814884
[Suspended]: Concurrency#thread-built-ins
47824885
[Structured Concurrency]: Concurrency.md#subtasks-and-supertasks
4886+
[Recursive Reentrance]: Concurrency.md#subtasks-and-supertasks
47834887
[Backpressure]: Concurrency.md#backpressure
47844888
[Current Thread]: Concurrency.md#current-thread-and-task
47854889
[Current Task]: Concurrency.md#current-thread-and-task

design/mvp/Explainer.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2881,7 +2881,7 @@ three runtime invariants:
28812881
component instance.
28822882
2. The Component Model disallows reentrance by trapping if a callee's
28832883
component-instance is already on the stack when the call starts.
2884-
(For details, see [`trap_if_on_the_stack`](CanonicalABI.md#task-state)
2884+
(For details, see [`call_might_be_recursive`](CanonicalABI.md#component-instance-state)
28852885
in the Canonical ABI explainer.) This default prevents obscure
28862886
composition-time bugs and also enables more-efficient non-reentrant
28872887
runtime glue code. This rule will be relaxed by an opt-in

0 commit comments

Comments
 (0)