Skip to content

Commit ec6f3ba

Browse files
committed
Refactor CABI to factor out export/import-only CallContext state (no change in behavior)
1 parent f44d237 commit ec6f3ba

File tree

2 files changed

+188
-150
lines changed

2 files changed

+188
-150
lines changed

design/mvp/CanonicalABI.md

Lines changed: 119 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -220,67 +220,30 @@ The subsequent definitions of loading and storing a value from linear memory
220220
require additional runtime state, which is threaded through most subsequent
221221
definitions via the `cx` parameter of type `CallContext`:
222222
```python
223+
@dataclass
223224
class CallContext:
224225
opts: CanonicalOptions
225226
inst: ComponentInstance
226-
lenders: list[HandleElem]
227-
borrow_count: int
228-
229-
def __init__(self, opts, inst):
230-
self.opts = opts
231-
self.inst = inst
232-
self.lenders = []
233-
self.borrow_count = 0
234227
```
235-
One `CallContext` is created for each call for both the component caller and
236-
callee (defined below in `canon_lower` and `canon_lift`, resp.). Thus, a
237-
cross-component call will create 2 `CallContext` objects for the call, while a
238-
component-to-host or host-to-component call will create a single `CallContext`
239-
for the component caller or callee, resp.
240228

241-
The meaning of the `opts` and `inst` fields are described with their associated
242-
types below.
243-
244-
The `lenders` and `borrow_count` fields are used by the following helper
245-
methods of `CallContext` plus the `{lift,lower}_{own,borrow}` operations. These
246-
fields are updated at the appropriate points in the lifecycle of a call (below)
247-
and maintain the bookkeeping to dynamically ensure that `own` handles are not
248-
dropped while they have been `borrow`ed and that all `borrow` handles created
249-
for a call are dropped before the end of the call.
250-
```python
251-
def track_owning_lend(self, lending_handle):
252-
assert(lending_handle.own)
253-
lending_handle.lend_count += 1
254-
self.lenders.append(lending_handle)
255-
256-
def exit_call(self):
257-
trap_if(self.borrow_count != 0)
258-
for h in self.lenders:
259-
h.lend_count -= 1
260-
```
261-
Note, the `lenders` list usually has a fixed size (in all cases except when a
262-
function signature has `borrow`s in `list`s) and thus can be stored inline in
263-
the native stack frame.
264-
265-
The `CanonicalOptions` class implements the `opts` field of `CallContext` and
266-
represents the [`canonopt`] values supplied to currently-executing `canon lift`
267-
or `canon lower`:
229+
The `opts` field of `CallContext` contains all the possible `canonopt`
230+
immediates that can be passed to the `canon` definition being implemented.
268231
```python
232+
@dataclass
269233
class CanonicalOptions:
270-
memory: bytearray
271-
string_encoding: str
272-
realloc: Callable[[int,int,int,int],int]
273-
post_return: Callable[[],None]
234+
memory: Optional[bytearray] = None
235+
string_encoding: Optional[str] = None
236+
realloc: Optional[Callable[[int,int,int,int],int]] = None
237+
post_return: Optional[Callable[[],None]] = None
274238
```
275239

276-
The `ComponentInstance` class implements the `inst` field of `CallContext` and
277-
represents the component instance that the currently-executing canonical
278-
definition is defined to execute inside. The `may_enter` and `may_leave` fields
279-
are used to enforce the [component invariants]: `may_leave` indicates whether
280-
the instance may call out to an import and the `may_enter` state indicates
281-
whether the instance may be called from the outside world through an export.
240+
The `inst` field of `CallContext` points to the component instance which the
241+
`canon`-generated function is closed over. Component instances contain all the
242+
core wasm instance as well as some extra state that is used exclusively by the
243+
Canonical ABI:
282244
```python
283245
class ComponentInstance:
246+
# core module instance state
284247
may_leave: bool
285248
may_enter: bool
286249
handles: HandleTables
@@ -290,9 +253,14 @@ class ComponentInstance:
290253
self.may_enter = True
291254
self.handles = HandleTables()
292255
```
293-
The `HandleTables` object held by `ComponentInstance` contains a mapping
294-
from `ResourceType` to `Table`s of `HandleElem`s (defined next), establishing
295-
a separate `i32`-indexed array per resource type.
256+
The `may_enter` and `may_leave` fields are used to enforce the [component
257+
invariants]: `may_leave` indicates whether the instance may call out to an
258+
import and the `may_enter` state indicates whether the instance may be called
259+
from the outside world through an export.
260+
261+
The `handles` field of `ComponentInstance` contains a mapping from
262+
`ResourceType` to `Table`s of `HandleElem`s (defined next), establishing a
263+
separate `i32`-indexed array per resource type.
296264
```python
297265
class HandleTables:
298266
rt_to_table: MutableMapping[ResourceType, Table[HandleElem]]
@@ -392,7 +360,7 @@ stored in `HandleTables`:
392360
class HandleElem:
393361
rep: int
394362
own: bool
395-
scope: Optional[CallContext]
363+
scope: Optional[ExportCall]
396364
lend_count: int
397365

398366
def __init__(self, rep, own, scope = None):
@@ -407,15 +375,14 @@ fixed to be an `i32`) passed to `resource.new`.
407375
The `own` field indicates whether this element was created from an `own` type
408376
(or, if false, a `borrow` type).
409377

410-
The `scope` field optionally stores the `CallContext` of the call that created
411-
this handle if the handle type was `borrow`. Until async is added to the
412-
Component Model, because of the non-reentrancy of components, there is at most
413-
one `CallContext` alive for a given component at a time and thus this field
414-
does not actually need to be stored per `HandleElem`.
378+
The `scope` field stores the `ExportCall` that created the borrowed handle.
379+
Until async is added to the Component Model, because of the non-reentrancy of
380+
components, there is at most one `ExportCall` alive for a given component at a
381+
time and thus this field does not actually need to be stored per `HandleElem`.
415382

416383
The `lend_count` field maintains a conservative approximation of the number of
417384
live handles that were lent from this `own` handle (by calls to `borrow`-taking
418-
functions). This count is maintained by the `CallContext` bookkeeping functions
385+
functions). This count is maintained by the `ImportCall` bookkeeping functions
419386
(above) and is ensured to be zero when an `own` handle is dropped.
420387

421388
An optimizing implementation can enumerate the canonical definitions present
@@ -424,6 +391,64 @@ table only contains `own` or `borrow` handles and then, based on this,
424391
statically eliminate the `own` and the `lend_count` xor `scope` fields,
425392
and guards thereof.
426393

394+
One `CallContext` is created for each call for both the component caller and
395+
callee (in `canon_lower` and `canon_lift`, resp., as defined below). Thus, a
396+
cross-component call will create 2 `CallContext` objects for the call, while a
397+
component-to-host or host-to-component call will create a single `CallContext`
398+
for the component caller or callee, resp.
399+
400+
Additional per-call state is required to check that callers and callees uphold
401+
their respective parts of the call contract:
402+
403+
The `ExportCall` subclass of `CallContext` tracks the number of borrowed
404+
handles that were passed as parameters to the export that have not yet been
405+
dropped (which might dangle if the caller destroys the resource after the
406+
call):
407+
```python
408+
class ExportCall(CallContext):
409+
borrow_count: int
410+
411+
def __init__(self, opts, inst):
412+
super().__init__(opts, inst)
413+
self.borrow_count = 0
414+
415+
def create_borrow(self):
416+
self.borrow_count += 1
417+
418+
def drop_borrow(self):
419+
assert(self.borrow_count > 0)
420+
self.borrow_count -= 1
421+
422+
def exit(self):
423+
trap_if(self.borrow_count != 0)
424+
```
425+
426+
The `ImportCall` subclass of `CallContext` tracks the owned handles that have
427+
been lent for the duration of an import call, ensuring that they aren't dropped
428+
during the call (which might create a dangling borrowed handle):
429+
```python
430+
class ImportCall(CallContext):
431+
lenders: list[HandleElem]
432+
433+
def __init__(self, opts, inst):
434+
assert(inst.entered)
435+
super().__init__(opts, inst)
436+
self.lenders = []
437+
438+
def track_owning_lend(self, lending_handle):
439+
assert(lending_handle.own)
440+
lending_handle.lend_count += 1
441+
self.lenders.append(lending_handle)
442+
443+
def exit(self):
444+
assert(self.inst.entered)
445+
for h in self.lenders:
446+
h.lend_count -= 1
447+
```
448+
Note, the `lenders` list usually has a fixed size (in all cases except when a
449+
function signature has `borrow`s in `list`s) and thus can be stored inline in
450+
the native stack frame.
451+
427452

428453
### Loading
429454

@@ -670,6 +695,7 @@ from the source handle, leaving the source handle intact in the current
670695
component instance's handle table:
671696
```python
672697
def lift_borrow(cx, i, t):
698+
assert(isinstance(cx, ImportCall))
673699
h = cx.inst.handles.get(t.rt, i)
674700
if h.own:
675701
cx.track_owning_lend(h)
@@ -1071,10 +1097,11 @@ def lower_own(cx, rep, t):
10711097
return cx.inst.handles.add(t.rt, h)
10721098

10731099
def lower_borrow(cx, rep, t):
1100+
assert(isinstance(cx, ExportCall))
10741101
if cx.inst is t.rt.impl:
10751102
return rep
10761103
h = HandleElem(rep, own=False, scope=cx)
1077-
cx.borrow_count += 1
1104+
cx.create_borrow()
10781105
return cx.inst.handles.add(t.rt, h)
10791106
```
10801107
The special case in `lower_borrow` is an optimization, recognizing that, when
@@ -1083,7 +1110,6 @@ type, the only thing the borrowed handle is good for is calling
10831110
`resource.rep`, so lowering might as well avoid the overhead of creating an
10841111
intermediate borrow handle.
10851112

1086-
10871113
### Flattening
10881114

10891115
With only the definitions above, the Canonical ABI would be forced to place all
@@ -1460,6 +1486,10 @@ greater-than-`max_flat` case by either allocating storage with `realloc` or
14601486
accepting a caller-allocated buffer as an out-param:
14611487
```python
14621488
def lower_values(cx, max_flat, vs, ts, out_param = None):
1489+
inst = cx.inst
1490+
assert(inst.may_leave)
1491+
inst.may_leave = False
1492+
14631493
flat_types = flatten_types(ts)
14641494
if len(flat_types) > max_flat:
14651495
tuple_type = Tuple(ts)
@@ -1471,13 +1501,21 @@ def lower_values(cx, max_flat, vs, ts, out_param = None):
14711501
trap_if(ptr != align_to(ptr, alignment(tuple_type)))
14721502
trap_if(ptr + elem_size(tuple_type) > len(cx.opts.memory))
14731503
store(cx, tuple_value, tuple_type, ptr)
1474-
return [ptr]
1504+
flat_vales = [ptr]
14751505
else:
14761506
flat_vals = []
14771507
for i in range(len(vs)):
14781508
flat_vals += lower_flat(cx, vs[i], ts[i])
1479-
return flat_vals
1509+
1510+
inst.may_leave = True
1511+
return flat_vals
14801512
```
1513+
The `may_leave` flag is used by `canon_lower` below to prevent a component from
1514+
calling out of the component while in the middle of lowering, ensuring that the
1515+
relative ordering of the side effects of `lift_values` and `lower_values`
1516+
cannot be observed and thus an implementation may reliably fuse `lift_values`
1517+
with `lower_values` when making a cross-component call, avoiding any
1518+
intermediate copy.
14811519

14821520
## Canonical Definitions
14831521

@@ -1518,27 +1556,25 @@ component*.
15181556
Given the above closure arguments, `canon_lift` is defined:
15191557
```python
15201558
def canon_lift(opts, inst, callee, ft, args):
1521-
cx = CallContext(opts, inst)
1559+
export_call = ExportCall(opts, inst)
15221560
trap_if(not inst.may_enter)
15231561

1524-
assert(inst.may_leave)
1525-
inst.may_leave = False
1526-
flat_args = lower_values(cx, MAX_FLAT_PARAMS, args, ft.param_types())
1527-
inst.may_leave = True
1528-
1529-
try:
1530-
flat_results = callee(flat_args)
1531-
except CoreWebAssemblyException:
1532-
trap()
1533-
1534-
results = lift_values(cx, MAX_FLAT_RESULTS, CoreValueIter(flat_results), ft.result_types())
1562+
flat_args = lower_values(export_call, MAX_FLAT_PARAMS, args, ft.param_types())
1563+
flat_results = call_and_trap_on_throw(callee, flat_args)
1564+
results = lift_values(export_call, MAX_FLAT_RESULTS, CoreValueIter(flat_results), ft.result_types())
15351565

15361566
def post_return():
15371567
if opts.post_return is not None:
1538-
opts.post_return(flat_results)
1539-
cx.exit_call()
1568+
call_and_trap_on_throw(opts.post_return, flat_results)
1569+
export_call.exit()
15401570

15411571
return (results, post_return)
1572+
1573+
def call_and_trap_on_throw(callee, args):
1574+
try:
1575+
return callee(args)
1576+
except CoreWebAssemblyException:
1577+
trap()
15421578
```
15431579
Uncaught Core WebAssembly [exceptions] result in a trap at component
15441580
boundaries. Thus, if a component wishes to signal an error, it must use some
@@ -1571,24 +1607,22 @@ containing a `hostfunc` that closes over `$opts`, `$inst`, `$callee` and `$ft`
15711607
and, when called from Core WebAssembly code, calls `canon_lower`, which is defined as:
15721608
```python
15731609
def canon_lower(opts, inst, callee, calling_import, ft, flat_args):
1574-
cx = CallContext(opts, inst)
1610+
import_call = ImportCall(opts, inst)
15751611
trap_if(not inst.may_leave)
15761612

15771613
assert(inst.may_enter)
15781614
if calling_import:
15791615
inst.may_enter = False
15801616

15811617
flat_args = CoreValueIter(flat_args)
1582-
args = lift_values(cx, MAX_FLAT_PARAMS, flat_args, ft.param_types())
1618+
args = lift_values(import_call, MAX_FLAT_PARAMS, flat_args, ft.param_types())
15831619

15841620
results, post_return = callee(args)
15851621

1586-
inst.may_leave = False
1587-
flat_results = lower_values(cx, MAX_FLAT_RESULTS, results, ft.result_types(), flat_args)
1588-
inst.may_leave = True
1622+
flat_results = lower_values(import_call, MAX_FLAT_RESULTS, results, ft.result_types(), flat_args)
15891623

15901624
post_return()
1591-
cx.exit_call()
1625+
import_call.exit()
15921626

15931627
if calling_import:
15941628
inst.may_enter = True
@@ -1626,20 +1660,6 @@ Because `may_enter` is not cleared on the exceptional exit path taken by
16261660
lowering, the component is left permanently un-enterable, ensuring the
16271661
lockdown-after-trap [component invariant].
16281662

1629-
The `may_leave` flag set during lowering in `canon_lift` and `canon_lower`
1630-
ensures that the relative ordering of the side effects of `lift` and `lower`
1631-
cannot be observed via import calls and thus an implementation may reliably
1632-
interleave `lift` and `lower` whenever making a cross-component call to avoid
1633-
the intermediate copy performed by `lift`. This unobservability of interleaving
1634-
depends on the shared-nothing property of components which guarantees that all
1635-
the low-level state touched by `lift` and `lower` are disjoint. Though it
1636-
should be rare, same-component-instance `canon_lift`+`canon_lower` call pairs
1637-
are technically allowed by the above rules (and may arise unintentionally in
1638-
component reexport scenarios). Such cases can be statically distinguished by
1639-
the AOT compiler as requiring an intermediate copy to implement the above
1640-
`lift`-then-`lower` semantics.
1641-
1642-
16431663
### `canon resource.new`
16441664

16451665
For a canonical definition:
@@ -1684,9 +1704,7 @@ def canon_resource_drop(inst, rt, i):
16841704
if rt.dtor:
16851705
rt.dtor(h.rep)
16861706
else:
1687-
assert(h.scope is not None)
1688-
assert(h.scope.borrow_count > 0)
1689-
h.scope.borrow_count -= 1
1707+
h.scope.drop_borrow()
16901708
return []
16911709
```
16921710
The `may_enter` guard ensures the non-reentrance [component invariant], since

0 commit comments

Comments
 (0)