|
| 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. |
0 commit comments