Skip to content

Commit b183f26

Browse files
feat(moonbit): implement component async bindings
1 parent dcc5b0c commit b183f26

72 files changed

Lines changed: 11688 additions & 1710 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/moonbit/CONTEXT.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# MoonBit Bindings Context
2+
3+
This context records shared language for the MoonBit binding generator. Async
4+
future and stream vocabulary is split into [Async Glossary](docs/async-glossary.md).
5+
6+
## Language
7+
8+
**MoonBit binding**:
9+
Generated MoonBit source that exposes WIT imports to MoonBit code or adapts
10+
MoonBit exports to the component ABI.
11+
12+
**Component adapter path**:
13+
The current implementation path where MoonBit emits core wasm and `wasm-tools`
14+
converts it into a component using adapter imports and exports.
15+
_Avoid_: direct component generation
16+
17+
## Async Design
18+
19+
- [Async design contract](docs/async-design.md)
20+
- [Async glossary](docs/async-glossary.md)
21+
- [ADR 0001: FFI-boundary conversion](docs/adr/0001-async-ffi-boundary-conversion.md)
22+
- [ADR 0002: local Future/Promise pair](docs/adr/0002-local-future-promise.md)
23+
24+
The implementation targets the official upstream generator architecture. WIT
25+
`future` and `stream` remain distinct from local MoonBit `Future` and `Stream`;
26+
generated code converts them only at concrete FFI positions whose intrinsic
27+
names are supplied by `wit-parser`. Local `Future::new()` returns a
28+
MoonBit-only Future/Promise pair, and local `Semaphore` coordinates coroutines;
29+
neither can select or own a component endpoint from `T`. Async support is always
30+
available in the MoonBit generator, but endpoint-free synchronous worlds do not
31+
emit its runtime or wrappers. Component endpoint wrappers are
32+
generated-code-only, enforce a single in-flight operation, and retain operation
33+
buffers until cancellation or completion is observed. Each top-level component
34+
task has its own waitable set and scheduler state.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Keep MoonBit async values detached from component futures and streams
2+
3+
Status: accepted; implemented in the current branch
4+
5+
MoonBit `Future[T]` handles and local `Stream[T]` values are over arbitrary
6+
MoonBit types, while component `future<T>` and `stream<T>` are ABI values over
7+
WIT-representable payloads. MoonBit async bindings will keep those concepts
8+
separate: generated FFI-boundary code converts between local async values and
9+
component endpoints only at concrete WIT positions where the endpoint operations
10+
and payload lift/lower code are known.
11+
12+
For MVP, component `future<T>` maps to a one-shot `Future[T]` handle. The handle
13+
represents a ready value or an owned local source computation with
14+
value-discarding drop cleanup. For an incoming component future, generated code
15+
supplies a source closure that captures the raw readable handle and directly
16+
binds the concrete WIT position's read/cancel/drop intrinsics. The generic local
17+
type has no CM-specific variant or operation table. A public local `Promise[T]`
18+
may settle the same Future state, but it is not a component writable endpoint.
19+
20+
## Consequences
21+
22+
- `Stream::new()` creates local MoonBit values only.
23+
- `Future::new()` creates a local `Future[T]` / `Promise[T]` pair only. It does
24+
not select or invoke a component `future.new` intrinsic.
25+
- Endpoint operation tables are not part of the target design. Generated
26+
position-specific helpers call canonical intrinsics directly and keep raw
27+
handle state inside generated source closures or producer tasks.
28+
- Component intrinsic module and field names are generated through
29+
`wit-parser`'s `WasmImport::{FutureIntrinsic, StreamIntrinsic}` API. The
30+
MoonBit generator does not reproduce position indices, export prefixes,
31+
unit-payload names, or async-lower prefixes by string formatting.
32+
- The Rust generator boundary owns a recursive conversion plan for each
33+
function. Ordinary lift/lower code requests position-specific helpers from
34+
that plan instead of naming runtime endpoint types or type-shaped tables.
35+
- The existing async generator boundary is retained. The static recursive
36+
rewrite happens behind it without changing ordinary lift/lower ownership or
37+
the endpoint-free sync path.
38+
- User-facing bindings expose local `Future[T]` handles and local `Stream[T]`
39+
for ordinary async composition.
40+
- A nested WIT shape such as `future<future<stream<T>>>` maps to
41+
`Future[Future[Stream[T]]]`. Only the current layer's readable handle appears
42+
at each canonical payload stage. Generated recursive lift/lower functions bind
43+
each layer to its own function-position intrinsic and apply generated
44+
commit/reject dispositions at every transfer boundary.
45+
- Recursive lower retains its canonical buffer and prepared producer state until
46+
the transfer reports a disposition. Commit starts producer work only after
47+
ownership transfers. Stream progress commits the accepted prefix and retries
48+
the same lowered suffix; an abandoned suffix is rejected exactly once.
49+
- A freshly-created component future is not fully rollbackable. Canonical ABI
50+
permits its writable end to be dropped only after a write succeeds or a write
51+
reports that the reader was dropped. If an outer transfer rejects a nested
52+
future readable, generated code drops that readable but must still drive the
53+
paired writer with the local future value until its write observes `dropped`.
54+
A cancelled write is not settlement and is retried while the component task
55+
remains alive.
56+
- Stream batches whose element type recursively contains a future use a
57+
one-element staging window for MVP. This bounds settlement obligations created
58+
before downstream acceptance without changing the public stream API.
59+
- Generated stream producers remain prepared when their component pair is
60+
created. Commit starts normal pumping. Parent rejection drops both
61+
untransferred component ends and performs state-aware local rejection: incoming
62+
component sources close immediately, buffered values use configured cleanup or
63+
generated cleanup as fallback, and an unstarted local producer is discarded
64+
without executing user code. `Stream::produce` accepts an optional
65+
`on_unstarted_drop` callback for resources captured by that branch and a
66+
separate per-element cleanup for values written after it starts.
67+
- Canonical `backpressure.inc/dec` controls admission of new async component
68+
tasks. It is not tied to future/stream bridge lifetime and is not called
69+
implicitly by the MoonBit runtime. Endpoint read/write suspension provides
70+
data-flow backpressure independently.
71+
- Async import argument settlement distinguishes `cancelled-before-started`
72+
from every state in which the callee may have started. The former recursively
73+
rejects owned resources and endpoints; the latter only reclaims guest-owned
74+
canonical list allocations.
75+
- Local future MVP has consuming `Future::get()` and value-discarding async
76+
`Future::drop()`. Strong cancellation belongs to `Task` and `TaskGroup`, not
77+
to a future-specific result type. `Future::drop()` is explicit cleanup, not a
78+
direct alias for component `future.drop-*`, and futures that may discard
79+
completed WIT payloads carry generated payload cleanup logic.
80+
- Outgoing component `future<T>` producer tasks must settle their raw writable
81+
handle by writing a real value or by attempting the write and observing reader
82+
drop. The MVP does not fabricate default values to satisfy unwritten futures.
83+
If user code produces a ready `T`, generated code may write immediately; if
84+
user code returns a pending `Future[T]`, the bridge exists before the CM
85+
`future.write` operation starts because the component boundary already needs a
86+
readable end to return.
87+
- After an outgoing component `future<T>` readable end is committed, or after a
88+
parent transfer rejects and locally drops it, the bridge shields producer work
89+
from ordinary task/subtask cancellation. Component-task cancellation is
90+
cooperative: it may resolve the cancelled call with `task.cancel`, but it does
91+
not forcibly destroy shielded settlement work. Only instance teardown or a
92+
trap can abandon the writer without settlement. Peer reader drop is
93+
loss-of-interest, not task cancellation; before `future.write` has started,
94+
the bridge does nothing special for it. If a later write reports `dropped`,
95+
the bridge cleans the value and settles the writer.
96+
- If that local future never produces `T`, the Component Model provides no
97+
generic close-without-value operation. This is an explicit liveness limit; the
98+
binding does not fabricate a default value or pretend an idle writer can be
99+
dropped safely. The same limit applies when the paired readable was created
100+
for a nested payload but the outer transfer rejected it before ownership
101+
crossed the boundary.
102+
- Local stream MVP has `Sink::close()` for graceful producer close and async
103+
`Stream::drop()` or `Stream[T]` drop for consumer loss-of-interest. It does
104+
not expose public `Sink::cancel()` because component `stream` has no distinct
105+
generic producer-failure signal to preserve across the boundary.
106+
- Local stream state keeps producer close separate from reader drop. Producer
107+
close preserves buffered `FixedArray[T]` chunks for draining; reader drop
108+
discards unread chunks and uses an explicit `Stream::new_with_cleanup` or
109+
`Stream::produce(cleanup=...)` callback for payloads that need resource
110+
cleanup.
111+
- Local stream capacity is measured in elements. Zero is strict rendezvous and
112+
a positive value is a hard bound on accepted unread values; local streams are
113+
never implicitly unbounded. Waiting readers and writers use direct FIFO
114+
handoff with completion-versus-cancellation race handling.
115+
- An incoming component stream uses a generated demand-driven source whose
116+
reads directly call its concrete site intrinsics. It does not start an eager
117+
pump into a local stream pipe.
118+
- Forwarding component endpoints without reading them is not the default path and
119+
needs an explicit advanced API if we decide to support it.
120+
- Runtime validation includes real `wasi:cli@0.3.0` stream output and a real
121+
`wasi:http@0.3.0` handler response whose body stream, trailers future, and
122+
transmission completion continue after the handler returns the response.
123+
- Local stream validation covers strict rendezvous, bounded buffering, cancelled
124+
waiter removal, completion-versus-cancellation ownership races, local
125+
producer reads, and unread resource cleanup.
126+
- Async export stubs intentionally expose
127+
`background_group : @async-core.TaskGroup[Unit]`. It adapts the mismatch
128+
between MoonBit structured completion and component task return. Generated code
129+
publishes component task return when the user function produces its result,
130+
then keeps the underlying MoonBit task group alive for hook-style post-return
131+
work. That work remains structurally owned and bounded by the component task
132+
or instance lifetime, but cannot change the already-published export result.
133+
- Lowering local `Future[T]` and `Stream[T]` values requires an active component
134+
async task scope, including recursive occurrences inside structured payloads.
135+
A sync WIT import called in that scope prepares its endpoint arguments and
136+
commits those same handles immediately after the core call returns. Sync export
137+
results, and sync imports called without an active scope, remain unsupported.
138+
Incoming lift remains lazy and does not require a producer task until user code
139+
later reads in an async scope. Scope-free sync lowering requires cooperative
140+
component-thread support and must not be faked by using the stackless callback
141+
ABI for a non-async WIT function.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Add a local Future/Promise pair
2+
3+
Status: accepted; implemented in the current branch
4+
5+
MoonBit needs a producer-facing one-shot primitive for local control-flow cycles
6+
that cannot be expressed as `Future::from(async () -> T)`. The motivating case
7+
is `wasi:http@0.3.0` request handling: `consume-body` takes a Future reporting
8+
processing completion before it returns the request body whose later processing
9+
determines that completion.
10+
11+
`Future::new()` returns `(Future[T], Promise[T])` and
12+
`Future::new_with_cleanup(cleanup)` adds explicit value-discard cleanup. The
13+
Promise can complete with a value, fail with a MoonBit error, or close without a
14+
value. It is local coordination state only. It does not create, wrap, or own a
15+
component `future` endpoint, and it works for arbitrary MoonBit `T`.
16+
17+
## Consequences
18+
19+
- `Promise::complete(value)` returns `true` only when the reader accepts
20+
ownership. If the Future was already dropped, it returns `false` and the
21+
caller retains `value`.
22+
- `Future::drop()` after accepted completion runs the cleanup supplied to
23+
`new_with_cleanup`. The plain `new()` constructor is appropriate only when
24+
discarded `T` needs no explicit cleanup.
25+
- `Promise::fail(error)` makes local `Future::get()` raise that error.
26+
`Promise::close()` makes it raise `PromiseClosed`.
27+
- Settlement is one-shot. Repeating `complete`, `fail`, or `close` after a
28+
successful settlement is a programmer error.
29+
- Dropping or otherwise abandoning a still-pending Promise does not implicitly
30+
close its Future because MoonBit has no generic deterministic destructor. The
31+
producer must explicitly complete, fail, or close it.
32+
- Task cancellation that reaches a waiting reader before settlement drops the
33+
reader, so later completion returns `false`. Once completion assigns the value
34+
and wakes the reader, completion wins a simultaneous cancellation race and the
35+
reader receives the value.
36+
- Explicit `Future::drop()` follows the same race rule while `get()` is pending:
37+
dropping before settlement wakes the reader with `Cancelled`, while an
38+
already-assigned value or error remains owned by the waiting reader.
39+
- A local failure or close cannot settle an already-exposed component future
40+
without a value. If such an outcome is expected across WIT, it belongs in the
41+
payload type, for example `Future[Result[V, E]]`.
42+
- Generated FFI-boundary code remains solely responsible for creating concrete
43+
component future pairs and satisfying their writable-end settlement rules.

0 commit comments

Comments
 (0)