Skip to content

Commit 9303365

Browse files
committed
CABI: redefine reentrance rules in terms of component instance flag
1 parent 91a165e commit 9303365

5 files changed

Lines changed: 555 additions & 450 deletions

File tree

design/mvp/CanonicalABI.md

Lines changed: 72 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ class ComponentInstance:
123123
parent: Optional[ComponentInstance]
124124
handles: Table[ResourceHandle | Waitable | WaitableSet | ErrorContext]
125125
threads: Table[Thread]
126+
may_enter: bool
126127
may_leave: bool
127128
backpressure: int
128129
num_waiting_to_enter: int
@@ -134,6 +135,7 @@ class ComponentInstance:
134135
self.parent = parent
135136
self.handles = Table()
136137
self.threads = Table()
138+
self.may_enter = True
137139
self.may_leave = True
138140
self.backpressure = 0
139141
self.num_waiting_to_enter = 0
@@ -155,26 +157,37 @@ by the host, `None`, in the `parent` field. Thus, the set of component instances
155157
in a store forms a forest rooted by the component instances that were
156158
instantiated directly by the host.
157159

158-
Based on this, the "reflexive ancestors" of a component instance (i.e., itself
159-
and all parent component instances up to the root component instance) can be
160-
enumerated and tested via these two helper functions:
160+
TODO
161161
```python
162-
def reflexive_ancestors(self) -> set[ComponentInstance]:
162+
def enter_from(self, caller: Optional[ComponentInstance]):
163+
for inst in self.entering(caller):
164+
trap_if(not inst.may_enter)
165+
inst.may_enter = False
166+
167+
def leave_to(self, caller: Optional[ComponentInstance]):
168+
for inst in self.entering(caller):
169+
assert(not inst.may_enter)
170+
inst.may_enter = True
171+
172+
def entering(self, caller: Optional[ComponentInstance]):
173+
if caller:
174+
return self.self_and_ancestors() - caller.self_and_ancestors()
175+
else:
176+
return self.self_and_ancestors()
177+
178+
def self_and_ancestors(self) -> set[ComponentInstance]:
163179
s = set()
164180
inst = self
165181
while inst is not None:
166182
s.add(inst)
167183
inst = inst.parent
168184
return s
169-
170-
def is_reflexive_ancestor_of(self, other):
171-
while other is not None:
172-
if self is other:
173-
return True
174-
other = other.parent
175-
return False
176185
```
177186

187+
Based on this, the "reflexive ancestors" of a component instance (i.e., itself
188+
and all parent component instances up to the root component instance) can be
189+
enumerated and tested via these two helper functions:
190+
...
178191
How the host instantiates and invokes root components is up to the host and not
179192
specified by the Component Model. Exports of previously-instantiated root
180193
components *may* be supplied as the imports of subsequently-instantiated root
@@ -206,76 +219,7 @@ cases are prevented via trap for several reasons:
206219
to link recursive calls and this requires opting in via some
207220
[TBD](Concurrency.md#TODO) function effect type or canonical ABI option.
208221

209-
To detect and prevent recursive calls, the runtime tracks the dynamic call
210-
stack via a linked list of `Supertask` nodes. The `inst` field of `Supertask`
211-
either points to the `ComponentInstance` of the calling component or, if
212-
`None`, indicates that the caller is the host.
213-
```python
214-
class Supertask:
215-
inst: Optional[ComponentInstance]
216-
supertask: Optional[Supertask]
217-
```
218-
219-
The `call_might_be_recursive` predicate is used by `Store.lift` to
220-
conservatively detect recursive reentrance and subsequently trap.
221-
```python
222-
def call_might_be_recursive(caller: Supertask, callee_inst: ComponentInstance):
223-
if caller.inst is None:
224-
while caller is not None:
225-
if caller.inst and caller.inst.reflexive_ancestors() & callee_inst.reflexive_ancestors():
226-
return True
227-
caller = caller.supertask
228-
return False
229-
else:
230-
return (caller.inst.is_reflexive_ancestor_of(callee_inst) or
231-
callee_inst.is_reflexive_ancestor_of(caller.inst))
232-
```
233-
The first case (where `caller.inst` is `None`) covers host-to-component calls.
234-
By testing whether any of the callers' reflexive anecestor sets intersect the
235-
callee's ancestor set, the following case is considered recursive:
236-
```
237-
+-------+
238-
| A |<-.
239-
| +---+ | |
240-
host-->| B |-->host
241-
| +---+ |
242-
+-------+
243-
```
244-
Here, when attempting to recursively call back into `A`, `caller` points to the
245-
following stack:
246-
```
247-
|inst=None| --supertask--> |inst=B| --supertask--> |inst=None| --supertask--> None
248-
```
249-
while `A` does not appear as the `inst` of any `Supertask` on this stack,
250-
`B.reflexive_ancestors()` is `{ B, A }`, so the loop correctly determines that
251-
`A` is being reentered. This ensures that child components are kept an
252-
encapsulated detail of the parent.
253-
254-
The second case (where `caller.inst` is not `None`) covers component-to-
255-
component calls by conservatively rejecting any call from a component to its
256-
anecestor or descendant (thereby preventing any possible recursion via ancestor
257-
`funcref`). Thus, the following sibling-to-sibling component call is allowed:
258-
```
259-
+----------------+
260-
| P |
261-
| +----+ +----+ |
262-
host-->| C1 |->| C2 | |
263-
| +----+ +----+ |
264-
+----------------+
265-
```
266-
while the following child-to-parent and parent-to-child calls are disallowed:
267-
```
268-
+----------+ +----------+
269-
| +---+ | | +---+ |
270-
host-->| C |->P | host->| P->| C | |
271-
| +---+ | | +---+ |
272-
+----------+ +----------+
273-
```
274-
This conservative approximation allows `call_might_be_recursive` to be computed
275-
ahead-of-time when compiling a fused component-to-component adapter (where both
276-
caller and callee intances and their relationship are statically known). In the
277-
future this check will be relaxed and more sophisticated optimizations can be
278-
used to statically eliminate the check in common cases.
222+
TODO
279223

280224
The other fields of `ComponentInstance` are described below as they are used.
281225

@@ -720,16 +664,14 @@ spec-level function type, where the host can be the caller, the callee or even
720664
OnStart = Callable[[], list[any]]
721665
OnResolve = Callable[[Optional[list[any]]], None]
722666
OnCancel = Callable[[], None]
723-
FuncInst = Callable[[Supertask, OnStart, OnResolve], OnCancel]
667+
FuncInst = Callable[[OnStart, OnResolve, Optional[ComponentInstance]], OnCancel]
724668
```
725669
The three parameters of `FuncInst` are:
726-
* an optional caller `Supertask` which is used to maintain the
727-
[async callstack][Structured Concurrency] and enforce the
728-
non-reentrance [component invariant];
729670
* an `OnStart` callback that is called by the callee when it is ready to
730671
receive its arguments after waiting for any [backpressure] to subside;
731672
* an `OnResolve` callback that is called by the callee when it is ready to
732673
return its value or, if cancellation has been requested, `None`.
674+
* the caller's `ComponentInstance`, if the caller is not the host
733675

734676
Critically, if the callee [blocks] at the wasm level, the spec-level `FuncInst`
735677
returns immediately to the caller while continuing to execute the callee in a
@@ -745,7 +687,7 @@ call creates a `Task` object to track the state of the call and ensure that the
745687
wasm guest code adheres to the above `FuncInst` calling convention (or else
746688
traps). `Task` is introduced in chunks, starting with fields and initialization:
747689
```python
748-
class Task(Supertask):
690+
class Task:
749691
class State(Enum):
750692
INITIAL = 1
751693
STARTED = 2
@@ -756,19 +698,17 @@ class Task(Supertask):
756698
ft: FuncType
757699
opts: CanonicalOptions
758700
inst: ComponentInstance
759-
supertask: Supertask
760701
on_start: OnStart
761702
on_resolve: OnResolve
762703
state: State
763704
num_borrows: int
764705
waiting_to_enter: Optional[Thread]
765706
threads: list[Thread]
766707

767-
def __init__(self, ft, opts, inst, supertask, on_start, on_resolve):
708+
def __init__(self, ft, opts, inst, on_start, on_resolve):
768709
self.ft = ft
769710
self.opts = opts
770711
self.inst = inst
771-
self.supertask = supertask
772712
self.on_start = on_start
773713
self.on_resolve = on_resolve
774714
self.state = Task.State.INITIAL
@@ -1013,44 +953,56 @@ CoreFuncInst = Callable[[list[CoreValType]], list[CoreValType]]
1013953

1014954
class Store:
1015955
waiting: list[Thread]
956+
nesting_depth: int
1016957

1017958
def __init__(self):
1018959
self.waiting = []
960+
self.nesting_depth = 0
1019961

1020-
def invoke(self, f: FuncInst, caller: Optional[Supertask], on_start, on_resolve) -> OnCancel:
1021-
host_caller = Supertask()
1022-
host_caller.inst = None
1023-
host_caller.supertask = caller
1024-
return f(host_caller, on_start, on_resolve)
962+
def invoke(self, f: FuncInst, on_start: OnStart, on_resolve: OnResolve) -> OnCancel:
963+
self.nesting_depth += 1
964+
on_cancel = f(on_start, on_resolve, caller = None)
965+
self.nesting_depth -= 1
966+
return on_cancel
1025967

1026968
def lift(self, f: CoreFuncInst, ft: FuncType, opts: CanonicalOptions, inst: ComponentInstance) -> FuncInst:
1027-
def func_inst(caller: Supertask, on_start: OnStart, on_resolve: OnResolve) -> OnCancel:
1028-
trap_if(call_might_be_recursive(caller, inst))
1029-
return canon_lift(f, ft, opts, inst, caller, on_start, on_resolve)
969+
def func_inst(on_start: OnStart, on_resolve: OnResolve, caller: Optional[ComponentInstance]) -> OnCancel:
970+
inst.enter_from(caller)
971+
on_cancel = canon_lift(f, ft, opts, inst, on_start, on_resolve)
972+
inst.leave_to(caller)
973+
return on_cancel
1030974
return func_inst
1031975

1032976
def lower(self, f: FuncInst, ft: FuncType, opts: CanonicalOptions, inst: ComponentInstance) -> CoreFuncInst:
1033977
def core_func_inst(args: list[CoreValType]) -> list[CoreValType]:
1034-
assert(current_instance() is inst)
978+
assert(current_instance() is inst and self.nesting_depth > 0)
1035979
return canon_lower(f, ft, opts, args)
1036980
return core_func_inst
1037981

1038982
def tick(self):
1039-
random.shuffle(self.waiting)
1040-
for thread in self.waiting:
1041-
if thread.ready():
1042-
thread.resume()
1043-
return
1044-
```
983+
assert(self.nesting_depth == 0)
984+
candidates = { t for t in self.waiting if t.ready() }
985+
if candidates:
986+
thread = random.choice(list(candidates))
987+
self.nesting_depth += 1
988+
thread.task.inst.enter_from(None)
989+
thread.resume()
990+
thread.task.inst.leave_to(None)
991+
self.nesting_depth -= 1
992+
```
993+
TODO: `nesting_depth`
994+
1045995
The `FuncInst` passed to `Store.invoke` is described above and can represent
1046996
either a guest function (produced by `Store.lift`) or (in the special case of a
1047997
component re-export of a host import) a host function. `Store.invoke` describes
1048998
how a `FuncInst` is invoked by the host, but `FuncInst`s can also be invoked by
1049999
guest code that calls the `CoreFuncInst` produced by `Store.lower`.
10501000

1001+
TODO: update
10511002
When a `FuncInst` is produced by lifting core wasm guest code, it is guarded
10521003
by a call to `call_might_be_recursive`, which is described above.
10531004

1005+
TODO: `nesting_depth == 0`
10541006
The `Store.tick` method does not have an analogue in Core WebAssembly and
10551007
enables [native concurrency support](Concurrency.md) in the Component Model. The
10561008
expectation is that the host will interleave calls to `invoke` with calls to
@@ -3503,7 +3455,7 @@ executes in a new *implicit thread* defined here by `thread_func`. The first
35033455
thing this implicit thread does is to wait for any backpressure, as defined by
35043456
`Task.enter_implicit_thread` above:
35053457
```python
3506-
def canon_lift(callee, ft, opts, inst, caller, on_start, on_resolve) -> OnCancel:
3458+
def canon_lift(callee, ft, opts, inst, on_start, on_resolve) -> OnCancel:
35073459
def thread_func():
35083460
if not task.enter_implicit_thread():
35093461
return
@@ -3635,7 +3587,7 @@ required by the `FuncInst` calling contract. Lastly, `canon_lift` returns
36353587
`Task.request_cancellation`, bound to the call's new task, as the required
36363588
`OnCancel` value.
36373589
```python
3638-
task = Task(ft, opts, inst, caller, on_start, on_resolve)
3590+
task = Task(ft, opts, inst, on_start, on_resolve)
36393591
thread = Thread(task, thread_func)
36403592
thread.resume()
36413593
return task.request_cancellation
@@ -3775,7 +3727,7 @@ above).
37753727
nonlocal flat_results
37763728
flat_results = lower_flat_values(cx, max_flat_results, result, ft.result_type(), flat_args)
37773729

3778-
subtask.on_cancel = callee(thread.task, on_start, on_resolve)
3730+
subtask.on_cancel = callee(on_start, on_resolve, caller = thread.task.inst)
37793731
assert(ft.async_ or subtask.state == Subtask.State.RETURNED)
37803732
```
37813733
The `Subtask.state` field is updated by the callbacks to keep track of the
@@ -3899,17 +3851,13 @@ def canon_resource_drop(rt, i):
38993851
trap_if(h.num_lends != 0)
39003852
if h.own:
39013853
assert(h.borrow_scope is None)
3902-
if inst is rt.impl:
3903-
if rt.dtor:
3904-
rt.dtor(h.rep)
3905-
else:
3906-
caller_opts = CanonicalOptions(async_ = False)
3907-
callee_opts = CanonicalOptions(async_ = rt.dtor_async, callback = rt.dtor_callback)
3908-
ft = FuncType([U32Type()],[], async_ = False)
3909-
dtor = rt.dtor or (lambda rep: [])
3910-
callee = inst.store.lift(dtor, ft, callee_opts, rt.impl)
3911-
caller = inst.store.lower(callee, ft, caller_opts, inst)
3912-
caller([h.rep])
3854+
caller_opts = CanonicalOptions(async_ = False)
3855+
callee_opts = CanonicalOptions(async_ = rt.dtor_async, callback = rt.dtor_callback)
3856+
ft = FuncType([U32Type()], [], async_ = False)
3857+
dtor = rt.dtor or (lambda rep: [])
3858+
callee = inst.store.lift(dtor, ft, callee_opts, rt.impl)
3859+
caller = inst.store.lower(callee, ft, caller_opts, inst)
3860+
caller([h.rep])
39133861
else:
39143862
h.borrow_scope.num_borrows -= 1
39153863
return []
@@ -3920,15 +3868,12 @@ the private `i32` representation as a parameter. Thus, destructors *may* block
39203868
on I/O, but only after they `task.return`, ensuring that `resource.drop` never
39213869
blocks.
39223870

3923-
Since there are valid reasons to call `resource.drop` in the same component
3924-
instance that defined the resource, which would otherwise trap at the
3925-
reentrance guard of `Store.lift`, an exception is made when the resource type's
3926-
implementation-instance is the same as the current instance (which is
3927-
statically known for any given `canon resource.drop`).
3928-
3929-
When a destructor isn't present, there is still a trap on recursive reentrance
3930-
since this is the caller's responsibility and the presence or absence of a
3931-
destructor is an encapsulated implementation detail of the resource type.
3871+
In particular, the `ComponentIntance.enter_from` method called by `Store.lift`
3872+
may triger a trap if the call to the destructor would reenter the destructor's
3873+
instance. In the special case where the `current_instance` is the *same* as the
3874+
destructor's instance, `ComponentInstance.enter_from` is defined to *not* trap
3875+
and thus, as one might expect, component instances can always `resource.drop`
3876+
the owned handles that they created (via `resource.new`).
39323877

39333878

39343879
### `canon resource.rep`

0 commit comments

Comments
 (0)