From 0dd13858baf0ff55ac081e3908205c52890f0592 Mon Sep 17 00:00:00 2001 From: zihang Date: Mon, 13 Jul 2026 16:11:15 +0800 Subject: [PATCH 01/10] fix(moonbit): update flavorful buffer type --- tests/runtime/flavorful/test.mbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/runtime/flavorful/test.mbt b/tests/runtime/flavorful/test.mbt index f4b230ef4..be0294ab1 100644 --- a/tests/runtime/flavorful/test.mbt +++ b/tests/runtime/flavorful/test.mbt @@ -65,7 +65,7 @@ pub fn errno_result() -> Result[Unit, MyErrno] { } } -pub fn write_utf8_char(buf : @buffer.T, value : Char) -> Unit { +pub fn write_utf8_char(buf : @buffer.Buffer, value : Char) -> Unit { let code = value.to_uint() match code { _..<0x80 => { From ed2a77afb8d7060ae1f587d330e37b5057e7a214 Mon Sep 17 00:00:00 2001 From: zihang Date: Tue, 14 Jul 2026 11:54:43 +0800 Subject: [PATCH 02/10] feat(moonbit): preserve kebab-case package names --- crates/moonbit/src/pkg.rs | 59 +++++++++++++++---- tests/runtime/demo/test.mbt | 2 +- tests/runtime/flavorful/test.mbt | 2 +- tests/runtime/future-cancel-read/test.mbt | 3 +- tests/runtime/future-cancel-write/test.mbt | 2 +- .../future-close-after-coming-back/test.mbt | 3 +- tests/runtime/list-in-variant/runner.mbt | 20 +++---- tests/runtime/lists/test.mbt | 2 +- tests/runtime/many-arguments/test.mbt | 2 +- tests/runtime/map/test.mbt | 2 +- tests/runtime/numbers/test.mbt | 2 +- tests/runtime/options/test.mbt | 2 +- tests/runtime/records/test.mbt | 2 +- tests/runtime/results/leaf.mbt | 2 +- tests/runtime/simple-future/test.mbt | 2 +- tests/runtime/simple-stream-payload/test.mbt | 2 +- tests/runtime/simple-stream/test.mbt | 3 +- tests/runtime/strings/test.mbt | 2 +- tests/runtime/variants/test.mbt | 2 +- 19 files changed, 74 insertions(+), 42 deletions(-) diff --git a/crates/moonbit/src/pkg.rs b/crates/moonbit/src/pkg.rs index e603ff7b6..ccae5ac18 100644 --- a/crates/moonbit/src/pkg.rs +++ b/crates/moonbit/src/pkg.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use heck::{ToLowerCamelCase, ToSnakeCase, ToUpperCamelCase}; +use heck::{ToSnakeCase, ToUpperCamelCase}; use wit_bindgen_core::{ Ns, dealias, wit_parser::{ @@ -39,9 +39,7 @@ impl PkgResolver { if let Some(alias) = imports.packages.get(name) { format!("@{alias}.") } else { - let alias = imports - .ns - .tmp(&name.split(".").last().unwrap().to_lower_camel_case()); + let alias = imports.ns.tmp(name.split(".").last().unwrap()); imports .packages .entry(name.to_string()) @@ -352,7 +350,7 @@ impl PkgResolver { } pub(crate) fn world_name(resolve: &Resolve, world: WorldId) -> String { - format!("world.{}", resolve.worlds[world].name.to_lower_camel_case()) + format!("world.{}", resolve.worlds[world].name) } pub(crate) fn interface_name(resolve: &Resolve, name: &WorldKey) -> String { @@ -367,17 +365,12 @@ impl PkgResolver { let name = match name { WorldKey::Name(name) => name, WorldKey::Interface(id) => resolve.interfaces[*id].name.as_ref().unwrap(), - } - .to_lower_camel_case(); + }; format!( "interface.{}{name}", if let Some(name) = &pkg { - format!( - "{}.{}.", - name.namespace.to_moonbit_ident(), - name.name.to_moonbit_ident() - ) + format!("{}.{}.", name.namespace, name.name) } else { String::new() } @@ -385,6 +378,48 @@ impl PkgResolver { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn package_names_and_aliases_preserve_kebab_case() { + let mut resolve = Resolve::default(); + let package = resolve + .push_str( + "test.wit", + r#" + package my:test; + + interface leaf-interface {} + + world http-proxy { + import leaf-interface; + } + "#, + ) + .unwrap(); + let world = resolve + .select_world(&[package], Some("http-proxy")) + .unwrap(); + let (key, _) = resolve.worlds[world].imports.iter().next().unwrap(); + + let interface = PkgResolver::interface_name(&resolve, key); + assert_eq!(interface, "interface.my.test.leaf-interface"); + assert_eq!(PkgResolver::world_name(&resolve, world), "world.http-proxy"); + + let mut packages = PkgResolver::default(); + assert_eq!( + packages.qualify_package("world.http-proxy", &interface), + "@leaf-interface." + ); + assert_eq!( + packages.package_import["world.http-proxy"].packages[&interface], + "leaf-interface" + ); + } +} + pub(crate) trait ToMoonBitIdent: ToOwned { fn to_moonbit_ident(&self) -> Self::Owned; } diff --git a/tests/runtime/demo/test.mbt b/tests/runtime/demo/test.mbt index 3b7ba8f8c..90c565866 100644 --- a/tests/runtime/demo/test.mbt +++ b/tests/runtime/demo/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/a/b/theTest/stub.mbt' +//@ path = 'gen/interface/a/b/the-test/stub.mbt' ///| pub fn x() -> Unit { diff --git a/tests/runtime/flavorful/test.mbt b/tests/runtime/flavorful/test.mbt index be0294ab1..ba178359a 100644 --- a/tests/runtime/flavorful/test.mbt +++ b/tests/runtime/flavorful/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/test_/flavorful/toTest/stub.mbt' +//@ path = 'gen/interface/test/flavorful/to-test/stub.mbt' ///| pub fn f_list_in_record1(_a : ListInRecord1) -> Unit { diff --git a/tests/runtime/future-cancel-read/test.mbt b/tests/runtime/future-cancel-read/test.mbt index bdb83de7b..5354c920e 100644 --- a/tests/runtime/future-cancel-read/test.mbt +++ b/tests/runtime/future-cancel-read/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/my/test_/i/stub.mbt' +//@ path = 'gen/interface/my/test/i/stub.mbt' ///| pub async fn cancel_before_read( @@ -35,4 +35,3 @@ pub async fn start_read_then_cancel( fn() { signal.read() catch { _ => raise @ffi.Cancelled::Cancelled } }, ) } - diff --git a/tests/runtime/future-cancel-write/test.mbt b/tests/runtime/future-cancel-write/test.mbt index 2b1f21e8d..ac2349645 100644 --- a/tests/runtime/future-cancel-write/test.mbt +++ b/tests/runtime/future-cancel-write/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/my/test_/i/stub.mbt' +//@ path = 'gen/interface/my/test/i/stub.mbt' ///| pub fn take_then_drop(x : @ffi.FutureReader[String]) -> Unit { diff --git a/tests/runtime/future-close-after-coming-back/test.mbt b/tests/runtime/future-close-after-coming-back/test.mbt index 9f6250656..a11ed06d6 100644 --- a/tests/runtime/future-close-after-coming-back/test.mbt +++ b/tests/runtime/future-close-after-coming-back/test.mbt @@ -1,7 +1,6 @@ //@ [lang] -//@ path = 'gen/interface/a/b/theTest/stub.mbt' +//@ path = 'gen/interface/a/b/the-test/stub.mbt' pub fn f(_param : @ffi.FutureReader[Unit]) -> @ffi.FutureReader[Unit] { _param } - diff --git a/tests/runtime/list-in-variant/runner.mbt b/tests/runtime/list-in-variant/runner.mbt index 00a92dc48..24228b707 100644 --- a/tests/runtime/list-in-variant/runner.mbt +++ b/tests/runtime/list-in-variant/runner.mbt @@ -1,36 +1,36 @@ //@ [lang] //@ path = 'gen/world/runner/stub.mbt' -//@ pkg_config = """{ "import": ["test/list-in-variant/interface/test_/list_in_variant/toTest"] }""" +//@ pkg_config = """{ "import": ["test/list-in-variant/interface/test/list-in-variant/to-test"] }""" ///| pub fn run() -> Unit { // list-in-option - let r1 = @toTest.list_in_option(Some(["hello", "world"])) + let r1 = @to-test.list_in_option(Some(["hello", "world"])) guard r1 == "hello,world" - let r2 = @toTest.list_in_option(None) + let r2 = @to-test.list_in_option(None) guard r2 == "none" // list-in-variant - let r3 = @toTest.list_in_variant(@toTest.PayloadOrEmpty::WithData(["foo", "bar", "baz"])) + let r3 = @to-test.list_in_variant(@to-test.PayloadOrEmpty::WithData(["foo", "bar", "baz"])) guard r3 == "foo,bar,baz" - let r4 = @toTest.list_in_variant(@toTest.PayloadOrEmpty::Empty) + let r4 = @to-test.list_in_variant(@to-test.PayloadOrEmpty::Empty) guard r4 == "empty" // list-in-result - let r5 = @toTest.list_in_result(Ok(["a", "b", "c"])) + let r5 = @to-test.list_in_result(Ok(["a", "b", "c"])) guard r5 == "a,b,c" - let r6 = @toTest.list_in_result(Err("oops")) + let r6 = @to-test.list_in_result(Err("oops")) guard r6 == "err:oops" // list-in-option-with-return (Bug 1 + Bug 2) - let s1 = @toTest.list_in_option_with_return(Some(["hello", "world"])) + let s1 = @to-test.list_in_option_with_return(Some(["hello", "world"])) guard s1.count == 2U guard s1.label == "hello,world" - let s2 = @toTest.list_in_option_with_return(None) + let s2 = @to-test.list_in_option_with_return(None) guard s2.count == 0U guard s2.label == "none" // top-level-list (contrast) - let r7 = @toTest.top_level_list(["x", "y", "z"]) + let r7 = @to-test.top_level_list(["x", "y", "z"]) guard r7 == "x,y,z" } diff --git a/tests/runtime/lists/test.mbt b/tests/runtime/lists/test.mbt index 6aa24a3fb..a4c19f926 100644 --- a/tests/runtime/lists/test.mbt +++ b/tests/runtime/lists/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/test_/lists/toTest/stub.mbt' +//@ path = 'gen/interface/test/lists/to-test/stub.mbt' ///| pub fn empty_list_param(a : FixedArray[Byte]) -> Unit { diff --git a/tests/runtime/many-arguments/test.mbt b/tests/runtime/many-arguments/test.mbt index ce19472f8..ff67014f4 100644 --- a/tests/runtime/many-arguments/test.mbt +++ b/tests/runtime/many-arguments/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/test_/many_arguments/toTest/stub.mbt' +//@ path = 'gen/interface/test/many-arguments/to-test/stub.mbt' ///| pub fn many_arguments( _a1 : UInt64, diff --git a/tests/runtime/map/test.mbt b/tests/runtime/map/test.mbt index 6c24ca5c8..4113d78af 100644 --- a/tests/runtime/map/test.mbt +++ b/tests/runtime/map/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/test_/maps/toTest/stub.mbt' +//@ path = 'gen/interface/test/maps/to-test/stub.mbt' ///| pub fn named_roundtrip(a : Map[UInt, String]) -> Map[String, UInt] { diff --git a/tests/runtime/numbers/test.mbt b/tests/runtime/numbers/test.mbt index 2f2e94d6a..bd1679c7b 100644 --- a/tests/runtime/numbers/test.mbt +++ b/tests/runtime/numbers/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/test_/numbers/numbers/stub.mbt' +//@ path = 'gen/interface/test/numbers/numbers/stub.mbt' ///| pub fn roundtrip_u8(a : Byte) -> Byte { diff --git a/tests/runtime/options/test.mbt b/tests/runtime/options/test.mbt index c2bbca782..b3c974e85 100644 --- a/tests/runtime/options/test.mbt +++ b/tests/runtime/options/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/test_/options/toTest/stub.mbt' +//@ path = 'gen/interface/test/options/to-test/stub.mbt' ///| pub fn option_none_param(a : String?) -> Unit { diff --git a/tests/runtime/records/test.mbt b/tests/runtime/records/test.mbt index eabb0a1e1..e9e423569 100644 --- a/tests/runtime/records/test.mbt +++ b/tests/runtime/records/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/test_/records/toTest/stub.mbt' +//@ path = 'gen/interface/test/records/to-test/stub.mbt' ///| pub fn multiple_results() -> (Byte, UInt) { diff --git a/tests/runtime/results/leaf.mbt b/tests/runtime/results/leaf.mbt index 40fc74fd1..860bba28f 100644 --- a/tests/runtime/results/leaf.mbt +++ b/tests/runtime/results/leaf.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/test_/results/test/stub.mbt' +//@ path = 'gen/interface/test/results/test/stub.mbt' ///| pub fn string_error(_a : Float) -> Result[Float, String] { diff --git a/tests/runtime/simple-future/test.mbt b/tests/runtime/simple-future/test.mbt index f4a0e49f0..d4fc4b7a0 100644 --- a/tests/runtime/simple-future/test.mbt +++ b/tests/runtime/simple-future/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/my/test_/i/stub.mbt' +//@ path = 'gen/interface/my/test/i/stub.mbt' ///| pub async fn read_future( diff --git a/tests/runtime/simple-stream-payload/test.mbt b/tests/runtime/simple-stream-payload/test.mbt index 31eb79be8..ef38dd6a6 100644 --- a/tests/runtime/simple-stream-payload/test.mbt +++ b/tests/runtime/simple-stream-payload/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/my/test_/i/stub.mbt' +//@ path = 'gen/interface/my/test/i/stub.mbt' pub async fn read_stream(x : @ffi.StreamReader[Byte]) -> Unit raise { let task = @ffi.current_task() diff --git a/tests/runtime/simple-stream/test.mbt b/tests/runtime/simple-stream/test.mbt index c227a69c4..c2f38c7a6 100644 --- a/tests/runtime/simple-stream/test.mbt +++ b/tests/runtime/simple-stream/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/my/test_/i/stub.mbt' +//@ path = 'gen/interface/my/test/i/stub.mbt' pub async fn read_stream(x : @ffi.StreamReader[Unit]) -> Unit noraise { let task = @ffi.current_task() @@ -12,4 +12,3 @@ pub async fn read_stream(x : @ffi.StreamReader[Unit]) -> Unit noraise { let _ = x.read(buffer, 2) catch { _ => raise @ffi.Cancelled::Cancelled } }) } - diff --git a/tests/runtime/strings/test.mbt b/tests/runtime/strings/test.mbt index 66095b056..e5956de65 100644 --- a/tests/runtime/strings/test.mbt +++ b/tests/runtime/strings/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/test_/strings/toTest/stub.mbt' +//@ path = 'gen/interface/test/strings/to-test/stub.mbt' ///| pub fn take_basic(s : String) -> Unit { diff --git a/tests/runtime/variants/test.mbt b/tests/runtime/variants/test.mbt index 88ab721a4..ecb4a9b98 100644 --- a/tests/runtime/variants/test.mbt +++ b/tests/runtime/variants/test.mbt @@ -1,5 +1,5 @@ //@ [lang] -//@ path = 'gen/interface/test_/variants/toTest/stub.mbt' +//@ path = 'gen/interface/test/variants/to-test/stub.mbt' ///| pub fn roundtrip_option(a : Float?) -> Byte? { From 3869c6fb2bddca139bdcb652e80fa5aa3ca9033e Mon Sep 17 00:00:00 2001 From: zihang Date: Fri, 17 Jul 2026 16:03:50 +0800 Subject: [PATCH 03/10] feat(moonbit): implement component async bindings --- crates/moonbit/CONTEXT.md | 34 + .../adr/0001-async-ffi-boundary-conversion.md | 141 + .../docs/adr/0002-local-future-promise.md | 43 + crates/moonbit/docs/async-design.md | 367 +++ crates/moonbit/docs/async-glossary.md | 488 +++ crates/moonbit/src/async/async_abi.mbt | 69 +- crates/moonbit/src/async/async_primitive.mbt | 6 +- crates/moonbit/src/async/cond_var.mbt | 72 + crates/moonbit/src/async/coroutine.mbt | 85 +- crates/moonbit/src/async/ev.mbt | 707 +++- crates/moonbit/src/async/moon.pkg.json | 10 +- crates/moonbit/src/async/mutex.mbt | 44 + crates/moonbit/src/async/promise.mbt | 200 ++ crates/moonbit/src/async/scheduler.mbt | 62 +- crates/moonbit/src/async/semaphore.mbt | 94 + crates/moonbit/src/async/task.mbt | 14 +- crates/moonbit/src/async/task_group.mbt | 114 +- crates/moonbit/src/async/trait.mbt | 914 +++++- crates/moonbit/src/async_support.rs | 2855 ++++++++++++++--- crates/moonbit/src/ffi/async_primitive.mbt | 2 +- crates/moonbit/src/ffi/future.mbt | 540 ---- crates/moonbit/src/lib.rs | 1602 ++++++--- crates/moonbit/src/pkg.rs | 10 +- crates/test/src/moonbit.rs | 8 +- tests/runtime/cancel-import/test.mbt | 19 + tests/runtime/future-cancel-read/test.mbt | 40 +- tests/runtime/future-cancel-write/test.mbt | 14 +- .../future-close-after-coming-back/test.mbt | 2 +- .../moonbit/async-import-cancel/gate.mbt | 28 + .../moonbit/async-import-cancel/runner.mbt | 313 ++ .../moonbit/async-import-cancel/test.rs | 181 ++ .../moonbit/async-import-cancel/test.wit | 66 + .../concurrent-export-isolation/runner.mbt | 133 + .../concurrent-export-isolation/test.mbt | 127 + .../concurrent-export-isolation/test.wit | 25 + .../deps/wasi-cli-0.3.0/package.wit | 27 + .../deps/wasi-clocks-0.3.0/package.wit | 42 + .../deps/wasi-http-0.3.0/package.wit | 509 +++ .../deps/wasi-random-0.3.0/package.wit | 17 + .../http-background-hook/runner-invalid.mbt | 51 + .../moonbit/http-background-hook/runner.mbt | 49 + .../moonbit/http-background-hook/test.mbt | 82 + .../moonbit/http-background-hook/test.wit | 12 + .../moonbit/http-background-hook/wkg.lock | 12 + .../http-body-trailers/deps/clocks.wit | 7 + .../moonbit/http-body-trailers/deps/http.wit | 460 +++ .../moonbit/http-body-trailers/runner.mbt | 47 + .../moonbit/http-body-trailers/test.mbt | 66 + .../moonbit/http-body-trailers/test.wit | 14 + .../moonbit/local-async-primitives/runner.mbt | 9 + .../moonbit/local-async-primitives/test.mbt | 311 ++ .../moonbit/local-async-primitives/test.wit | 15 + .../moonbit/nested-future-stream/runner.rs | 144 + .../moonbit/nested-future-stream/test.mbt | 149 + .../moonbit/nested-future-stream/test.wit | 30 + .../moonbit/resource-payloads/leaf.mbt | 213 ++ .../runtime/moonbit/resource-payloads/leaf.rs | 144 + .../moonbit/resource-payloads/runner.mbt | 321 ++ .../moonbit/resource-payloads/test.mbt | 714 +++++ .../moonbit/resource-payloads/test.wit | 75 + .../stream-write-cancel/holder-service.rs | 90 + .../moonbit/stream-write-cancel/runner.rs | 43 + .../moonbit/stream-write-cancel/test.mbt | 96 + .../moonbit/stream-write-cancel/test.wit | 44 + .../moonbit/wasi-cli-p3-stdout/deps/cli.wit | 26 + .../moonbit/wasi-cli-p3-stdout/runner.mbt | 9 + .../moonbit/wasi-cli-p3-stdout/test.mbt | 28 + .../moonbit/wasi-cli-p3-stdout/test.wit | 17 + tests/runtime/simple-future/test.mbt | 19 +- .../simple-import-params-results/test.mbt | 24 +- tests/runtime/simple-stream-payload/test.mbt | 36 +- tests/runtime/simple-stream/test.mbt | 17 +- 72 files changed, 11688 insertions(+), 1710 deletions(-) create mode 100644 crates/moonbit/CONTEXT.md create mode 100644 crates/moonbit/docs/adr/0001-async-ffi-boundary-conversion.md create mode 100644 crates/moonbit/docs/adr/0002-local-future-promise.md create mode 100644 crates/moonbit/docs/async-design.md create mode 100644 crates/moonbit/docs/async-glossary.md create mode 100644 crates/moonbit/src/async/cond_var.mbt create mode 100644 crates/moonbit/src/async/mutex.mbt create mode 100644 crates/moonbit/src/async/promise.mbt create mode 100644 crates/moonbit/src/async/semaphore.mbt delete mode 100644 crates/moonbit/src/ffi/future.mbt create mode 100644 tests/runtime/cancel-import/test.mbt create mode 100644 tests/runtime/moonbit/async-import-cancel/gate.mbt create mode 100644 tests/runtime/moonbit/async-import-cancel/runner.mbt create mode 100644 tests/runtime/moonbit/async-import-cancel/test.rs create mode 100644 tests/runtime/moonbit/async-import-cancel/test.wit create mode 100644 tests/runtime/moonbit/concurrent-export-isolation/runner.mbt create mode 100644 tests/runtime/moonbit/concurrent-export-isolation/test.mbt create mode 100644 tests/runtime/moonbit/concurrent-export-isolation/test.wit create mode 100644 tests/runtime/moonbit/http-background-hook/deps/wasi-cli-0.3.0/package.wit create mode 100644 tests/runtime/moonbit/http-background-hook/deps/wasi-clocks-0.3.0/package.wit create mode 100644 tests/runtime/moonbit/http-background-hook/deps/wasi-http-0.3.0/package.wit create mode 100644 tests/runtime/moonbit/http-background-hook/deps/wasi-random-0.3.0/package.wit create mode 100644 tests/runtime/moonbit/http-background-hook/runner-invalid.mbt create mode 100644 tests/runtime/moonbit/http-background-hook/runner.mbt create mode 100644 tests/runtime/moonbit/http-background-hook/test.mbt create mode 100644 tests/runtime/moonbit/http-background-hook/test.wit create mode 100644 tests/runtime/moonbit/http-background-hook/wkg.lock create mode 100644 tests/runtime/moonbit/http-body-trailers/deps/clocks.wit create mode 100644 tests/runtime/moonbit/http-body-trailers/deps/http.wit create mode 100644 tests/runtime/moonbit/http-body-trailers/runner.mbt create mode 100644 tests/runtime/moonbit/http-body-trailers/test.mbt create mode 100644 tests/runtime/moonbit/http-body-trailers/test.wit create mode 100644 tests/runtime/moonbit/local-async-primitives/runner.mbt create mode 100644 tests/runtime/moonbit/local-async-primitives/test.mbt create mode 100644 tests/runtime/moonbit/local-async-primitives/test.wit create mode 100644 tests/runtime/moonbit/nested-future-stream/runner.rs create mode 100644 tests/runtime/moonbit/nested-future-stream/test.mbt create mode 100644 tests/runtime/moonbit/nested-future-stream/test.wit create mode 100644 tests/runtime/moonbit/resource-payloads/leaf.mbt create mode 100644 tests/runtime/moonbit/resource-payloads/leaf.rs create mode 100644 tests/runtime/moonbit/resource-payloads/runner.mbt create mode 100644 tests/runtime/moonbit/resource-payloads/test.mbt create mode 100644 tests/runtime/moonbit/resource-payloads/test.wit create mode 100644 tests/runtime/moonbit/stream-write-cancel/holder-service.rs create mode 100644 tests/runtime/moonbit/stream-write-cancel/runner.rs create mode 100644 tests/runtime/moonbit/stream-write-cancel/test.mbt create mode 100644 tests/runtime/moonbit/stream-write-cancel/test.wit create mode 100644 tests/runtime/moonbit/wasi-cli-p3-stdout/deps/cli.wit create mode 100644 tests/runtime/moonbit/wasi-cli-p3-stdout/runner.mbt create mode 100644 tests/runtime/moonbit/wasi-cli-p3-stdout/test.mbt create mode 100644 tests/runtime/moonbit/wasi-cli-p3-stdout/test.wit diff --git a/crates/moonbit/CONTEXT.md b/crates/moonbit/CONTEXT.md new file mode 100644 index 000000000..8add75ee5 --- /dev/null +++ b/crates/moonbit/CONTEXT.md @@ -0,0 +1,34 @@ +# MoonBit Bindings Context + +This context records shared language for the MoonBit binding generator. Async +future and stream vocabulary is split into [Async Glossary](docs/async-glossary.md). + +## Language + +**MoonBit binding**: +Generated MoonBit source that exposes WIT imports to MoonBit code or adapts +MoonBit exports to the component ABI. + +**Component adapter path**: +The current implementation path where MoonBit emits core wasm and `wasm-tools` +converts it into a component using adapter imports and exports. +_Avoid_: direct component generation + +## Async Design + +- [Async design contract](docs/async-design.md) +- [Async glossary](docs/async-glossary.md) +- [ADR 0001: FFI-boundary conversion](docs/adr/0001-async-ffi-boundary-conversion.md) +- [ADR 0002: local Future/Promise pair](docs/adr/0002-local-future-promise.md) + +The implementation targets the official upstream generator architecture. WIT +`future` and `stream` remain distinct from local MoonBit `Future` and `Stream`; +generated code converts them only at concrete FFI positions whose intrinsic +names are supplied by `wit-parser`. Local `Future::new()` returns a +MoonBit-only Future/Promise pair, and local `Semaphore` coordinates coroutines; +neither can select or own a component endpoint from `T`. Async support is always +available in the MoonBit generator, but endpoint-free synchronous worlds do not +emit its runtime or wrappers. Component endpoint wrappers are +generated-code-only, enforce a single in-flight operation, and retain operation +buffers until cancellation or completion is observed. Each top-level component +task has its own waitable set and scheduler state. diff --git a/crates/moonbit/docs/adr/0001-async-ffi-boundary-conversion.md b/crates/moonbit/docs/adr/0001-async-ffi-boundary-conversion.md new file mode 100644 index 000000000..1cc18ec12 --- /dev/null +++ b/crates/moonbit/docs/adr/0001-async-ffi-boundary-conversion.md @@ -0,0 +1,141 @@ +# Keep MoonBit async values detached from component futures and streams + +Status: accepted; implemented in the current branch + +MoonBit `Future[T]` handles and local `Stream[T]` values are over arbitrary +MoonBit types, while component `future` and `stream` are ABI values over +WIT-representable payloads. MoonBit async bindings will keep those concepts +separate: generated FFI-boundary code converts between local async values and +component endpoints only at concrete WIT positions where the endpoint operations +and payload lift/lower code are known. + +For MVP, component `future` maps to a one-shot `Future[T]` handle. The handle +represents a ready value or an owned local source computation with +value-discarding drop cleanup. For an incoming component future, generated code +supplies a source closure that captures the raw readable handle and directly +binds the concrete WIT position's read/cancel/drop intrinsics. The generic local +type has no CM-specific variant or operation table. A public local `Promise[T]` +may settle the same Future state, but it is not a component writable endpoint. + +## Consequences + +- `Stream::new()` creates local MoonBit values only. +- `Future::new()` creates a local `Future[T]` / `Promise[T]` pair only. It does + not select or invoke a component `future.new` intrinsic. +- Endpoint operation tables are not part of the target design. Generated + position-specific helpers call canonical intrinsics directly and keep raw + handle state inside generated source closures or producer tasks. +- Component intrinsic module and field names are generated through + `wit-parser`'s `WasmImport::{FutureIntrinsic, StreamIntrinsic}` API. The + MoonBit generator does not reproduce position indices, export prefixes, + unit-payload names, or async-lower prefixes by string formatting. +- The Rust generator boundary owns a recursive conversion plan for each + function. Ordinary lift/lower code requests position-specific helpers from + that plan instead of naming runtime endpoint types or type-shaped tables. +- The existing async generator boundary is retained. The static recursive + rewrite happens behind it without changing ordinary lift/lower ownership or + the endpoint-free sync path. +- User-facing bindings expose local `Future[T]` handles and local `Stream[T]` + for ordinary async composition. +- A nested WIT shape such as `future>>` maps to + `Future[Future[Stream[T]]]`. Only the current layer's readable handle appears + at each canonical payload stage. Generated recursive lift/lower functions bind + each layer to its own function-position intrinsic and apply generated + commit/reject dispositions at every transfer boundary. +- Recursive lower retains its canonical buffer and prepared producer state until + the transfer reports a disposition. Commit starts producer work only after + ownership transfers. Stream progress commits the accepted prefix and retries + the same lowered suffix; an abandoned suffix is rejected exactly once. +- A freshly-created component future is not fully rollbackable. Canonical ABI + permits its writable end to be dropped only after a write succeeds or a write + reports that the reader was dropped. If an outer transfer rejects a nested + future readable, generated code drops that readable but must still drive the + paired writer with the local future value until its write observes `dropped`. + A cancelled write is not settlement and is retried while the component task + remains alive. +- Stream batches whose element type recursively contains a future use a + one-element staging window for MVP. This bounds settlement obligations created + before downstream acceptance without changing the public stream API. +- Generated stream producers remain prepared when their component pair is + created. Commit starts normal pumping. Parent rejection drops both + untransferred component ends and performs state-aware local rejection: incoming + component sources close immediately, buffered values use configured cleanup or + generated cleanup as fallback, and an unstarted local producer is discarded + without executing user code. `Stream::produce` accepts an optional + `on_unstarted_drop` callback for resources captured by that branch and a + separate per-element cleanup for values written after it starts. +- Canonical `backpressure.inc/dec` controls admission of new async component + tasks. It is not tied to future/stream bridge lifetime and is not called + implicitly by the MoonBit runtime. Endpoint read/write suspension provides + data-flow backpressure independently. +- Async import argument settlement distinguishes `cancelled-before-started` + from every state in which the callee may have started. The former recursively + rejects owned resources and endpoints; the latter only reclaims guest-owned + canonical list allocations. +- Local future MVP has consuming `Future::get()` and value-discarding async + `Future::drop()`. Strong cancellation belongs to `Task` and `TaskGroup`, not + to a future-specific result type. `Future::drop()` is explicit cleanup, not a + direct alias for component `future.drop-*`, and futures that may discard + completed WIT payloads carry generated payload cleanup logic. +- Outgoing component `future` producer tasks must settle their raw writable + handle by writing a real value or by attempting the write and observing reader + drop. The MVP does not fabricate default values to satisfy unwritten futures. + If user code produces a ready `T`, generated code may write immediately; if + user code returns a pending `Future[T]`, the bridge exists before the CM + `future.write` operation starts because the component boundary already needs a + readable end to return. +- After an outgoing component `future` readable end is committed, or after a + parent transfer rejects and locally drops it, the bridge shields producer work + from ordinary task/subtask cancellation. Component-task cancellation is + cooperative: it may resolve the cancelled call with `task.cancel`, but it does + not forcibly destroy shielded settlement work. Only instance teardown or a + trap can abandon the writer without settlement. Peer reader drop is + loss-of-interest, not task cancellation; before `future.write` has started, + the bridge does nothing special for it. If a later write reports `dropped`, + the bridge cleans the value and settles the writer. +- If that local future never produces `T`, the Component Model provides no + generic close-without-value operation. This is an explicit liveness limit; the + binding does not fabricate a default value or pretend an idle writer can be + dropped safely. The same limit applies when the paired readable was created + for a nested payload but the outer transfer rejected it before ownership + crossed the boundary. +- Local stream MVP has `Sink::close()` for graceful producer close and async + `Stream::drop()` or `Stream[T]` drop for consumer loss-of-interest. It does + not expose public `Sink::cancel()` because component `stream` has no distinct + generic producer-failure signal to preserve across the boundary. +- Local stream state keeps producer close separate from reader drop. Producer + close preserves buffered `FixedArray[T]` chunks for draining; reader drop + discards unread chunks and uses an explicit `Stream::new_with_cleanup` or + `Stream::produce(cleanup=...)` callback for payloads that need resource + cleanup. +- Local stream capacity is measured in elements. Zero is strict rendezvous and + a positive value is a hard bound on accepted unread values; local streams are + never implicitly unbounded. Waiting readers and writers use direct FIFO + handoff with completion-versus-cancellation race handling. +- An incoming component stream uses a generated demand-driven source whose + reads directly call its concrete site intrinsics. It does not start an eager + pump into a local stream pipe. +- Forwarding component endpoints without reading them is not the default path and + needs an explicit advanced API if we decide to support it. +- Runtime validation includes real `wasi:cli@0.3.0` stream output and a real + `wasi:http@0.3.0` handler response whose body stream, trailers future, and + transmission completion continue after the handler returns the response. +- Local stream validation covers strict rendezvous, bounded buffering, cancelled + waiter removal, completion-versus-cancellation ownership races, local + producer reads, and unread resource cleanup. +- Async export stubs intentionally expose + `background_group : @async-core.TaskGroup[Unit]`. It adapts the mismatch + between MoonBit structured completion and component task return. Generated code + publishes component task return when the user function produces its result, + then keeps the underlying MoonBit task group alive for hook-style post-return + work. That work remains structurally owned and bounded by the component task + or instance lifetime, but cannot change the already-published export result. +- Lowering local `Future[T]` and `Stream[T]` values requires an active component + async task scope, including recursive occurrences inside structured payloads. + A sync WIT import called in that scope prepares its endpoint arguments and + commits those same handles immediately after the core call returns. Sync export + results, and sync imports called without an active scope, remain unsupported. + Incoming lift remains lazy and does not require a producer task until user code + later reads in an async scope. Scope-free sync lowering requires cooperative + component-thread support and must not be faked by using the stackless callback + ABI for a non-async WIT function. diff --git a/crates/moonbit/docs/adr/0002-local-future-promise.md b/crates/moonbit/docs/adr/0002-local-future-promise.md new file mode 100644 index 000000000..96d847644 --- /dev/null +++ b/crates/moonbit/docs/adr/0002-local-future-promise.md @@ -0,0 +1,43 @@ +# Add a local Future/Promise pair + +Status: accepted; implemented in the current branch + +MoonBit needs a producer-facing one-shot primitive for local control-flow cycles +that cannot be expressed as `Future::from(async () -> T)`. The motivating case +is `wasi:http@0.3.0` request handling: `consume-body` takes a Future reporting +processing completion before it returns the request body whose later processing +determines that completion. + +`Future::new()` returns `(Future[T], Promise[T])` and +`Future::new_with_cleanup(cleanup)` adds explicit value-discard cleanup. The +Promise can complete with a value, fail with a MoonBit error, or close without a +value. It is local coordination state only. It does not create, wrap, or own a +component `future` endpoint, and it works for arbitrary MoonBit `T`. + +## Consequences + +- `Promise::complete(value)` returns `true` only when the reader accepts + ownership. If the Future was already dropped, it returns `false` and the + caller retains `value`. +- `Future::drop()` after accepted completion runs the cleanup supplied to + `new_with_cleanup`. The plain `new()` constructor is appropriate only when + discarded `T` needs no explicit cleanup. +- `Promise::fail(error)` makes local `Future::get()` raise that error. + `Promise::close()` makes it raise `PromiseClosed`. +- Settlement is one-shot. Repeating `complete`, `fail`, or `close` after a + successful settlement is a programmer error. +- Dropping or otherwise abandoning a still-pending Promise does not implicitly + close its Future because MoonBit has no generic deterministic destructor. The + producer must explicitly complete, fail, or close it. +- Task cancellation that reaches a waiting reader before settlement drops the + reader, so later completion returns `false`. Once completion assigns the value + and wakes the reader, completion wins a simultaneous cancellation race and the + reader receives the value. +- Explicit `Future::drop()` follows the same race rule while `get()` is pending: + dropping before settlement wakes the reader with `Cancelled`, while an + already-assigned value or error remains owned by the waiting reader. +- A local failure or close cannot settle an already-exposed component future + without a value. If such an outcome is expected across WIT, it belongs in the + payload type, for example `Future[Result[V, E]]`. +- Generated FFI-boundary code remains solely responsible for creating concrete + component future pairs and satisfying their writable-end settlement rules. diff --git a/crates/moonbit/docs/async-design.md b/crates/moonbit/docs/async-design.md new file mode 100644 index 000000000..589a10515 --- /dev/null +++ b/crates/moonbit/docs/async-design.md @@ -0,0 +1,367 @@ +# MoonBit Component Async Design + +Status: MVP implemented on the upstream generator architecture. + +This document is the design contract for MoonBit component-model `async`, +`future`, and `stream` bindings. It intentionally omits implementation +history. Terms are defined in the [async glossary](async-glossary.md). The two +main decisions and their detailed consequences are recorded separately: + +- [ADR 0001: keep local async values detached from component endpoints](adr/0001-async-ffi-boundary-conversion.md) +- [ADR 0002: add a local Future/Promise pair](adr/0002-local-future-promise.md) + +## Goals + +- Give MoonBit code small, local async types that work for arbitrary MoonBit + payloads. +- Convert those values to component endpoints only in generated code for a + concrete WIT function position. +- Preserve canonical ABI ownership through completion, partial progress, + cancellation, peer drop, and recursive endpoint payloads. +- Keep ordinary synchronous binding generation independent of async support. +- Align coroutine and structured-concurrency behavior with + `moonbitlang/async` where the component protocol does not require a + difference. + +The MVP does not include component `error-context`, public BYOB/`read_into`, an +advanced raw-endpoint forwarding interface, or scope-free production of +component futures and streams from synchronous WIT calls. + +## Constraints + +1. MoonBit emits core wasm. `wasm-tools` adapts it into a component. +2. Future and stream intrinsics are identified by the containing WIT function + and canonical endpoint index. A payload type `T` cannot select an intrinsic. + Every intrinsic name must therefore come from `wit-parser`. +3. A component `future` or `stream` value is the readable end. Only + generated `future.new` or `stream.new` calls create its paired writable end. +4. Canonical read and write operations borrow ABI memory until an immediate or + terminal waitable result. Stream operations may transfer only a prefix. +5. MoonBit cannot enforce linear endpoint ownership or borrowed-view lifetimes. + The interface must keep invalid states private and make resource cleanup + explicit where static enforcement is unavailable. +6. A component future has no generic successful close-without-value operation. + Once a writer is exposed, it must write a real value or attempt that write + and observe reader drop before the writable end can be dropped. +7. Stackless callback exports can resume suspended work only inside an active + component async task scope. + +## Architecture + +The design has three layers. + +### Local runtime + +The generated `@async-core` package owns source-language concepts: + +- `Future[T]`, `Promise[T]`, `Stream[T]`, and `Sink[T]`; +- `Task[T]`, `TaskGroup[T]`, cancellation shielding, and coroutine scheduling; +- `Semaphore`, `Mutex`, and `CondVar`; +- payload-independent waitable event decoding. + +It does not contain generic component endpoint types, endpoint vtables, or a +generic `future.new`/`stream.new` operation. + +### Recursive generator plan + +For each WIT function, the Rust generator builds a recursive endpoint plan from +the canonical type shape and `wit-parser`'s ordered future/stream occurrences. +Each plan node records: + +- whether it is a future or stream; +- its position-specific intrinsic names from `wit-parser`; +- child endpoint occurrences in its payload; +- payload lift, lower, commit, reject, and cleanup behavior. + +This is generator data, not a runtime table. + +### Generated site helpers + +Generated `ffi.mbt` code owns raw readable and writable handles, canonical ABI +buffers, and copy-operation state. A helper directly calls the intrinsics for +one plan node and converts between that endpoint and a local value. + +The deletion boundary remains important: endpoint-free synchronous worlds do +not emit the async runtime or wrappers, and their generated output remains +unchanged as async support evolves. + +## Public Interface + +The main local interface is: + +```mbt +async fn Future::get(self : Future[T]) -> T +async fn Future::drop(self : Future[T]) -> Unit +fn Future::ready(value : T) -> Future[T] +fn Future::ready_with_cleanup(value : T, cleanup : (T) -> Unit) -> Future[T] +fn Future::from( + producer : async () -> T, + on_unstarted_drop? : () -> Unit, +) -> Future[T] + +fn Future::new() -> (Future[T], Promise[T]) +fn Future::new_with_cleanup( + cleanup : (T) -> Unit, +) -> (Future[T], Promise[T]) +fn Promise::complete(self : Promise[T], value : T) -> Bool +fn Promise::fail(self : Promise[T], error : Error) -> Bool +fn Promise::close(self : Promise[T]) -> Bool + +fn Stream::new(capacity? : Int = 0) -> (Stream[T], Sink[T]) +fn Stream::new_with_cleanup( + cleanup : (T) -> Unit, + capacity? : Int = 0, +) -> (Stream[T], Sink[T]) +fn Stream::produce( + producer : async (Sink[T]) -> Unit, + cleanup? : (T) -> Unit, + on_unstarted_drop? : () -> Unit, +) -> Stream[T] +async fn Stream::read(self : Stream[T], max : Int) -> FixedArray[T]? +async fn Stream::drop(self : Stream[T]) -> Unit + +async fn Sink::write(self : Sink[T], values : ArrayView[T]) -> Int +async fn Sink::write_all(self : Sink[T], values : ArrayView[T]) -> Bool +fn Sink::is_open(self : Sink[T]) -> Bool +async fn Sink::close(self : Sink[T]) -> Unit +``` + +`Sink[Byte]` additionally provides `write_bytes(BytesView)` and +`write_all_bytes(BytesView)` so immutable `Bytes` do not require an `Array` +conversion. + +`Future[T]` is one-shot. `Promise[T]` is local coordination, never a component +writable endpoint. Expected cross-component failure belongs in `T`, for example +`Future[Result[V, E]]`; local `Promise::fail` and `Promise::close` cannot settle +an exposed component future without a value. + +Streams support both directions without exposing endpoint machinery: + +- consumers pull owned `FixedArray[T]` chunks from `Stream[T]`; +- producers push immutable `ArrayView[T]` values into `Sink[T]`; +- the runtime copies a bounded staging window before suspension; +- capacity is measured in elements, with zero meaning strict rendezvous; +- `write` returns the consumed prefix length. When cleanup is configured, + consumption includes values accepted by the peer and staged values recursively + cleaned after peer drop; +- `is_open` distinguishes a fully accepted write from one that consumed values + while closing, and `write_all` returns whether the endpoint remained open + through the operation; +- `Sink::close` is graceful producer completion and preserves buffered values; +- `Stream::drop` is consumer loss-of-interest and cleans unread owned values. + +There is no public `Sink::cancel`. Component streams have no distinct generic +producer-failure signal to preserve. Such failure belongs in the payload or +surrounding WIT protocol. + +Async export implementations also receive: + +```mbt +background_group : @async-core.TaskGroup[Unit] +``` + +The generated adapter publishes component task return when the implementation +returns its result, then allows this group to finish hook-style work. The work +remains structurally owned in MoonBit but cannot alter the published result. + +## Boundary Conversion + +The same four conversions cover import parameters/results and export +parameters/results: + +| Component value | Local value | Generated owner | +| --- | --- | --- | +| Incoming `future` readable | lazy `Future[T]` source | source closure owns readable and read state | +| Outgoing `Future[T]` | new component future pair | producer task owns writable and settlement | +| Incoming `stream` readable | demand-driven `Stream[T]` source | source closure owns readable and read state | +| Outgoing `Stream[T]` | new component stream pair | producer task owns writable and staged writes | + +Incoming conversion is lazy and creates no endpoint pair. `Future::get` or +`Stream::read` calls the concrete site's read intrinsic when the user requests +data. Dropping the local value calls the same site's cancel/drop path if needed. + +Outgoing conversion first prepares a pair. The readable end may be nested in a +larger canonical payload, so producer work starts only after that payload's +ownership disposition is known: + +- commit means the peer owns the readable end and starts normal production; +- reject cleans an untransferred stream pair and local stream state; +- rejecting a future drops its readable end but still drives the paired writer + until a write observes that reader drop. + +### Recursive endpoints + +A WIT value such as: + +```wit +future>> +``` + +maps directly to: + +```mbt +Future[Future[Stream[T]]] +``` + +The local types contain no CM index. The recursive generator plan binds each +layer to its own site. Reading the outer future reveals the next readable +handle; reading that future reveals the stream handle. Conversion is staged, +not flattened at the original function boundary. + +Every lowered nested payload has explicit commit and reject actions. For a +partial `stream>` write, the accepted prefix belongs to the peer; a +staged rejected suffix is settled by generated cleanup, while an unstaged tail +is cleaned by `Sink`. All three are included in the consumed prefix returned to +the caller, which must not retry any of them. The MVP stages one such element at +a time so backpressure cannot create an unbounded number of unsettled future +writers. + +## Runtime Invariants + +### Ownership + +- Every raw endpoint has exactly one owner: generated source state, generated + producer state, an in-flight canonical transfer, or the peer. +- A copy-operation buffer remains live until an immediate or terminal result. + Calling `cancel-*` alone does not return the buffer. +- A completed stream copy transfers exactly its canonical reported prefix. Any + staged suffix is rejected and recursively cleaned exactly once; `Sink::write` + reports both portions as consumed so aliases cannot retry rejected values. +- Generated cleanup recursively follows the active record, tuple, variant, + list, resource, future, and stream shape. +- Rejecting a materialized local stream installs generated payload cleanup on + its pipe before waking writers, so buffered values, pending writer values, + and writes attempted after rejection are each cleaned exactly once. +- `Future::new_with_cleanup`, `Stream::new_with_cleanup`, and + `Stream::produce(cleanup=...)` clean accepted local values discarded before + consumption. Variants without element cleanup are appropriate only when + discarded `T` needs no explicit cleanup. +- Lazy `Future::from` and `Stream::produce` may carry `on_unstarted_drop` for + captured resources. `Stream::produce` separately accepts per-element cleanup + for values written after production starts. + +Ownership through `ArrayView[T]` is a best-effort contract because MoonBit +cannot prevent aliases. Callers must treat the consumed prefix as moved, whether +the peer accepted it or generated cleanup rejected it. `write_all` additionally +assumes disposal responsibility for the unstaged tail and cleans it before +returning `false` or propagating an error. + +### Cancellation + +Three events must remain distinct: + +- MoonBit task cancellation cooperatively stops local work; +- endpoint copy cancellation recovers an in-flight operation buffer; +- peer drop reports loss-of-interest through a read or write result. + +An incoming read cancelled by MoonBit first issues the concrete endpoint +cancel operation, waits for its terminal event, reclaims the buffer, and only +then drops the readable end. `CondVar` provides direct cleanup notification; +the runtime must not poll by repeatedly yielding. + +The generated source records the component task that registered an in-flight +read. If another export drops that source, cancellation is routed to the +original task's waitable set before the reader is woken. + +An outgoing stream serializes writes and close with `Mutex`, because the +canonical ABI permits only one active operation on an endpoint. MoonBit task +cancellation issues the concrete `stream.cancel-write`, waits for buffer +ownership to return, commits any transferred prefix, rejects the staged suffix, +and then drops the writable end. Unlike a future, a stream can terminate without +inventing a payload value. + +An exposed outgoing future writer is a settlement obligation. Its producer is +shielded from ordinary task/subtask cancellation until it writes one real value +or a write reports reader drop. If its local future never produces `T`, no +generic cleanup can settle it. The writer may remain pending until instance +teardown; the binding does not fabricate a default value. + +### Scheduling + +- Each top-level component task owns a distinct waitable set and scheduler + queue, preventing concurrent exports from consuming each other's events. +- A scheduling round runs only work ready at its start, preserving an event-loop + poll opportunity between rounds. +- Repeated wakes are deduplicated. +- A task cancelled before first execution still enters its body so entry cleanup + can be installed. +- `TaskGroup` failure propagation and cancellation shielding follow + `moonbitlang/async` semantics. +- Canonical `backpressure.inc/dec` controls admission of component tasks. It is + not tied to endpoint producer lifetime; read/write suspension already provides + data-flow backpressure. + +## Synchronous WIT Functions + +WIT sync functions remain sync. The generator does not silently give them a +stackless async callback ABI. + +Incoming endpoint lift is lazy, so a sync function can receive a readable end +without immediately starting a copy. Reading it later still requires an async +task scope. + +Lowering a local `Future` or `Stream` requires an active component async task +scope. A sync import called from such a scope is supported: generated code +prepares endpoint arguments, performs the core call, and commits those handles +immediately afterward. Scope-free sync imports and sync export results that +create producer work are unsupported. Supporting them requires cooperative +component threads, not a callback-ABI workaround. + +Raw endpoint identity forwarding is also not implicit. A future advanced +interface may provide it explicitly without weakening the ordinary local types. + +## `moonbitlang/async` Alignment + +The runtime is audited against `moonbitlang/async` main at commit `18533c8d`. +The continuation primitive, cancellation races, shielding, wake behavior, +fairness, `Task`, `TaskGroup`, `Semaphore`, `Mutex`, and `CondVar` semantics are +kept aligned. + +Intentional differences are limited to the component environment: + +- component waitable sets replace the platform event loop; +- scheduler state is partitioned by waitable set; +- generated bridge work is owned by the component task's waitable set, not a + user `TaskGroup`, so lazy producers can continue after the export returns; +- public timers, retry, async queues, `spawn_loop`, and `pause` are omitted; +- local Future/Promise and Stream/Sink encode WIT ownership needs not provided + by `moonbitlang/async`. + +## Supported MVP + +Implemented and covered by composed runtime tests: + +- async imports and exports; +- local Future/Promise and Stream/Sink coordination; +- incoming and outgoing component futures and streams; +- nested endpoint payloads in both directions; +- partial stream progress, concurrent writes, cancellation races, and resource + payload cleanup; +- concurrent component-task isolation; +- `wasi:cli@0.3.0` stream output; +- `wasi:http@0.3.0` body, trailers, and post-response background work; +- deletion guards proving endpoint-free sync generation is unchanged without + async support. + +Known limits: + +- component `error-context` is out of scope; +- public BYOB/`read_into` is deferred; +- scope-free sync lowering is unsupported; +- fixed-length WIT lists use runtime-checked `FixedArray[T]`; combining one + with a future or stream in either nesting direction is not yet supported; +- a future producer that never produces a value cannot settle its component + writer generically; +- explicitly captured resources require `on_unstarted_drop` before lazy + production; lazy streams of managed values also require per-element cleanup + after production starts; +- generated glue and user implementations still share a MoonBit package, so + `#internal` is documentation control rather than an access boundary. + +## Deferred Decisions + +1. Whether named WIT future/stream aliases should become distinct MoonBit names. +2. Whether to expose a site-aware endpoint-forwarding interface. +3. Whether `Sink::close` needs a separate downstream `finish`/`flush` operation. +4. Whether measured workloads justify BYOB or larger nested-future stream + staging windows. diff --git a/crates/moonbit/docs/async-glossary.md b/crates/moonbit/docs/async-glossary.md new file mode 100644 index 000000000..3807c883f --- /dev/null +++ b/crates/moonbit/docs/async-glossary.md @@ -0,0 +1,488 @@ +# MoonBit Async Glossary + +This glossary records the vocabulary used by the MoonBit async future and stream +design notes. The naming rule is: + +- lowercase code-form names, `future` and `stream`, mean component-model WIT + types and operations; +- uppercase code-form names, `Future` and `Stream`, mean MoonBit-owned local + async types; +- `CM` describes component-model concepts in prose. The target runtime does not + expose reusable generic `CMFuture*` or `CMStream*` wrapper types; raw endpoint + handles and their direct operations stay inside generated site helpers. + +## Component Model Language + +**Component `future`**: +The component-model `future` WIT type. In a WIT signature it transfers +ownership of a readable end, not a MoonBit-local computation. +_Avoid_: `Future`, local Future + +**Component `stream`**: +The component-model `stream` WIT type. In a WIT signature it transfers +ownership of a readable end, not a MoonBit-owned `Stream` buffer. +_Avoid_: `Stream`, local Stream + +**Readable end**: +The endpoint of a component `future` or component `stream` that can be read and +transferred through WIT. + +**Writable end**: +The endpoint created with `future.new` or `stream.new` that stays in the +producing component and writes values to the paired readable end. + +**Async task scope**: +The component-model task context that lets stackless MoonBit async export code +join waitables, receive callback events, and resume suspended bridge +continuations. +_Avoid_: global event loop + +**Background group**: +The `background_group : @async-core.TaskGroup[Unit]` parameter intentionally +exposed by an async MoonBit export. The generated adapter publishes component +task return when the user function produces its result, then keeps the +underlying MoonBit task group alive for this hook-style work. The work remains +structurally owned inside MoonBit and bounded by the component task or instance +lifetime, but its later outcome cannot change the already-published export +result. +_Avoid_: leaked task group, detached global task + +**Async core package**: +The generated `@async-core` MoonBit package. It exposes the local `Future[T]`, +`Promise[T]`, `Stream[T]`, `Sink[T]`, `Task[T]`, `TaskGroup[T]`, `Semaphore`, +`Mutex`, and `CondVar` API while keeping component-model endpoint handles and +scheduler machinery private. +Interface-specific `ffi.mbt` files remain generated boundary implementations +and are not this package. + +**Sync lower producer gap**: +The unsupported case where boundary code without an active component async task +scope must lower a local Future or Stream, including recursive occurrences in a +sync import parameter or sync export result. The producer must continue after +the call returns, but the stackless callback ABI cannot resume it. This requires +cooperative component-thread support. Incoming lift is not this case because it +can remain lazy until a later async read. +_Avoid_: hidden async export + +## MoonBit Binding Language + +**CM future endpoint**: +A conceptual component `future` readable or writable handle owned by generated +boundary code. Its operations are statically bound to a concrete function site, +not stored in a generic MoonBit operation table. +_Avoid_: `Future`, local Future + +**CM stream endpoint**: +A conceptual component `stream` readable or writable handle owned by generated +boundary code. Its operations are statically bound to a concrete function site, +not stored in a generic MoonBit operation table. +_Avoid_: `Stream`, local Stream + +**Endpoint site**: +One component `future` or component `stream` node in a concrete WIT function's +canonical type traversal. A site binds its payload plan and intrinsic names to +that function context. It is generator metadata, not a runtime value. +_Avoid_: payload type alone + +**Recursive boundary plan**: +The generator-owned tree that connects an endpoint site to the endpoint sites +inside its payload. It drives direct lift/lower code and recursive cleanup for +records, variants, collections, nested futures, and nested streams. +_Avoid_: endpoint vtable, flat type lookup + +**Generated readable source**: +A private source closure produced when lifting an incoming component endpoint. +It exclusively owns one raw readable handle and directly calls that site's +read, cancel-read, and drop-readable intrinsics. A future source plugs into local +`Future[T]`; a stream source implements demand-driven pulls for local +`Stream[T]`. +_Avoid_: `CMFutureReader[T]`, `CMStreamReader[T]` + +**Generated writable state**: +A private prepared producer state created when lowering a local Future or Stream. +It exclusively owns one raw writable handle and binds directly to that site's +write/drop intrinsics. Lowering does not start user computation. Commit starts a +normal producer task; rejection starts future writer settlement when required or +discards a prepared stream without transferring its readable end. A future state +carries a settlement obligation from the moment `future.new` succeeds. +_Avoid_: `CMFutureWriter[T]`, `CMStreamWriter[T]` + +**Staged nested endpoint payload**: +The rule that only the current endpoint layer's readable handle appears in its +canonical payload. For `future>>`, the function boundary carries +the outer future handle, reading it carries the inner future handle, and reading +that carries the stream handle. +_Avoid_: flattened endpoint graph + +**Transfer commit/reject**: +Generated ownership handling after lowering a payload containing resources or +nested endpoints. A successful transfer commits the transferred values to the +peer. A rejected ordinary resource is cleaned. A prepared stream drops its +untransferred component pair and rejects guest-owned local state without +pretending the peer accepted values. A rejected future drops its readable end, +but its paired writer and local source remain owned until a later write reaches +settlement. A partial stream transfer commits the accepted prefix. Generated +code retries the remainder of the current staging window while the peer remains +open; peer drop or write cancellation rejects that staged remainder exactly +once. Values beyond the staging window remain caller-owned. +_Avoid_: unconditional cleanup + +**Future writer settlement obligation**: +The non-rollbackable obligation created by `future.new`. The writable end can be +dropped only after a successful write or after a write reports that the readable +end was dropped. Dropping a still-guest-owned readable end does not settle its +paired writer; the generated producer must still obtain a real payload and +attempt the write. A `cancelled` write has returned its ABI buffer but has not +settled the writer. +_Avoid_: future rollback + +**Prepared lower payload**: +The private combination of a canonical ABI value or buffer, unstarted generated +producer state, and recursive `commit`/`reject` paths. Commit starts producer work +only after ownership transfers. The same lowered buffer is retained across +blocked or partial writes. For a staging window, the accepted prefix is +committed and the staged suffix is retried from the same buffer or rejected. +Both portions are consumed from the public `Sink` input; only the unstaged tail +remains caller-owned. It is not a user-facing MoonBit type. +_Avoid_: lowered value alone + +**Local Future**: +A MoonBit-only one-shot handle, expected to be spelled `Future[T]`, that wraps an +owned source computation plus cancellation/drop state. Its generic state has no +CM endpoint variant and does not require `T` to be representable in WIT. A +generated readable source may capture a raw handle inside its private closure, +but the local runtime cannot inspect or forward it. +_Avoid_: component `future`, future endpoint + +**One-shot async thunk**: +A MoonBit `async () -> T` value wrapped by local `Future[T]` or generated bridge +code to read or produce exactly one value. It is the computation inside the +handle, not the public component `future` representation by itself. +_Avoid_: future handle + +**Future state cell**: +The local mutable runtime state owned by a `Future[T]` handle. Depending on the +constructor, it records a ready value, an owned source, or a shared local +Future/Promise settlement cell. It does not contain CM-specific states or own +writable ends. +_Avoid_: endpoint table + +**Owned Future source**: +The generic pending source stored by local `Future[T]`. It has a one-shot async +`run` operation and may have an explicit unstarted cleanup action. Ordinary +MoonBit futures use a local thunk; generated incoming futures use a readable +source whose closure owns the endpoint. +_Avoid_: pending CM state + +**Unstarted producer cleanup**: +The optional `on_unstarted_drop : () -> Unit` action attached to a lazy local +`Future::from` or `Stream::produce`. It owns resources captured by the producer +only until the producer starts. Drop or prepared-transfer rejection invokes it +without executing the producer; get, read, or commit clears it before producer +execution. It is not a cancellation handler for an already-running task. +_Avoid_: producer finalizer, implicit cancellation + +**Bridge-owned future write**: +The separate generated task state that drives a local `Future[T]` computation +into an outgoing component `future`. This state owns the raw writable handle; +the local `Future[T]` does not. +_Avoid_: local Promise, component future writer + +**Future writer settlement obligation**: +The rule that once an outgoing component future pair has exposed its readable +end, the generated task owning the writable handle must eventually write one +real value or attempt the write and observe that the reader was dropped before +the writable end can be dropped. It cannot be satisfied by silently dropping +the writer. +_Avoid_: writer close + +**In-flight future write**: +A generated writable operation that has already called component `future.write` +and is waiting for the `FUTURE_WRITE` waitable event. If the peer +drops the readable end while this operation is in flight, the event reports +`dropped` and returns ownership of the lowered payload buffer to the writer. +_Avoid_: bridge task in flight + +**Pre-write future writer**: +A generated task that owns the writable end but has not started component +`future.write` because the payload value is not available yet. The +bridge task may be running or suspended on local work in this phase, but there +is no CM write operation waiting in a waitable set. Reader drop is observed when +the later `future.write` attempt returns or reports `dropped`. +_Avoid_: idle writer, in-flight future write + +**Bridge-shielded future task**: +An outgoing future bridge task that is protected from ordinary task +cancellation after its readable end has crossed the component boundary. Peer +loss-of-interest does not cancel it; the bridge keeps running until it satisfies +the future writer settlement obligation, unless the whole component instance is +trapping or being torn down. +_Avoid_: cancellable bridge + +**Generated future site helper**: +A generated function that directly calls `future.new` and the other future +intrinsics for one concrete WIT function site. It returns or consumes raw handles +only within generated boundary code. +_Avoid_: local `Future::new()`, generic component endpoint factory + +**Local Promise**: +A MoonBit-only producer side paired with a local `Future[T]` by +`Future::new()` or `Future::new_with_cleanup()`. `complete` transfers a value +only if the reader still exists; `fail` and `close` settle local waiters without +a value. It never creates or owns a component `future` writable end. If its +paired Future later crosses an FFI boundary, generated code creates and owns the +separate component endpoint pair. +_Avoid_: future writer + +**Local Semaphore**: +A MoonBit-only counting semaphore used for coroutine coordination. Waiters are +FIFO. If a release assigns a permit before cancellation resumes the waiter, the +permit wins that race and acquire succeeds so the permit is not lost. It is not +a component-model waitable or backpressure counter. +_Avoid_: waitable, `backpressure.inc` + +**Local Mutex**: +A MoonBit-only FIFO mutex implemented by a one-permit Local Semaphore. Its +acquire operation has the same cancellation race semantics as the semaphore. +Generated stream writers use it to serialize writes and close without polling +the scheduler. +_Avoid_: component stream lock + +**Local Condition Variable**: +A MoonBit-only `CondVar` used for direct task notification. If a signal has +already been assigned when cancellation resumes a waiter, the signal wins so +it cannot be lost. Generated future and stream sources use it to wait for +in-flight component read cancellation to return the operation buffer. +_Avoid_: waitable-set event, predicate polling + +**Local Stream**: +A MoonBit-only stream type, expected to be spelled `Stream[T]`, that coordinates +MoonBit coroutines or wraps a generic demand-driven source without a CM-specific +state variant. A generated readable source may capture a raw component handle, +but the local runtime cannot inspect or forward it, and `T` need not be +representable in WIT. +_Avoid_: component `stream`, stream endpoint + +**Local Sink**: +The producer side of a local Stream. It is not a component `stream` writable end. +_Avoid_: stream writer + +**Producer close**: +The graceful local-stream terminal action performed by `Sink::close()`. It means +no more values will be written, while already-buffered values remain readable. +_Avoid_: producer cancel + +**Consumer stream drop**: +The local-stream terminal action performed when the `Stream[T]` side is dropped +or explicitly consumed by async `Stream::drop()`. It means the consumer has lost +interest, so buffered values still owned by the local stream are cleaned and +producer-side waiters are woken. Bridge internals may cancel in-flight endpoint +copy operations to recover buffers, but the user-facing operation is not hard +cancellation of a producer. +_Avoid_: `Stream::cancel`, stream close + +**Producer failure**: +A possible future local-stream operation where the producer ends the stream with +a failure state instead of graceful EOF. It is not `unreachable`, a wasm trap, or +process abort. It is not part of the MVP public `Sink[T]` interface because +component `stream` does not carry a distinct generic producer-failure signal. +_Avoid_: producer abort, sink close, trap + +**Stream pipe**: +The shared runtime state behind a local `Stream[T]` and `Sink[T]` pair. It owns +bounded local chunk storage, FIFO waiting readers and writers, and separate +writer-close and reader-drop state. Capacity zero means strict rendezvous; +positive capacity is the maximum number of accepted unread elements. It does +not own CM stream endpoints. +_Avoid_: stream endpoint + +**Owned stream chunk**: +An owned, exact-length batch of stream items returned by local `Stream[T]` +reads. The expected representation is `FixedArray[T]`; local stream +implementation should pass chunks by ownership instead of assembling them in a +growable `Array[T]`. +_Avoid_: ABI buffer + +**Borrowed stream chunk**: +A temporary view of stream items, such as `ArrayView[T]`, accepted by the MVP +local `Sink[T]` write API. Before a write operation suspends, the stream runtime +must materialize the relevant view window into owned stream storage or an owned +canonical ABI buffer. +_Avoid_: stream storage + +**Staged write window**: +The bounded part of a borrowed stream chunk that one awaited write operation +materializes into owned storage. MVP `Sink[T]` writes stage at most one window +per awaited operation; `write_all` loops over windows. +_Avoid_: whole input buffer + +**CM operation buffer**: +The canonical ABI buffer supplied to an in-flight component `future` or +component `stream` read/write operation. The buffer is not reusable by MoonBit +until the operation completes or cancellation/drop is observed through the +terminal waitable event. +_Avoid_: local stream buffer + +**Accepted stream prefix**: +The prefix length reported by a completed or terminal stream copy event. Values +inside this prefix have semantically transferred across the stream operation. +For runtime-owned buffers, the source side must not clean them again. For +caller-owned MoonBit values, this is a best-effort API contract rather than a +type-system-enforced lifetime. Values outside the prefix remain owned by the +side that staged the buffer. +_Avoid_: whole chunk + +**Stream cleanup invariant**: +After every stream read, write, close, cancel, peer drop, or partial transfer, +each value is caller-owned, stream-owned, bridge-operation-owned, transferred to +the component peer, or cleaned exactly once. +_Avoid_: best-effort cleanup + +**Local producer close**: +`Sink::close()` stops local writes and marks graceful EOF. Buffered chunks remain +stream-owned and can still be drained before `Stream::read` returns `None`. +_Avoid_: reader drop, cancellation + +**Local reader drop**: +`Stream::drop()` records local consumer loss-of-interest. It wakes blocked +writers, discards unread chunks, and invokes the cleanup operation supplied by +`Stream::new_with_cleanup` or `Stream::produce(cleanup=...)` for each unread +owned value. A suspended +`Sink::write` owns one bounded staged `FixedArray`; `write_all` uses the same +cleanup for any remaining suffix that never entered the stream buffer. +_Avoid_: producer close, hard cancellation + +**Generated stream adapter**: +Boundary-generated state connecting local and component stream semantics. An +incoming adapter is a lazy readable source and starts no pump task. An outgoing +adapter is a producer task that owns the raw writable handle and pulls from a +local `Stream[T]`. +_Avoid_: local stream state + +**Incoming stream demand policy**: +The rule that a generated incoming stream source issues one component read only +for a corresponding local read demand. It does not prefetch into a local pipe. +_Avoid_: unbounded prefetch + +**Source cleanup hook**: +An owned cleanup action stored with a generic local source. Explicit local +cleanup, such as async `Future::drop` or `Stream::drop`, runs the generated hook +so an idle endpoint is dropped directly or an in-flight copy is cancelled and +observed before buffers and handles are released. +_Avoid_: destructor + +**BYOB stream read**: +A "bring your own buffer" read shape where the caller supplies storage to fill. +This is useful for component ABI buffers and specialized fast paths, but should +not be part of the MVP local `Stream[T]` interface for arbitrary `T`. + +**Local-component bridge**: +Generated FFI-boundary code that adapts between a one-shot async thunk or local +Stream and component future/stream endpoints. It exists only for a concrete WIT +site whose payload has generated recursive lift/lower operations. + +**Async generator boundary**: +The Rust generator layer that decides which MoonBit async runtime package, +endpoint bridge helpers, and public async type names to emit. This boundary +lets the old async runtime be peeled out and the replacement runtime added back +without scattering runtime-specific decisions across ordinary lift/lower code. +_Avoid_: inline async codegen + +**Mergeable prototype**: +A production-intended implementation slice built after the async generator +boundary exists. It is allowed to be experimental in scope, but it must use the +real generator boundary, carry tests, and be suitable to harden in place if the +shape proves correct. It is not throwaway code. +_Avoid_: throwaway prototype + +**ABI-compatible payload**: +A MoonBit type that is the generated representation of a WIT type and has the +payload lift/lower operations needed at a specific FFI boundary. +_Avoid_: arbitrary `T` + +**Bridge task**: +Runtime-owned async work that drives a local-component bridge after a readable +end has been returned or passed to another component. + +**Bridge continuation**: +The suspended MoonBit coroutine captured while a local-component bridge is +waiting on a component-model waitable operation. Reader drop, writer drop, and +task cancellation must be routed through this continuation rather than modeled +only as ordinary value destruction. +_Avoid_: destructor + +**Endpoint copy cancellation**: +Cancellation of one in-flight component `future` or component `stream` read or +write operation. This returns ownership of the operation's buffer when the +cancelled event is observed. It is not the same thing as cancelling the MoonBit +coroutine that requested the operation. +_Avoid_: task cancellation + +**Hard cancellation**: +A MoonBit-side request to stop local async work with strong semantics. Examples +include task/subtask cancellation and component teardown or failure. Hard +cancellation may cancel a producer coroutine or cancel and observe an in-flight +endpoint copy operation. It is not the same as the peer dropping a component +endpoint. +_Avoid_: peer drop, consumer loss-of-interest + +**Peer loss-of-interest**: +The opposite component endpoint was dropped. When an endpoint operation observes +this, the component result is `dropped`, not `cancelled`. If there is no endpoint +operation that can observe it yet, such as a pre-write outgoing future writer, +the bridge must not infer hard cancellation from it. +_Avoid_: task cancellation + +**Bridge cancellation**: +A hard cancellation of the MoonBit bridge continuation that is driving +conversion between local async work and a component endpoint. For streams and +in-flight future operations, this may cancel endpoint copy work. For a pre-write +outgoing component future writer, peer readable-end drop is not bridge +cancellation; it is observed only if a later `future.write` reports `dropped`. +_Avoid_: endpoint copy cancellation + +**Future drop**: +The async user-facing operation that consumes a local `Future[T]` handle and +runs its explicit cleanup protocol. If the handle is backed by an unread component +readable end and no read is in flight, this cleanup may call +`future.drop-readable`; otherwise it may need to cancel and observe cleanup +first. If cancellation races with completion, `Future::drop` cleans the produced +value using the future's payload cleanup operation. It is not a direct alias for +component `future.drop-*`, and it is not an automatic destructor. +_Avoid_: read cancellation + +**Payload cleanup operation**: +A generated or stored function that cleans a produced payload value when a +future or stream operation owns that value but will not return it to user code. +For WIT payloads, this operation is generated from lift/lower cleanup logic. A +generic local `Future[T]` or `Stream[T]` cannot invent this operation for +arbitrary `T`; local constructors therefore accept it explicitly through +`Future::ready_with_cleanup`, `Stream::new_with_cleanup`, and +`Stream::produce(cleanup=...)` when needed. +_Avoid_: generic destructor + +## Adapter Generation Language + +**Adapter-generated intrinsic**: +A canonical ABI import generated by the core-wasm-to-component adapter path. For +component `future` and component `stream`, these intrinsics are identified by +the containing function and a discovered component `future` or component +`stream` position. + +**Function-position index**: +The enumeration index assigned to a component `future` or component `stream` +discovered in one concrete WIT function. It is not derivable from the MoonBit +payload type alone. +_Avoid_: parameter index, type index + +**Endpoint operation table**: +The rejected design where adapter intrinsics and payload callbacks are stored in +a runtime record selected for a generic endpoint wrapper. The target design emits +direct site-specific calls instead. +_Avoid_: vtable + +**Endpoint factory**: +A generated site helper that directly creates a component future or stream pair +because it is tied to a concrete WIT function position and therefore knows the +adapter-generated intrinsic names. +_Avoid_: local `Future::new()`, local `Stream::new()`, generic endpoint factory diff --git a/crates/moonbit/src/async/async_abi.mbt b/crates/moonbit/src/async/async_abi.mbt index f4491a0a5..e15b80413 100644 --- a/crates/moonbit/src/async/async_abi.mbt +++ b/crates/moonbit/src/async/async_abi.mbt @@ -33,14 +33,10 @@ fn SubTask::from(code : Int) -> SubTask { } ///| -/// None : the subtask is blocked -fn SubTask::cancel(self : SubTask) -> SubTaskState? { - let result = subtask_cancel(self.handle) - if result == -1 { - None - } else { - Some(SubTaskState::from(subtask_cancel(self.handle))) - } +/// The baseline intrinsic cooperatively waits in the component model and +/// returns only after the subtask reaches a terminal state. +fn SubTask::cancel(self : SubTask) -> SubTaskState { + SubTaskState::from(subtask_cancel(self.handle)) } // #endregion @@ -101,7 +97,12 @@ fn Events::new(code : EventCode, i : Int, j : Int) -> Events { // #region waitable set ///| -struct WaitableSet(Int) derive(Eq, Show, Hash) +#borrow(array) +extern "wasm" fn int_array2ptr(array : FixedArray[Int]) -> Int = + #|(func (param i32) (result i32) local.get 0) + +///| +struct WaitableSet(Int) derive(Eq, Hash) ///| fn WaitableSet::new() -> WaitableSet { @@ -113,6 +114,13 @@ fn WaitableSet::drop(self : Self) -> Unit { waitable_set_drop(self.0) } +///| +fn WaitableSet::poll(self : Self) -> (Events, Int) { + let payload : FixedArray[Int] = FixedArray::make(2, 0) + let event = waitable_set_poll(self.0, int_array2ptr(payload)) + (Events::new(EventCode::from(event), payload[0], payload[1]), payload[0]) +} + // #endregion // #region Future @@ -120,14 +128,14 @@ fn WaitableSet::drop(self : Self) -> Unit { ///| priv enum FutureReadResult { Completed = 0 - Cancelled = 1 + Cancelled = 2 } ///| fn FutureReadResult::from(int : Int) -> FutureReadResult { match int { 0 => Completed - 1 => Cancelled + 2 => Cancelled _ => panic() } } @@ -193,7 +201,6 @@ priv enum CallbackCode { Completed Yield Wait(WaitableSet) - Poll(WaitableSet) } ///| @@ -202,18 +209,16 @@ fn CallbackCode::encode(self : Self) -> Int { Completed => 0 Yield => 1 Wait(id) => 2 | (id.0 << 4) - Poll(id) => 3 | (id.0 << 4) } } ///| -fn CallbackCode::decode(int : Int) -> CallbackCode { +fn CallbackCode::_decode(int : Int) -> CallbackCode { let id = int >> 4 match int & 0xf { 0 => Completed 1 => Yield 2 => Wait(id) - 3 => Poll(id) _ => panic() } } @@ -223,42 +228,48 @@ fn CallbackCode::decode(int : Int) -> CallbackCode { // #region Component async primitives ///| -/// Return whether is cancelled. -/// Use for non-callback implementation. -fn _yield() -> Bool = "$root" "[cancellable][yield]" +fn subtask_cancel(id : Int) -> Int = "$root" "[subtask-cancel]" ///| -fn _backpressure_inc() = "$root" "[backpressure-inc]" +fn subtask_drop(id : Int) = "$root" "[subtask-drop]" ///| -fn _backpressure_dec() = "$root" "[backpressure-dec]" +fn tls_set(tls : Int) = "$root" "[context-set-0]" ///| -fn subtask_cancel(id : Int) -> Int = "$root" "[subtask-cancel]" +fn tls_get() -> Int = "$root" "[context-get-0]" ///| -fn subtask_drop(id : Int) = "$root" "[subtask-drop]" +fn task_cancel() = "[export]$root" "[task-cancel]" ///| -pub fn context_set(task : Int) = "$root" "[context-set-0]" +fn waitable_set_new() -> Int = "$root" "[waitable-set-new]" ///| -pub fn context_get() -> Int = "$root" "[context-get-0]" +fn waitable_set_drop(set : Int) = "$root" "[waitable-set-drop]" ///| -fn tls_set(tls : Int) = "$root" "[context-set-0]" +/// This poll is deliberately non-cancellable. Component-task cancellation is +/// delivered by the stackless callback instead of being consumed here. +fn waitable_set_poll(set : Int, payload : Int) -> Int = "$root" "[waitable-set-poll]" ///| -fn tls_get() -> Int = "$root" "[context-get-0]" +fn task_wakeup_stream_new() -> Int64 = "$root" "[stream-new-unit]" ///| -pub fn task_cancel() = "[export]$root" "[task-cancel]" +fn task_wakeup_stream_read(handle : Int, ptr : Int, len : Int) -> Int = "$root" "[async-lower][stream-read-unit]" ///| -fn waitable_set_new() -> Int = "$root" "[waitable-set-new]" +fn task_wakeup_stream_write(handle : Int, ptr : Int, len : Int) -> Int = "$root" "[async-lower][stream-write-unit]" ///| -fn waitable_set_drop(set : Int) = "$root" "[waitable-set-drop]" +fn task_wakeup_stream_cancel_read(handle : Int) -> Int = "$root" "[stream-cancel-read-unit]" + +///| +fn task_wakeup_stream_drop_readable(handle : Int) = "$root" "[stream-drop-readable-unit]" + +///| +fn task_wakeup_stream_drop_writable(handle : Int) = "$root" "[stream-drop-writable-unit]" ///| fn waitable_join(waitable : Int, set : Int) = "$root" "[waitable-join]" diff --git a/crates/moonbit/src/async/async_primitive.mbt b/crates/moonbit/src/async/async_primitive.mbt index b603b1cc9..4f97aa4ce 100644 --- a/crates/moonbit/src/async/async_primitive.mbt +++ b/crates/moonbit/src/async/async_primitive.mbt @@ -13,9 +13,9 @@ // limitations under the License. ///| -async fn[T] async_suspend( - cb : ((T) -> Unit, (Cancelled) -> Unit) -> Unit, -) -> T raise Cancelled = "%async.suspend" +async fn[T, E : Error] async_suspend( + cb : ((T) -> Unit, (E) -> Unit) -> Unit, +) -> T raise E = "%async.suspend" ///| fn run_async(f : async () -> Unit noraise) = "%async.run" diff --git a/crates/moonbit/src/async/cond_var.mbt b/crates/moonbit/src/async/cond_var.mbt new file mode 100644 index 000000000..39dac21a8 --- /dev/null +++ b/crates/moonbit/src/async/cond_var.mbt @@ -0,0 +1,72 @@ +// Copyright 2025 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +priv struct CondVarWaiter { + // Once a signal is assigned, cancellation must not lose it before the + // waiter resumes. This matches moonbitlang/async's condition variable. + mut woken : Bool + mut coro : Coroutine? +} + +///| +/// A condition variable for synchronization between tasks. +pub struct CondVar { + priv waiters : @deque.Deque[CondVarWaiter] +} + +///| +/// Create a new condition variable. +#alias(new, deprecated) +pub fn CondVar::CondVar() -> CondVar { + { waiters: Deque([]) } +} + +///| +/// Wait until the condition variable is signaled. +pub async fn CondVar::wait(self : CondVar) -> Unit { + let waiter = { woken: false, coro: Some(current_coroutine()) } + self.waiters.push_back(waiter) + suspend() catch { + _ if waiter.woken => () + err => { + waiter.coro = None + raise err + } + } +} + +///| +/// Wake the first waiting task. Does nothing when no task is waiting. +pub fn CondVar::signal(self : CondVar) -> Unit { + while self.waiters.pop_front() is Some(waiter) { + if waiter.coro is Some(coro) { + waiter.woken = true + coro.wake() + break + } + } +} + +///| +/// Wake all waiting tasks. Does nothing when no task is waiting. +pub fn CondVar::broadcast(self : CondVar) -> Unit { + for waiter in self.waiters { + if waiter.coro is Some(coro) { + waiter.woken = true + coro.wake() + } + } + self.waiters.clear() +} diff --git a/crates/moonbit/src/async/coroutine.mbt b/crates/moonbit/src/async/coroutine.mbt index cfd962695..6f10baae1 100644 --- a/crates/moonbit/src/async/coroutine.mbt +++ b/crates/moonbit/src/async/coroutine.mbt @@ -17,75 +17,74 @@ priv enum State { Done Fail(Error) Running - Suspend(ok_cont~ : (Unit) -> Unit, err_cont~ : (Cancelled) -> Unit) + Suspend(ok_cont~ : (Unit) -> Unit, err_cont~ : (Error) -> Unit) } ///| struct Coroutine { coro_id : Int + waitable_set : WaitableSet mut state : State mut shielded : Bool mut cancelled : Bool mut ready : Bool + mut spawner : ((async () -> Unit) -> Unit)? downstream : Set[Coroutine] } ///| -impl Eq for Coroutine with equal(c1, c2) { +impl Eq for Coroutine with fn equal(c1, c2) { c1.coro_id == c2.coro_id } ///| -impl Hash for Coroutine with hash_combine(self, hasher) { +impl Hash for Coroutine with fn hash_combine(self, hasher) { self.coro_id.hash_combine(hasher) } ///| fn Coroutine::wake(self : Coroutine) -> Unit { - self.ready = true - scheduler.run_later.push_back(self) + if !self.ready { + self.ready = true + enqueue(self) + signal_component_task(self.waitable_set) + } } ///| pub fn is_being_cancelled() -> Bool { let coro = current_coroutine() - coro.cancelled && not(coro.shielded) + coro.cancelled && !coro.shielded +} + +///| +pub fn check_cancellation() -> Unit raise { + if is_being_cancelled() { + raise Cancelled::Cancelled + } } ///| -pub(all) suberror Cancelled derive(Show) +pub(all) suberror Cancelled derive(Debug) ///| fn Coroutine::cancel(self : Coroutine) -> Unit { self.cancelled = true - if not(self.shielded || self.ready) { + if !self.shielded { self.wake() } } ///| -pub async fn pause() -> Unit raise Cancelled { +async fn suspend() -> Unit { guard scheduler.curr_coro is Some(coro) - if coro.cancelled && not(coro.shielded) { + if coro.cancelled && !coro.shielded { raise Cancelled::Cancelled } - async_suspend(fn(ok_cont, err_cont) { - guard coro.state is Running - coro.state = Suspend(ok_cont~, err_cont~) - coro.ready = true - scheduler.run_later.push_back(coro) - }) -} - -///| -pub async fn suspend() -> Unit raise Cancelled { - guard scheduler.curr_coro is Some(coro) - if coro.cancelled && not(coro.shielded) { - raise Cancelled::Cancelled - } - scheduler.blocking += 1 + let schedule = task_schedule(coro.waitable_set) + schedule.blocking += 1 defer { - scheduler.blocking -= 1 + schedule.blocking -= 1 } async_suspend(fn(ok_cont, err_cont) { guard coro.state is Running @@ -94,18 +93,24 @@ pub async fn suspend() -> Unit raise Cancelled { } ///| -fn spawn(f : async () -> Unit) -> Coroutine { +fn spawn_owned( + f : async () -> Unit, + waitable_set : WaitableSet, + inherited_spawner : ((async () -> Unit) -> Unit)?, +) -> Coroutine { scheduler.coro_id += 1 let coro = { state: Running, ready: true, shielded: true, - downstream: Set::new(), + downstream: Set([]), coro_id: scheduler.coro_id, + waitable_set, cancelled: false, + spawner: inherited_spawner, } fn run(_) { - run_async(fn() { + run_async(() => { coro.shielded = false try f() catch { err => coro.state = Fail(err) @@ -120,10 +125,18 @@ fn spawn(f : async () -> Unit) -> Coroutine { } coro.state = Suspend(ok_cont=run, err_cont=_ => ()) - scheduler.run_later.push_back(coro) + enqueue(coro) coro } +///| +fn spawn(f : async () -> Unit) -> Coroutine { + match scheduler.curr_coro { + Some(parent) => spawn_owned(f, parent.waitable_set, parent.spawner) + None => spawn_owned(f, current_waitableset(), None) + } +} + ///| fn Coroutine::unwrap(self : Coroutine) -> Unit raise { match self.state { @@ -136,7 +149,7 @@ fn Coroutine::unwrap(self : Coroutine) -> Unit raise { ///| async fn Coroutine::wait(target : Coroutine) -> Unit { guard scheduler.curr_coro is Some(coro) - guard not(physical_equal(coro, target)) + guard !physical_equal(coro, target) match target.state { Done => return Fail(err) => raise err @@ -162,7 +175,10 @@ fn Coroutine::check_error(coro : Coroutine) -> Unit raise { } ///| -pub async fn protect_from_cancel(f : async () -> Unit) -> Unit { +pub async fn[X] protect_from_cancel( + f : async () -> X, + resume_on_cancel? : Bool = false, +) -> X { guard scheduler.curr_coro is Some(coro) if coro.shielded { // already in a shield, do nothing @@ -172,9 +188,10 @@ pub async fn protect_from_cancel(f : async () -> Unit) -> Unit { defer { coro.shielded = false } - f() - if coro.cancelled { + let result = f() + if !resume_on_cancel && coro.cancelled { raise Cancelled::Cancelled } + result } } diff --git a/crates/moonbit/src/async/ev.mbt b/crates/moonbit/src/async/ev.mbt index 23c642cdd..e08383271 100644 --- a/crates/moonbit/src/async/ev.mbt +++ b/crates/moonbit/src/async/ev.mbt @@ -14,15 +14,34 @@ ///| priv struct EventLoop { - subscribes : Map[Int, Subscriber] + subscribes : Map[WaitableSet, Map[Int, Subscriber]] + wakeups : Map[WaitableSet, TaskWakeup] tasks : Map[WaitableSet, Coroutine] + owned_coroutines : Map[WaitableSet, @set.Set[Coroutine]] finished : Map[WaitableSet, Bool] + resolved : Map[WaitableSet, Bool] + cancellations : Map[WaitableSet, TaskCancellation] + post_return_flush : Map[WaitableSet, Bool] +} + +///| +priv struct TaskWakeup { + reader : Int + writer : Int + mut reading : Bool + mut signaled : Bool } ///| priv struct Subscriber { mut event : Events? - coro : Coroutine + coro : @set.Set[Coroutine] +} + +///| +priv enum TaskCancellation { + Requested + Acknowledged } ///| @@ -31,86 +50,464 @@ fn current_waitableset() -> WaitableSet { } ///| +fn subscribers_for(waitable_set : WaitableSet) -> Map[Int, Subscriber] { + match ev.subscribes.get(waitable_set) { + Some(subscribers) => subscribers + None => { + let subscribers = Map([]) + ev.subscribes.set(waitable_set, subscribers) + subscribers + } + } +} + +///| +fn clear_subscribers(waitable_set : WaitableSet) -> Unit { + if ev.subscribes.get(waitable_set) is Some(subscribers) { + subscribers.each(fn(waitable_id, _subscriber) { + waitable_join(waitable_id, 0) + }) + ev.subscribes.remove(waitable_set) + } +} + +///| +fn finish_waitableset(waitable_set : WaitableSet) -> Unit { + ev.tasks.remove(waitable_set) + ev.owned_coroutines.remove(waitable_set) + ev.finished.remove(waitable_set) + ev.resolved.remove(waitable_set) + ev.cancellations.remove(waitable_set) + ev.post_return_flush.remove(waitable_set) + drop_task_wakeup(waitable_set) + clear_subscribers(waitable_set) + forget_schedule(waitable_set) + waitable_set.drop() + tls_set(0) +} + +///| +fn acknowledge_cancellation(waitable_set : WaitableSet) -> Unit { + guard ev.cancellations.get(waitable_set) is Some(Requested) else { return } + guard ev.finished.get(waitable_set) is Some(true) else { return } + guard ev.tasks.get(waitable_set) is Some(coro) else { return } + match coro.state { + Done => () // The generated wrapper already called task.return. + Fail(_) => + if !(ev.resolved.get(waitable_set) is Some(true)) { + task_cancel() + ev.resolved.set(waitable_set, true) + } + Running | Suspend(_) => return + } + ev.cancellations.set(waitable_set, Acknowledged) +} + +///| +fn notify_subscriber( + waitable_set : WaitableSet, + waitable_id : Int, + event : Events, +) -> Unit { + if ev.wakeups.get(waitable_set) is Some(wakeup) && + wakeup.reader == waitable_id { + guard event is StreamRead(i, { progress: 1, copy_result: Completed }) && + i == waitable_id + wakeup.reading = false + wakeup.signaled = false + remove_subscriber(waitable_set, waitable_id) + waitable_join(waitable_id, 0) + return + } + guard ev.subscribes.get(waitable_set) is Some(subscribers) + guard subscribers.get(waitable_id) is Some(subscriber) + subscriber.event = Some(event) + subscriber.coro.each(Coroutine::wake) + // Keep the delivered event discoverable until its waiter consumes it. A + // local cancellation can race with the wakeup, and cancelling an already + // terminal canonical operation would trap. + waitable_join(waitable_id, 0) +} + +///| +fn next_callback(waitable_set : WaitableSet) -> Int { + if ev.post_return_flush.get(waitable_set) is Some(true) { + ev.post_return_flush.remove(waitable_set) + reschedule(waitable_set) + } + acknowledge_cancellation(waitable_set) + if ev.finished.get(waitable_set) is Some(true) && no_more_work(waitable_set) { + let resolved = ev.resolved.get(waitable_set) is Some(true) + let failed = match ev.tasks.get(waitable_set) { + Some(coro) => coro.state is Fail(_) + None => false + } + finish_waitableset(waitable_set) + if !resolved { + if failed { + abort("async export failed before task return") + } else { + abort("async export completed without task return") + } + } + return CallbackCode::Completed.encode() + } + if has_immediately_ready_task(waitable_set) { + if ev.subscribes.get(waitable_set) is Some(subscribers) && + !subscribers.is_empty() { + let (event, waitable_id) = waitable_set.poll() + match event { + None => () + TaskCancelled => panic() + _ => notify_subscriber(waitable_set, waitable_id, event) + } + } + tls_set(waitable_set.0) + return CallbackCode::Yield.encode() + } + tls_set(waitable_set.0) + arm_task_wakeup(waitable_set) + CallbackCode::Wait(waitable_set.0).encode() +} + +///| +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) pub fn with_waitableset(f : async () -> Unit) -> Int { let waitable_set = WaitableSet::new() + let owned = @set.Set([]) tls_set(waitable_set.0) - let coro = spawn(async fn() -> Unit { - defer ev.finished.set(waitable_set, true) - f() - }) + let coro = spawn_owned( + async fn() -> Unit { + let coro = current_coroutine() + defer owned.remove(coro) + defer ev.finished.set(waitable_set, true) + f() + }, + waitable_set, + None, + ) ev.tasks.set(waitable_set, coro) + owned.add(coro) + ev.owned_coroutines.set(waitable_set, owned) ev.finished.set(waitable_set, false) - reschedule() - if ev.finished.get(waitable_set) is Some(true) { - ev.tasks.remove(waitable_set) - ev.finished.remove(waitable_set) - waitable_set.drop() - return CallbackCode::Completed.encode() - } else { - return CallbackCode::Wait(waitable_set.0).encode() - } + ev.resolved.set(waitable_set, false) + reschedule(waitable_set) + next_callback(waitable_set) +} + +///| +/// Record that the generated export wrapper has resolved the component-model +/// task. One additional fair scheduling round lets the enclosing MoonBit task +/// group observe its body completing without draining runnable background work. +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) +pub fn task_returned() -> Unit { + let waitable_set = current_waitableset() + guard ev.tasks.get(waitable_set) is Some(_) + ev.post_return_flush.set(waitable_set, true) + ev.resolved.set(waitable_set, true) } ///| +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) pub fn cb(event : Int, waitable_id : Int, code : Int) -> Int { let waitable_set = current_waitableset() let events = Events::new(EventCode::from(event), waitable_id, code) + let preserve_task_wakeup = match events { + StreamRead(i, _) => + match ev.wakeups.get(waitable_set) { + Some(wakeup) => wakeup.reader == i + None => false + } + _ => false + } + cancel_task_wakeup_read(waitable_set, preserve=preserve_task_wakeup) match events { + None => { + reschedule(waitable_set) + next_callback(waitable_set) + } TaskCancelled => { guard ev.tasks.get(waitable_set) is Some(coro) + ev.cancellations.set(waitable_set, Requested) coro.cancel() - reschedule() - if ev.finished.get(waitable_set) is Some(true) { - ev.tasks.remove(waitable_set) - ev.finished.remove(waitable_set) - waitable_set.drop() - task_cancel() - return CallbackCode::Completed.encode() - } else { - // Unlikely to reach here - return CallbackCode::Wait(waitable_set.0).encode() + if ev.owned_coroutines.get(waitable_set) is Some(owned) { + owned.each(Coroutine::cancel) } + reschedule(waitable_set) + next_callback(waitable_set) } _ => { - let sub = ev.subscribes.get(waitable_id) - guard sub is Some(subscriber) - subscriber.event = Some(events) - subscriber.coro.wake() - reschedule() - if ev.finished.get(waitable_set) is Some(true) { - ev.tasks.remove(waitable_set) - ev.finished.remove(waitable_set) - waitable_set.drop() - return CallbackCode::Completed.encode() - } else { - return CallbackCode::Wait(waitable_set.0).encode() + notify_subscriber(waitable_set, waitable_id, events) + reschedule(waitable_set) + next_callback(waitable_set) + } + } +} + +///| +let ev : EventLoop = { + subscribes: Map([]), + wakeups: Map([]), + tasks: Map([]), + owned_coroutines: Map([]), + finished: Map([]), + resolved: Map([]), + cancellations: Map([]), + post_return_flush: Map([]), +} + +///| +/// Spawn a coroutine owned by the current component-model async task. +/// +/// This is intended for runtime bridge work such as lowering local futures and +/// streams to component-model handles. The coroutine does not participate in +/// `TaskGroup` structured concurrency, but it is cancelled when the current +/// component-model task is cancelled and keeps the waitable-set alive until it +/// terminates. +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) +pub fn spawn_component_task_current(f : async () -> Unit) -> Unit { + let waitable_set = current_waitableset() + guard ev.tasks.get(waitable_set) is Some(_) + guard ev.owned_coroutines.get(waitable_set) is Some(owned) + let coro = spawn_owned( + async fn() -> Unit { + let coro = current_coroutine() + defer owned.remove(coro) + f() + }, + waitable_set, + Some(spawn_component_task_current), + ) + owned.add(coro) +} + +///| +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) +pub fn has_component_task_scope() -> Bool { + let waitable_set = current_waitableset() + ev.tasks.get(waitable_set) is Some(_) && + ev.owned_coroutines.get(waitable_set) is Some(_) +} + +///| +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) +pub fn current_component_task_token() -> Int { + current_waitableset().0 +} + +///| +fn detach_waitable(waitable_id : Int) -> Unit { + let waitable_set = current_waitableset() + if ev.subscribes.get(waitable_set) is Some(subscribers) { + if subscribers.get(waitable_id) is Some(subscriber) { + subscriber.coro.clear() + subscribers.remove(waitable_id) + } + if subscribers.is_empty() { + ev.subscribes.remove(waitable_set) + } + } + waitable_join(waitable_id, 0) +} + +///| +fn remove_subscriber(waitable_set : WaitableSet, waitable_id : Int) -> Unit { + if ev.subscribes.get(waitable_set) is Some(subscribers) { + subscribers.remove(waitable_id) + if subscribers.is_empty() { + ev.subscribes.remove(waitable_set) + } + } +} + +///| +fn task_wakeup(waitable_set : WaitableSet) -> TaskWakeup { + match ev.wakeups.get(waitable_set) { + Some(wakeup) => wakeup + None => { + let pair = task_wakeup_stream_new() + let wakeup = { + reader: pair.to_int(), + writer: (pair >> 32).to_int(), + reading: false, + signaled: false, } + ev.wakeups.set(waitable_set, wakeup) + wakeup } } } ///| -let ev : EventLoop = { subscribes: {}, tasks: {}, finished: {} } +fn arm_task_wakeup(waitable_set : WaitableSet) -> Unit { + let wakeup = task_wakeup(waitable_set) + if wakeup.reading { + return + } + let result = task_wakeup_stream_read(wakeup.reader, 0, 1) + guard result == -1 + let subscribers = subscribers_for(waitable_set) + guard subscribers.get(wakeup.reader) is None + subscribers.set(wakeup.reader, { event: None, coro: @set.Set([]) }) + waitable_join(wakeup.reader, waitable_set.0) + wakeup.reading = true + wakeup.signaled = false +} + +///| +fn signal_component_task(waitable_set : WaitableSet) -> Unit { + guard ev.wakeups.get(waitable_set) is Some(wakeup) else { return } + if !wakeup.reading || wakeup.signaled { + return + } + let result = StreamResult::from(task_wakeup_stream_write(wakeup.writer, 0, 1)) + guard result.progress == 1 && result.copy_result is Completed + wakeup.signaled = true +} + +///| +fn cancel_task_wakeup_read( + waitable_set : WaitableSet, + preserve~ : Bool, +) -> Unit { + guard ev.wakeups.get(waitable_set) is Some(wakeup) else { return } + if !wakeup.reading || preserve { + return + } + waitable_join(wakeup.reader, 0) + remove_subscriber(waitable_set, wakeup.reader) + ignore(task_wakeup_stream_cancel_read(wakeup.reader)) + wakeup.reading = false + wakeup.signaled = false +} + +///| +fn drop_task_wakeup(waitable_set : WaitableSet) -> Unit { + guard ev.wakeups.get(waitable_set) is Some(wakeup) else { return } + if wakeup.reading { + waitable_join(wakeup.reader, 0) + remove_subscriber(waitable_set, wakeup.reader) + ignore(task_wakeup_stream_cancel_read(wakeup.reader)) + } + task_wakeup_stream_drop_readable(wakeup.reader) + task_wakeup_stream_drop_writable(wakeup.writer) + ev.wakeups.remove(waitable_set) +} + +///| +fn cancel_waitable_event( + owner_task : Int, + waitable_id : Int, + cancel : () -> Events, +) -> Events { + let waitable_set = WaitableSet(owner_task) + guard ev.subscribes.get(waitable_set) is Some(subscribers) + guard subscribers.get(waitable_id) is Some(subscriber) + + // Baseline cancel-read is synchronous and traps while its endpoint belongs + // to a waitable set. Detach before invoking the generated intrinsic. + waitable_join(waitable_id, 0) + let event = match subscriber.event { + Some(event) => event + None => cancel() + } + subscriber.event = Some(event) + subscriber.coro.each(Coroutine::wake) + remove_subscriber(waitable_set, waitable_id) + event +} + +///| +/// Complete an operation immediately or suspend until its terminal waitable +/// event arrives. This is the single owner of subscription registration and +/// detachment for future and stream copy operations. +async fn waitable_event(waitable_id : Int, immediate : Events?) -> Events { + let waitable_set = current_waitableset() + match immediate { + Some(event) => { + if ev.subscribes.get(waitable_set) is Some(subscribers) { + if subscribers.get(waitable_id) is Some(subscriber) { + subscriber.event = Some(event) + subscriber.coro.each(Coroutine::wake) + subscriber.coro.clear() + waitable_join(waitable_id, 0) + } + remove_subscriber(waitable_set, waitable_id) + } + event + } + None => { + let subscribers = subscribers_for(waitable_set) + let subscriber = if subscribers.get(waitable_id) is Some(subscriber) { + subscriber + } else { + let subscriber = { event: None, coro: @set.Set([]) } + subscribers.set(waitable_id, subscriber) + subscriber + } + guard subscriber.event is None + waitable_join(waitable_id, waitable_set.0) + let coro = current_coroutine() + subscriber.coro.add(coro) + defer subscriber.coro.remove(coro) + let event = try suspend() catch { + err => + match subscriber.event { + // Completion owns the canonical buffer once its event is + // delivered, even if local cancellation wakes the same coroutine. + Some(event) => event + None => raise err + } + } noraise { + _ => subscriber.event.unwrap() + } + remove_subscriber(waitable_set, waitable_id) + event + } + } +} ///| +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) pub async fn suspend_for_subtask( val : Int, - cleanup_after_started : () -> Unit, + settle_lowered_arguments : (Bool) -> Unit, + drop_returned_result : () -> Unit, ) -> Unit { let task = SubTask::from(val) - defer subtask_drop(task.handle) let mut cleaned = false - // Helper: ensure cleanup is called once we've moved past Starting state - fn ensure_cleanup(state : SubTaskState) -> Unit { - if not(cleaned) && !(state is Starting) { - cleanup_after_started() + // Once the subtask leaves Starting, canonical arguments are either + // transferred or rejected. Generated code owns the corresponding action. + fn settle_arguments(state : SubTaskState) -> Unit { + if !cleaned && !(state is Starting) { cleaned = true + settle_lowered_arguments(state is Cancelled_before_started) } } + // Immediate completion without a handle. + if task.handle == 0 { + settle_arguments(task.state) + match task.state { + Returned => return + Cancelled_before_started => raise SubTaskCancelled(before_started=true) + Cancelled_before_returned => raise SubTaskCancelled(before_started=false) + _ => panic() + } + } + + defer subtask_drop(task.handle) + // Initial state, return if finished - ensure_cleanup(task.state) + settle_arguments(task.state) match task.state { Returned => return Cancelled_before_started => raise SubTaskCancelled(before_started=true) @@ -118,40 +515,64 @@ pub async fn suspend_for_subtask( _ => () } - // Create subscriber to wait for events - let subscriber = { event: None, coro: current_coroutine() } - ev.subscribes.set(task.handle, subscriber) - defer ev.subscribes.remove(task.handle) - waitable_join(task.handle, current_waitableset().0) - defer waitable_join(task.handle, 0) - for { + // A subtask can report intermediate states, so register the same subscriber + // again after each callback until a terminal state arrives. + let waitable_set = current_waitableset() + let set = @set.Set([]) + let subscriber = { event: None, coro: set } + let coro = current_coroutine() + set.add(coro) + defer { + set.remove(coro) + detach_waitable(task.handle) + } + for ;; { + let subscribers = subscribers_for(waitable_set) + guard subscribers.get(task.handle) is None + subscribers.set(task.handle, subscriber) + waitable_join(task.handle, waitable_set.0) suspend() catch { Cancelled::Cancelled => // Cancel the subtask return protect_from_cancel(() => { - subscriber.event = task - .cancel() - .map(state => Subtask(task.handle, state)) - while subscriber.event is None { - suspend() + detach_waitable(task.handle) + // A terminal event can race with local cancellation after waking + // this coroutine. Once delivered, cancelling the subtask again + // traps, so consume that event before requesting cancellation. + if subscriber.event is Some(Subtask(i, state)) { + guard i == task.handle + settle_arguments(state) + match state { + Returned => { + drop_returned_result() + return + } + Cancelled_before_started | Cancelled_before_returned => + raise Cancelled::Cancelled + Starting | Started => subscriber.event = None + } } - guard subscriber.event is Some(Subtask(i, state)) && i == task.handle - ensure_cleanup(state) + let state = task.cancel() + settle_arguments(state) match state { - Returned => return - Cancelled_before_started => - raise SubTaskCancelled(before_started=true) - Cancelled_before_returned => - raise SubTaskCancelled(before_started=false) - _ => panic() // should not happen + Returned => { + drop_returned_result() + return + } + Cancelled_before_started | Cancelled_before_returned => + raise Cancelled::Cancelled + Starting | Started => panic() } }) + err => raise err } + remove_subscriber(waitable_set, task.handle) + // Subsequent state, return if finished if subscriber.event is Some(Subtask(i, state)) { guard i == task.handle - ensure_cleanup(state) + settle_arguments(state) match state { Returned => return Cancelled_before_started => raise SubTaskCancelled(before_started=true) @@ -164,56 +585,105 @@ pub async fn suspend_for_subtask( } ///| +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) pub async fn suspend_for_future_read(idx : Int, val : Int) -> Unit { - let result = if val == -1 { - let subscriber = { event: None, coro: current_coroutine() } - ev.subscribes.set(idx, subscriber) - defer ev.subscribes.remove(idx) - waitable_join(idx, current_waitableset().0) - defer waitable_join(idx, 0) - suspend() - guard subscriber.event is Some(FutureRead(i, result)) && i == idx - result + let event = if val == -1 { + waitable_event(idx, None) } else { - FutureReadResult::from(val) + let result = FutureReadResult::from(val) + waitable_event(idx, Some(FutureRead(idx, result))) } + guard event is FutureRead(i, result) && i == idx match result { Completed => return - Cancelled => raise FutureReadCancelled + Cancelled => raise FutureReadError::Cancelled } } ///| -pub async fn suspend_for_future_write(idx : Int) -> Bool { - let subscriber = { event: None, coro: current_coroutine() } - ev.subscribes.set(idx, subscriber) - defer ev.subscribes.remove(idx) - waitable_join(idx, current_waitableset().0) - defer waitable_join(idx, 0) - suspend() - guard subscriber.event is Some(FutureWrite(i, result)) && i == idx +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) +pub fn cancel_future_read( + owner_task : Int, + idx : Int, + cancel : () -> Int, +) -> Bool { + let event = cancel_waitable_event(owner_task, idx, () => { + FutureRead(idx, FutureReadResult::from(cancel())) + }) + guard event is FutureRead(i, result) && i == idx match result { Completed => true - Dropped => false - Cancelled => raise FutureWriteCancelled + Cancelled => false + } +} + +///| +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) +pub fn cancel_stream_read( + owner_task : Int, + idx : Int, + cancel : () -> Int, +) -> Int { + let event = cancel_waitable_event(owner_task, idx, () => { + StreamRead(idx, StreamResult::from(cancel())) + }) + guard event is StreamRead(i, result) && i == idx + result.progress +} + +///| +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) +pub fn cancel_stream_write(idx : Int, cancel : () -> Int) -> Int { + let event = cancel_waitable_event(current_component_task_token(), idx, () => { + StreamWrite(idx, StreamResult::from(cancel())) + }) + guard event is StreamWrite(i, result) && i == idx + result.progress +} + +///| +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) +pub async fn suspend_for_future_write_terminal(idx : Int, val : Int) -> Bool? { + let event = if val == -1 { + waitable_event(idx, None) + } else { + let result = FutureWriteResult::from(val) + waitable_event(idx, Some(FutureWrite(idx, result))) + } + guard event is FutureWrite(i, result) && i == idx + match result { + Completed => Some(true) + Dropped => Some(false) + Cancelled => None } } ///| +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) +pub async fn suspend_for_future_write(idx : Int, val : Int) -> Bool { + match suspend_for_future_write_terminal(idx, val) { + Some(transferred) => transferred + None => raise FutureWriteCancelled + } +} + +///| +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) pub async fn suspend_for_stream_read(idx : Int, val : Int) -> (Int, Bool) { - let { progress, copy_result } = if val == -1 { - // Blocked, wait for event - let subscriber = { event: None, coro: current_coroutine() } - ev.subscribes.set(idx, subscriber) - defer ev.subscribes.remove(idx) - waitable_join(idx, current_waitableset().0) - defer waitable_join(idx, 0) - suspend() - guard subscriber.event is Some(StreamRead(i, result)) && i == idx - result + let event = if val == -1 { + waitable_event(idx, None) } else { - StreamResult::from(val) + let result = StreamResult::from(val) + waitable_event(idx, Some(StreamRead(idx, result))) } + guard event is StreamRead(i, { progress, copy_result }) && i == idx match copy_result { Completed => return (progress, false) Dropped => return (progress, true) @@ -227,38 +697,39 @@ pub async fn suspend_for_stream_read(idx : Int, val : Int) -> (Int, Bool) { } ///| +#internal(wit_bindgen, "generated binding code only") +#doc(hidden) pub async fn suspend_for_stream_write(idx : Int, val : Int) -> (Int, Bool) { - let { progress, copy_result } = if val != -1 { - // Not blocked - StreamResult::from(val) + let event = if val == -1 { + waitable_event(idx, None) } else { - // Blocked, wait for event - let subscriber = { event: None, coro: current_coroutine() } - ev.subscribes.set(idx, subscriber) - defer ev.subscribes.remove(idx) - waitable_join(idx, current_waitableset().0) - defer waitable_join(idx, 0) - suspend() - guard subscriber.event is Some(StreamWrite(i, result)) && i == idx - result + let result = StreamResult::from(val) + waitable_event(idx, Some(StreamWrite(idx, result))) } + guard event is StreamWrite(i, { progress, copy_result }) && i == idx match copy_result { Completed => return (progress, false) Dropped => return (progress, true) - Cancelled => - if progress > 0 { - return (progress, false) - } else { - raise StreamWriteCancelled - } + Cancelled => return (progress, false) } } ///| pub suberror OpCancelled { SubTaskCancelled(before_started~ : Bool) - StreamWriteCancelled StreamReadCancelled FutureWriteCancelled - FutureReadCancelled +} + +///| +pub(all) suberror FutureReadError { + Cancelled + Dropped +} + +///| +#internal(wit_bindgen, "generated binding code only") +pub(all) suberror EndpointBusy { + Read + Write } diff --git a/crates/moonbit/src/async/moon.pkg.json b/crates/moonbit/src/async/moon.pkg.json index b7a5c45c3..ae0b479d4 100644 --- a/crates/moonbit/src/async/moon.pkg.json +++ b/crates/moonbit/src/async/moon.pkg.json @@ -1 +1,9 @@ -{ "warn-list": "-44", "supported-targets": ["wasm"] } \ No newline at end of file +{ + "warn-list": "-44", + "import": [ + { "path": "moonbitlang/core/deque", "alias": "deque" }, + { "path": "moonbitlang/core/ref", "alias": "ref" }, + { "path": "moonbitlang/core/set", "alias": "set" } + ], + "supported-targets": "+wasm" +} diff --git a/crates/moonbit/src/async/mutex.mbt b/crates/moonbit/src/async/mutex.mbt new file mode 100644 index 000000000..90197d047 --- /dev/null +++ b/crates/moonbit/src/async/mutex.mbt @@ -0,0 +1,44 @@ +// Copyright 2025 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +/// A mutex for synchronization between tasks. +#valtype +pub struct Mutex { + priv semaphore : Semaphore +} + +///| +/// Create a mutex in the released state. +pub fn Mutex::Mutex() -> Mutex { + { semaphore: Semaphore(1) } +} + +///| +/// Acquire the mutex in FIFO order. +pub async fn Mutex::acquire(self : Mutex) -> Unit { + self.semaphore.acquire() +} + +///| +/// Try to acquire the mutex without waiting. +pub fn Mutex::try_acquire(self : Mutex) -> Bool { + self.semaphore.try_acquire() +} + +///| +/// Release the mutex. The mutex must already be acquired. +pub fn Mutex::release(self : Mutex) -> Unit { + self.semaphore.release() +} diff --git a/crates/moonbit/src/async/promise.mbt b/crates/moonbit/src/async/promise.mbt new file mode 100644 index 000000000..ec72b21b7 --- /dev/null +++ b/crates/moonbit/src/async/promise.mbt @@ -0,0 +1,200 @@ +// Copyright 2025 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +priv enum PromiseState[X] { + Pending + Completed(X) + Failed(Error) + Closed + ReaderDropped + Consumed +} + +///| +priv struct PromiseCell[X] { + mut state : PromiseState[X] + ready : Semaphore + cleanup : ((X) -> Unit)? + mut reading : Bool +} + +///| +/// The local producer side of a one-shot `Future[X]`. +/// +/// This is a MoonBit coordination value, not a component-model future writable +/// endpoint. Conversion to a component future remains generated at an FFI site. +pub struct Promise[X] { + priv cell : Ref[PromiseCell[X]] +} + +///| +pub(all) suberror PromiseClosed derive(Debug) + +///| +fn[X] new_future_promise(cleanup : ((X) -> Unit)?) -> (Future[X], Promise[X]) { + let cell : Ref[PromiseCell[X]] = { + val: { + state: Pending, + ready: Semaphore(1, initial_value=0), + cleanup, + reading: false, + }, + } + (Future::{ inner: Promised(cell) }, Promise::{ cell, }) +} + +///| +/// Create a local one-shot Future/Promise pair. +pub fn[X] Future::new() -> (Future[X], Promise[X]) { + new_future_promise(None) +} + +///| +/// Create a local one-shot Future/Promise pair with value-discard cleanup. +/// +/// The cleanup callback runs when the promise completed successfully but the +/// future is dropped before consuming the value. +pub fn[X] Future::new_with_cleanup( + cleanup : (X) -> Unit, +) -> (Future[X], Promise[X]) { + new_future_promise(Some(cleanup)) +} + +///| +/// Complete the paired future with `value`. +/// +/// `true` means the future accepted ownership. `false` means its reader was +/// already dropped and the caller retains ownership of `value`. +pub fn[X] Promise::complete(self : Promise[X], value : X) -> Bool { + match self.cell.val.state { + Pending => { + self.cell.val.state = Completed(value) + self.cell.val.ready.release() + true + } + ReaderDropped => false + Completed(_) | Failed(_) | Closed | Consumed => + abort("promise already settled") + } +} + +///| +/// Complete the paired future with an error raised by `Future::get`. +pub fn[X] Promise::fail(self : Promise[X], error : Error) -> Bool { + match self.cell.val.state { + Pending => { + self.cell.val.state = Failed(error) + self.cell.val.ready.release() + true + } + ReaderDropped => false + Completed(_) | Failed(_) | Closed | Consumed => + abort("promise already settled") + } +} + +///| +/// Close the paired future without producing a value. +/// +/// A local waiter observes `PromiseClosed`. An outgoing component future still +/// cannot represent this terminal state and follows the bridge's no-value +/// failure policy if it has already crossed an FFI boundary. +pub fn[X] Promise::close(self : Promise[X]) -> Bool { + match self.cell.val.state { + Pending => { + self.cell.val.state = Closed + self.cell.val.ready.release() + true + } + ReaderDropped => false + Completed(_) | Failed(_) | Closed | Consumed => + abort("promise already settled") + } +} + +///| +fn[X] consume_promised_future(cell : Ref[PromiseCell[X]]) -> X raise { + match cell.val.state { + Completed(value) => { + cell.val.state = Consumed + value + } + Failed(error) => { + cell.val.state = Consumed + raise error + } + Closed => { + cell.val.state = Consumed + raise PromiseClosed + } + Pending => panic() + ReaderDropped => raise Cancelled::Cancelled + Consumed => abort("future already consumed") + } +} + +///| +async fn[X] promised_future_get(cell : Ref[PromiseCell[X]]) -> X { + if cell.val.reading { + abort("future read already in progress") + } + match cell.val.state { + Pending => { + cell.val.reading = true + cell.val.ready.acquire() catch { + err => { + cell.val.reading = false + if cell.val.state is Pending { + cell.val.state = ReaderDropped + } + raise err + } + } + cell.val.reading = false + consume_promised_future(cell) + } + Completed(_) | Failed(_) | Closed => consume_promised_future(cell) + ReaderDropped => consume_promised_future(cell) + Consumed => abort("future already consumed") + } +} + +///| +fn[X] promised_future_drop(cell : Ref[PromiseCell[X]]) -> Unit { + if cell.val.reading { + match cell.val.state { + Pending => { + cell.val.state = ReaderDropped + cell.val.ready.release() + } + // Once settlement assigned a result, the waiting reader owns it. + Completed(_) | Failed(_) | Closed => () + ReaderDropped | Consumed => () + } + return + } + match cell.val.state { + Pending => cell.val.state = ReaderDropped + Completed(value) => { + cell.val.state = Consumed + match cell.val.cleanup { + Some(cleanup) => cleanup(value) + None => () + } + } + Failed(_) | Closed => cell.val.state = Consumed + ReaderDropped | Consumed => () + } +} diff --git a/crates/moonbit/src/async/scheduler.mbt b/crates/moonbit/src/async/scheduler.mbt index c99947b54..0b50070b4 100644 --- a/crates/moonbit/src/async/scheduler.mbt +++ b/crates/moonbit/src/async/scheduler.mbt @@ -16,42 +16,76 @@ priv struct Scheduler { mut coro_id : Int mut curr_coro : Coroutine? + tasks : Map[WaitableSet, TaskSchedule] +} + +///| +priv struct TaskSchedule { mut blocking : Int run_later : @deque.Deque[Coroutine] } ///| -let scheduler : Scheduler = { - coro_id: 0, - curr_coro: None, - blocking: 0, - run_later: @deque.new(), -} +let scheduler : Scheduler = { coro_id: 0, curr_coro: None, tasks: Map([]) } ///| -pub fn current_coroutine() -> Coroutine { +fn current_coroutine() -> Coroutine { scheduler.curr_coro.unwrap() } ///| -pub fn has_immediately_ready_task() -> Bool { - !scheduler.run_later.is_empty() +fn task_schedule(waitable_set : WaitableSet) -> TaskSchedule { + match scheduler.tasks.get(waitable_set) { + Some(schedule) => schedule + None => { + let schedule = { blocking: 0, run_later: Deque([]) } + scheduler.tasks.set(waitable_set, schedule) + schedule + } + } +} + +///| +fn enqueue(coro : Coroutine) -> Unit { + task_schedule(coro.waitable_set).run_later.push_back(coro) +} + +///| +fn has_immediately_ready_task(waitable_set : WaitableSet) -> Bool { + match scheduler.tasks.get(waitable_set) { + Some(schedule) => !schedule.run_later.is_empty() + None => false + } +} + +///| +fn no_more_work(waitable_set : WaitableSet) -> Bool { + match scheduler.tasks.get(waitable_set) { + Some(schedule) => schedule.blocking == 0 && schedule.run_later.is_empty() + None => true + } } ///| -pub fn no_more_work() -> Bool { - scheduler.blocking == 0 && scheduler.run_later.is_empty() +fn forget_schedule(waitable_set : WaitableSet) -> Unit { + scheduler.tasks.remove(waitable_set) } ///| -pub fn reschedule() -> Unit { - while scheduler.run_later.pop_front() is Some(coro) { +/// Run one fair scheduling round for a component task. Coroutines woken or +/// spawned during this round are left for the next round so the component +/// event loop gets a chance to poll completed waitables. +fn reschedule(waitable_set : WaitableSet) -> Unit { + guard scheduler.tasks.get(waitable_set) is Some(schedule) else { return } + let count = schedule.run_later.length() + for _ in 0.. Semaphore { + if size <= 0 { + abort("size of semaphore must be positive") + } + guard initial_value >= 0 && initial_value <= size + { value: initial_value, size, waiters: Deque([]) } +} + +///| +/// Release one permit. Waiting coroutines acquire permits in FIFO order. +/// +/// Each call must correspond one-to-one with a successful `acquire`. Calling +/// `release` without owning a permit is invalid. +pub fn Semaphore::release(self : Semaphore) -> Unit { + if self.value >= self.size { + abort("semaphore: too many release") + } + while self.waiters.pop_front() is Some(waiter) { + if waiter.coro is Some(coro) { + waiter.acquired = true + coro.wake() + break + } + } nobreak { + self.value += 1 + } +} + +///| +/// Acquire one permit, suspending in FIFO order when none is available. +pub async fn Semaphore::acquire(self : Semaphore) -> Unit { + if self.value > 0 { + self.value -= 1 + } else { + let waiter = { coro: Some(current_coroutine()), acquired: false } + self.waiters.push_back(waiter) + suspend() catch { + _ if waiter.acquired => () + err => { + waiter.coro = None + raise err + } + } + } +} + +///| +/// Try to acquire one permit without suspending. +pub fn Semaphore::try_acquire(self : Semaphore) -> Bool { + if self.value > 0 { + self.value -= 1 + true + } else { + false + } +} diff --git a/crates/moonbit/src/async/task.mbt b/crates/moonbit/src/async/task.mbt index 676936293..20e18e056 100644 --- a/crates/moonbit/src/async/task.mbt +++ b/crates/moonbit/src/async/task.mbt @@ -15,9 +15,9 @@ ///| /// `Task[X]` represents a running task with result type `X`, /// it can be used to wait and retrieve the result value of the task. -struct Task_[X] { - value : Ref[X?] - coro : Coroutine +pub struct Task[X] { + priv value : Ref[X?] + priv coro : Coroutine } ///| @@ -25,7 +25,7 @@ struct Task_[X] { /// If the task fails, `wait` will also fail with the same error. /// /// If the current task is cancelled, `wait` return immediately with error. -pub async fn[X] Task_::wait(self : Task_[X]) -> X { +pub async fn[X] Task::wait(self : Task[X]) -> X { self.coro.wait() self.value.val.unwrap() } @@ -36,15 +36,13 @@ pub async fn[X] Task_::wait(self : Task_[X]) -> X { /// If the task already failed, `try_wait` will fail immediately. /// If the task is still running, `try_wait` returns `None`. /// `try_wait` is a synchoronous function: it never blocks. -pub fn[X] Task_::try_wait(self : Task_[X]) -> X? raise { +pub fn[X] Task::try_wait(self : Task[X]) -> X? raise { self.coro.check_error() self.value.val } ///| /// Cancel a task. Subsequent attempt to wait for the task will receive error. -/// Note that if the task is *not* spawned with `allow_failure=true`, -/// the whole task group will fail too. -pub fn[X] Task_::cancel(self : Task_[X]) -> Unit { +pub fn[X] Task::cancel(self : Task[X]) -> Unit { self.coro.cancel() } diff --git a/crates/moonbit/src/async/task_group.mbt b/crates/moonbit/src/async/task_group.mbt index 03384b941..66186eb9e 100644 --- a/crates/moonbit/src/async/task_group.mbt +++ b/crates/moonbit/src/async/task_group.mbt @@ -31,19 +31,15 @@ priv enum TaskGroupState { /// /// The type parameter `X` in `TaskGroup[X]` is the result type of the group, /// see `with_task_group` for more detail. -struct TaskGroup[X] { - children : Set[Coroutine] - parent : Coroutine - mut waiting : Int - mut state : TaskGroupState - mut result : X? - group_defer : Array[async () -> Unit] +pub struct TaskGroup[X] { + priv children : Set[Coroutine] + priv parent : Coroutine + priv mut waiting : Int + priv mut state : TaskGroupState + priv mut result : X? + priv group_defer : Array[async () -> Unit] } -///| -#deprecated("this error is no longer emitted") -pub suberror AlreadyTerminated derive(Show) - ///| fn[X] TaskGroup::spawn_coroutine( self : TaskGroup[X], @@ -51,17 +47,17 @@ fn[X] TaskGroup::spawn_coroutine( no_wait~ : Bool, allow_failure~ : Bool, ) -> Coroutine { - guard self.state is Running else { + guard self.state is Running || !self.children.is_empty() else { abort("trying to spawn from a terminated task group") } - if not(no_wait) { + if !no_wait { self.waiting += 1 } async fn worker() { let coro = current_coroutine() defer { self.children.remove(coro) - if not(no_wait) { + if !no_wait { self.waiting -= 1 if self.waiting == 0 && self.state is Running { for child in self.children { @@ -74,16 +70,16 @@ fn[X] TaskGroup::spawn_coroutine( self.parent.wake() } } - guard self.state is Running else { } f() catch { err if allow_failure => raise err + Cancelled::Cancelled as err if is_being_cancelled() => raise err err => { if self.state is Running { for child in self.children { child.cancel() } self.state = Fail(err) - } else if not(err is Cancelled::Cancelled) { + } else { self.state = Fail(err) } raise err @@ -93,6 +89,9 @@ fn[X] TaskGroup::spawn_coroutine( let coro = spawn(worker) self.children.add(coro) + if !(self.state is Running) { + coro.cancel() + } coro } @@ -100,14 +99,23 @@ fn[X] TaskGroup::spawn_coroutine( /// Spawn a child task in a task group, and run it asynchronously in the background. /// /// Unless `no_wait` (`false` by default) is `true`, -/// the whole task group will only exit after this child task terminates. +/// the task group will wait for this task to complete before normal exit. +/// No matter what the value of `no_wait` is, +/// `with_task_group` will only return after all child tasks terminate. +/// The task will be cancelled automatically if it is still running +/// when the task group wishes to terminate. /// /// Unless `allow_failure` (`false` by default) is `true`, -/// Ithe whole task group will also fail if the spawned task fails, +/// the whole task group will also fail if the spawned task fails, /// other tasks in the group will be cancelled in this case. /// -/// If the task group is already cancelled or has been terminated, -/// `spawn_bg` will fail with error and the child task will not be spawned. +/// Cancelled tasks are not considered failing, although they raise a +/// cancellation error. A different error raised during cancellation is still +/// considered a failure. +/// +/// A task spawned while the group is cancelling still starts, but begins in a +/// cancelled state so its entry cleanup can run. Spawning after the group has +/// terminated or from a group defer is invalid and aborts. /// /// It is undefined whether the child task will start running immediately /// before `spawn_bg` returns. @@ -126,14 +134,23 @@ pub fn[X] TaskGroup::spawn_bg( /// and retrieved using `.wait()`, or cancelled using `.cancel()`. /// /// Unless `no_wait` (`false` by default) is `true`, -/// the whole task group will only exit after this child task terminates. +/// the task group will wait for this task to complete before normal exit. +/// No matter what the value of `no_wait` is, +/// `with_task_group` will only return after all child tasks terminate. +/// The task will be cancelled automatically if it is still running +/// when the task group wishes to terminate. /// /// Unless `allow_failure` (`false` by default) is `true`, -/// Ithe whole task group will also fail if the spawned task fails, +/// the whole task group will also fail if the spawned task fails, /// other tasks in the group will be cancelled in this case. /// -/// If the task group is already cancelled or has been terminated, -/// `spawn` will fail with error and the child task will not be spawned. +/// Cancelled tasks are not considered failing, although they raise a +/// cancellation error. A different error raised during cancellation is still +/// considered a failure. +/// +/// A task spawned while the group is cancelling still starts, but begins in a +/// cancelled state so its entry cleanup can run. Spawning after the group has +/// terminated or from a group defer is invalid and aborts. /// /// It is undefined whether the child task will start running immediately /// before `spawn` returns. @@ -142,7 +159,7 @@ pub fn[G, X] TaskGroup::spawn( f : async () -> X, no_wait? : Bool = false, allow_failure? : Bool = false, -) -> Task_[X] { +) -> Task[X] { let value = @ref.new(Option::None) let coro = self.spawn_coroutine( () => value.val = Some(f()), @@ -154,7 +171,8 @@ pub fn[G, X] TaskGroup::spawn( ///| /// Attach a defer block, represented as a cleanup function, to a task group. -/// The clenaup function will be invoked when the group terminates. +/// The cleanup function runs after every child task has terminated. Spawning a +/// new child from a group defer is invalid and aborts. /// Group scoped defer blocks are executed in FILO order, just like normal `defer`. /// `with_task_group` will only exit after all group defer blocks terminate. /// @@ -183,36 +201,39 @@ pub fn[X] TaskGroup::add_defer( /// `with_task_group` will return the result of `f`. pub async fn[X] with_task_group(f : async (TaskGroup[X]) -> X) -> X { let tg = { - children: Set::new(), + children: Set([]), parent: current_coroutine(), waiting: 0, state: Running, result: None, group_defer: [], } - tg.spawn_bg(fn() { + let curr = current_coroutine() + let prev = curr.spawner + curr.spawner = Some(fn(child) { tg.spawn_bg(child) }) + defer { + curr.spawner = prev + } + tg.spawn_bg(() => { let value = f(tg) if tg.result is None { tg.result = Some(value) } }) - if not(tg.children.is_empty()) { - suspend() catch { - err => - if tg.state is Running { - tg.state = Fail(err) - for child in tg.children { - child.cancel() - } + suspend() catch { + err => { + if tg.state is Running { + tg.state = Fail(err) + } + if !tg.children.is_empty() { + for child in tg.children { + child.cancel() } + protect_from_cancel(() => suspend(), resume_on_cancel=true) + } } } - if not(tg.children.is_empty()) { - protect_from_cancel(() => suspend()) catch { - _ => () - } - } - tg.children.clear() + guard tg.children.is_empty() while tg.group_defer.pop() is Some(defer_block) { defer_block() catch { err => if tg.state is Done { tg.state = Fail(err) } @@ -238,12 +259,11 @@ pub fn[X] TaskGroup::return_immediately( } if self.state is Running { self.state = Done - let curr_coro = current_coroutine() for child in self.children { - if child != curr_coro { - child.cancel() - } + child.cancel() } } - raise Cancelled::Cancelled + if is_being_cancelled() { + raise Cancelled::Cancelled + } } diff --git a/crates/moonbit/src/async/trait.mbt b/crates/moonbit/src/async/trait.mbt index ad2c20b76..1413502d0 100644 --- a/crates/moonbit/src/async/trait.mbt +++ b/crates/moonbit/src/async/trait.mbt @@ -1,50 +1,908 @@ ///| -pub(all) struct FutureR[X](async () -> X) +pub struct Sink[X] { + priv write : async (ArrayView[X]) -> Int + priv close : async () -> Unit + priv is_open : () -> Bool + priv has_cleanup : () -> Bool + priv cleanup : ((X) -> Unit)? +} + +///| +let sink_write_window_size : Int = 64 + +///| +/// The write callback must either raise before consuming any value or return +/// the number consumed. It must not raise after partial consumption because +/// `ArrayView` cannot carry an ownership offset through an exception. +#internal(wit_bindgen, "generated binding code only") +pub fn[X] Sink::from_callbacks( + write : async (ArrayView[X]) -> Int, + close : async () -> Unit, + is_open : () -> Bool, + cleanup : ((X) -> Unit)?, +) -> Sink[X] { + { write, close, is_open, has_cleanup: () => cleanup is Some(_), cleanup } +} + +///| +/// Consumes up to one bounded window and returns the number of values consumed. +/// +/// Consumption includes values accepted by the peer and values recursively +/// cleaned after the peer closes. Check `is_open()` to distinguish those cases. +pub async fn[X] Sink::write(self : Sink[X], data : ArrayView[X]) -> Int { + if data.length() == 0 { + return 0 + } + let length = if data.length() < sink_write_window_size { + data.length() + } else { + sink_write_window_size + } + // The callback may suspend, so it must not retain the caller's borrowed view. + let buffer = FixedArray::makei(length, i => data[i]) + let written = (self.write)(buffer[:]) + guard written >= 0 && written <= length + if !(self.is_open)() && written < length && (self.has_cleanup)() { + self.cleanup_unaccepted(buffer[:], written) + return length + } + written +} + +///| +/// Writes one bounded window from an immutable byte view. +/// +/// This is the byte-stream counterpart of `write(ArrayView[T])`. MoonBit keeps +/// `BytesView` and `ArrayView[Byte]` as distinct types, so byte strings need a +/// separate entry point until the language provides a shared read-only view. +pub async fn Sink::write_bytes(self : Sink[Byte], data : BytesView) -> Int { + if data.length() == 0 { + return 0 + } + let length = if data.length() < sink_write_window_size { + data.length() + } else { + sink_write_window_size + } + let buffer = FixedArray::makei(length, i => data[i]) + let written = (self.write)(buffer[:]) + guard written >= 0 && written <= length + if !(self.is_open)() && written < length && (self.has_cleanup)() { + self.cleanup_unaccepted(buffer[:], written) + return length + } + written +} + +///| +/// Returns whether the sink can still accept values from its peer. +pub fn[X] Sink::is_open(self : Sink[X]) -> Bool { + (self.is_open)() +} + +///| +fn[X] Sink::cleanup_unaccepted( + self : Sink[X], + data : ArrayView[X], + offset : Int, +) -> Unit { + match self.cleanup { + Some(cleanup) => + for i in offset.. () + } +} + +///| +fn Sink::cleanup_unaccepted_bytes( + self : Sink[Byte], + data : BytesView, + offset : Int, +) -> Unit { + match self.cleanup { + Some(cleanup) => + for i in offset.. () + } +} + +///| +/// Writes the entire slice to the sink unless the sink closes first. +/// +/// Returns `true` if all items were written while the sink remained open and +/// `false` if the endpoint closed during the operation. When the sink has a +/// cleanup callback, every item was either accepted or cleaned and must not be +/// retried. Producers of explicitly managed resources must use such a sink. +pub async fn[X] Sink::write_all(self : Sink[X], data : ArrayView[X]) -> Bool { + let mut offset = 0 + for ;; { + if offset >= data.length() { + return true + } + let written = self.write(data[offset:]) catch { + err => { + self.cleanup_unaccepted(data, offset) + raise err + } + } + offset = offset + written + if !self.is_open() { + self.cleanup_unaccepted(data, offset) + return false + } + guard written > 0 + } +} + +///| +/// Writes the entire byte view unless the sink closes first. +pub async fn Sink::write_all_bytes(self : Sink[Byte], data : BytesView) -> Bool { + let mut offset = 0 + for ;; { + if offset >= data.length() { + return true + } + let written = self.write_bytes(data[offset:]) catch { + err => { + self.cleanup_unaccepted_bytes(data, offset) + raise err + } + } + offset = offset + written + if !self.is_open() { + self.cleanup_unaccepted_bytes(data, offset) + return false + } + guard written > 0 + } +} + +///| +pub async fn[X] Sink::close(self : Sink[X]) -> Unit { + (self.close)() +} + +///| +priv struct LocalFutureState[X] { + mut value : X? + mut closed : Bool + cleanup : ((X) -> Unit)? +} + +///| +fn[X] local_future_close(state : Ref[LocalFutureState[X]]) -> Unit { + if state.val.closed { + return + } + match state.val.value { + Some(value) => { + state.val.value = None + match state.val.cleanup { + Some(cleanup) => cleanup(value) + None => () + } + } + None => () + } + state.val.closed = true +} + +///| +fn[X] local_future_get(state : Ref[LocalFutureState[X]]) -> X raise { + match state.val.value { + Some(value) => { + state.val.value = None + state.val.closed = true + value + } + None => raise Cancelled::Cancelled + } +} ///| -pub(all) struct StreamR[X] { - read : async (Int) -> ArrayView[X]? +priv struct StreamPipeReader[X] { + max : Int + mut value : FixedArray[X]? + mut ended : Bool + mut coro : Coroutine? +} + +///| +priv struct StreamPipeWriter[X] { + mut value : FixedArray[X]? + mut accepted : Int + mut coro : Coroutine? +} + +///| +priv struct StreamPipe[X] { + capacity : Int + chunks : @deque.Deque[FixedArray[X]] + mut buffered : Int + mut writer_closed : Bool + mut reader_dropped : Bool + mut cleanup : ((X) -> Unit)? + mut head : FixedArray[X]? + mut head_pos : Int + readers : @deque.Deque[StreamPipeReader[X]] + writers : @deque.Deque[StreamPipeWriter[X]] +} + +///| +fn[X] wake_stream_pipe_reader( + reader : StreamPipeReader[X], + value : FixedArray[X]?, + ended : Bool, +) -> Unit { + match reader.coro { + Some(coro) => { + reader.value = value + reader.ended = ended + reader.coro = None + coro.wake() + } + None => () + } +} + +///| +fn[X] wake_stream_pipe_writer( + writer : StreamPipeWriter[X], + accepted : Int, +) -> Unit { + match writer.coro { + Some(coro) => { + writer.accepted = accepted + writer.value = None + writer.coro = None + coro.wake() + } + None => writer.value = None + } +} + +///| +fn[X] take_stream_pipe_reader( + pipe : Ref[StreamPipe[X]], +) -> StreamPipeReader[X]? { + while pipe.val.readers.pop_front() is Some(reader) { + if reader.coro is Some(_) { + return Some(reader) + } + } + None +} + +///| +fn[X] take_stream_pipe_writer( + pipe : Ref[StreamPipe[X]], +) -> StreamPipeWriter[X]? { + while pipe.val.writers.pop_front() is Some(writer) { + if writer.coro is Some(_) && writer.value is Some(_) { + return Some(writer) + } + } + None +} + +///| +fn[X] fill_stream_pipe_from_writers(pipe : Ref[StreamPipe[X]]) -> Unit { + if pipe.val.capacity <= 0 || pipe.val.writer_closed || pipe.val.reader_dropped { + return + } + for ;; { + let available = pipe.val.capacity - pipe.val.buffered + if available <= 0 { + return + } + guard take_stream_pipe_writer(pipe) is Some(writer) else { return } + guard writer.value is Some(data) else { continue } + let take = if available < data.length() { available } else { data.length() } + let chunk = FixedArray::makei(take, i => data[i]) + pipe.val.chunks.push_back(chunk) + pipe.val.buffered = pipe.val.buffered + take + wake_stream_pipe_writer(writer, take) + } +} + +///| +fn[X] stream_pipe_drop_reader(pipe : Ref[StreamPipe[X]]) -> Unit { + stream_pipe_drop_reader_with_cleanup(pipe, pipe.val.cleanup) +} + +///| +fn[X] stream_pipe_drop_reader_with_cleanup( + pipe : Ref[StreamPipe[X]], + cleanup : ((X) -> Unit)?, +) -> Unit { + if pipe.val.reader_dropped { + return + } + match cleanup { + Some(cleanup) => { + match pipe.val.head { + Some(head) => + for i in pipe.val.head_pos.. () + } + while pipe.val.chunks.pop_front() is Some(chunk) { + for value in chunk { + cleanup(value) + } + } + } + None => () + } + pipe.val.head = None + pipe.val.head_pos = 0 + pipe.val.chunks.clear() + pipe.val.buffered = 0 + pipe.val.reader_dropped = true + while pipe.val.readers.pop_front() is Some(reader) { + wake_stream_pipe_reader(reader, None, true) + } + while pipe.val.writers.pop_front() is Some(writer) { + let consumed = match (cleanup, writer.value) { + (Some(cleanup), Some(data)) => { + for value in data { + cleanup(value) + } + data.length() + } + _ => 0 + } + wake_stream_pipe_writer(writer, consumed) + } +} + +///| +fn[X] stream_pipe_reject_reader( + pipe : Ref[StreamPipe[X]], + fallback : (X) -> Unit, +) -> Unit { + if pipe.val.cleanup is None { + pipe.val.cleanup = Some(fallback) + } + stream_pipe_drop_reader(pipe) +} + +///| +fn[X] stream_pipe_close_writer(pipe : Ref[StreamPipe[X]]) -> Unit { + if pipe.val.writer_closed { + return + } + pipe.val.writer_closed = true + while pipe.val.writers.pop_front() is Some(writer) { + wake_stream_pipe_writer(writer, 0) + } + if pipe.val.buffered == 0 { + while pipe.val.readers.pop_front() is Some(reader) { + wake_stream_pipe_reader(reader, None, true) + } + } +} + +///| +async fn[X] stream_pipe_write( + pipe : Ref[StreamPipe[X]], + data : ArrayView[X], +) -> Int { + if data.length() == 0 || pipe.val.writer_closed || pipe.val.reader_dropped { + return 0 + } + match take_stream_pipe_reader(pipe) { + Some(reader) => { + let take = if reader.max < data.length() { + reader.max + } else { + data.length() + } + let chunk = FixedArray::makei(take, i => data[i]) + wake_stream_pipe_reader(reader, Some(chunk), false) + return take + } + None => () + } + if pipe.val.capacity > 0 && pipe.val.buffered < pipe.val.capacity { + let available = pipe.val.capacity - pipe.val.buffered + let take = if available < data.length() { available } else { data.length() } + let chunk = FixedArray::makei(take, i => data[i]) + pipe.val.chunks.push_back(chunk) + pipe.val.buffered = pipe.val.buffered + take + return take + } + let writer = StreamPipeWriter::{ + value: Some(FixedArray::makei(data.length(), i => data[i])), + accepted: 0, + coro: Some(current_coroutine()), + } + pipe.val.writers.push_back(writer) + suspend() catch { + _ if writer.coro is None => return writer.accepted + err => { + writer.coro = None + writer.value = None + raise err + } + } + writer.value = None + writer.accepted +} + +///| +async fn[X] stream_pipe_read( + pipe : Ref[StreamPipe[X]], + count : Int, +) -> FixedArray[X]? { + if count <= 0 { + return Some([]) + } + for ;; { + if pipe.val.reader_dropped { + return None + } + match pipe.val.head { + Some(head) => { + let available = head.length() - pipe.val.head_pos + let take = if count < available { count } else { available } + let result = FixedArray::makei(take, i => head[pipe.val.head_pos + i]) + pipe.val.head_pos = pipe.val.head_pos + take + pipe.val.buffered = pipe.val.buffered - take + if pipe.val.head_pos >= head.length() { + pipe.val.head = None + pipe.val.head_pos = 0 + } + fill_stream_pipe_from_writers(pipe) + return Some(result) + } + None => () + } + if pipe.val.chunks.pop_front() is Some(chunk) { + pipe.val.head = Some(chunk) + pipe.val.head_pos = 0 + continue + } + match take_stream_pipe_writer(pipe) { + Some(writer) => { + guard writer.value is Some(data) else { continue } + let take = if count < data.length() { count } else { data.length() } + let result = FixedArray::makei(take, i => data[i]) + wake_stream_pipe_writer(writer, take) + return Some(result) + } + None => () + } + if pipe.val.writer_closed { + return None + } + let reader = StreamPipeReader::{ + max: count, + value: None, + ended: false, + coro: Some(current_coroutine()), + } + pipe.val.readers.push_back(reader) + suspend() catch { + err => { + reader.coro = None + match reader.value { + Some(value) => return Some(value) + None if reader.ended => return None + None => raise err + } + } + } + match reader.value { + Some(value) => return Some(value) + None if reader.ended => return None + None => () + } + } +} + +///| +priv struct FutureSource[X] { + get : async () -> X close : async () -> Unit + close_sync : () -> Unit +} + +///| +priv struct FutureProducerState[X] { + mut producer : (async () -> X)? + mut on_unstarted_drop : (() -> Unit)? +} + +///| +priv enum FutureState[X] { + Source(FutureSource[X]) + Local(Ref[LocalFutureState[X]]) + Pending(Ref[FutureProducerState[X]]) + Promised(Ref[PromiseCell[X]]) +} + +///| +pub struct Future[X] { + priv inner : FutureState[X] +} + +///| +pub fn[X] Future::ready_with_cleanup( + value : X, + cleanup : (X) -> Unit, +) -> Future[X] { + let state : Ref[LocalFutureState[X]] = { + val: { value: Some(value), closed: false, cleanup: Some(cleanup) }, + } + Future::{ inner: Local(state) } +} + +///| +pub fn[X] Future::ready(value : X) -> Future[X] { + let state : Ref[LocalFutureState[X]] = { + val: { value: Some(value), closed: false, cleanup: None }, + } + Future::{ inner: Local(state) } +} + +///| +/// Creates a lazy future from an async producer. +/// +/// `on_unstarted_drop`, when present, releases resources captured by a producer +/// that is discarded before its first `get`. Once the producer starts, it owns +/// its runtime cleanup and this callback is never invoked. +pub fn[X] Future::from( + producer : async () -> X, + on_unstarted_drop? : () -> Unit, +) -> Future[X] { + Future::{ + inner: Pending({ val: { producer: Some(producer), on_unstarted_drop } }), + } +} + +///| +#internal(wit_bindgen, "generated binding code only") +pub fn[X] Future::from_source( + get : async () -> X, + close : async () -> Unit, + close_sync : () -> Unit, +) -> Future[X] { + Future::{ inner: Source({ get, close, close_sync }) } +} + +///| +pub async fn[X] Future::get(self : Future[X]) -> X { + match self.inner { + Source(source) => (source.get)() + Local(state) => local_future_get(state) + Pending(state) => + match state.val.producer { + Some(producer) => { + state.val.producer = None + state.val.on_unstarted_drop = None + producer() + } + None => raise Cancelled::Cancelled + } + Promised(cell) => promised_future_get(cell) + } +} + +///| +pub async fn[X] Future::drop(self : Future[X]) -> Unit noraise { + match self.inner { + Source(source) => (source.close)() catch { _ => () } + Local(state) => local_future_close(state) + Pending(state) => { + let cleanup = state.val.on_unstarted_drop + state.val.producer = None + state.val.on_unstarted_drop = None + match cleanup { + Some(cleanup) => cleanup() + None => () + } + } + Promised(cell) => promised_future_drop(cell) + } +} + +///| +#internal(wit_bindgen, "generated binding code only") +pub fn[X] Future::drop_sync(self : Future[X]) -> Unit { + match self.inner { + Source(source) => (source.close_sync)() + Local(state) => local_future_close(state) + Pending(state) => { + let cleanup = state.val.on_unstarted_drop + state.val.producer = None + state.val.on_unstarted_drop = None + match cleanup { + Some(cleanup) => cleanup() + None => () + } + } + Promised(cell) => promised_future_drop(cell) + } } ///| -pub(all) struct StreamW[X] { - write : async (ArrayView[X]) -> Int +priv struct StreamProducerState[X] { + mut producer : (async (Sink[X]) -> Unit)? + mut cleanup : ((X) -> Unit)? + mut on_unstarted_drop : (() -> Unit)? + mut pipe : Ref[StreamPipe[X]]? +} + +///| +priv struct StreamSource[X] { + read : async (Int) -> FixedArray[X]? close : async () -> Unit + close_sync : () -> Unit } ///| -struct OutStream[X] { - mut stream : StreamW[X]? - mut coroutine : Coroutine? -} derive(Default) +priv enum StreamState[X] { + Source(StreamSource[X]) + Local(Ref[StreamPipe[X]]) + Outgoing(Ref[StreamProducerState[X]]) +} ///| -pub async fn[X] OutStream::get_stream(self : OutStream[X]) -> StreamW[X] { - if self.stream is Some(s) { - return s - } else { - guard self.coroutine is None - self.coroutine = Some(current_coroutine()) - suspend() catch { - e => { - if self.stream is Some(s) { - (s.close)() +pub struct Stream[X] { + priv inner : StreamState[X] +} + +///| +fn[X] new_stream_pipe( + capacity : Int, + cleanup : ((X) -> Unit)?, +) -> Ref[StreamPipe[X]] { + if capacity < 0 { + abort("stream capacity must not be negative") + } + { + val: { + capacity, + chunks: @deque.Deque([]), + buffered: 0, + writer_closed: false, + reader_dropped: false, + cleanup, + head: None, + head_pos: 0, + readers: @deque.Deque([]), + writers: @deque.Deque([]), + }, + } +} + +///| +fn[X] stream_pipe_sink(pipe : Ref[StreamPipe[X]]) -> Sink[X] { + Sink::{ + write: (data : ArrayView[X]) => stream_pipe_write(pipe, data), + close: () => stream_pipe_close_writer(pipe), + is_open: () => !pipe.val.writer_closed && !pipe.val.reader_dropped, + has_cleanup: () => pipe.val.cleanup is Some(_), + cleanup: Some(fn(value) { + match pipe.val.cleanup { + Some(cleanup) => cleanup(value) + None => () + } + }), + } +} + +///| +fn[X] new_local_stream( + capacity : Int, + cleanup : ((X) -> Unit)?, +) -> (Stream[X], Sink[X]) { + let pipe = new_stream_pipe(capacity, cleanup) + let stream = Stream::{ inner: Local(pipe) } + let sink = stream_pipe_sink(pipe) + (stream, sink) +} + +///| +fn spawn_stream_producer(task : async () -> Unit) -> Unit { + let current = current_coroutine() + match current.spawner { + Some(spawner) => spawner(task) + None => { + let _ = spawn(task) + } + } +} + +///| +fn[X] materialize_stream_producer( + state : Ref[StreamProducerState[X]], +) -> Ref[StreamPipe[X]] { + match state.val.pipe { + Some(pipe) => pipe + None => { + let pipe = new_stream_pipe(0, state.val.cleanup) + state.val.cleanup = None + state.val.pipe = Some(pipe) + match state.val.producer { + Some(producer) => { + state.val.producer = None + state.val.on_unstarted_drop = None + let sink = stream_pipe_sink(pipe) + spawn_stream_producer(async fn() { + producer(sink) catch { + _ => { + sink.close() catch { + _ => () + } + return + } + } + sink.close() + }) } - raise e + None => stream_pipe_close_writer(pipe) } + pipe } - self.stream.unwrap() } } ///| -pub fn[X] OutStream::put_stream( - self : OutStream[X], - stream : StreamW[X], -) -> Unit { - self.stream = Some(stream) - if self.coroutine is Some(coro) { - coro.wake() +pub fn[X] Stream::new(capacity? : Int = 0) -> (Stream[X], Sink[X]) { + new_local_stream(capacity, None) +} + +///| +/// Creates a local stream whose unread values are cleaned up when its readable +/// end is dropped. +pub fn[X] Stream::new_with_cleanup( + cleanup : (X) -> Unit, + capacity? : Int = 0, +) -> (Stream[X], Sink[X]) { + new_local_stream(capacity, Some(cleanup)) +} + +///| +/// Creates a lazy stream from an async producer. +/// +/// `cleanup`, when present, releases buffered or pending values if the readable +/// end is dropped after the producer starts. Producers of explicitly managed +/// resources should provide it. +/// +/// `on_unstarted_drop`, when present, releases resources captured by a producer +/// that is discarded before its first read or before a prepared component +/// transfer commits. Once the producer starts, it owns its runtime cleanup and +/// this callback is never invoked. +pub fn[X] Stream::produce( + producer : async (Sink[X]) -> Unit, + cleanup? : (X) -> Unit, + on_unstarted_drop? : () -> Unit, +) -> Stream[X] { + Stream::{ + inner: Outgoing({ + val: { producer: Some(producer), cleanup, on_unstarted_drop, pipe: None }, + }), + } +} + +///| +#internal(wit_bindgen, "generated binding code only") +pub fn[X] Stream::from_source( + read : async (Int) -> FixedArray[X]?, + close : async () -> Unit, + close_sync : () -> Unit, +) -> Stream[X] { + Stream::{ inner: Source({ read, close, close_sync }) } +} + +///| +pub async fn[X] Stream::read(self : Stream[X], count : Int) -> FixedArray[X]? { + match self.inner { + Source(source) => (source.read)(count) + Local(pipe) => stream_pipe_read(pipe, count) + Outgoing(state) => + stream_pipe_read(materialize_stream_producer(state), count) + } +} + +///| +pub async fn[X] Stream::drop(self : Stream[X]) -> Unit noraise { + match self.inner { + Source(source) => (source.close)() catch { _ => () } + Local(pipe) => stream_pipe_drop_reader(pipe) + Outgoing(state) => + match state.val.pipe { + Some(pipe) => stream_pipe_drop_reader(pipe) + None => { + let cleanup = state.val.on_unstarted_drop + state.val.producer = None + state.val.cleanup = None + state.val.on_unstarted_drop = None + match cleanup { + Some(cleanup) => cleanup() + None => () + } + } + } + } +} + +///| +#internal(wit_bindgen, "generated binding code only") +pub fn[X] Stream::drop_sync(self : Stream[X]) -> Unit { + match self.inner { + Source(source) => (source.close_sync)() + Local(pipe) => stream_pipe_drop_reader(pipe) + Outgoing(state) => + match state.val.pipe { + Some(pipe) => stream_pipe_drop_reader(pipe) + None => { + let cleanup = state.val.on_unstarted_drop + state.val.producer = None + state.val.cleanup = None + state.val.on_unstarted_drop = None + match cleanup { + Some(cleanup) => cleanup() + None => () + } + } + } + } +} + +///| +#internal(wit_bindgen, "generated binding code only") +pub fn[X] Stream::take_producer(self : Stream[X]) -> (async (Sink[X]) -> Unit)? { + match self.inner { + Outgoing(state) => + match state.val.pipe { + Some(_) => None + None => { + let producer = state.val.producer + state.val.producer = None + state.val.cleanup = None + state.val.on_unstarted_drop = None + producer + } + } + Source(_) | Local(_) => None + } +} + +///| +/// Rejects a stream that was prepared for a component-model transfer but was +/// never transferred. Component sources are closed immediately and buffered +/// local values are cleaned synchronously. An unstarted producer is discarded +/// without running it; its optional `on_unstarted_drop` callback owns cleanup of +/// captured resources. +#internal(wit_bindgen, "generated binding code only") +pub async fn[X] Stream::reject( + self : Stream[X], + cleanup : (X) -> Unit, +) -> Unit noraise { + match self.inner { + Source(source) => (source.close)() catch { _ => () } + Local(pipe) => stream_pipe_reject_reader(pipe, cleanup) + Outgoing(state) => + match state.val.pipe { + Some(pipe) => stream_pipe_reject_reader(pipe, cleanup) + None => { + let cleanup = state.val.on_unstarted_drop + state.val.producer = None + state.val.cleanup = None + state.val.on_unstarted_drop = None + match cleanup { + Some(cleanup) => cleanup() + None => () + } + } + } } } diff --git a/crates/moonbit/src/async_support.rs b/crates/moonbit/src/async_support.rs index 47ec5933e..128d41afd 100644 --- a/crates/moonbit/src/async_support.rs +++ b/crates/moonbit/src/async_support.rs @@ -1,80 +1,297 @@ -use std::{ - collections::{HashMap, HashSet}, - fmt::Write, -}; +use std::{collections::HashSet, fmt::Write, mem, ops::Range}; use heck::{ToSnakeCase, ToUpperCamelCase}; use wit_bindgen_core::{ - Files, Source, - abi::{self, WasmSignature}, - uwriteln, - wit_parser::{Function, Param, Resolve, Type, TypeDefKind, TypeId}, + AsyncFilterSet, Direction, Files, Ns, Source, + abi::{self, AbiVariant, WasmSignature, WasmType}, + uwrite, uwriteln, + wit_parser::{ + Function, FutureIntrinsic, LiftLowerAbi, Mangling, ManglingAndAbi, Param, Resolve, + StreamIntrinsic, Type, TypeDefKind, TypeId, WasmExport, WasmExportKind, WasmImport, + WorldKey, + }, }; use crate::{ - FFI, FFI_DIR, indent, - pkg::{MoonbitSignature, ToMoonBitIdent}, + ffi, indent, + pkg::{ASYNC_CORE_DIR, MoonbitSignature, ToMoonBitIdent}, }; -use super::{FunctionBindgen, InterfaceGenerator, PayloadFor}; +use super::FunctionBindgen; +use super::InterfaceGenerator; +use super::wasm_type; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum PayloadFor { + Future, + Stream, +} + +impl PayloadFor { + fn label(self) -> &'static str { + match self { + PayloadFor::Future => "future", + PayloadFor::Stream => "stream", + } + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct AsyncFunctionState { + task_return: AsyncTaskReturnState, + endpoint_sites: Vec, + endpoint_uses: Vec, + next_endpoint: usize, +} + +#[derive(Clone, Copy, Debug, Default)] +struct EndpointUse { + lift: bool, + lower: bool, + lower_committed: bool, +} + +#[derive(Clone, Copy, Debug)] +enum EndpointOperation { + Lift, + Lower, + LowerCommitted, +} + +#[derive(Clone, Debug)] +enum AsyncTaskReturnState { + None, + Generating { + prev_src: String, + prev_needs_cleanup_list: bool, + return_param: String, + return_value: String, + }, + Emitted { + params: Vec<(WasmType, String)>, + body: String, + needs_cleanup_list: bool, + return_param: String, + return_value: String, + }, +} + +impl Default for AsyncTaskReturnState { + fn default() -> Self { + Self::None + } +} + +#[derive(Clone, Debug)] +struct AsyncEndpointSite { + kind: PayloadFor, + ty: TypeId, + index: usize, + binding_name: String, + symbol_name: String, + payload_sites: Range, + intrinsics: AsyncEndpointIntrinsics, +} + +#[derive(Clone, Debug)] +struct AsyncIntrinsicName { + module: String, + field: String, +} + +#[derive(Clone, Debug)] +struct AsyncEndpointIntrinsics { + new: AsyncIntrinsicName, + read: AsyncIntrinsicName, + write: AsyncIntrinsicName, + cancel_read: AsyncIntrinsicName, + cancel_write: AsyncIntrinsicName, + drop_readable: AsyncIntrinsicName, + drop_writable: AsyncIntrinsicName, +} + +#[derive(Clone, Debug)] +pub(crate) struct AsyncFunctionPlan { + endpoint_sites: Vec, +} + +impl AsyncFunctionPlan { + fn new(endpoint_sites: Vec) -> Self { + Self { endpoint_sites } + } + + pub(crate) fn state(&self) -> AsyncFunctionState { + AsyncFunctionState::from_sites(self.endpoint_sites.clone()) + } + + pub(crate) fn has_endpoints(&self) -> bool { + !self.endpoint_sites.is_empty() + } + + fn payload_sites(&self, site: &AsyncEndpointSite) -> Vec { + self.endpoint_sites[site.payload_sites.clone()].to_vec() + } + + fn endpoint_uses(&self, state: &AsyncFunctionState) -> Vec { + assert_eq!(self.endpoint_sites.len(), state.endpoint_uses.len()); + let mut uses = state.endpoint_uses.clone(); + for (site_index, site) in self.endpoint_sites.iter().enumerate().rev() { + let owner = uses[site_index]; + for index in site.payload_sites.clone() { + uses[index].lift |= owner.lift || owner.lower; + uses[index].lower |= owner.lower; + } + } + uses + } +} + +impl AsyncFunctionState { + fn from_sites(endpoint_sites: Vec) -> Self { + let endpoint_uses = vec![EndpointUse::default(); endpoint_sites.len()]; + Self { + endpoint_sites, + endpoint_uses, + next_endpoint: 0, + ..Self::default() + } + } + + fn next_site( + &mut self, + kind: PayloadFor, + ty: TypeId, + operation: EndpointOperation, + ) -> AsyncEndpointSite { + let Some(offset) = self.endpoint_sites[self.next_endpoint..] + .iter() + .position(|site| site.kind == kind && site.ty == ty) + else { + unreachable!("missing async endpoint site for {kind:?} {ty:?}") + }; + let index = self.next_endpoint + offset; + self.next_endpoint = index + 1; + match operation { + EndpointOperation::Lift => self.endpoint_uses[index].lift = true, + EndpointOperation::Lower => self.endpoint_uses[index].lower = true, + EndpointOperation::LowerCommitted => { + self.endpoint_uses[index].lower = true; + self.endpoint_uses[index].lower_committed = true; + } + } + self.endpoint_sites[index].clone() + } +} + +pub(crate) struct AsyncImportPlan { + is_async: bool, +} + +impl AsyncImportPlan { + pub(crate) fn mangling_and_abi(&self) -> ManglingAndAbi { + if self.is_async { + ManglingAndAbi::Legacy(LiftLowerAbi::AsyncCallback) + } else { + ManglingAndAbi::Legacy(LiftLowerAbi::Sync) + } + } + + pub(crate) fn abi_variant(&self) -> AbiVariant { + self.mangling_and_abi().import_variant() + } + + pub(crate) fn signature_is_async(&self) -> bool { + self.is_async + } + + pub(crate) fn is_async(&self) -> bool { + self.is_async + } +} + +pub(crate) struct AsyncImportBody { + pub(crate) src: String, + pub(crate) needs_cleanup_list: bool, + pub(crate) state: AsyncFunctionState, +} + +pub(crate) struct AsyncExportPlan { + is_async: bool, +} + +impl AsyncExportPlan { + pub(crate) fn mangling_and_abi(&self) -> ManglingAndAbi { + if self.is_async { + ManglingAndAbi::Legacy(LiftLowerAbi::AsyncCallback) + } else { + ManglingAndAbi::Legacy(LiftLowerAbi::Sync) + } + } + + pub(crate) fn abi_variant(&self) -> AbiVariant { + self.mangling_and_abi().export_variant() + } + + pub(crate) fn signature_is_async(&self) -> bool { + self.is_async + } -const ASYNC_PRIMITIVE: &str = include_str!("./ffi/async_primitive.mbt"); -const ASYNC_FUTURE: &str = include_str!("./ffi/future.mbt"); -const ASYNC_WASM_PRIMITIVE: &str = include_str!("./ffi/wasm_primitive.mbt"); -const ASYNC_WAITABLE_SET: &str = include_str!("./ffi/waitable_task.mbt"); -const ASYNC_SUBTASK: &str = include_str!("./ffi/subtask.mbt"); + pub(crate) fn is_async(&self) -> bool { + self.is_async + } +} -// NEW Async Impl const ASYNC_ABI: &str = include_str!("./async/async_abi.mbt"); -const ASYNC_CORO: &str = include_str!("./async/coroutine.mbt"); +const ASYNC_COND_VAR: &str = include_str!("./async/cond_var.mbt"); +const ASYNC_COROUTINE: &str = include_str!("./async/coroutine.mbt"); const ASYNC_EV: &str = include_str!("./async/ev.mbt"); +const ASYNC_MUTEX: &str = include_str!("./async/mutex.mbt"); +const ASYNC_PRIMITIVE: &str = include_str!("./async/async_primitive.mbt"); +const ASYNC_PROMISE: &str = include_str!("./async/promise.mbt"); const ASYNC_SCHEDULER: &str = include_str!("./async/scheduler.mbt"); +const ASYNC_SEMAPHORE: &str = include_str!("./async/semaphore.mbt"); const ASYNC_TASK: &str = include_str!("./async/task.mbt"); const ASYNC_TASK_GROUP: &str = include_str!("./async/task_group.mbt"); const ASYNC_TRAIT: &str = include_str!("./async/trait.mbt"); -const ASYNC_PKG_JSON: &str = include_str!("./async/moon.pkg.json"); -const ASYNC_PRIM: &str = include_str!("./async/async_primitive.mbt"); +const ASYNC_PKG: &str = include_str!("./async/moon.pkg.json"); struct Segment<'a> { name: &'a str, src: &'a str, } -const ASYNC_UTILS: [&Segment; 5] = [ +const ASYNC_UTILS: [&Segment; 12] = [ &Segment { name: "async_primitive", src: ASYNC_PRIMITIVE, }, &Segment { - name: "async_future", - src: ASYNC_FUTURE, + name: "async_abi", + src: ASYNC_ABI, }, &Segment { - name: "async_wasm_primitive", - src: ASYNC_WASM_PRIMITIVE, + name: "async_coroutine", + src: ASYNC_COROUTINE, }, &Segment { - name: "async_waitable_set", - src: ASYNC_WAITABLE_SET, + name: "async_ev", + src: ASYNC_EV, }, &Segment { - name: "async_subtask", - src: ASYNC_SUBTASK, + name: "async_cond_var", + src: ASYNC_COND_VAR, }, -]; - -const ASYNC_IMPL: [&Segment; 8] = [ &Segment { - name: "async_abi", - src: ASYNC_ABI, + name: "async_semaphore", + src: ASYNC_SEMAPHORE, }, &Segment { - name: "async_coro", - src: ASYNC_CORO, + name: "async_mutex", + src: ASYNC_MUTEX, }, &Segment { - name: "async_ev", - src: ASYNC_EV, + name: "async_promise", + src: ASYNC_PROMISE, }, &Segment { name: "async_scheduler", @@ -92,221 +309,480 @@ const ASYNC_IMPL: [&Segment; 8] = [ name: "async_trait", src: ASYNC_TRAIT, }, - &Segment { - name: "async_primitive", - src: ASYNC_PRIM, - }, ]; -pub(crate) const ASYNC_DIR: &str = "async"; - #[derive(Default)] pub(crate) struct AsyncSupport { - is_async: bool, - futures: HashMap>, + runtime_required: bool, + endpoints: HashSet, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct AsyncEndpointKey { + binding_name: String, + index: usize, + kind: PayloadFor, +} + +#[derive(Clone, Debug)] +struct AsyncEndpointNames { + binding_name: String, +} + +impl AsyncEndpointNames { + fn import(resolve: &Resolve, interface: Option<&WorldKey>, func: &Function) -> Self { + let (module, func_name) = resolve.wasm_import_name( + ManglingAndAbi::Legacy(LiftLowerAbi::Sync), + WasmImport::Func { interface, func }, + ); + let binding_name = format!("{module}#{func_name}"); + Self { binding_name } + } + + fn export(resolve: &Resolve, interface: Option<&WorldKey>, func: &Function) -> Self { + let export_name = resolve.wasm_export_name( + ManglingAndAbi::Legacy(LiftLowerAbi::Sync), + WasmExport::Func { + interface, + func, + kind: WasmExportKind::Normal, + }, + ); + Self { + binding_name: format!("[export]{export_name}"), + } + } } impl AsyncSupport { - pub(crate) fn mark_async(&mut self) { - self.is_async = true; + pub(crate) fn import_plan( + &mut self, + async_filter: &mut AsyncFilterSet, + resolve: &Resolve, + module: Option<&WorldKey>, + func: &Function, + ) -> AsyncImportPlan { + let is_async = async_filter.is_async(resolve, module, func, true); + if is_async { + self.runtime_required = true; + } + AsyncImportPlan { is_async } + } + + pub(crate) fn export_plan( + &mut self, + async_filter: &mut AsyncFilterSet, + resolve: &Resolve, + interface: Option<&WorldKey>, + func: &Function, + ) -> AsyncExportPlan { + let is_async = async_filter.is_async(resolve, interface, func, false); + if is_async { + self.runtime_required = true; + } + AsyncExportPlan { is_async } + } + + pub(crate) fn require_runtime(&mut self) { + self.runtime_required = true; } - pub(crate) fn register_future_or_stream(&mut self, module: &str, ty: TypeId) -> bool { - self.futures - .entry(module.to_string()) - .or_default() - .insert(ty) + fn register_future_or_stream( + &mut self, + binding_name: &str, + index: usize, + kind: PayloadFor, + ) -> bool { + self.endpoints.insert(AsyncEndpointKey { + binding_name: binding_name.to_string(), + index, + kind, + }) } - pub(crate) fn emit_utils(&self, files: &mut Files, version: &str) { - if !self.is_async && self.futures.is_empty() { + pub(crate) fn emit_runtime_files(&self, files: &mut Files, version: &str) { + if !self.runtime_required && self.endpoints.is_empty() { return; } let mut body = Source::default(); wit_bindgen_core::generated_preamble(&mut body, version); - body.push_str(FFI); - files.push(&format!("{FFI_DIR}/top.mbt"), indent(&body).as_bytes()); + files.push( + &format!("{ASYNC_CORE_DIR}/top.mbt"), + indent(&body).as_bytes(), + ); ASYNC_UTILS.iter().for_each(|s| { files.push( - &format!("{FFI_DIR}/{}.mbt", s.name), - indent(s.src).as_bytes(), - ); - }); - ASYNC_IMPL.iter().for_each(|s| { - files.push( - &format!("{ASYNC_DIR}/{}.mbt", s.name), - indent(s.src).as_bytes(), + &format!("{ASYNC_CORE_DIR}/{}.mbt", s.name), + s.src.as_bytes(), ); }); files.push( - &format!("{ASYNC_DIR}/moon.pkg.json"), - indent(ASYNC_PKG_JSON).as_bytes(), + &format!("{ASYNC_CORE_DIR}/moon.pkg.json"), + ASYNC_PKG.as_bytes(), ); - files.push( - &format!("{FFI_DIR}/moon.pkg.json"), - "{ \"warn-list\": \"-44\", \"supported-targets\": [\"wasm\"] }".as_bytes(), + } +} + +fn async_endpoint_sites( + resolve: &Resolve, + names: &AsyncEndpointNames, + interface: Option<&WorldKey>, + func: &Function, + exported: bool, +) -> Vec { + let mut sites = func + .find_futures_and_streams(resolve) + .into_iter() + .enumerate() + .map(|(index, ty)| { + let kind = match &resolve.types[ty].kind { + TypeDefKind::Future(_) => PayloadFor::Future, + TypeDefKind::Stream(_) => PayloadFor::Stream, + _ => unreachable!(), + }; + async_endpoint_site(resolve, names, interface, func, exported, index, kind, ty) + }) + .collect::>(); + + for index in 0..sites.len() { + let payload_type = match &resolve.types[sites[index].ty].kind { + TypeDefKind::Future(payload) | TypeDefKind::Stream(payload) => payload.as_ref(), + _ => unreachable!(), + }; + let Some(payload_type) = payload_type else { + sites[index].payload_sites = index..index; + continue; + }; + let mut payload_types = Vec::new(); + find_futures_and_streams_in_type(resolve, payload_type, &mut payload_types); + let start = index + .checked_sub(payload_types.len()) + .expect("nested async endpoint payload sites must precede their owner"); + assert_eq!( + sites[start..index] + .iter() + .map(|site| site.ty) + .collect::>(), + payload_types, + "upstream async endpoint order changed" ); + sites[index].payload_sites = start..index; } + + sites } -/// Async-specific helpers used by `InterfaceGenerator` to keep the main -/// visitor implementation focused on shared lowering/lifting logic. -impl<'a> InterfaceGenerator<'a> { - /// Builds the MoonBit body for async imports, wiring wasm subtasks into the - /// runtime and lowering/lifting payloads as needed. - #[allow(dead_code, reason = "used by follow-up async generation work")] - pub(super) fn generate_async_import_function( - &mut self, - func: &Function, - mbt_sig: MoonbitSignature, - sig: &WasmSignature, - ) -> String { - let mut body = String::default(); - let mut lower_params = Vec::new(); - let mut lower_results = Vec::new(); +fn async_endpoint_site( + resolve: &Resolve, + names: &AsyncEndpointNames, + interface: Option<&WorldKey>, + func: &Function, + exported: bool, + index: usize, + kind: PayloadFor, + ty: TypeId, +) -> AsyncEndpointSite { + let stem = endpoint_stem(&names.binding_name, index, kind); + AsyncEndpointSite { + kind, + ty, + index, + binding_name: names.binding_name.clone(), + symbol_name: stem.to_upper_camel_case(), + payload_sites: index..index, + intrinsics: async_endpoint_intrinsics(resolve, interface, func, exported, kind, ty), + } +} - if sig.indirect_params { - match &func.params[..] { - [] => {} - [_] => { - lower_params.push("_lower_ptr".into()); - } - multiple_params => { - let params = multiple_params.iter().map(|Param { ty, .. }| ty); - let offsets = self.world_gen.sizes.field_offsets(params.clone()); - let elem_info = self.world_gen.sizes.params(params); - body.push_str(&format!( - r#" - let _lower_ptr : Int = {ffi}malloc({}) - "#, - elem_info.size.size_wasm32(), - ffi = self - .world_gen - .pkg_resolver - .qualify_package(self.name, FFI_DIR) - )); +#[derive(Clone, Copy)] +enum EndpointIntrinsic { + New, + Read, + Write, + CancelRead, + CancelWrite, + DropReadable, + DropWritable, +} - for ((offset, ty), name) in offsets.iter().zip( - multiple_params - .iter() - .map(|Param { name, .. }| name.to_moonbit_ident()), - ) { - let result = self.lower_to_memory( - &format!("_lower_ptr + {}", offset.size_wasm32()), - &name, - ty, - self.name, - ); - body.push_str(&result); - } +impl EndpointIntrinsic { + fn async_lowered(self) -> bool { + matches!(self, Self::Read | Self::Write) + } - lower_params.push("_lower_ptr".into()); - } - } - } else { - let mut f = FunctionBindgen::new(self, Box::new([])); - for (name, ty) in mbt_sig.params.iter() { - lower_params.extend(abi::lower_flat( - f.interface_gen.resolve, - &mut f, - name.clone(), - ty, - )); - } - lower_results.push(f.src.clone()); + fn future(self) -> FutureIntrinsic { + match self { + Self::New => FutureIntrinsic::New, + Self::Read => FutureIntrinsic::Read, + Self::Write => FutureIntrinsic::Write, + Self::CancelRead => FutureIntrinsic::CancelRead, + Self::CancelWrite => FutureIntrinsic::CancelWrite, + Self::DropReadable => FutureIntrinsic::DropReadable, + Self::DropWritable => FutureIntrinsic::DropWritable, } + } - let func_name = func.name.to_upper_camel_case(); + fn stream(self) -> StreamIntrinsic { + match self { + Self::New => StreamIntrinsic::New, + Self::Read => StreamIntrinsic::Read, + Self::Write => StreamIntrinsic::Write, + Self::CancelRead => StreamIntrinsic::CancelRead, + Self::CancelWrite => StreamIntrinsic::CancelWrite, + Self::DropReadable => StreamIntrinsic::DropReadable, + Self::DropWritable => StreamIntrinsic::DropWritable, + } + } +} - let ffi = self - .world_gen - .pkg_resolver - .qualify_package(self.name, FFI_DIR); +fn async_endpoint_intrinsics( + resolve: &Resolve, + interface: Option<&WorldKey>, + func: &Function, + exported: bool, + kind: PayloadFor, + ty: TypeId, +) -> AsyncEndpointIntrinsics { + let intrinsic_ty = match &resolve.types[ty].kind { + TypeDefKind::Future(None) | TypeDefKind::Stream(None) => None, + TypeDefKind::Future(Some(_)) | TypeDefKind::Stream(Some(_)) => Some(ty), + _ => unreachable!(), + }; + let name = |intrinsic: EndpointIntrinsic| { + let import = match kind { + PayloadFor::Future => WasmImport::FutureIntrinsic { + interface, + func, + ty: intrinsic_ty, + intrinsic: intrinsic.future(), + exported, + async_: intrinsic.async_lowered(), + }, + PayloadFor::Stream => WasmImport::StreamIntrinsic { + interface, + func, + ty: intrinsic_ty, + intrinsic: intrinsic.stream(), + exported, + async_: intrinsic.async_lowered(), + }, + }; + let (module, field) = + resolve.wasm_import_name(ManglingAndAbi::Legacy(LiftLowerAbi::Sync), import); + AsyncIntrinsicName { module, field } + }; - let call_import = |params: &Vec| { - format!( - r#" - let _subtask_code = wasmImport{func_name}({}) - let _subtask_status = {ffi}SubtaskStatus::decode(_subtask_code) - let _subtask = @ffi.Subtask::from_handle(_subtask_status.handle(), code=_subtask_code) - - let task = @ffi.current_task() - task.add_waitable(_subtask, @ffi.current_coroutine()) - defer task.remove_waitable(_subtask) - - for {{ - if _subtask.done() || _subtask_status is Returned(_) {{ - break - }} else {{ - @ffi.suspend() - }} - }} + AsyncEndpointIntrinsics { + new: name(EndpointIntrinsic::New), + read: name(EndpointIntrinsic::Read), + write: name(EndpointIntrinsic::Write), + cancel_read: name(EndpointIntrinsic::CancelRead), + cancel_write: name(EndpointIntrinsic::CancelWrite), + drop_readable: name(EndpointIntrinsic::DropReadable), + drop_writable: name(EndpointIntrinsic::DropWritable), + } +} - "#, - params.join(", ") - ) - }; - match &func.result { - Some(ty) => { - lower_params.push("_result_ptr".into()); - let call_import = call_import(&lower_params); - let (lift, lift_result) = &self.lift_from_memory("_result_ptr", ty, self.name); - body.push_str(&format!( - r#" - {} - {} - {call_import} - {lift} - {lift_result} - "#, - lower_results.join("\n"), - &self.malloc_memory("_result_ptr", "1", ty) - )); +fn find_futures_and_streams_in_type(resolve: &Resolve, ty: &Type, results: &mut Vec) { + let Type::Id(id) = ty else { + return; + }; + + match &resolve.types[*id].kind { + TypeDefKind::Resource + | TypeDefKind::Handle(_) + | TypeDefKind::Flags(_) + | TypeDefKind::Enum(_) => {} + TypeDefKind::Record(record) => { + for field in &record.fields { + find_futures_and_streams_in_type(resolve, &field.ty, results); } - None => { - let call_import = call_import(&lower_params); - body.push_str(&call_import); + } + TypeDefKind::Tuple(tuple) => { + for ty in &tuple.types { + find_futures_and_streams_in_type(resolve, ty, results); + } + } + TypeDefKind::Variant(variant) => { + for case in &variant.cases { + if let Some(ty) = &case.ty { + find_futures_and_streams_in_type(resolve, ty, results); + } + } + } + TypeDefKind::Option(ty) + | TypeDefKind::List(ty) + | TypeDefKind::FixedLengthList(ty, ..) + | TypeDefKind::Type(ty) => find_futures_and_streams_in_type(resolve, ty, results), + TypeDefKind::Map(key, value) => { + find_futures_and_streams_in_type(resolve, key, results); + find_futures_and_streams_in_type(resolve, value, results); + } + TypeDefKind::Result(result) => { + if let Some(ty) = &result.ok { + find_futures_and_streams_in_type(resolve, ty, results); + } + if let Some(ty) = &result.err { + find_futures_and_streams_in_type(resolve, ty, results); + } + } + TypeDefKind::Future(payload) => { + if let Some(ty) = payload { + find_futures_and_streams_in_type(resolve, ty, results); + } + results.push(*id); + } + TypeDefKind::Stream(payload) => { + if let Some(ty) = payload { + find_futures_and_streams_in_type(resolve, ty, results); } + results.push(*id); } + TypeDefKind::Unknown => unreachable!(), + } +} + +fn endpoint_stem(binding_name: &str, index: usize, kind: PayloadFor) -> String { + format!( + "{}_{}_{}", + moonbit_identifier_stem(binding_name), + kind.label(), + index, + ) +} + +fn moonbit_identifier_stem(input: &str) -> String { + input + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::() + .to_snake_case() +} - body.to_string() +fn background_group_name(func: &Function) -> String { + let mut names = Ns::default(); + for param in &func.params { + names.tmp(¶m.name.to_moonbit_ident()); } + names.tmp("background_group") +} - /// Ensures async futures and streams referenced by `func` have their helper - /// import tables generated for the given module prefix. - pub(super) fn generation_futures_and_streams_import( +/// Async-specific helpers used by `InterfaceGenerator` to keep the main +/// visitor implementation focused on shared lowering/lifting logic. +impl<'a> InterfaceGenerator<'a> { + pub(super) fn add_async_export_stub_parameter( &mut self, - prefix: &str, func: &Function, - module: &str, + is_async: bool, + params: &mut Vec, ) { - let module = format!("{prefix}{module}"); - for (index, ty) in func - .find_futures_and_streams(self.resolve) - .into_iter() - .enumerate() - { - let func_name = &func.name; + if is_async && matches!(self.direction, Direction::Export) { + let ffi = self + .world_gen + .pkg_resolver + .qualify_package(self.name, ASYNC_CORE_DIR); + let background_group = background_group_name(func); + params.push(format!("{background_group} : {ffi}TaskGroup[Unit]")); + } + } - match &self.resolve.types[ty].kind { + pub(super) fn emit_async_export_wrapper( + &mut self, + plan: &AsyncExportPlan, + func: &Function, + func_name: &str, + params: &str, + result_type: &str, + cleanup_list: &str, + body: &str, + ) -> bool { + if !plan.is_async() { + return false; + } + let ffi = self + .world_gen + .pkg_resolver + .qualify_package(self.name, ASYNC_CORE_DIR); + let background_group = background_group_name(func); + uwrite!( + self.ffi, + r#" + #doc(hidden) + pub fn {func_name}({params}) -> {result_type} {{ + return {ffi}with_waitableset(async fn() {{ + {ffi}with_task_group(async fn({background_group}) {{ + {cleanup_list} + {body} + }}) + }}) + }} + "#, + ); + true + } + + pub(super) fn import_async_function_plan( + &self, + interface: Option<&WorldKey>, + func: &Function, + ) -> AsyncFunctionPlan { + let names = AsyncEndpointNames::import(self.resolve, interface, func); + AsyncFunctionPlan::new(async_endpoint_sites( + self.resolve, + &names, + interface, + func, + false, + )) + } + + pub(super) fn export_async_function_plan( + &self, + interface: Option<&WorldKey>, + func: &Function, + ) -> AsyncFunctionPlan { + let names = AsyncEndpointNames::export(self.resolve, interface, func); + AsyncFunctionPlan::new(async_endpoint_sites( + self.resolve, + &names, + interface, + func, + true, + )) + } + + pub(super) fn generate_async_import_body( + &mut self, + endpoint_plan: &AsyncFunctionPlan, + func: &Function, + mbt_sig: &MoonbitSignature, + sig: &WasmSignature, + ) -> AsyncImportBody { + self.generate_async_import_function(endpoint_plan, func, mbt_sig, sig) + } + + pub(super) fn emit_future_stream_helpers( + &mut self, + plan: &AsyncFunctionPlan, + state: &AsyncFunctionState, + ) { + let endpoint_uses = plan.endpoint_uses(state); + for (site, endpoint_use) in plan.endpoint_sites.iter().zip(endpoint_uses) { + match &self.resolve.types[site.ty].kind { TypeDefKind::Future(payload_type) => { - self.r#generate_async_future_or_stream_import( - PayloadFor::Future, - &module, - index, - func_name, - ty, + self.generate_async_future_or_stream_import( + site, + endpoint_use, + plan.payload_sites(site), payload_type.as_ref(), ); } TypeDefKind::Stream(payload_type) => { - self.r#generate_async_future_or_stream_import( - PayloadFor::Stream, - &module, - index, - func_name, - ty, + self.generate_async_future_or_stream_import( + site, + endpoint_use, + plan.payload_sites(site), payload_type.as_ref(), ); } @@ -315,195 +791,1449 @@ impl<'a> InterfaceGenerator<'a> { } } - fn generate_async_future_or_stream_import( + pub(super) fn emit_async_export_callback( &mut self, - payload_for: PayloadFor, - module: &str, - index: usize, - func_name: &str, - ty: TypeId, - result_type: Option<&Type>, - ) { - if !self - .world_gen - .async_support - .register_future_or_stream(module, ty) - { - return; + plan: &AsyncExportPlan, + interface: Option<&WorldKey>, + func: &Function, + camel_name: &str, + async_state: AsyncFunctionState, + ) -> bool { + if !plan.is_async() { + return false; } - let result = match result_type { - Some(ty) => self.world_gen.pkg_resolver.type_name(self.name, ty), + + let export_func_name = self + .world_gen + .export_ns + .tmp(&format!("wasmExportAsync{camel_name}")); + let AsyncTaskReturnState::Emitted { + body: task_return_body, + needs_cleanup_list: task_return_needs_cleanup, + params: task_return_params, + return_param, + return_value, + } = async_state.task_return + else { + unreachable!() + }; + let (task_return_module, task_return_name, _) = + func.task_return_import(self.resolve, interface, Mangling::Legacy); + let callback_export_name = self.resolve.wasm_export_name( + plan.mangling_and_abi(), + WasmExport::Func { + interface, + func, + kind: WasmExportKind::Callback, + }, + ); + let task_return_param_tys = task_return_params + .iter() + .enumerate() + .map(|(idx, (ty, _expr))| format!("p{}: {}", idx, wasm_type(*ty))) + .collect::>() + .join(", "); + let task_return_param_exprs = task_return_params + .iter() + .map(|(_ty, expr)| expr.as_str()) + .collect::>() + .join(", "); + let helper_package = self.name; + let return_ty = match &func.result { + Some(result) => self + .world_gen + .pkg_resolver + .type_name(helper_package, result) + .to_string(), None => "Unit".into(), }; - - let type_name = self + let return_expr = match return_ty.as_str() { + "Unit" => "".into(), + _ => format!("{return_param}: Ref[{return_ty}?]",), + }; + let cleanup_list = if task_return_needs_cleanup { + self.ffi_imports.insert(ffi::FREE); + " + let cleanup_list : Array[Int] = [] + " + } else { + "" + }; + let cleanup = if task_return_needs_cleanup { + " + cleanup_list.each(mbt_ffi_free) + " + } else { + "" + }; + let ffi = self .world_gen .pkg_resolver - .type_name(self.name, &Type::Id(ty)); - let name = result.to_upper_camel_case(); - let kind = match payload_for { - PayloadFor::Future => "future", - PayloadFor::Stream => "stream", - }; - let table_name = format!("{}_{}_table", type_name.to_snake_case(), kind); - let camel_kind = kind.to_upper_camel_case(); - let payload_len_arg = match payload_for { - PayloadFor::Future => "", - PayloadFor::Stream => " ,length : Int", + .qualify_package(helper_package, ASYNC_CORE_DIR); + let task_return = format!( + r#" + {cleanup_list} + {task_return_body} + {export_func_name}TaskReturn({task_return_param_exprs}) + {cleanup} + {ffi}task_returned() + "# + ); + let task_return = match return_ty.as_str() { + "Unit" => task_return, + _ => format!( + r#" + match {return_param}.val {{ + Some({return_value}) => {{ + {task_return} + }} + None => () + }} + "# + ), }; + let snake_func_name = func.name.to_moonbit_ident().to_string(); - let payload_lift_func = match payload_for { - PayloadFor::Future => "", - PayloadFor::Stream => "List", - }; + uwriteln!( + self.ffi, + r#" + fn {export_func_name}TaskReturn({task_return_param_tys}) = "{task_return_module}" "{task_return_name}" + + fn {snake_func_name}_task_return({return_expr}) -> Unit {{ + {task_return} + }} + "# + ); + + uwriteln!( + self.ffi, + r#" + #doc(hidden) + pub fn {export_func_name}(event_raw: Int, waitable: Int, code: Int) -> Int {{ + {ffi}cb(event_raw, waitable, code) + }} + "# + ); + + let gen_dir = self.world_gen.opts.gen_dir.clone(); + let package = self + .world_gen + .pkg_resolver + .qualify_package(&gen_dir, self.name); + let export = format!( + r#" + #doc(hidden) + pub fn {export_func_name}(event_raw: Int, waitable: Int, code: Int) -> Int {{ + {package}{export_func_name}(event_raw, waitable, code) + }} + "#, + ); + self.world_gen + .export + .insert(callback_export_name, (export_func_name.clone(), export)); + + true + } + + /// Builds the MoonBit body for async imports, wiring wasm subtasks into the + /// runtime and lowering/lifting payloads as needed. + fn generate_async_import_function( + &mut self, + endpoint_plan: &AsyncFunctionPlan, + func: &Function, + mbt_sig: &MoonbitSignature, + sig: &WasmSignature, + ) -> AsyncImportBody { + let mut body = String::default(); + let mut lower_params = Vec::new(); + let mut lower_results = Vec::new(); + let mut async_state = endpoint_plan.state(); + let mut needs_cleanup_list = false; + self.ffi_imports.insert(ffi::FREE); let ffi = self .world_gen .pkg_resolver - .qualify_package(self.name, FFI_DIR); - - let mut dealloc_list; - let malloc; - let lift; - let lower; - let lift_result; - let lift_list: String; - let lower_list: String; - if let Some(result_type) = result_type { - (lift, lift_result) = self.lift_from_memory("ptr", result_type, module); - lower = self.lower_to_memory("ptr", "value", result_type, module); - dealloc_list = self.deallocate_lists( - std::slice::from_ref(result_type), - &[String::from("ptr")], - true, - module, - ); - lift_list = self.list_lift_from_memory( - "ptr", - "length", - &format!("wasm{name}{kind}Lift"), - result_type, - ); - lower_list = - self.list_lower_to_memory(&format!("wasm{name}{kind}Lower"), "value", result_type); + .qualify_package(self.name, ASYNC_CORE_DIR); - malloc = self.malloc_memory("ptr", "length", result_type); + if sig.indirect_params { + match &func.params[..] { + [] => {} + [Param { name, ty, .. }] => { + body.push_str(&self.malloc_memory("_lower_ptr", "1", ty, self.name)); + body.push_str("\ndefer mbt_ffi_free(_lower_ptr)\n"); + body.push_str(&self.lower_to_memory( + "_lower_ptr", + &name.to_moonbit_ident(), + ty, + self.name, + &mut async_state, + )); + lower_params.push("_lower_ptr".into()); + } + multiple_params => { + let params = multiple_params.iter().map(|Param { ty, .. }| ty); + let offsets = self.world_gen.sizes.field_offsets(params.clone()); + let elem_info = self.world_gen.sizes.params(params); + self.ffi_imports.insert(ffi::MALLOC); + body.push_str(&format!( + r#" + let _lower_ptr : Int = mbt_ffi_malloc({}) + defer mbt_ffi_free(_lower_ptr) + "#, + elem_info.size.size_wasm32(), + )); + + for ((offset, ty), name) in offsets.iter().zip( + multiple_params + .iter() + .map(|Param { name, .. }| name.to_moonbit_ident()), + ) { + let result = self.lower_to_memory( + &format!("_lower_ptr + {}", offset.size_wasm32()), + &name, + ty, + self.name, + &mut async_state, + ); + body.push_str(&result); + } - if dealloc_list.is_empty() { - dealloc_list = "let _ = ptr".to_string(); + lower_params.push("_lower_ptr".into()); + } } } else { - lift = "let _ = ptr".to_string(); - lower = "let _ = (ptr, value)".to_string(); - dealloc_list = "let _ = ptr".to_string(); - malloc = "let ptr = 0;".into(); - lift_result = "".into(); - lift_list = "FixedArray::make(length, Unit::default())".into(); - lower_list = "0".into(); - } - - let (mut lift_func, mut lower_func) = if result_type - .is_some_and(|ty| self.is_list_canonical(self.resolve, ty)) - && matches!(payload_for, PayloadFor::Stream) + let mut f = FunctionBindgen::new(self, Box::new([])) + .with_async_state(mem::take(&mut async_state)) + .without_block_cleanup(); + for (name, ty) in mbt_sig.params.iter() { + lower_params.extend(abi::lower_flat( + f.interface_gen.resolve, + &mut f, + name.clone(), + ty, + )); + } + lower_results.push(f.src.clone()); + needs_cleanup_list = f.needs_cleanup_list; + async_state = f.async_state; + } + + if !sig.indirect_params { + let mut stable_params = Vec::with_capacity(lower_params.len()); + let bindings = lower_params + .iter() + .enumerate() + .map(|(index, value)| { + let name = format!("_lower_arg{index}"); + stable_params.push(name.clone()); + format!("let {name} = {value}") + }) + .collect::>() + .join("\n"); + lower_results.push(bindings); + lower_params = stable_params; + } + + let argument_types = func + .params + .iter() + .map(|Param { ty, .. }| *ty) + .collect::>(); + let argument_operands = lower_params.clone(); + let (commit_arguments, commit_state) = self.commit_lists_and_endpoints_with_state( + &argument_types, + &argument_operands, + sig.indirect_params, + self.name, + endpoint_plan.endpoint_sites.clone(), + ); + let (reject_arguments, rejection_state) = self.deallocate_lists_and_own_with_state( + &argument_types, + &argument_operands, + sig.indirect_params, + self.name, + endpoint_plan.endpoint_sites.clone(), + ); + for (used, rejected) in async_state + .endpoint_uses + .iter_mut() + .zip(rejection_state.endpoint_uses) + { + used.lift |= rejected.lift; + used.lower |= rejected.lower; + } + for (used, committed) in async_state + .endpoint_uses + .iter_mut() + .zip(commit_state.endpoint_uses) { - ("".into(), "".into()) + used.lift |= committed.lift; + used.lower |= committed.lower; + } + let commit_arguments = if commit_arguments.trim().is_empty() { + "()".to_string() } else { - ( - format!( + commit_arguments + }; + let reject_arguments = if reject_arguments.trim().is_empty() { + "()".to_string() + } else { + reject_arguments + }; + let settle_arguments = format!( + r#" + fn(before_started) {{ + if before_started {{ + {reject_arguments} + }} else {{ + {commit_arguments} + }} + }} + "# + ); + let func_name = func.name.to_upper_camel_case(); + + let call_import = |params: &Vec, drop_returned_result: &str| { + format!( + r#" + let _subtask_code = wasmImport{func_name}({}) + {ffi}suspend_for_subtask( + _subtask_code, + {settle_arguments}, + fn() {{ + {drop_returned_result} + }}, + ) + + "#, + params.join(", ") + ) + }; + match &func.result { + Some(ty) => { + lower_params.push("_result_ptr".into()); + let (drop_returned_result, drop_result_state) = self + .deallocate_lists_and_own_with_state( + std::slice::from_ref(ty), + &["_result_ptr".into()], + true, + self.name, + endpoint_plan.endpoint_sites.clone(), + ); + for (used, dropped) in async_state + .endpoint_uses + .iter_mut() + .zip(drop_result_state.endpoint_uses) + { + used.lift |= dropped.lift; + used.lower |= dropped.lower; + } + let call_import = call_import(&lower_params, &drop_returned_result); + let (lift, lift_result) = + &self.lift_from_memory("_result_ptr", ty, self.name, &mut async_state); + body.push_str(&format!( r#" - fn wasm{name}{kind}Lift(ptr: Int) -> {result} {{ + {} + {} + defer mbt_ffi_free(_result_ptr) + {call_import} {lift} {lift_result} + "#, + lower_results.join("\n"), + &self.malloc_memory("_result_ptr", "1", ty, self.name) + )); + } + None => { + let call_import = call_import(&lower_params, "()"); + body.push_str(&format!("{}\n{call_import}", lower_results.join("\n"))); + } + } + + AsyncImportBody { + src: body, + needs_cleanup_list, + state: async_state, + } + } + + fn generate_async_future_or_stream_import( + &mut self, + site: &AsyncEndpointSite, + endpoint_use: EndpointUse, + payload_sites: Vec, + result_type: Option<&Type>, + ) { + if !self.world_gen.async_support.register_future_or_stream( + &site.binding_name, + site.index, + site.kind, + ) { + return; + } + let helper_package = self.name.to_string(); + let result = match result_type { + Some(ty) => self.world_gen.pkg_resolver.type_name(&helper_package, ty), + None => "Unit".into(), + }; + + let symbol_name = &site.symbol_name; + let payload_len_arg = match site.kind { + PayloadFor::Future => "", + PayloadFor::Stream => " ,length : Int", + }; + let new_module = &site.intrinsics.new.module; + let new_field = &site.intrinsics.new.field; + let read_module = &site.intrinsics.read.module; + let read_field = &site.intrinsics.read.field; + let write_module = &site.intrinsics.write.module; + let write_field = &site.intrinsics.write.field; + let cancel_read_module = &site.intrinsics.cancel_read.module; + let cancel_read_field = &site.intrinsics.cancel_read.field; + let cancel_write_module = &site.intrinsics.cancel_write.module; + let cancel_write_field = &site.intrinsics.cancel_write.field; + let drop_readable_module = &site.intrinsics.drop_readable.module; + let drop_readable_field = &site.intrinsics.drop_readable.field; + let drop_writable_module = &site.intrinsics.drop_writable.module; + let drop_writable_field = &site.intrinsics.drop_writable.field; + let ffi = self + .world_gen + .pkg_resolver + .qualify_package(&helper_package, ASYNC_CORE_DIR); + + let elem_size = result_type + .map(|ty| self.world_gen.sizes.size(ty).size_wasm32()) + .unwrap_or(0); + let read_chunk_owns_buffer = + result_type.is_some_and(|ty| self.is_list_canonical(self.resolve, ty)); + let staging_window = if payload_sites.is_empty() { 64 } else { 1 }; + + let (lift, lift_result, lower, malloc, lift_list, commit, reject, free_outer) = + if let Some(result_type) = result_type { + let mut payload_state = AsyncFunctionState::from_sites(payload_sites.clone()); + let (lift, lift_result) = + self.lift_from_memory("ptr", result_type, &helper_package, &mut payload_state); + let mut payload_state = AsyncFunctionState::from_sites(payload_sites.clone()); + let lower = self.lower_to_memory( + "ptr", + "value", + result_type, + &helper_package, + &mut payload_state, + ); + let (commit, _) = self.commit_lists_and_endpoints_with_state( + std::slice::from_ref(result_type), + &[String::from("elem_ptr")], + true, + &helper_package, + payload_sites.clone(), + ); + let reject = self.deallocate_lists_and_own( + std::slice::from_ref(result_type), + &[String::from("elem_ptr")], + true, + &helper_package, + payload_sites.clone(), + ); + let lift_list = self.list_lift_from_memory( + "ptr", + "length", + &format!("wasm{symbol_name}Lift"), + result_type, + &helper_package, + ); + let malloc = self.malloc_memory("ptr", "length", result_type, &helper_package); + self.ffi_imports.insert(ffi::FREE); + ( + lift, + lift_result, + lower, + malloc, + lift_list, + commit, + reject, + "mbt_ffi_free(ptr)".to_string(), + ) + } else { + ( + "let _ = ptr".into(), + "".into(), + "let _ = (ptr, value)".into(), + "let ptr = 0".into(), + "FixedArray::make(length, Unit::default())".into(), + String::new(), + String::new(), + "let _ = ptr".into(), + ) + }; + + let commit = if commit.trim().is_empty() { + "let _ = (ptr, start, length)".to_string() + } else { + format!( + r#" + for i = start; i < start + length; i = i + 1 {{ + let elem_ptr = ptr + i * {elem_size} + {commit} }} "# - ), - format!( - r#" - fn wasm{name}{kind}Lower(value: {result}, ptr: Int) -> Unit {{ - {lower} + ) + }; + let reject = if reject.trim().is_empty() { + "let _ = (ptr, start, length)".to_string() + } else { + format!( + r#" + for i = start; i < start + length; i = i + 1 {{ + let elem_ptr = ptr + i * {elem_size} + {reject} }} "# - ), ) }; - if matches!(payload_for, PayloadFor::Stream) { - lift_func.push_str(&format!( + let lift_func = (endpoint_use.lift + && !(matches!(site.kind, PayloadFor::Stream) && read_chunk_owns_buffer)) + .then(|| { + format!( + r#" + fn wasm{symbol_name}Lift(ptr: Int) -> {result} {{ + {lift} + {lift_result} + }} + "# + ) + }) + .unwrap_or_default(); + let lower_func = endpoint_use + .lower + .then(|| { + format!( + r#" + fn wasm{symbol_name}Lower(value: {result}, ptr: Int) -> Unit {{ + {lower} + }} + "# + ) + }) + .unwrap_or_default(); + let list_lift_func = (endpoint_use.lift && matches!(site.kind, PayloadFor::Stream)) + .then(|| { + format!( + r#" + fn wasm{symbol_name}ListLift( + ptr: Int, + length: Int, + ) -> FixedArray[{result}] {{ + {lift_list} + }} + "# + ) + }) + .unwrap_or_default(); + + let lift_intrinsics = endpoint_use + .lift + .then(|| { + format!( + r#" + fn wasmImport{symbol_name}Read(handle : Int, buffer_ptr : Int{payload_len_arg}) -> Int = "{read_module}" "{read_field}" + fn wasmImport{symbol_name}CancelRead(handle : Int) -> Int = "{cancel_read_module}" "{cancel_read_field}" + "# + ) + }) + .unwrap_or_default(); + let drop_readable_intrinsic = (endpoint_use.lift || endpoint_use.lower) + .then(|| { + format!( + r#" + fn wasmImport{symbol_name}DropReadable(handle : Int) = "{drop_readable_module}" "{drop_readable_field}" + "# + ) + }) + .unwrap_or_default(); + let cancel_write_intrinsic = (endpoint_use.lower + && matches!(site.kind, PayloadFor::Stream)) + .then(|| { + format!( r#" - fn wasm{name}{kind}ListLift(ptr: Int, length: Int) -> FixedArray[{result}] {{ - {lift_list} + fn wasmImport{symbol_name}CancelWrite(handle : Int) -> Int = "{cancel_write_module}" "{cancel_write_field}" + "#, + ) + }) + .unwrap_or_default(); + let lower_intrinsics = endpoint_use + .lower + .then(|| { + format!( + r#" + fn wasmImport{symbol_name}New() -> UInt64 = "{new_module}" "{new_field}" + fn wasmImport{symbol_name}Write(handle : Int, buffer_ptr : Int{payload_len_arg}) -> Int = "{write_module}" "{write_field}" + {cancel_write_intrinsic} + fn wasmImport{symbol_name}DropWritable(handle : Int) = "{drop_writable_module}" "{drop_writable_field}" + "# + ) + }) + .unwrap_or_default(); + + let commit_func = endpoint_use + .lower + .then(|| { + format!( + r#" + fn wasm{symbol_name}Commit( + ptr: Int, + start: Int, + length: Int, + ) -> Unit {{ + {commit} + }} + "# + ) + }) + .unwrap_or_default(); + let reject_future_read = format!("wasm{symbol_name}Reject(self.read_buffer, 0, 1)"); + let reject_stream_read = format!("wasm{symbol_name}Reject(self.read_buffer, 0, progress)"); + + let bridge_func = match site.kind { + PayloadFor::Future => { + let reject_prepared = (endpoint_use.lift && endpoint_use.lower) + .then(|| { + format!( + r#" + if wasm{symbol_name}FutureRejectPrepared(self.handle) {{ + self.closed = true + return + }} + "# + ) + }) + .unwrap_or_default(); + let reject_prepared_func = (endpoint_use.lift && endpoint_use.lower) + .then(|| { + format!( + r#" +fn wasm{symbol_name}FutureRejectPrepared(handle: Int) -> Bool {{ + guard wasm{symbol_name}FutureProducers.get(handle) is Some(producer) else {{ + return false + }} + wasm{symbol_name}FutureProducers.remove(handle) + let writer = producer.writer + wasmImport{symbol_name}DropReadable(handle) + {ffi}spawn_component_task_current(async fn() {{ + defer wasmImport{symbol_name}DropWritable(writer) + {ffi}protect_from_cancel( + () => {{ + let value = producer.future.get() + let ptr = wasm{symbol_name}Malloc(1) + wasm{symbol_name}Lower(value, ptr) + let terminal : Ref[Bool?] = {{ val: None }} + while terminal.val is None {{ + terminal.val = {ffi}suspend_for_future_write_terminal( + writer, + wasmImport{symbol_name}Write(writer, ptr), + ) }} - "# - )); + guard terminal.val is Some(transferred) + if transferred {{ + wasm{symbol_name}Commit(ptr, 0, 1) + }} else {{ + wasm{symbol_name}Reject(ptr, 0, 1) + }} + {free_outer} + }}, + resume_on_cancel=true, + ) catch {{ + _ => abort("rejected component future producer ended without a value") + }} + }}) + true +}} + "# + ) + }) + .unwrap_or_default(); + let lift_bridge = endpoint_use + .lift + .then(|| { + format!( + r#" +priv struct Wasm{symbol_name}FutureSource {{ + handle : Int + mut closed : Bool + mut reading : Bool + mut read_task : Int + mut read_buffer : Int + mut read_discarding : Bool + mut read_cleanup_done : Bool + read_cleanup : {ffi}CondVar +}} - lower_func.push_str(&format!( - r#" - fn wasm{name}{kind}ListLower(value: FixedArray[{result}]) -> Int {{ - {lower_list} +fn Wasm{symbol_name}FutureSource::finish_read( + self : Wasm{symbol_name}FutureSource, +) -> Unit {{ + self.reading = false + self.read_task = 0 + self.read_buffer = 0 + self.read_discarding = false + // Keep completion latched until a later read starts so every waiter woken + // by the broadcast can observe it. +}} + +async fn Wasm{symbol_name}FutureSource::wait_for_read_cleanup( + self : Wasm{symbol_name}FutureSource, +) -> Unit noraise {{ + {ffi}protect_from_cancel( + () => {{ + while !self.read_cleanup_done {{ + self.read_cleanup.wait() + }} + }}, + resume_on_cancel=true, + ) catch {{ + _ => () + }} +}} + +async fn Wasm{symbol_name}FutureSource::cancel_active_read( + self : Wasm{symbol_name}FutureSource, +) -> Unit noraise {{ + if !self.reading {{ + return + }} + if self.read_discarding {{ + self.wait_for_read_cleanup() + return + }} + self.read_discarding = true + let completed = Ref(false) + {ffi}protect_from_cancel( + () => {{ + completed.val = {ffi}cancel_future_read( + self.read_task, + self.handle, + () => wasmImport{symbol_name}CancelRead(self.handle), + ) + }}, + resume_on_cancel=true, + ) catch {{ + _ => () + }} + if completed.val && self.read_buffer != 0 {{ + {reject_future_read} + }} + self.read_cleanup_done = true + self.read_cleanup.broadcast() +}} + +async fn Wasm{symbol_name}FutureSource::close( + self : Wasm{symbol_name}FutureSource, +) -> Unit noraise {{ + if self.closed {{ + return + }} + if self.reading {{ + self.cancel_active_read() + }} + self.closed = true + wasmImport{symbol_name}DropReadable(self.handle) +}} + +fn Wasm{symbol_name}FutureSource::close_sync( + self : Wasm{symbol_name}FutureSource, +) -> Unit {{ + if self.closed || self.reading {{ + return + }} + {reject_prepared} + self.closed = true + wasmImport{symbol_name}DropReadable(self.handle) +}} + +async fn Wasm{symbol_name}FutureSource::read( + self : Wasm{symbol_name}FutureSource, +) -> {result} {{ + if self.closed {{ + raise {ffi}FutureReadError::Dropped + }} + if self.reading {{ + raise {ffi}EndpointBusy::Read + }} + let ptr = wasm{symbol_name}Malloc(1) + defer {{ {free_outer} }} + self.reading = true + self.read_task = {ffi}current_component_task_token() + self.read_buffer = ptr + self.read_discarding = false + self.read_cleanup_done = false + {ffi}suspend_for_future_read( + self.handle, + wasmImport{symbol_name}Read(self.handle, ptr), + ) catch {{ + err => {{ + if self.read_discarding {{ + self.wait_for_read_cleanup() + self.finish_read() + raise {ffi}FutureReadError::Dropped + }} + if err is {ffi}Cancelled::Cancelled {{ + self.cancel_active_read() + if !self.closed {{ + self.closed = true + wasmImport{symbol_name}DropReadable(self.handle) }} - "# - )); + }} + self.finish_read() + raise err + }} + }} + if self.read_discarding {{ + self.wait_for_read_cleanup() + self.finish_read() + raise {ffi}FutureReadError::Dropped + }} + let value = wasm{symbol_name}Lift(ptr) + self.finish_read() + if !self.closed {{ + self.closed = true + wasmImport{symbol_name}DropReadable(self.handle) + }} + value +}} + +fn wasm{symbol_name}FutureLift(handle: Int) -> {ffi}Future[{result}] {{ + let source = Wasm{symbol_name}FutureSource::{{ + handle, + closed: false, + reading: false, + read_task: 0, + read_buffer: 0, + read_discarding: false, + read_cleanup_done: false, + read_cleanup: {ffi}CondVar(), + }} + {ffi}Future::from_source( + () => source.read(), + () => source.close(), + () => source.close_sync(), + ) +}} +"# + ) + }) + .unwrap_or_default(); + let lower_committed = endpoint_use + .lower_committed + .then(|| { + format!( + r#" +fn wasm{symbol_name}FutureLowerCommitted(future: {ffi}Future[{result}]) -> Int {{ + let reader = wasm{symbol_name}FutureLower(future) + wasm{symbol_name}FutureCommit(reader) + reader +}} + "# + ) + }) + .unwrap_or_default(); + let lower_bridge = if endpoint_use.lower { + format!( + r#" +priv struct Wasm{symbol_name}FutureProducer {{ + future : {ffi}Future[{result}] + writer : Int +}} + +let wasm{symbol_name}FutureProducers : Map[Int, Wasm{symbol_name}FutureProducer] = Map([]) + +{reject_prepared_func} + +fn wasm{symbol_name}FutureCommit(handle: Int) -> Unit {{ + guard wasm{symbol_name}FutureProducers.get(handle) is Some(producer) else {{ + return + }} + wasm{symbol_name}FutureProducers.remove(handle) + let writer = producer.writer + if !{ffi}has_component_task_scope() {{ + abort("component future producer requires an async task scope") + }} + {ffi}spawn_component_task_current(async fn() {{ + {ffi}protect_from_cancel( + () => {{ + let value = producer.future.get() + let ptr = wasm{symbol_name}Malloc(1) + wasm{symbol_name}Lower(value, ptr) + let terminal : Ref[Bool?] = {{ val: None }} + while terminal.val is None {{ + terminal.val = {ffi}suspend_for_future_write_terminal( + writer, + wasmImport{symbol_name}Write(writer, ptr), + ) + }} + guard terminal.val is Some(transferred) + if transferred {{ + wasm{symbol_name}Commit(ptr, 0, 1) + }} else {{ + wasm{symbol_name}Reject(ptr, 0, 1) + }} + {free_outer} + wasmImport{symbol_name}DropWritable(writer) + }}, + resume_on_cancel=true, + ) catch {{ + _ => abort("component future producer ended without a value") + }} + }}) +}} + +fn wasm{symbol_name}FutureLower(future: {ffi}Future[{result}]) -> Int {{ + if !{ffi}has_component_task_scope() {{ + abort("component future producer requires an async task scope") + }} + let pair = wasmImport{symbol_name}New() + let reader = pair.to_int() + let writer = (pair >> 32).to_int() + wasm{symbol_name}FutureProducers.set(reader, {{ future, writer }}) + reader +}} + +{lower_committed} +"# + ) + } else { + String::new() + }; + format!("{lift_bridge}{lower_bridge}") + } + PayloadFor::Stream => { + let reject_prepared = (endpoint_use.lift && endpoint_use.lower) + .then(|| { + format!( + r#" + if wasm{symbol_name}StreamRejectPrepared(self.handle) {{ + self.closed = true + return + }} + "# + ) + }) + .unwrap_or_default(); + let reject_prepared_func = (endpoint_use.lift && endpoint_use.lower) + .then(|| { + format!( + r#" +fn wasm{symbol_name}StreamRejectPrepared(handle: Int) -> Bool {{ + guard wasm{symbol_name}StreamProducers.get(handle) is Some(prepared) else {{ + return false + }} + wasm{symbol_name}StreamProducers.remove(handle) + let writer = prepared.writer + wasmImport{symbol_name}DropReadable(handle) + wasmImport{symbol_name}DropWritable(writer) + {ffi}spawn_component_task_current(async fn() {{ + {ffi}protect_from_cancel( + () => {{ + prepared.stream.reject((value : {result}) => {{ + let ptr = wasm{symbol_name}Malloc(1) + wasm{symbol_name}Lower(value, ptr) + wasm{symbol_name}Reject(ptr, 0, 1) + {free_outer} + }}) + }}, + resume_on_cancel=true, + ) catch {{ + _ => () + }} + }}) + true +}} + "# + ) + }) + .unwrap_or_default(); + let lift_bridge = endpoint_use + .lift + .then(|| { + format!( + r#" +priv struct Wasm{symbol_name}StreamSource {{ + handle : Int + mut closed : Bool + mut reading : Bool + mut read_task : Int + mut read_buffer : Int + mut read_discarding : Bool + mut read_cleanup_done : Bool + read_cleanup : {ffi}CondVar +}} + +fn Wasm{symbol_name}StreamSource::finish_read( + self : Wasm{symbol_name}StreamSource, +) -> Unit {{ + self.reading = false + self.read_task = 0 + self.read_buffer = 0 + self.read_discarding = false + // Keep completion latched until a later read starts so every waiter woken + // by the broadcast can observe it. +}} + +async fn Wasm{symbol_name}StreamSource::wait_for_read_cleanup( + self : Wasm{symbol_name}StreamSource, +) -> Unit noraise {{ + {ffi}protect_from_cancel( + () => {{ + while !self.read_cleanup_done {{ + self.read_cleanup.wait() + }} + }}, + resume_on_cancel=true, + ) catch {{ + _ => () + }} +}} + +async fn Wasm{symbol_name}StreamSource::cancel_active_read( + self : Wasm{symbol_name}StreamSource, +) -> Unit noraise {{ + if !self.reading {{ + return + }} + if self.read_discarding {{ + self.wait_for_read_cleanup() + return + }} + self.read_discarding = true + let progress_ref = Ref(0) + {ffi}protect_from_cancel( + () => {{ + progress_ref.val = {ffi}cancel_stream_read( + self.read_task, + self.handle, + () => wasmImport{symbol_name}CancelRead(self.handle), + ) + }}, + resume_on_cancel=true, + ) catch {{ + _ => () + }} + let progress = progress_ref.val + if progress > 0 && self.read_buffer != 0 {{ + {reject_stream_read} + }} + self.read_cleanup_done = true + self.read_cleanup.broadcast() +}} + +async fn Wasm{symbol_name}StreamSource::close( + self : Wasm{symbol_name}StreamSource, +) -> Unit noraise {{ + if self.closed {{ + return + }} + if self.reading {{ + self.cancel_active_read() + }} + self.closed = true + wasmImport{symbol_name}DropReadable(self.handle) +}} + +fn Wasm{symbol_name}StreamSource::close_sync( + self : Wasm{symbol_name}StreamSource, +) -> Unit {{ + if self.closed || self.reading {{ + return + }} + {reject_prepared} + self.closed = true + wasmImport{symbol_name}DropReadable(self.handle) +}} + +async fn Wasm{symbol_name}StreamSource::read( + self : Wasm{symbol_name}StreamSource, + count : Int, +) -> FixedArray[{result}]? {{ + if self.closed {{ + return None + }} + if count <= 0 {{ + return Some([]) + }} + if self.reading {{ + raise {ffi}EndpointBusy::Read + }} + let ptr = wasm{symbol_name}Malloc(count) + let mut owns_buffer = false + defer {{ + if !owns_buffer {{ + {free_outer} + }} + }} + self.reading = true + self.read_task = {ffi}current_component_task_token() + self.read_buffer = ptr + self.read_discarding = false + self.read_cleanup_done = false + let (progress, end) = {ffi}suspend_for_stream_read( + self.handle, + wasmImport{symbol_name}Read(self.handle, ptr, count), + ) catch {{ + err => {{ + if self.read_discarding {{ + self.wait_for_read_cleanup() + self.finish_read() + return None + }} + if err is {ffi}Cancelled::Cancelled {{ + self.cancel_active_read() + if !self.closed {{ + self.closed = true + wasmImport{symbol_name}DropReadable(self.handle) + }} + }} + self.finish_read() + raise err + }} + }} + if self.read_discarding {{ + self.wait_for_read_cleanup() + self.finish_read() + return None + }} + if progress == 0 {{ + self.finish_read() + if end {{ + self.close() + return None + }} + return Some([]) + }} + let values = wasm{symbol_name}ListLift(ptr, progress) + owns_buffer = {read_chunk_owns_buffer} + self.finish_read() + if end {{ + self.close() + }} + Some(values) +}} + +fn wasm{symbol_name}StreamLift(handle: Int) -> {ffi}Stream[{result}] {{ + let source = Wasm{symbol_name}StreamSource::{{ + handle, + closed: false, + reading: false, + read_task: 0, + read_buffer: 0, + read_discarding: false, + read_cleanup_done: false, + read_cleanup: {ffi}CondVar(), + }} + {ffi}Stream::from_source( + (count) => source.read(count), + () => source.close(), + () => source.close_sync(), + ) +}} +"# + ) + }) + .unwrap_or_default(); + let lower_committed = endpoint_use + .lower_committed + .then(|| { + format!( + r#" +fn wasm{symbol_name}StreamLowerCommitted(stream: {ffi}Stream[{result}]) -> Int {{ + let reader = wasm{symbol_name}StreamLower(stream) + wasm{symbol_name}StreamCommit(reader) + reader +}} + "# + ) + }) + .unwrap_or_default(); + let lower_bridge = if endpoint_use.lower { + format!( + r#" +priv struct Wasm{symbol_name}StreamProducer {{ + stream : {ffi}Stream[{result}] + writer : Int +}} + +let wasm{symbol_name}StreamProducers : Map[Int, Wasm{symbol_name}StreamProducer] = Map([]) + +{reject_prepared_func} + +fn wasm{symbol_name}StreamCommit(handle: Int) -> Unit {{ + guard wasm{symbol_name}StreamProducers.get(handle) is Some(prepared) else {{ + return + }} + wasm{symbol_name}StreamProducers.remove(handle) + let stream = prepared.stream + let writer = prepared.writer + if !{ffi}has_component_task_scope() {{ + abort("component stream producer requires an async task scope") + }} + let producer = stream.take_producer() + {ffi}spawn_component_task_current(async fn() {{ + let writer_closed = Ref(false) + let writer_lock = {ffi}Mutex() + let close_writer = fn() -> Unit {{ + if !writer_closed.val {{ + writer_closed.val = true + wasmImport{symbol_name}DropWritable(writer) + }} + }} + defer close_writer() + let cleanup_value = fn(value : {result}) -> Unit {{ + let ptr = wasm{symbol_name}Malloc(1) + wasm{symbol_name}Lower(value, ptr) + wasm{symbol_name}Reject(ptr, 0, 1) + {free_outer} + }} + let sink = {ffi}Sink::from_callbacks( + (data : ArrayView[{result}]) => {{ + writer_lock.acquire() + defer writer_lock.release() + if writer_closed.val || data.length() == 0 {{ + return 0 + }} + let data_len = if data.length() < {staging_window} {{ + data.length() + }} else {{ + {staging_window} + }} + let ptr = wasm{symbol_name}Malloc(data_len) + for i = 0; i < data_len; i = i + 1 {{ + let value : {result} = data[i] + wasm{symbol_name}Lower(value, ptr + i * {elem_size}) + }} + let settle_staging = fn(transferred: Int) -> Unit {{ + wasm{symbol_name}Commit(ptr, 0, transferred) + if transferred < data_len {{ + wasm{symbol_name}Reject( + ptr, + transferred, + data_len - transferred, + ) + }} + {free_outer} + }} + let mut total = 0 + let mut dropped = false + while total < data_len {{ + let (progress, end) = {ffi}suspend_for_stream_write( + writer, + wasmImport{symbol_name}Write( + writer, + ptr + total * {elem_size}, + data_len - total, + ), + ) catch {{ + _ => {{ + total = total + {ffi}cancel_stream_write( + writer, + () => wasmImport{symbol_name}CancelWrite(writer), + ) + settle_staging(total) + close_writer() + return data_len + }} + }} + total = total + progress + if end {{ + dropped = true + break + }} + }} + settle_staging(total) + if dropped {{ + close_writer() + }} + // The canonical writer accepted `total`; generated cleanup + // consumed the rest of this staging window. + data_len + }}, + () => {{ + writer_lock.acquire() + defer writer_lock.release() + close_writer() + }}, + () => !writer_closed.val, + Some(cleanup_value), + ) + let relay_source = producer is None + let run_producer = async fn() -> Unit {{ + match producer {{ + Some(producer) => {{ + producer(sink) + sink.close() + }} + None => + for ;; {{ + match stream.read({staging_window}) {{ + Some(data) => + if !sink.write_all(data[:]) {{ + stream.reject(cleanup_value) + return + }} + None => {{ + sink.close() + return + }} + }} + }} + }} + }} + run_producer() catch {{ + _ => + if relay_source {{ + {ffi}protect_from_cancel( + () => stream.reject(cleanup_value), + resume_on_cancel=true, + ) catch {{ + _ => () + }} + }} + }} + }}) +}} + +fn wasm{symbol_name}StreamLower(stream: {ffi}Stream[{result}]) -> Int {{ + if !{ffi}has_component_task_scope() {{ + abort("component stream producer requires an async task scope") + }} + let pair = wasmImport{symbol_name}New() + let reader = pair.to_int() + let writer = (pair >> 32).to_int() + wasm{symbol_name}StreamProducers.set(reader, {{ stream, writer }}) + reader +}} + +{lower_committed} +"# + ) + } else { + String::new() + }; + format!("{lift_bridge}{lower_bridge}") + } }; uwriteln!( self.ffi, r#" -fn wasmImport{name}{kind}New() -> UInt64 = "{module}" "[{kind}-new-{index}]{func_name}" -fn wasmImport{name}{kind}Read(handle : Int, buffer_ptr : Int{payload_len_arg}) -> Int = "{module}" "[async-lower][{kind}-read-{index}]{func_name}" -fn wasmImport{name}{kind}Write(handle : Int, buffer_ptr : Int{payload_len_arg}) -> Int = "{module}" "[async-lower][{kind}-write-{index}]{func_name}" -fn wasmImport{name}{kind}CancelRead(handle : Int) -> Int = "{module}" "[{kind}-cancel-read-{index}]{func_name}" -fn wasmImport{name}{kind}CancelWrite(handle : Int) -> Int = "{module}" "[{kind}-cancel-write-{index}]{func_name}" -fn wasmImport{name}{kind}DropReadable(handle : Int) = "{module}" "[{kind}-drop-readable-{index}]{func_name}" -fn wasmImport{name}{kind}DropWritable(handle : Int) = "{module}" "[{kind}-drop-writable-{index}]{func_name}" -fn wasm{name}{kind}Deallocate(ptr: Int) -> Unit {{ - {dealloc_list} -}} -fn wasm{name}{kind}Malloc(length: Int) -> Int {{ +{lift_intrinsics} +{drop_readable_intrinsic} +{lower_intrinsics} + +fn wasm{symbol_name}Malloc(length: Int) -> Int {{ {malloc} ptr }} -fn {table_name}() -> {ffi}{camel_kind}VTable[{result}] {{ - {ffi}{camel_kind}VTable::new( - wasmImport{name}{kind}New, - wasmImport{name}{kind}Read, - wasmImport{name}{kind}Write, - wasmImport{name}{kind}CancelRead, - wasmImport{name}{kind}CancelWrite, - wasmImport{name}{kind}DropReadable, - wasmImport{name}{kind}DropWritable, - wasm{name}{kind}Malloc, - wasm{name}{kind}Deallocate, - wasm{name}{kind}{payload_lift_func}Lift, - wasm{name}{kind}{payload_lift_func}Lower, - ) +{commit_func} + +fn wasm{symbol_name}Reject( + ptr: Int, + start: Int, + length: Int, +) -> Unit {{ + {reject} }} + {lift_func} {lower_func} -"# +{list_lift_func} +{bridge_func} +"#, ); } - fn deallocate_lists( + fn deallocate_lists_and_own( &mut self, types: &[Type], operands: &[String], indirect: bool, - _module: &str, + package: &str, + endpoint_sites: Vec, ) -> String { - let mut f = FunctionBindgen::new(self, Box::new([])); - abi::deallocate_lists_in_types(f.interface_gen.resolve, types, operands, indirect, &mut f); - f.src + self.deallocate_lists_and_own_with_state(types, operands, indirect, package, endpoint_sites) + .0 } - fn lift_from_memory(&mut self, address: &str, ty: &Type, _module: &str) -> (String, String) { - let mut f = FunctionBindgen::new(self, Box::new([])); + fn deallocate_lists_and_own_with_state( + &mut self, + types: &[Type], + operands: &[String], + indirect: bool, + package: &str, + endpoint_sites: Vec, + ) -> (String, AsyncFunctionState) { + let mut f = FunctionBindgen::new(self, Box::new([])) + .with_type_context(package) + .with_async_state(AsyncFunctionState::from_sites(endpoint_sites)) + .with_sync_endpoint_drop(); + abi::deallocate_lists_and_own_in_types( + f.interface_gen.resolve, + types, + operands, + indirect, + &mut f, + ); + (f.src, f.async_state) + } + + fn commit_lists_and_endpoints_with_state( + &mut self, + types: &[Type], + operands: &[String], + indirect: bool, + package: &str, + endpoint_sites: Vec, + ) -> (String, AsyncFunctionState) { + let mut f = FunctionBindgen::new(self, Box::new([])) + .with_type_context(package) + .with_async_state(AsyncFunctionState::from_sites(endpoint_sites)) + .with_endpoint_commit(); + abi::deallocate_lists_and_own_in_types( + f.interface_gen.resolve, + types, + operands, + indirect, + &mut f, + ); + (f.src, f.async_state) + } + + fn lift_from_memory( + &mut self, + address: &str, + ty: &Type, + package: &str, + async_state: &mut AsyncFunctionState, + ) -> (String, String) { + let mut f = FunctionBindgen::new(self, Box::new([])) + .with_type_context(package) + .with_async_state(mem::take(async_state)); let result = abi::lift_from_memory(f.interface_gen.resolve, &mut f, address.into(), ty); + *async_state = f.async_state; (f.src, result) } - fn lower_to_memory(&mut self, address: &str, value: &str, ty: &Type, _module: &str) -> String { - let mut f = FunctionBindgen::new(self, Box::new([])); + fn lower_to_memory( + &mut self, + address: &str, + value: &str, + ty: &Type, + package: &str, + async_state: &mut AsyncFunctionState, + ) -> String { + let mut f = FunctionBindgen::new(self, Box::new([])) + .with_type_context(package) + .with_async_state(mem::take(async_state)) + .without_block_cleanup(); abi::lower_to_memory( f.interface_gen.resolve, &mut f, @@ -511,16 +2241,15 @@ fn {table_name}() -> {ffi}{camel_kind}VTable[{result}] {{ value.into(), ty, ); + *async_state = f.async_state; f.src } - fn malloc_memory(&mut self, address: &str, length: &str, ty: &Type) -> String { + fn malloc_memory(&mut self, address: &str, length: &str, ty: &Type, package: &str) -> String { let size = self.world_gen.sizes.size(ty).size_wasm32(); - let ffi = self - .world_gen - .pkg_resolver - .qualify_package(self.name, FFI_DIR); - format!("let {address} = {ffi}malloc({size} * {length});") + let _ = package; + self.ffi_imports.insert(ffi::MALLOC); + format!("let {address} = mbt_ffi_malloc({size} * {length});") } fn is_list_canonical(&self, _resolve: &Resolve, element: &Type) -> bool { @@ -536,26 +2265,26 @@ fn {table_name}() -> {ffi}{camel_kind}VTable[{result}] {{ length: &str, lift_func: &str, ty: &Type, + package: &str, ) -> String { - let ffi = self - .world_gen - .pkg_resolver - .qualify_package(self.name, FFI_DIR); + let _ = package; if self.is_list_canonical(self.resolve, ty) { if ty == &Type::U8 { - return format!("{ffi}ptr2bytes({address}, {length})"); + self.ffi_imports.insert(ffi::PTR2BYTES); + return format!("mbt_ffi_ptr2bytes({address}, {length})"); } - let ty = match ty { - Type::U32 => "uint", - Type::U64 => "uint64", - Type::S32 => "int", - Type::S64 => "int64", - Type::F32 => "float", - Type::F64 => "double", + let (ty, builtin) = match ty { + Type::U32 => ("uint", ffi::PTR2UINT_ARRAY), + Type::U64 => ("uint64", ffi::PTR2UINT64_ARRAY), + Type::S32 => ("int", ffi::PTR2INT_ARRAY), + Type::S64 => ("int64", ffi::PTR2INT64_ARRAY), + Type::F32 => ("float", ffi::PTR2FLOAT_ARRAY), + Type::F64 => ("double", ffi::PTR2DOUBLE_ARRAY), _ => unreachable!(), }; - return format!("{ffi}ptr2{ty}_array({address}, {length})"); + self.ffi_imports.insert(builtin); + return format!("mbt_ffi_ptr2{ty}_array({address}, {length})"); } let size = self.world_gen.sizes.size(ty).size_wasm32(); format!( @@ -570,40 +2299,400 @@ fn {table_name}() -> {ffi}{camel_kind}VTable[{result}] {{ "# ) } +} - fn list_lower_to_memory(&mut self, lower_func: &str, value: &str, ty: &Type) -> String { - // Align the address, moonbit only supports wasm32 for now - let ffi = self - .world_gen - .pkg_resolver - .qualify_package(self.name, FFI_DIR); - if self.is_list_canonical(self.resolve, ty) { - if ty == &Type::U8 { - return format!("{ffi}bytes2ptr({value})"); +impl<'a, 'b> FunctionBindgen<'a, 'b> { + pub(super) fn with_type_context(mut self, type_context: &str) -> Self { + self.type_context = type_context.to_string(); + self + } + + pub(super) fn without_block_cleanup(mut self) -> Self { + self.suppress_block_cleanup = true; + self + } + + pub(super) fn with_sync_endpoint_drop(mut self) -> Self { + self.sync_endpoint_drop = true; + self + } + + pub(super) fn with_endpoint_commit(mut self) -> Self { + self.commit_endpoints = true; + self + } + + pub(super) fn with_async_state(mut self, async_state: AsyncFunctionState) -> Self { + self.async_state = async_state; + self + } + + pub(super) fn with_sync_import_commit(mut self, argument_types: Vec) -> Self { + self.sync_import_argument_types = Some(argument_types); + self + } + + pub(super) fn commit_sync_import_arguments( + &mut self, + sig: &WasmSignature, + operands: &[String], + ) { + let Some(argument_types) = self.sync_import_argument_types.clone() else { + return; + }; + if self.async_state.endpoint_sites.is_empty() { + return; + } + + let argument_count = if sig.indirect_params { + 1 + } else { + operands.len() - usize::from(sig.retptr) + }; + let argument_operands = operands[..argument_count].to_vec(); + let sites = self.async_state.endpoint_sites.clone(); + let previous_state = + mem::replace(&mut self.async_state, AsyncFunctionState::from_sites(sites)); + let previous_commit_endpoints = mem::replace(&mut self.commit_endpoints, true); + let previous_preserve_allocations = + mem::replace(&mut self.preserve_guest_allocations, true); + + abi::deallocate_lists_and_own_in_types( + self.interface_gen.resolve, + &argument_types, + &argument_operands, + sig.indirect_params, + self, + ); + + let commit_state = mem::replace(&mut self.async_state, previous_state); + self.commit_endpoints = previous_commit_endpoints; + self.preserve_guest_allocations = previous_preserve_allocations; + for (used, committed) in self + .async_state + .endpoint_uses + .iter_mut() + .zip(commit_state.endpoint_uses) + { + used.lift |= committed.lift; + used.lower |= committed.lower; + } + } + + pub(super) fn emit_async_call_interface( + &mut self, + func: &Function, + operands: &[String], + results: &mut Vec, + ) { + let name = self.interface_gen.world_gen.pkg_resolver.func_call( + &self.type_context, + func, + &self.func_interface, + ); + let task_return_name = format!("{}_task_return", func.name.to_moonbit_ident()); + + let mut args = operands.to_vec(); + if matches!(self.interface_gen.direction, Direction::Export) { + args.push(background_group_name(func)); + } + let args = args.join(", "); + let (return_param, return_value) = match func.result { + Some(ty) => { + let return_param = self.locals.tmp("return_result"); + let return_value = self.locals.tmp("return_value"); + let task_return_type = self + .interface_gen + .world_gen + .pkg_resolver + .type_name(&self.type_context, &ty); + results.push(return_value.clone()); + uwrite!( + self.src, + r#" + let {return_param}: Ref[{task_return_type}?] = Ref(None) + {return_param}.val = Some({name}({args})) + {task_return_name}({return_param}) + "#, + ); + (return_param, return_value) + } + None => { + uwrite!( + self.src, + r#" + {name}({args}) + {task_return_name}() + "#, + ); + (String::new(), String::new()) } + }; + assert!(matches!( + self.async_state.task_return, + AsyncTaskReturnState::None + )); + self.async_state.task_return = AsyncTaskReturnState::Generating { + prev_src: mem::take(&mut self.src), + prev_needs_cleanup_list: mem::replace(&mut self.needs_cleanup_list, false), + return_param, + return_value, + }; + } - let ty = match ty { - Type::U32 => "uint", - Type::U64 => "uint64", - Type::S32 => "int", - Type::S64 => "int64", - Type::F32 => "float", - Type::F64 => "double", + pub(super) fn emit_future_lift( + &mut self, + ty: TypeId, + operands: &[String], + results: &mut Vec, + ) { + let result = self.locals.tmp("result"); + let op = &operands[0]; + let site = self + .async_state + .next_site(PayloadFor::Future, ty, EndpointOperation::Lift); + let lift_name = format!("wasm{}FutureLift", site.symbol_name); + + if self.commit_endpoints { + let commit_name = format!("wasm{}FutureCommit", site.symbol_name); + uwriteln!(self.src, r#"{commit_name}({op});"#); + results.push("()".into()); + return; + } + + uwriteln!(self.src, r#"let {result} = {lift_name}({op});"#,); + + results.push(result); + } + + pub(super) fn emit_future_lower( + &mut self, + ty: TypeId, + operands: &[String], + results: &mut Vec, + ) { + let committed = matches!( + self.async_state.task_return, + AsyncTaskReturnState::Generating { .. } + ); + let operation = if committed { + EndpointOperation::LowerCommitted + } else { + EndpointOperation::Lower + }; + let site = self + .async_state + .next_site(PayloadFor::Future, ty, operation); + let suffix = if committed { + "FutureLowerCommitted" + } else { + "FutureLower" + }; + let lower_name = format!("wasm{}{suffix}", site.symbol_name); + let op = &operands[0]; + results.push(format!("{lower_name}({op})")); + } + + pub(super) fn capture_task_return(&mut self, params: &[WasmType], operands: &[String]) { + let (body, needs_cleanup_list, return_param, return_value) = + match &mut self.async_state.task_return { + AsyncTaskReturnState::Generating { + prev_src, + prev_needs_cleanup_list, + return_param, + return_value, + } => { + mem::swap(&mut self.src, prev_src); + let needs_cleanup_list = + mem::replace(&mut self.needs_cleanup_list, *prev_needs_cleanup_list); + ( + mem::take(prev_src), + needs_cleanup_list, + return_param.clone(), + return_value.clone(), + ) + } _ => unreachable!(), }; - return format!("{ffi}{ty}_array2ptr({value})"); + assert_eq!(params.len(), operands.len()); + self.async_state.task_return = AsyncTaskReturnState::Emitted { + body, + needs_cleanup_list, + params: params + .iter() + .zip(operands) + .map(|(a, b)| (*a, b.clone())) + .collect(), + return_param, + return_value, + }; + } + + pub(super) fn emit_stream_lower( + &mut self, + ty: TypeId, + operands: &[String], + results: &mut Vec, + ) { + let committed = matches!( + self.async_state.task_return, + AsyncTaskReturnState::Generating { .. } + ); + let operation = if committed { + EndpointOperation::LowerCommitted + } else { + EndpointOperation::Lower + }; + let site = self + .async_state + .next_site(PayloadFor::Stream, ty, operation); + let suffix = if committed { + "StreamLowerCommitted" + } else { + "StreamLower" + }; + let lower_name = format!("wasm{}{suffix}", site.symbol_name); + let op = &operands[0]; + results.push(format!("{lower_name}({op})")); + } + + pub(super) fn emit_stream_lift( + &mut self, + ty: TypeId, + operands: &[String], + results: &mut Vec, + ) { + let result = self.locals.tmp("result"); + let op = &operands[0]; + let site = self + .async_state + .next_site(PayloadFor::Stream, ty, EndpointOperation::Lift); + let lift_name = format!("wasm{}StreamLift", site.symbol_name); + + if self.commit_endpoints { + let commit_name = format!("wasm{}StreamCommit", site.symbol_name); + uwriteln!(self.src, r#"{commit_name}({op});"#); + results.push("()".into()); + return; } - let size = self.world_gen.sizes.size(ty).size_wasm32(); - format!( - r#" - let address = {ffi}malloc(({value}).length() * {size}); - for index = 0; index < ({value}).length(); index = index + 1 {{ - let ptr = (address) + (index * {size}); - let value = {value}[index]; - {lower_func}(value, ptr); - }} - address - "# - ) + + uwriteln!(self.src, r#"let {result} = {lift_name}({op});"#,); + + results.push(result); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wit_bindgen_core::wit_parser::{Docs, FunctionKind, Stability, TypeDef, TypeOwner}; + + fn future_type(resolve: &mut Resolve, payload: Option) -> TypeId { + resolve.types.alloc(TypeDef { + name: None, + kind: TypeDefKind::Future(payload), + owner: TypeOwner::None, + docs: Docs::default(), + stability: Stability::Unknown, + span: Default::default(), + external_id: None, + }) + } + + fn test_function(params: Vec<(&str, Type)>, result: Option) -> Function { + Function { + name: "f".into(), + kind: FunctionKind::Freestanding, + params: params + .into_iter() + .map(|(name, ty)| Param { + name: name.to_string(), + ty, + span: Default::default(), + }) + .collect(), + result, + docs: Docs::default(), + stability: Stability::Unknown, + span: Default::default(), + external_id: None, + } + } + + #[test] + fn duplicate_future_sites_have_distinct_symbols() { + let mut resolve = Resolve::default(); + let future = future_type(&mut resolve, Some(Type::U32)); + let func = test_function( + vec![("a", Type::Id(future)), ("b", Type::Id(future))], + Some(Type::Id(future)), + ); + + let names = AsyncEndpointNames::import(&resolve, None, &func); + let sites = async_endpoint_sites(&resolve, &names, None, &func, false); + assert_eq!(sites.len(), 3); + assert_eq!(sites[0].ty, future); + assert_eq!(sites[1].ty, future); + assert_eq!(sites[2].ty, future); + assert_ne!(sites[0].symbol_name, sites[1].symbol_name); + assert_ne!(sites[1].symbol_name, sites[2].symbol_name); + + let first_symbol = sites[0].symbol_name.clone(); + let second_symbol = sites[1].symbol_name.clone(); + let third_symbol = sites[2].symbol_name.clone(); + let mut state = AsyncFunctionState::from_sites(sites); + + assert_eq!( + state + .next_site(PayloadFor::Future, future, EndpointOperation::Lower) + .symbol_name, + first_symbol + ); + assert_eq!( + state + .next_site(PayloadFor::Future, future, EndpointOperation::Lower) + .symbol_name, + second_symbol + ); + assert_eq!( + state + .next_site(PayloadFor::Future, future, EndpointOperation::Lower) + .symbol_name, + third_symbol + ); + } + + #[test] + fn nested_payload_sites_keep_their_original_indices() { + let mut resolve = Resolve::default(); + let inner = future_type(&mut resolve, Some(Type::U32)); + let outer = future_type(&mut resolve, Some(Type::Id(inner))); + let func = test_function(vec![("a", Type::Id(outer)), ("b", Type::Id(outer))], None); + + let names = AsyncEndpointNames::import(&resolve, None, &func); + let sites = async_endpoint_sites(&resolve, &names, None, &func, false); + assert_eq!( + sites.iter().map(|site| site.index).collect::>(), + vec![0, 1, 2, 3] + ); + assert_eq!( + sites.iter().map(|site| site.ty).collect::>(), + vec![inner, outer, inner, outer] + ); + + let plan = AsyncFunctionPlan::new(sites.clone()); + let first_outer_payload = plan.payload_sites(&sites[1]); + let second_outer_payload = plan.payload_sites(&sites[3]); + + assert_eq!(first_outer_payload[0].index, 0); + assert_eq!(second_outer_payload[0].index, 2); + + let mut state = AsyncFunctionState::from_sites(second_outer_payload); + assert_eq!( + state + .next_site(PayloadFor::Future, inner, EndpointOperation::Lift) + .symbol_name, + sites[2].symbol_name + ); } } diff --git a/crates/moonbit/src/ffi/async_primitive.mbt b/crates/moonbit/src/ffi/async_primitive.mbt index 3a15c1330..11cea7e12 100644 --- a/crates/moonbit/src/ffi/async_primitive.mbt +++ b/crates/moonbit/src/ffi/async_primitive.mbt @@ -78,7 +78,7 @@ pub fn Coroutine::cancel(self : Coroutine) -> Unit { } ///| -pub async fn pause() -> Unit { +async fn pause() -> Unit { guard scheduler.curr_coro is Some(coro) if coro.cancelled && not(coro.shielded) { raise Cancelled::Cancelled diff --git a/crates/moonbit/src/ffi/future.mbt b/crates/moonbit/src/ffi/future.mbt deleted file mode 100644 index b8b0c19a3..000000000 --- a/crates/moonbit/src/ffi/future.mbt +++ /dev/null @@ -1,540 +0,0 @@ -///| -pub struct FutureVTable[T] { - new : () -> UInt64 - read : (Int, Int) -> Int - write : (Int, Int) -> Int - cancel_read : (Int) -> Int - cancel_write : (Int) -> Int - drop_readable : (Int) -> Unit - drop_writable : (Int) -> Unit - malloc : (Int) -> Int - free : (Int) -> Unit - lift : (Int) -> T - lower : (T, Int) -> Unit -} - -///| -pub fn[T] FutureVTable::new( - new : () -> UInt64, - read : (Int, Int) -> Int, - write : (Int, Int) -> Int, - cancel_read : (Int) -> Int, - cancel_write : (Int) -> Int, - drop_readable : (Int) -> Unit, - drop_writable : (Int) -> Unit, - malloc : (Int) -> Int, - free : (Int) -> Unit, - lift : (Int) -> T, - lower : (T, Int) -> Unit, -) -> FutureVTable[T] { - { - new, - read, - write, - cancel_read, - cancel_write, - drop_readable, - drop_writable, - malloc, - free, - lift, - lower, - } -} - -///| -pub fn[T] new_future( - vtable : FutureVTable[T], -) -> (FutureReader[T], FutureWriter[T]) { - let handle = (vtable.new)() - let left_handle = handle.to_int() - let right_handle = (handle >> 32).to_int() - ( - FutureReader::new(left_handle, vtable), - FutureWriter::new(right_handle, vtable), - ) -} - -///| -pub struct FutureReader[T] { - handle : Int - vtable : FutureVTable[T] - mut code : Int? - mut dropped : Bool - memory_refs : Array[Int] -} - -///| -pub fn[T] FutureReader::new( - handle : Int, - vtable : FutureVTable[T], -) -> FutureReader[T] { - { handle, vtable, code: None, memory_refs: [], dropped: false } -} - -///| -pub impl[T] Waitable for FutureReader[T] with update(self, code~ : Int) -> Unit { - self.code = Some(code) -} - -///| -pub impl[T] Eq for FutureReader[T] with equal(self, other) -> Bool { - self.handle == other.handle -} - -///| -pub impl[T] Waitable for FutureReader[T] with handle(self) -> Int { - self.handle -} - -///| -pub impl[T] Waitable for FutureReader[T] with cancel(self) -> Unit { - if self.code is Some(code) && WaitableStatus::decode(code) is Cancelled(_) { - return - } - self.code = Some((self.vtable.cancel_read)(self.handle)) -} - -///| -pub impl[T] Waitable for FutureReader[T] with drop(self) -> Bool { - _async_debug("stream-reader-drop(\{self.handle})") - if self.dropped { - return false - } - (self.vtable.drop_readable)(self.handle) - self.dropped = true - for ptr in self.memory_refs { - self.free(ptr) - } - true -} - -///| -pub impl[T] Waitable for FutureReader[T] with done(self) -> Bool { - match self.code { - Some(c) => - match WaitableStatus::decode(c) { - Completed(_) | Dropped(_) | Cancelled(_) => true - Blocking => false - } - None => false - } -} - -///| -pub fn[T] FutureReader::malloc(self : FutureReader[T]) -> Int { - let ptr = (self.vtable.malloc)(1) - ptr -} - -///| -pub fn[T] FutureReader::free(self : FutureReader[T], ptr : Int) -> Unit { - (self.vtable.free)(ptr) -} - -///| -pub fn[T] FutureReader::lift(self : FutureReader[T], ptr : Int) -> T { - let res = (self.vtable.lift)(ptr) - res -} - -///| -pub fn[T] FutureReader::lower_read(self : FutureReader[T], ptr : Int) -> Int { - (self.vtable.read)(self.handle, ptr) -} - -///| -pub async fn[T] FutureReader::read(self : FutureReader[T]) -> T { - let buf_ptr = self.malloc() - self.memory_refs.push(buf_ptr) - self.code = Some(self.lower_read(buf_ptr)) - _async_debug("future-read(\{self.handle}) -> \{self.code.unwrap()}") - // register this waitable to the current task - let task = current_task() - task.add_waitable(self, current_coroutine()) - defer task.remove_waitable(self) - - // wait until ready - for { - let status = WaitableStatus::decode(self.code.unwrap()) - match status { - Cancelled(_) | Dropped(_) => raise Cancelled::Cancelled - Completed(_) => break - Blocking => suspend() - } - } - // when receive event, continue this coroutine - let value = self.lift(buf_ptr) - return value -} - -///| -pub struct FutureWriter[T] { - handle : Int - vtable : FutureVTable[T] - mut code : Int? - mut dropped : Bool - memory_refs : Array[Int] -} - -///| -pub fn[T] FutureWriter::new( - handle : Int, - vtable : FutureVTable[T], -) -> FutureWriter[T] { - { handle, vtable, code: None, memory_refs: [], dropped: false } -} - -///| -pub impl[T] Waitable for FutureWriter[T] with update(self, code~ : Int) -> Unit { - self.code = Some(code) -} - -///| -pub impl[T] Eq for FutureWriter[T] with equal(self, other) -> Bool { - self.handle == other.handle -} - -///| -pub impl[T] Waitable for FutureWriter[T] with handle(self) -> Int { - self.handle -} - -///| -pub impl[T] Waitable for FutureWriter[T] with cancel(self) -> Unit { - if self.code is Some(code) && WaitableStatus::decode(code) is Cancelled(_) { - return - } - self.code = Some((self.vtable.cancel_write)(self.handle)) -} - -///| -pub impl[T] Waitable for FutureWriter[T] with drop(self) -> Bool { - _async_debug("stream-writer-drop(\{self.handle})") - if self.dropped { - return false - } - (self.vtable.drop_writable)(self.handle) - self.dropped = true - for ptr in self.memory_refs { - self.free(ptr) - } - true -} - -///| -pub impl[T] Waitable for FutureWriter[T] with done(self) -> Bool { - match self.code { - Some(c) => - match WaitableStatus::decode(c) { - Completed(_) | Dropped(_) | Cancelled(_) => true - Blocking => false - } - None => false - } -} - -///| -pub fn[T] FutureWriter::malloc(self : FutureWriter[T]) -> Int { - (self.vtable.malloc)(1) -} - -///| -pub fn[T] FutureWriter::free(self : FutureWriter[T], ptr : Int) -> Unit { - (self.vtable.free)(ptr) -} - -///| -pub fn[T] FutureWriter::lower( - self : FutureWriter[T], - value : T, - ptr : Int, -) -> Unit { - (self.vtable.lower)(value, ptr) -} - -///| -pub fn[T] FutureWriter::lower_write(self : FutureWriter[T], ptr : Int) -> Int { - (self.vtable.write)(self.handle, ptr) -} - -///| -pub async fn[T] FutureWriter::write(self : FutureWriter[T], value : T) -> Unit { - // register this waitable to the current task - let task = current_task() - task.add_waitable(self, current_coroutine()) - defer task.remove_waitable(self) - let buf_ptr = self.malloc() - self.memory_refs.push(buf_ptr) - self.lower(value, buf_ptr) - self.code = Some(self.lower_write(buf_ptr)) - defer self.free(buf_ptr) - - // wait until ready - for { - let status = WaitableStatus::decode(self.code.unwrap()) - match status { - Cancelled(_) | Dropped(_) => raise Cancelled::Cancelled - Completed(_) => break - Blocking => suspend() - } - } - // when receive event, continue this coroutine - return -} - -///| -pub suberror StreamCancelled (Int, Cancelled) derive(Show) - -///| -pub struct StreamVTable[T] { - new : () -> UInt64 - read : (Int, Int, Int) -> Int - write : (Int, Int, Int) -> Int - cancel_read : (Int) -> Int - cancel_write : (Int) -> Int - drop_readable : (Int) -> Unit - drop_writable : (Int) -> Unit - malloc : (Int) -> Int - free : (Int) -> Unit - lift : (Int, Int) -> FixedArray[T] - lower : (FixedArray[T]) -> Int -} - -///| -pub fn[T] StreamVTable::new( - new : () -> UInt64, - read : (Int, Int, Int) -> Int, - write : (Int, Int, Int) -> Int, - cancel_read : (Int) -> Int, - cancel_write : (Int) -> Int, - drop_readable : (Int) -> Unit, - drop_writable : (Int) -> Unit, - malloc : (Int) -> Int, - free : (Int) -> Unit, - lift : (Int, Int) -> FixedArray[T], - lower : (FixedArray[T]) -> Int, -) -> StreamVTable[T] { - { - new, - read, - write, - cancel_read, - cancel_write, - drop_readable, - drop_writable, - malloc, - free, - lift, - lower, - } -} - -///| -pub fn[T] new_stream( - vtable : StreamVTable[T], -) -> (StreamReader[T], StreamWriter[T]) { - let handle = (vtable.new)() - let left_handle = handle.to_int() - let right_handle = (handle >> 32).to_int() - ( - StreamReader::new(left_handle, vtable), - StreamWriter::new(right_handle, vtable), - ) -} - -///| -pub struct StreamReader[T] { - handle : Int - vtable : StreamVTable[T] - mut code : Int? - mut dropped : Bool - memory_refs : Array[Int] -} - -///| -pub impl[T] Waitable for StreamReader[T] with update(self, code~ : Int) -> Unit { - self.code = Some(code) -} - -///| -pub impl[T] Eq for StreamReader[T] with equal(self, other) -> Bool { - self.handle == other.handle -} - -///| -pub impl[T] Waitable for StreamReader[T] with handle(self) -> Int { - self.handle -} - -///| -pub impl[T] Waitable for StreamReader[T] with cancel(self) -> Unit { - if self.code is Some(code) && WaitableStatus::decode(code) is Cancelled(_) { - return - } - self.code = Some((self.vtable.cancel_read)(self.handle)) -} - -///| -pub impl[T] Waitable for StreamReader[T] with drop(self) -> Bool { - _async_debug("stream-reader-drop(\{self.handle})") - if self.dropped { - return false - } - (self.vtable.drop_readable)(self.handle) - self.dropped = true - for ptr in self.memory_refs { - (self.vtable.free)(ptr) - } - true -} - -///| -pub impl[T] Waitable for StreamReader[T] with done(self) -> Bool { - match self.code { - Some(c) => - match WaitableStatus::decode(c) { - Completed(_) | Dropped(_) | Cancelled(_) => true - Blocking => false - } - None => false - } -} - -///| -pub fn[T] StreamReader::new( - handle : Int, - vtable : StreamVTable[T], -) -> StreamReader[T] { - { handle, vtable, code: None, memory_refs: [], dropped: false } -} - -///| -pub async fn[T] StreamReader::read( - self : StreamReader[T], - buffer : FixedArray[T], - offset? : Int = 0, - length : Int, -) -> Int { - // register this waitable to the current task - let task = current_task() - task.add_waitable(self, current_coroutine()) - defer task.remove_waitable(self) - let buf_ptr = (self.vtable.malloc)(length) - self.code = Some((self.vtable.read)(self.handle, buf_ptr, length)) - _async_debug("stream-read(\{self.handle}) -> \{self.code.unwrap()}") - for { - let status = WaitableStatus::decode(self.code.unwrap()) - match status { - Completed(n) => { - let read_result = (self.vtable.lift)(buf_ptr, n) - for i in 0.. { - let read_result = (self.vtable.lift)(buf_ptr, n) - for i in 0.. suspend() - } - } -} - -///| -pub struct StreamWriter[T] { - handle : Int - vtable : StreamVTable[T] - mut code : Int? - mut dropped : Bool - memory_refs : Array[Int] -} - -///| -pub impl[T] Waitable for StreamWriter[T] with update(self, code~ : Int) -> Unit { - self.code = Some(code) -} - -///| -pub impl[T] Eq for StreamWriter[T] with equal(self, other) -> Bool { - self.handle == other.handle -} - -///| -pub impl[T] Waitable for StreamWriter[T] with handle(self) -> Int { - self.handle -} - -///| -pub impl[T] Waitable for StreamWriter[T] with cancel(self) -> Unit { - if self.code is Some(code) && WaitableStatus::decode(code) is Cancelled(_) { - return - } - self.code = Some((self.vtable.cancel_write)(self.handle)) -} - -///| -pub impl[T] Waitable for StreamWriter[T] with drop(self) -> Bool { - _async_debug("stream-writer-drop(\{self.handle})") - let task = current_task() - let coro = task.children.get(self.handle) - if coro is Some((_, coro)) { - coro.cancel() - coro.wake() - } - if self.dropped { - return false - } - (self.vtable.drop_writable)(self.handle) - self.dropped = true - for ptr in self.memory_refs { - (self.vtable.free)(ptr) - } - true -} - -///| -pub impl[T] Waitable for StreamWriter[T] with done(self) -> Bool { - match self.code { - Some(c) => - match WaitableStatus::decode(c) { - Completed(_) | Dropped(_) | Cancelled(_) => true - Blocking => false - } - None => false - } -} - -///| -pub fn[T] StreamWriter::new( - handle : Int, - vtable : StreamVTable[T], -) -> StreamWriter[T] { - { handle, vtable, code: None, memory_refs: [], dropped: false } -} - -///| -pub async fn[T] StreamWriter::write( - self : StreamWriter[T], - buffer : FixedArray[T], -) -> Int { - // register this waitable to the current task - let task = current_task() - task.add_waitable(self, current_coroutine()) - defer task.remove_waitable(self) - let write_buf = (self.vtable.lower)(buffer) - self.code = Some((self.vtable.write)(self.handle, write_buf, buffer.length())) - for { - let status = WaitableStatus::decode(self.code.unwrap()) - match status { - Completed(n) => return n - Cancelled(n) | Dropped(n) => - raise StreamCancelled::StreamCancelled((n, Cancelled::Cancelled)) - Blocking => suspend() - } - } -} diff --git a/crates/moonbit/src/lib.rs b/crates/moonbit/src/lib.rs index 7939d6146..99189d9f8 100644 --- a/crates/moonbit/src/lib.rs +++ b/crates/moonbit/src/lib.rs @@ -1,6 +1,6 @@ use anyhow::Result; use core::panic; -use heck::{ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase}; +use heck::{ToShoutySnakeCase, ToUpperCamelCase}; use std::{ collections::{HashMap, HashSet}, fmt::Write, @@ -15,12 +15,14 @@ use wit_bindgen_core::{ wit_parser::{ Alignment, ArchitectureSize, Docs, Enum, Flags, FlagsRepr, Function, Int, InterfaceId, LiftLowerAbi, ManglingAndAbi, Param, Record, Resolve, ResourceIntrinsic, Result_, - SizeAlign, Tuple, Type, TypeId, Variant, WasmExport, WasmExportKind, WasmImport, WorldId, - WorldKey, + SizeAlign, Tuple, Type, TypeDefKind, TypeId, Variant, WasmExport, WasmExportKind, + WasmImport, WorldId, WorldKey, }, }; -use crate::async_support::AsyncSupport; +use wit_bindgen_core::wit_parser::WorldItem; + +use crate::async_support::{AsyncFunctionState, AsyncSupport}; use crate::pkg::{Imports, MoonbitSignature, PkgResolver, ToMoonBitIdent, ToMoonBitTypeIdent}; mod async_support; @@ -39,10 +41,6 @@ mod pkg; // We use AsyncCallback ABI for async functions // TODO: Export will share the type signatures with the import by using a newtype alias -pub(crate) const FFI_DIR: &str = "ffi"; - -pub(crate) const FFI: &str = include_str!("./ffi/ffi.mbt"); - const VERSION: &str = env!("CARGO_PKG_VERSION"); #[derive(Default, Debug, Clone)] @@ -115,11 +113,6 @@ impl InterfaceFragment { } } -enum PayloadFor { - Future, - Stream, -} - #[derive(Default)] pub struct MoonBit { opts: Opts, @@ -245,6 +238,15 @@ impl MoonBit { /// final export FFI module. Async helpers are emitted when required. impl WorldGenerator for MoonBit { fn preprocess(&mut self, resolve: &Resolve, world: WorldId) -> Result<()> { + if world_contains_endpoint_fixed_length_list_combination(resolve, world) { + anyhow::bail!( + "MoonBit async bindings do not yet support combining future or stream types with fixed-length lists" + ); + } + if world_contains_future_or_stream(resolve, world) { + self.async_support.require_runtime(); + } + self.pkg_resolver.resolve = resolve.clone(); self.project_name = self .opts @@ -518,7 +520,7 @@ impl WorldGenerator for MoonBit { fn finish(&mut self, _resolve: &Resolve, _id: WorldId, files: &mut Files) -> Result<()> { // If async is used, export async utils - self.async_support.emit_utils(files, VERSION); + self.async_support.emit_runtime_files(files, VERSION); // Export project files if !self.opts.ignore_stub && !self.opts.ignore_module_file { @@ -590,100 +592,82 @@ impl InterfaceGenerator<'_> { } fn import(&mut self, func: &Function) { - // Determine if the function is async - let async_ = self - .world_gen - .opts - .async_ - .is_async(self.resolve, self.interface, func, false); - if async_ { - self.world_gen.async_support.mark_async(); - } - - let ffi_import_name = format!("wasmImport{}", func.name.to_upper_camel_case()); - let mut bindgen = FunctionBindgen::new( - self, - func.params - .iter() - .map(|Param { name, .. }| name.to_moonbit_ident()) - .collect(), - ); - - abi::call( - bindgen.interface_gen.resolve, - AbiVariant::GuestImport, - LiftLower::LowerArgsLiftResults, + let async_plan = self.world_gen.async_support.import_plan( + &mut self.world_gen.opts.async_, + self.resolve, + self.interface, func, - &mut bindgen, - false, ); + let variant = async_plan.abi_variant(); + let endpoint_plan = self.import_async_function_plan(self.interface, func); + let wasm_sig = self.resolve.wasm_signature(variant, func); + let mbt_sig = self.world_gen.pkg_resolver.mbt_sig(self.name, func, false); + let (src, needs_cleanup_list, endpoint_state) = if async_plan.is_async() { + let body = self.generate_async_import_body(&endpoint_plan, func, &mbt_sig, &wasm_sig); + (body.src, body.needs_cleanup_list, body.state) + } else { + let mut bindgen = FunctionBindgen::new( + self, + func.params + .iter() + .map(|Param { name, .. }| name.to_moonbit_ident()) + .collect(), + ) + .with_async_state(endpoint_plan.state()); + if endpoint_plan.has_endpoints() { + bindgen = bindgen.with_sync_import_commit( + func.params.iter().map(|Param { ty, .. }| *ty).collect(), + ); + } - let mut src = bindgen.src.clone(); + abi::call( + bindgen.interface_gen.resolve, + AbiVariant::GuestImport, + LiftLower::LowerArgsLiftResults, + func, + &mut bindgen, + false, + ); + (bindgen.src, bindgen.needs_cleanup_list, bindgen.async_state) + }; - let cleanup_list = if bindgen.needs_cleanup_list { + let cleanup_list = if needs_cleanup_list { "let cleanup_list : Array[Int] = []" } else { "" }; - let mbt_sig = self.world_gen.pkg_resolver.mbt_sig(self.name, func, false); - let sig = self.sig_string(&mbt_sig, async_); - - // Generate the core wasm abi - let wasm_sig = self.resolve.wasm_signature( - if async_ { - AbiVariant::GuestImportAsync - } else { - AbiVariant::GuestImport - }, - func, - ); let (import_module, import_name) = self.resolve.wasm_import_name( - ManglingAndAbi::Legacy(if async_ { - LiftLowerAbi::AsyncCallback - } else { - LiftLowerAbi::Sync - }), + async_plan.mangling_and_abi(), WasmImport::Func { interface: self.interface, func, }, ); - { - let result_type = match &wasm_sig.results[..] { - [] => "".into(), - [result] => format!("-> {}", wasm_type(*result)), - _ => unimplemented!("multi-value results are not supported yet"), - }; - - let params = wasm_sig - .params - .iter() - .enumerate() - .map(|(i, param)| format!("p{i} : {}", wasm_type(*param))) - .collect::>() - .join(", "); - - uwriteln!( - self.ffi, - r#" - fn {ffi_import_name}({params}) {result_type} = "{import_module}" "{import_name}" - "# - ); - } + let result_type = match &wasm_sig.results[..] { + [] => "".into(), + [result] => format!("-> {}", wasm_type(*result)), + _ => unimplemented!("multi-value results are not supported yet"), + }; + let params = wasm_sig + .params + .iter() + .enumerate() + .map(|(i, param)| format!("p{i} : {}", wasm_type(*param))) + .collect::>() + .join(", "); + let ffi_import_name = format!("wasmImport{}", func.name.to_upper_camel_case()); + uwriteln!( + self.ffi, + r#" + fn {ffi_import_name}({params}) {result_type} = "{import_module}" "{import_name}" + "# + ); - // Generate the MoonBit wrapper - if async_ { - let interface_name = match self.interface { - Some(key) => self.resolve.name_world_key(key), - None => "$root".into(), - }; - self.generation_futures_and_streams_import("", func, &interface_name); - src = self.generate_async_import_function(func, mbt_sig, &wasm_sig); - } + self.emit_future_stream_helpers(&endpoint_plan, &endpoint_state); print_docs(&mut self.src, &func.docs); - + let sig = self.sig_string(func, &mbt_sig, async_plan.signature_is_async()); uwrite!( self.src, r#" @@ -696,43 +680,31 @@ impl InterfaceGenerator<'_> { } fn export(&mut self, func: &Function) { - // Determine if is async - let async_ = self - .world_gen - .opts - .async_ - .is_async(self.resolve, self.interface, func, false); - if async_ { - self.world_gen.async_support.mark_async(); - } - - // Generate declarations for user implementations. - { - let mbt_sig = self.world_gen.pkg_resolver.mbt_sig(self.name, func, false); - let func_sig = self.sig_string(&mbt_sig, async_); - - print_docs(&mut self.src, &func.docs); - uwrite!( - self.src, - r#" - declare {func_sig} - "# - ); - } - - // Generate the caller function - let variant = if async_ { - AbiVariant::GuestExportAsync - } else { - AbiVariant::GuestExport - }; - + let async_plan = self.world_gen.async_support.export_plan( + &mut self.world_gen.opts.async_, + self.resolve, + self.interface, + func, + ); + let variant = async_plan.abi_variant(); let sig = self.resolve.wasm_signature(variant, func); + let mbt_sig = self.world_gen.pkg_resolver.mbt_sig(self.name, func, false); + let func_sig = self.sig_string(func, &mbt_sig, async_plan.signature_is_async()); + + print_docs(&mut self.src, &func.docs); + uwrite!( + self.src, + r#" + declare {func_sig} + "# + ); + let endpoint_plan = self.export_async_function_plan(self.interface, func); let mut bindgen = FunctionBindgen::new( self, (0..sig.params.len()).map(|i| format!("p{i}")).collect(), - ); + ) + .with_async_state(endpoint_plan.state()); abi::call( bindgen.interface_gen.resolve, @@ -740,15 +712,15 @@ impl InterfaceGenerator<'_> { LiftLower::LiftArgsLowerResults, func, &mut bindgen, - async_, + async_plan.is_async(), ); - // TODO: adapt async cleanup - assert!(!bindgen.needs_cleanup_list); - - // Async functions deferred task return - let deferred_task_return = bindgen.deferred_task_return.clone(); - + let cleanup_list = if bindgen.needs_cleanup_list { + "let cleanup_list : Array[Int] = []" + } else { + "" + }; + let async_state = bindgen.async_state.clone(); let src = bindgen.src; let result_type = match &sig.results[..] { @@ -775,36 +747,36 @@ impl InterfaceGenerator<'_> { .collect::>() .join(", "); - // Async functions return type - let interface_name = match self.interface { - Some(key) => Some(self.resolve.name_world_key(key)), - None => None, - }; - - let module_name = interface_name.as_deref().unwrap_or("$root"); - self.r#generation_futures_and_streams_import("[export]", func, module_name); - - uwrite!( - self.ffi, - r#" - #doc(hidden) - pub fn {func_name}({params}) -> {result_type} {{ - {src} - }} - "#, - ); let export_name = self.resolve.wasm_export_name( - ManglingAndAbi::Legacy(if async_ { - LiftLowerAbi::AsyncCallback - } else { - LiftLowerAbi::Sync - }), + async_plan.mangling_and_abi(), WasmExport::Func { interface: self.interface, func, kind: WasmExportKind::Normal, }, ); + self.emit_future_stream_helpers(&endpoint_plan, &async_state); + + if !self.emit_async_export_wrapper( + &async_plan, + func, + &func_name, + ¶ms, + result_type, + cleanup_list, + &src, + ) { + uwrite!( + self.ffi, + r#" + #doc(hidden) + pub fn {func_name}({params}) -> {result_type} {{ + {cleanup_list} + {src} + }} + "#, + ); + } let export = format!( r#" @@ -826,104 +798,14 @@ impl InterfaceGenerator<'_> { .export .insert(export_name, (func_name, export)); - // If async, we also need a callback function and a task_return intrinsic - if async_ { - let export_func_name = self - .world_gen - .export_ns - .tmp(&format!("wasmExportAsync{camel_name}")); - let DeferredTaskReturn::Emitted { - body: task_return_body, - params: task_return_params, - return_param, - } = deferred_task_return - else { - unreachable!() - }; - let export_name = self.resolve.wasm_export_name( - ManglingAndAbi::Legacy(LiftLowerAbi::AsyncCallback), - WasmExport::Func { - interface: self.interface, - func, - kind: WasmExportKind::Callback, - }, - ); - - let task_return_param_tys = task_return_params - .iter() - .enumerate() - .map(|(idx, (ty, _expr))| format!("p{}: {}", idx, wasm_type(*ty))) - .collect::>() - .join(", "); - let task_return_param_exprs = task_return_params - .iter() - .map(|(_ty, expr)| expr.as_str()) - .collect::>() - .join(", "); - let return_ty = match &func.result { - Some(result) => self - .world_gen - .pkg_resolver - .type_name(self.name, result) - .to_string(), - None => "Unit".into(), - }; - let return_expr = match return_ty.as_str() { - "Unit" => "".into(), - _ => format!("{return_param}: {return_ty}",), - }; - let snake_func_name = func.name.to_moonbit_ident().to_string(); - let ffi = self - .world_gen - .pkg_resolver - .qualify_package(self.name, FFI_DIR); - - let (task_return_module, task_return_name) = self.resolve.wasm_import_name( - ManglingAndAbi::Legacy(LiftLowerAbi::AsyncCallback), - WasmImport::Func { - interface: self.interface, - func, - }, - ); - - uwriteln!( - self.src, - r#" - fn {export_func_name}TaskReturn({task_return_param_tys}) = "{task_return_module}" "{task_return_name}" - - pub fn {snake_func_name}_task_return({return_expr}) -> Unit {{ - {task_return_body} - {export_func_name}TaskReturn({task_return_param_exprs}) - }} - "# - ); - - uwriteln!( - self.ffi, - r#" - pub fn {export_func_name}(event_raw: Int, waitable: Int, code: Int) -> Int {{ - {ffi}callback(event_raw, waitable, code) - }} - "# - ); - let export = format!( - r#" - pub fn {export_func_name}(event_raw: Int, waitable: Int, code: Int) -> Int {{ - {}{snake_func_name}_callback(event_raw, waitable, code) - }} - "#, - self.world_gen - .pkg_resolver - .qualify_package(self.world_gen.opts.gen_dir.as_str(), self.name), - ); - - self.world_gen - .export - .insert(export_name, (export_func_name.clone(), export)); - } - - // If post return is needed, generate it - if abi::guest_export_needs_post_return(self.resolve, func) { + if !self.emit_async_export_callback( + &async_plan, + self.interface, + func, + &camel_name, + async_state, + ) && abi::guest_export_needs_post_return(self.resolve, func) + { let params = sig .results .iter() @@ -987,8 +869,8 @@ impl InterfaceGenerator<'_> { } } - fn sig_string(&mut self, sig: &MoonbitSignature, async_: bool) -> String { - let params = sig + fn sig_string(&mut self, func: &Function, sig: &MoonbitSignature, async_: bool) -> String { + let mut params = sig .params .iter() .map(|(name, ty)| { @@ -997,6 +879,8 @@ impl InterfaceGenerator<'_> { }) .collect::>(); + self.add_async_export_stub_parameter(func, async_, &mut params); + let params = params.join(", "); let result_type = match &sig.result_type { None => "Unit".into(), @@ -1034,25 +918,34 @@ impl<'a> wit_bindgen_core::InterfaceGenerator<'a> for InterfaceGenerator<'a> { .collect::>() .join("; "); + let contains_endpoint = record + .fields + .iter() + .any(|field| type_contains_future_or_stream(self.resolve, &field.ty)); let mut deriviation: Vec<_> = Vec::new(); - if self.derive_opts.derive_debug { + if self.derive_opts.derive_debug && !contains_endpoint { deriviation.push("Debug") } - if self.derive_opts.derive_show { + if self.derive_opts.derive_show && !contains_endpoint { deriviation.push("Show") } - if self.derive_opts.derive_eq { + if self.derive_opts.derive_eq && !contains_endpoint { deriviation.push("Eq") } + let derivation = if deriviation.is_empty() { + String::new() + } else { + format!(" derive({})", deriviation.join(", ")) + }; + uwrite!( self.src, " pub(all) struct {name} {{ {parameters} - }} derive({}) - ", - deriviation.join(", ") + }}{derivation} + " ); } @@ -1326,14 +1219,19 @@ impl<'a> wit_bindgen_core::InterfaceGenerator<'a> for InterfaceGenerator<'a> { .collect::>() .join("\n "); + let contains_endpoint = variant + .cases + .iter() + .filter_map(|case| case.ty.as_ref()) + .any(|ty| type_contains_future_or_stream(self.resolve, ty)); let mut deriviation: Vec<_> = Vec::new(); - if self.derive_opts.derive_debug { + if self.derive_opts.derive_debug && !contains_endpoint { deriviation.push("Debug") } - if self.derive_opts.derive_show { + if self.derive_opts.derive_show && !contains_endpoint { deriviation.push("Show") } - if self.derive_opts.derive_eq { + if self.derive_opts.derive_eq && !contains_endpoint { deriviation.push("Eq") } let declaration = if self.derive_opts.derive_error && name.contains("Error") { @@ -1342,14 +1240,19 @@ impl<'a> wit_bindgen_core::InterfaceGenerator<'a> for InterfaceGenerator<'a> { "enum" }; + let derivation = if deriviation.is_empty() { + String::new() + } else { + format!(" derive({})", deriviation.join(", ")) + }; + uwrite!( self.src, " pub(all) {declaration} {name} {{ {cases} - }} derive({}) - ", - deriviation.join(", ") + }}{derivation} + " ); } @@ -1464,11 +1367,11 @@ impl<'a> wit_bindgen_core::InterfaceGenerator<'a> for InterfaceGenerator<'a> { } fn type_future(&mut self, _id: TypeId, _name: &str, _ty: &Option, _docs: &Docs) { - unimplemented!() // Not needed + // Rendered inline by `PkgResolver::type_name`. } fn type_stream(&mut self, _id: TypeId, _name: &str, _ty: &Option, _docs: &Docs) { - unimplemented!() // Not needed + // Rendered inline by `PkgResolver::type_name`. } fn type_builtin(&mut self, _id: TypeId, _name: &str, _ty: &Type, _docs: &Docs) { @@ -1490,22 +1393,10 @@ struct BlockStorage { cleanup: Vec, } -#[derive(Clone, Debug)] -enum DeferredTaskReturn { - None, - Generating { - prev_src: String, - return_param: String, - }, - Emitted { - params: Vec<(WasmType, String)>, - body: String, - return_param: String, - }, -} - struct FunctionBindgen<'a, 'b> { interface_gen: &'b mut InterfaceGenerator<'a>, + type_context: String, + func_interface: String, params: Box<[String]>, src: String, locals: Ns, @@ -1514,7 +1405,12 @@ struct FunctionBindgen<'a, 'b> { payloads: Vec, cleanup: Vec, needs_cleanup_list: bool, - deferred_task_return: DeferredTaskReturn, + suppress_block_cleanup: bool, + preserve_guest_allocations: bool, + sync_endpoint_drop: bool, + commit_endpoints: bool, + sync_import_argument_types: Option>, + async_state: AsyncFunctionState, } impl<'a, 'b> FunctionBindgen<'a, 'b> { @@ -1526,8 +1422,11 @@ impl<'a, 'b> FunctionBindgen<'a, 'b> { params.iter().for_each(|str| { locals.tmp(str); }); + let type_context = r#gen.name.to_string(); Self { interface_gen: r#gen, + func_interface: type_context.clone(), + type_context, params, src: String::new(), locals, @@ -1536,7 +1435,12 @@ impl<'a, 'b> FunctionBindgen<'a, 'b> { payloads: Vec::new(), cleanup: Vec::new(), needs_cleanup_list: false, - deferred_task_return: DeferredTaskReturn::None, + suppress_block_cleanup: false, + preserve_guest_allocations: false, + sync_endpoint_drop: false, + commit_endpoints: false, + sync_import_argument_types: None, + async_state: AsyncFunctionState::default(), } } @@ -1711,21 +1615,14 @@ impl<'a, 'b> FunctionBindgen<'a, 'b> { self.interface_gen .world_gen .pkg_resolver - .type_constructor(self.interface_gen.name, ty) + .type_constructor(&self.type_context, ty) } fn resolve_type_name(&mut self, ty: &Type) -> String { self.interface_gen .world_gen .pkg_resolver - .type_name(self.interface_gen.name, ty) - } - - fn resolve_pkg(&mut self, pkg: &str) -> String { - self.interface_gen - .world_gen - .pkg_resolver - .qualify_package(self.interface_gen.name, pkg) + .type_name(&self.type_context, ty) } fn use_ffi(&mut self, str: &'static str) { @@ -2367,78 +2264,38 @@ impl Bindgen for FunctionBindgen<'_, '_> { }; let func_name = name.to_upper_camel_case(); - - let operands = operands.join(", "); + let call_operands = if self.sync_import_argument_types.is_some() { + operands + .iter() + .map(|operand| { + let stable = self.locals.tmp("lower_arg"); + uwriteln!(self.src, "let {stable} = {operand}"); + stable + }) + .collect::>() + } else { + operands.clone() + }; + let arguments = call_operands.join(", "); // TODO: handle this to support async functions - uwriteln!(self.src, "{assignment} wasmImport{func_name}({operands});"); + uwriteln!(self.src, "{assignment} wasmImport{func_name}({arguments});"); + self.commit_sync_import_arguments(sig, &call_operands); } Instruction::CallInterface { func, async_ } => { + if *async_ { + self.emit_async_call_interface(func, operands, results); + return; + } + let name = self.interface_gen.world_gen.pkg_resolver.func_call( - self.interface_gen.name, + &self.type_context, func, - self.interface_gen.name, + &self.func_interface, ); let args = operands.join(", "); - if *async_ { - let (async_func_result, task_return_result, task_return_type) = - match func.result { - Some(ty) => { - let res = self.locals.tmp("return_result"); - (res.clone(), res, self.resolve_type_name(&ty)) - } - None => ("_ignore".into(), "".into(), "Unit".into()), - }; - - if func.result.is_some() { - results.push(async_func_result.clone()); - } - let ffi = self.resolve_pkg(FFI_DIR); - uwrite!( - self.src, - r#" - let task = {ffi}current_task(); - let _ = task.with_waitable_set(fn(task) {{ - let {async_func_result}: Ref[{task_return_type}?] = Ref::new(None) - task.wait(fn() {{ - {async_func_result}.val = Some({name}({args})); - }}) - for {{ - if task.no_wait() && {async_func_result}.val is Some({async_func_result}){{ - {name}_task_return({task_return_result}); - break; - }} else {{ - {ffi}suspend() catch {{ - _ => {{ - {ffi}task_cancel(); - }} - }} - }} - }} - }}) - if task.is_fail() is Some({ffi}Cancelled::Cancelled) {{ - {ffi}task_cancel(); - return {ffi}CallbackCode::Exit.encode() - }} - if task.is_done() {{ - return {ffi}CallbackCode::Exit.encode() - }} - return {ffi}CallbackCode::Wait(task.handle()).encode() - "#, - ); - assert!(matches!( - self.deferred_task_return, - DeferredTaskReturn::None - )); - self.deferred_task_return = DeferredTaskReturn::Generating { - prev_src: mem::take(&mut self.src), - return_param: async_func_result.to_string(), - }; - return; - } - let assignment = match func.result { None => "let _ = ".into(), Some(ty) => { @@ -2651,13 +2508,17 @@ impl Bindgen for FunctionBindgen<'_, '_> { } Instruction::GuestDeallocate { .. } => { - self.use_ffi(ffi::FREE); - uwriteln!(self.src, "mbt_ffi_free({})", operands[0]) + if !self.preserve_guest_allocations { + self.use_ffi(ffi::FREE); + uwriteln!(self.src, "mbt_ffi_free({})", operands[0]) + } } Instruction::GuestDeallocateString => { - self.use_ffi(ffi::FREE); - uwriteln!(self.src, "mbt_ffi_free({})", operands[0]) + if !self.preserve_guest_allocations { + self.use_ffi(ffi::FREE); + uwriteln!(self.src, "mbt_ffi_free({})", operands[0]) + } } Instruction::GuestDeallocateVariant { blocks } => { @@ -2722,8 +2583,10 @@ impl Bindgen for FunctionBindgen<'_, '_> { ); } - self.use_ffi(ffi::FREE); - uwriteln!(self.src, "mbt_ffi_free({address})",); + if !self.preserve_guest_allocations { + self.use_ffi(ffi::FREE); + uwriteln!(self.src, "mbt_ffi_free({address})",); + } } Instruction::Flush { amt } => { @@ -2731,80 +2594,42 @@ impl Bindgen for FunctionBindgen<'_, '_> { } Instruction::FutureLift { ty, .. } => { - let result = self.locals.tmp("result"); - let op = &operands[0]; - // let qualifier = self.r#gen.qualify_package(self.func_interface); - let ty = self.resolve_type_name(&Type::Id(*ty)); - let ffi = self - .interface_gen - .world_gen - .pkg_resolver - .qualify_package(self.interface_gen.name, FFI_DIR); - - let snake_name = format!("static_{}_future_table", ty.to_snake_case(),); - - uwriteln!( - self.src, - r#"let {result} = {ffi}FutureReader::new({op}, {snake_name});"#, - ); - - results.push(result); + self.emit_future_lift(*ty, operands, results); } - Instruction::FutureLower { .. } => { - let op = &operands[0]; - results.push(format!("{op}.handle")); + Instruction::FutureLower { ty, .. } => { + self.emit_future_lower(*ty, operands, results); } Instruction::AsyncTaskReturn { params, .. } => { - let (body, return_param) = match &mut self.deferred_task_return { - DeferredTaskReturn::Generating { - prev_src, - return_param, - } => { - mem::swap(&mut self.src, prev_src); - (mem::take(prev_src), return_param.clone()) - } - _ => unreachable!(), - }; - assert_eq!(params.len(), operands.len()); - self.deferred_task_return = DeferredTaskReturn::Emitted { - body, - params: params - .iter() - .zip(operands) - .map(|(a, b)| (*a, b.clone())) - .collect(), - return_param, - }; + self.capture_task_return(params, operands); } - Instruction::StreamLower { .. } => { - let op = &operands[0]; - results.push(format!("{op}.handle")); + Instruction::StreamLower { ty, .. } => { + self.emit_stream_lower(*ty, operands, results); } Instruction::StreamLift { ty, .. } => { - let result = self.locals.tmp("result"); - let op = &operands[0]; - let qualifier = self.resolve_pkg(self.interface_gen.name); - let ty = self.resolve_type_name(&Type::Id(*ty)); - let ffi = self.resolve_pkg(FFI_DIR); - let snake_name = format!( - "static_{}_stream_table", - ty.replace(&qualifier, "").to_snake_case(), - ); - - uwriteln!( - self.src, - r#"let {result} = {ffi}StreamReader::new({op}, {snake_name});"#, - ); - - results.push(result); + self.emit_stream_lift(*ty, operands, results); } - Instruction::ErrorContextLower { .. } - | Instruction::ErrorContextLift { .. } - | Instruction::DropHandle { .. } => todo!(), + Instruction::DropHandle { ty } => { + let is_endpoint = match ty { + Type::Id(id) => matches!( + &self.interface_gen.resolve.types[*id].kind, + TypeDefKind::Future(_) | TypeDefKind::Stream(_) + ), + _ => false, + }; + if !self.commit_endpoints { + let method = if self.sync_endpoint_drop && is_endpoint { + "drop_sync" + } else { + "drop" + }; + uwriteln!(self.src, "{}.{method}()", operands[0]); + } + } + Instruction::ErrorContextLower { .. } | Instruction::ErrorContextLift { .. } => todo!(), Instruction::FixedLengthListLift { element: _, size, @@ -2824,13 +2649,18 @@ impl Bindgen for FunctionBindgen<'_, '_> { size, id: _, } => { + uwriteln!( + self.src, + "if ({}).length() != {size} {{ panic() }}", + operands[0] + ); for i in 0..(*size as usize) { results.push(format!("({})[{i}]", operands[0])); } } Instruction::FixedLengthListLowerToMemory { element, - size: _, + size: fixed_length, id: _, } => { let Block { @@ -2847,7 +2677,8 @@ impl Bindgen for FunctionBindgen<'_, '_> { uwrite!( self.src, " - for {index} = 0; {index} < ({vec}).length(); {index} = {index} + 1 {{ + if ({vec}).length() != {fixed_length} {{ panic() }} + for {index} = 0; {index} < {fixed_length}; {index} = {index} + 1 {{ let iter_elem = ({vec})[{index}] let iter_base = ({target}) + ({index} * {size}) {body} @@ -3000,8 +2831,10 @@ impl Bindgen for FunctionBindgen<'_, '_> { ); } - self.use_ffi(ffi::FREE); - uwriteln!(self.src, "mbt_ffi_free({address})",); + if !self.preserve_guest_allocations { + self.use_ffi(ffi::FREE); + uwriteln!(self.src, "mbt_ffi_free({address})",); + } } } } @@ -3034,7 +2867,7 @@ impl Bindgen for FunctionBindgen<'_, '_> { fn finish_block(&mut self, operands: &mut Vec) { let BlockStorage { body, cleanup } = self.block_storage.pop().unwrap(); - if !self.cleanup.is_empty() { + if !self.cleanup.is_empty() && !self.suppress_block_cleanup { self.needs_cleanup_list = true; self.use_ffi(ffi::FREE); @@ -3121,6 +2954,218 @@ fn flags_repr(flags: &Flags) -> Int { } } +fn type_contains_future_or_stream(resolve: &Resolve, ty: &Type) -> bool { + let Type::Id(id) = ty else { + return false; + }; + + match &resolve.types[*id].kind { + TypeDefKind::Future(_) | TypeDefKind::Stream(_) => true, + TypeDefKind::Record(record) => record + .fields + .iter() + .any(|field| type_contains_future_or_stream(resolve, &field.ty)), + TypeDefKind::Tuple(tuple) => tuple + .types + .iter() + .any(|ty| type_contains_future_or_stream(resolve, ty)), + TypeDefKind::Variant(variant) => variant + .cases + .iter() + .filter_map(|case| case.ty.as_ref()) + .any(|ty| type_contains_future_or_stream(resolve, ty)), + TypeDefKind::Option(ty) + | TypeDefKind::List(ty) + | TypeDefKind::FixedLengthList(ty, _) + | TypeDefKind::Type(ty) => type_contains_future_or_stream(resolve, ty), + TypeDefKind::Map(key, value) => { + type_contains_future_or_stream(resolve, key) + || type_contains_future_or_stream(resolve, value) + } + TypeDefKind::Result(result) => result + .ok + .iter() + .chain(result.err.iter()) + .any(|ty| type_contains_future_or_stream(resolve, ty)), + TypeDefKind::Resource + | TypeDefKind::Handle(_) + | TypeDefKind::Flags(_) + | TypeDefKind::Enum(_) => false, + TypeDefKind::Unknown => unreachable!(), + } +} + +fn type_contains_endpoint_fixed_length_list_combination( + resolve: &Resolve, + ty: &Type, + inside_fixed_length_list: bool, + inside_endpoint: bool, +) -> bool { + let Type::Id(id) = ty else { + return false; + }; + + match &resolve.types[*id].kind { + TypeDefKind::Future(payload) | TypeDefKind::Stream(payload) => { + inside_fixed_length_list + || payload.as_ref().is_some_and(|ty| { + type_contains_endpoint_fixed_length_list_combination(resolve, ty, false, true) + }) + } + TypeDefKind::FixedLengthList(ty, _) => { + inside_endpoint + || type_contains_endpoint_fixed_length_list_combination(resolve, ty, true, false) + } + TypeDefKind::Record(record) => record.fields.iter().any(|field| { + type_contains_endpoint_fixed_length_list_combination( + resolve, + &field.ty, + inside_fixed_length_list, + inside_endpoint, + ) + }), + TypeDefKind::Tuple(tuple) => tuple.types.iter().any(|ty| { + type_contains_endpoint_fixed_length_list_combination( + resolve, + ty, + inside_fixed_length_list, + inside_endpoint, + ) + }), + TypeDefKind::Variant(variant) => variant + .cases + .iter() + .filter_map(|case| case.ty.as_ref()) + .any(|ty| { + type_contains_endpoint_fixed_length_list_combination( + resolve, + ty, + inside_fixed_length_list, + inside_endpoint, + ) + }), + TypeDefKind::Option(ty) | TypeDefKind::List(ty) | TypeDefKind::Type(ty) => { + type_contains_endpoint_fixed_length_list_combination( + resolve, + ty, + inside_fixed_length_list, + inside_endpoint, + ) + } + TypeDefKind::Map(key, value) => { + type_contains_endpoint_fixed_length_list_combination( + resolve, + key, + inside_fixed_length_list, + inside_endpoint, + ) || type_contains_endpoint_fixed_length_list_combination( + resolve, + value, + inside_fixed_length_list, + inside_endpoint, + ) + } + TypeDefKind::Result(result) => result.ok.iter().chain(result.err.iter()).any(|ty| { + type_contains_endpoint_fixed_length_list_combination( + resolve, + ty, + inside_fixed_length_list, + inside_endpoint, + ) + }), + TypeDefKind::Resource + | TypeDefKind::Handle(_) + | TypeDefKind::Flags(_) + | TypeDefKind::Enum(_) => false, + TypeDefKind::Unknown => unreachable!(), + } +} + +fn world_contains_endpoint_fixed_length_list_combination( + resolve: &Resolve, + world: WorldId, +) -> bool { + let item_contains_endpoint = |item: &WorldItem| match item { + WorldItem::Function(func) => { + func.params.iter().any(|param| { + type_contains_endpoint_fixed_length_list_combination( + resolve, ¶m.ty, false, false, + ) + }) || func.result.as_ref().is_some_and(|ty| { + type_contains_endpoint_fixed_length_list_combination(resolve, ty, false, false) + }) + } + WorldItem::Interface { id, .. } => { + let interface = &resolve.interfaces[*id]; + interface.types.values().any(|id| { + type_contains_endpoint_fixed_length_list_combination( + resolve, + &Type::Id(*id), + false, + false, + ) + }) || interface.functions.values().any(|func| { + func.params.iter().any(|param| { + type_contains_endpoint_fixed_length_list_combination( + resolve, ¶m.ty, false, false, + ) + }) || func.result.as_ref().is_some_and(|ty| { + type_contains_endpoint_fixed_length_list_combination(resolve, ty, false, false) + }) + }) + } + WorldItem::Type { id, .. } => type_contains_endpoint_fixed_length_list_combination( + resolve, + &Type::Id(*id), + false, + false, + ), + }; + let world = &resolve.worlds[world]; + world + .imports + .values() + .chain(world.exports.values()) + .any(item_contains_endpoint) +} + +fn world_contains_future_or_stream(resolve: &Resolve, world: WorldId) -> bool { + let item_contains_endpoint = |item: &WorldItem| match item { + WorldItem::Function(func) => { + func.params + .iter() + .any(|param| type_contains_future_or_stream(resolve, ¶m.ty)) + || func + .result + .as_ref() + .is_some_and(|ty| type_contains_future_or_stream(resolve, ty)) + } + WorldItem::Interface { id, .. } => { + let interface = &resolve.interfaces[*id]; + interface + .types + .values() + .any(|id| type_contains_future_or_stream(resolve, &Type::Id(*id))) + || interface.functions.values().any(|func| { + func.params + .iter() + .any(|param| type_contains_future_or_stream(resolve, ¶m.ty)) + || func + .result + .as_ref() + .is_some_and(|ty| type_contains_future_or_stream(resolve, ty)) + }) + } + WorldItem::Type { id, .. } => type_contains_future_or_stream(resolve, &Type::Id(*id)), + }; + let world = &resolve.worlds[world]; + world + .imports + .values() + .chain(world.exports.values()) + .any(item_contains_endpoint) +} + fn indent(code: &str) -> Source { let mut indented = Source::default(); let mut was_empty = false; @@ -3155,3 +3200,706 @@ fn print_docs(src: &mut String, docs: &Docs) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn try_generate_with_opts(wit: &str, world: &str, opts: Opts) -> Result { + let mut resolve = Resolve::default(); + let pkg = resolve.push_str("test.wit", wit).unwrap(); + let world = resolve.select_world(&[pkg], Some(world)).unwrap(); + let mut files = Files::default(); + let mut generator = MoonBit { + opts, + ..MoonBit::default() + }; + generator.generate(&mut resolve, world, &mut files)?; + Ok(files) + } + + fn try_generate(wit: &str, world: &str) -> Result { + try_generate_with_opts( + wit, + world, + Opts { + gen_dir: "gen".into(), + ..Opts::default() + }, + ) + } + + fn generate(wit: &str, world: &str) -> Files { + try_generate(wit, world).unwrap() + } + + fn file<'a>(files: &'a Files, path: &str) -> &'a str { + let contents = files + .iter() + .find_map(|(name, contents)| (name == path).then_some(contents)) + .unwrap_or_else(|| { + let names = files + .iter() + .map(|(name, _)| name) + .collect::>() + .join(", "); + std::panic!("missing generated file `{path}`; generated: {names}") + }); + std::str::from_utf8(contents).unwrap() + } + + #[test] + fn endpoint_free_sync_generation_matches_golden() { + let files = generate( + r#" + package a:b; + + world runner { + import add: func(a: u32, b: u32) -> u32; + export echo: func(value: u32) -> u32; + } + "#, + "runner", + ); + + // This exact-output fingerprint runs with both async adapters. Ignore only + // the release-version preamble, which is unrelated to generated ABI. + let mut entries = files.iter().collect::>(); + entries.sort_by_key(|(name, _)| *name); + let fingerprints = entries + .into_iter() + .map(|(name, contents)| { + let contents = if contents.starts_with(b"// Generated by") { + let preamble_end = contents.iter().position(|byte| *byte == b'\n').unwrap() + 1; + &contents[preamble_end..] + } else { + contents + }; + let hash = contents + .iter() + .fold(0xcbf29ce484222325_u64, |mut hash, byte| { + hash ^= u64::from(*byte); + hash.wrapping_mul(0x100000001b3) + }); + (name.to_string(), hash) + }) + .collect::>(); + + assert_eq!( + fingerprints, + vec![ + ("gen/ffi.mbt".into(), 10220319382745692950), + ("gen/moon.pkg.json".into(), 15894505084782869543), + ("gen/world/runner/ffi.mbt".into(), 14715999128234894449), + ("gen/world/runner/moon.pkg.json".into(), 6361049410124596525,), + ("gen/world/runner/top.mbt".into(), 12192865914091673515,), + ("moon.mod.json".into(), 14111159726816684443), + ("world/runner/ffi_import.mbt".into(), 17812050158059242657,), + ("world/runner/import.mbt".into(), 5430383198437179961), + ("world/runner/moon.pkg.json".into(), 6361049410124596525,), + ] + ); + } + + #[test] + fn sync_world_does_not_emit_async_runtime_or_wrappers() { + let files = generate( + r#" + package a:b; + + world runner { + import add: func(a: u32, b: u32) -> u32; + } + "#, + "runner", + ); + + let import = file(&files, "world/runner/import.mbt"); + assert!(import.contains("pub fn add(")); + for async_name in [ + "pub async fn", + "with_waitableset", + "TaskGroup", + "background_group", + "CMFuture", + "CMStream", + ] { + assert!(!import.contains(async_name)); + } + assert!( + files + .iter() + .all(|(name, _)| !name.starts_with("async-core/async_")) + ); + } + + #[test] + fn async_filters_respect_import_export_direction() { + let wit = r#" + package a:b; + world runner { import run: func(); } + "#; + + let mut import_opts = Opts { + gen_dir: "gen".into(), + ..Opts::default() + }; + import_opts.async_.push("import:run"); + let import_files = try_generate_with_opts(wit, "runner", import_opts).unwrap(); + let import = file(&import_files, "world/runner/import.mbt"); + let import_ffi = file(&import_files, "world/runner/ffi_import.mbt"); + assert!(import.contains("pub async fn run("), "{import}"); + assert!(import_ffi.contains("[async-lower]run"), "{import_ffi}"); + + let mut export_opts = Opts { + gen_dir: "gen".into(), + ..Opts::default() + }; + export_opts.async_.push("export:run"); + let export_files = try_generate_with_opts(wit, "runner", export_opts).unwrap(); + let import = file(&export_files, "world/runner/import.mbt"); + let import_ffi = file(&export_files, "world/runner/ffi_import.mbt"); + assert!(import.contains("pub fn run("), "{import}"); + assert!(!import.contains("pub async fn run("), "{import}"); + assert!(!import_ffi.contains("[async-lower]run"), "{import_ffi}"); + } + + #[test] + fn async_import_indirect_params_emit_malloc_builtin() { + let files = generate( + r#" + package a:b; + + interface types { + resource descriptor { + run: async func(first: string, second: list); + } + } + + world bindings { import types; } + "#, + "bindings", + ); + + let ffi = file(&files, "interface/a/b/types/ffi.mbt"); + assert!(ffi.contains("extern \"wasm\" fn mbt_ffi_malloc"), "{ffi}"); + } + + #[test] + fn type_only_endpoints_emit_async_runtime() { + let files = generate( + r#" + package a:b; + + interface types { + record payload { ready: future } + } + + world runner { import types; } + "#, + "runner", + ); + + let source = file(&files, "interface/a/b/types/top.mbt"); + assert!(source.contains("@async-core.Future[UInt]"), "{source}"); + file(&files, "async-core/moon.pkg.json"); + file(&files, "async-core/async_trait.mbt"); + } + + #[test] + fn endpoint_container_types_omit_value_derives() { + let mut resolve = Resolve::default(); + let pkg = resolve + .push_str( + "test.wit", + r#" + package a:b; + + interface types { + record plain { value: u32 } + record nested { value: option> } + variant streamed { none, value(list>) } + use-types: func(a: plain, b: nested, c: streamed); + } + + world runner { import types; } + "#, + ) + .unwrap(); + let world = resolve.select_world(&[pkg], Some("runner")).unwrap(); + let mut files = Files::default(); + let mut generator = MoonBit { + opts: Opts { + derive: DeriveOpts { + derive_debug: true, + derive_show: true, + derive_eq: true, + derive_error: false, + }, + gen_dir: "gen".into(), + ..Opts::default() + }, + ..MoonBit::default() + }; + generator.generate(&mut resolve, world, &mut files).unwrap(); + + let source = file(&files, "interface/a/b/types/top.mbt"); + assert!( + source.contains("struct Plain {\n value : UInt\n} derive(Debug, Show, Eq)"), + "{source}" + ); + assert!( + source.contains("struct Nested {\n value : @async-core.Future[UInt]?\n}"), + "{source}" + ); + assert!( + !source.contains("struct Nested {\n value : @async-core.Future[UInt]?\n} derive(") + ); + assert!( + source.contains( + "Streamed {\n None\n Value(Array[@async-core.Stream[String]])\n}" + ), + "{source}" + ); + assert!(!source.contains( + "Streamed {\n None\n Value(Array[@async-core.Stream[String]])\n} derive(" + )); + } + + #[test] + fn sync_functions_with_endpoints_remain_sync() { + let files = generate( + r#" + package a:b; + + world runner { + import exchange: func( + input: stream, + ready: future, + ) -> tuple, future>; + } + "#, + "runner", + ); + + let import = file(&files, "world/runner/import.mbt"); + assert!(import.contains("pub fn exchange(")); + assert!(import.contains("@async-core.Stream[Byte]")); + assert!(import.contains("@async-core.Future[UInt]")); + assert!(!import.contains("pub async fn exchange")); + assert!(!import.contains("background_group")); + let call = import.find("wasmImportExchange").unwrap(); + let after_call = &import[call..]; + assert!(after_call.contains("StreamCommit"), "{import}"); + assert!(after_call.contains("FutureCommit"), "{import}"); + assert_eq!(import.matches("StreamLower(input)").count(), 1, "{import}"); + assert_eq!(import.matches("FutureLower(ready)").count(), 1, "{import}"); + assert!( + files + .iter() + .any(|(name, _)| name == "async-core/async_trait.mbt") + ); + } + + #[test] + fn async_export_background_group_name_is_deconflicted() { + let files = generate( + r#" + package a:b; + world service { + export handle: async func(background-group: u32); + } + "#, + "service", + ); + let public = file(&files, "gen/world/service/top.mbt"); + assert!( + public.contains( + "background_group : UInt, background_group0 : @async-core.TaskGroup[Unit]" + ), + "{public}" + ); + let wrapper = file(&files, "gen/world/service/ffi.mbt"); + assert!( + wrapper.contains("with_task_group(async fn(background_group0)"), + "{wrapper}" + ); + assert!( + wrapper.contains("handle((p0).reinterpret_as_uint(), background_group0)"), + "{wrapper}" + ); + } + + #[test] + fn async_export_surface_hides_component_model_bridge_types() { + let files = generate( + r#" + package a:b; + + interface handler { + handle: async func( + input: stream, + ready: future, + ) -> tuple, future>; + } + + world service { + export handler; + } + "#, + "service", + ); + + let public = file(&files, "gen/interface/a/b/handler/top.mbt"); + assert!(public.contains("input : @async-core.Stream[Byte]")); + assert!(public.contains("ready : @async-core.Future[UInt]")); + assert!(public.contains("background_group : @async-core.TaskGroup[Unit]")); + for internal_name in [ + "CMFuture", + "CMStream", + "VTable", + "take_cm_handle", + "take_producer", + "from_callbacks", + ] { + assert!(!public.contains(internal_name)); + } + + let wrapper = file(&files, "gen/interface/a/b/handler/ffi.mbt"); + assert!(wrapper.contains("with_task_group(async fn(background_group)")); + let root_wrapper = file(&files, "gen/ffi.mbt"); + for ffi in [wrapper, root_wrapper] { + let lines = ffi.lines().collect::>(); + for (index, line) in lines.iter().enumerate() { + if line.trim_start().starts_with("pub fn wasmExport") { + assert_eq!(lines[index - 1].trim(), "#doc(hidden)", "{ffi}"); + } + } + } + + let coroutine = file(&files, "async-core/async_coroutine.mbt"); + assert!(!coroutine.contains("fn pause()")); + assert!(!coroutine.contains("wait_until")); + let task = file(&files, "async-core/async_task.mbt"); + assert!(task.contains("pub struct Task[X] {\n priv value : Ref[X?]")); + let promise = file(&files, "async-core/async_promise.mbt"); + assert!(promise.contains("pub struct Promise[X]")); + assert!(promise.contains("pub fn[X] Future::new()")); + assert!(promise.contains("pub fn[X] Promise::complete")); + let semaphore = file(&files, "async-core/async_semaphore.mbt"); + assert!(semaphore.contains("pub struct Semaphore")); + assert!(semaphore.contains("pub async fn Semaphore::acquire")); + let cond_var = file(&files, "async-core/async_cond_var.mbt"); + assert!(cond_var.contains("pub struct CondVar")); + assert!(cond_var.contains("pub async fn CondVar::wait")); + let mutex = file(&files, "async-core/async_mutex.mbt"); + assert!(mutex.contains("pub struct Mutex")); + assert!(mutex.contains("pub async fn Mutex::acquire")); + + let async_core = files + .iter() + .filter(|(name, _)| name.starts_with("async-core/")) + .map(|(_, contents)| String::from_utf8_lossy(contents)) + .collect::>() + .join("\n"); + for hidden in [ + "#doc(hidden)\npub fn with_waitableset", + "#doc(hidden)\npub fn cb", + "#doc(hidden)\npub fn spawn_component_task_current", + "#doc(hidden)\npub fn has_component_task_scope", + "#doc(hidden)\npub async fn suspend_for_subtask", + "#doc(hidden)\npub async fn suspend_for_future_read", + "#doc(hidden)\npub async fn suspend_for_future_write", + "#doc(hidden)\npub async fn suspend_for_stream_read", + "#doc(hidden)\npub async fn suspend_for_stream_write", + ] { + assert!( + async_core.contains(hidden), + "runtime helper is public: {hidden}" + ); + } + for raw in [ + "pub extern \"wasm\" fn malloc", + "pub extern \"wasm\" fn load32", + "pub fn context_set", + "pub fn context_get", + "pub fn task_cancel", + "pub fn backpressure_inc", + "pub fn backpressure_dec", + "[backpressure-inc]", + "[backpressure-dec]", + "pub fn current_coroutine", + "pub fn detach_waitable", + "pub fn has_immediately_ready_task", + "pub fn no_more_work", + "pub fn reschedule", + "pub fn spawn(", + "pub fn spawn_bg_current", + "pub async fn suspend()", + ] { + assert!( + !async_core.contains(raw), + "raw async-core API leaked: {raw}" + ); + } + } + + #[test] + fn nested_endpoints_use_static_boundary_helpers() { + let files = generate( + r#" + package test:moonbit-nested; + + interface nested { + relay: async func( + value: future>>, + ) -> future>>; + relay-stream: async func( + value: stream>, + ) -> stream>; + } + + world service { export nested; } + "#, + "service", + ); + + let ffi = file(&files, "gen/interface/test/moonbit-nested/nested/ffi.mbt"); + for site in [ + "RelayStream0StreamSource", + "RelayFuture1FutureSource", + "RelayFuture2FutureSource", + "RelayStream3StreamLower", + "RelayFuture4FutureLower", + "RelayFuture5FutureLower", + ] { + assert!(ffi.contains(site), "missing static endpoint site {site}"); + } + assert!(ffi.contains("[async-lower][future-read-2]relay")); + assert!(ffi.contains("[async-lower][future-write-2]relay")); + assert!(ffi.contains("suspend_for_future_write_terminal")); + assert!(ffi.contains("let data_len = if data.length() < 1")); + assert!(ffi.contains("let writer_lock = @async-core.Mutex()")); + assert!(ffi.contains("writer_lock.acquire()")); + assert!(ffi.contains("defer writer_lock.release()")); + assert!(ffi.contains("read_cleanup : @async-core.CondVar")); + assert!(ffi.contains("self.read_cleanup.broadcast()")); + assert!(ffi.contains("@async-core.cancel_future_read(")); + assert!(ffi.contains("@async-core.cancel_stream_read(")); + assert!(ffi.contains("@async-core.cancel_stream_write(")); + assert!(ffi.contains("[stream-cancel-write-")); + assert!(!ffi.contains("suspend_for_future_cancel_read")); + assert!(!ffi.contains("wait_until")); + assert!(ffi.contains("RelayFuture5FutureLowerCommitted")); + assert!(ffi.contains("RelayFuture4FutureRejectPrepared")); + assert!(ffi.contains("RelayStream3StreamRejectPrepared")); + assert!(!ffi.contains("RelayFuture5FutureRejectPrepared")); + + let generated = files + .iter() + .map(|(name, contents)| format!("{name}\n{}", String::from_utf8_lossy(contents))) + .collect::>() + .join("\n"); + for legacy in [ + "async_cm.mbt", + "CMFutureVTable", + "CMStreamVTable", + "take_cm_handle", + "new_cm_future", + "new_cm_stream", + ] { + assert!( + !generated.contains(legacy), + "legacy bridge leaked: {legacy}" + ); + } + + let imports = generate( + r#" + package test:moonbit-nested; + + interface nested { + relay: async func( + value: future>>, + ) -> future>>; + relay-stream: async func( + value: stream>, + ) -> stream>; + } + + world client { import nested; } + "#, + "client", + ); + let import = file(&imports, "interface/test/moonbit-nested/nested/top.mbt"); + assert!(import.contains("if before_started")); + assert!(import.contains(".drop_sync()")); + assert!(import.contains("defer mbt_ffi_free(_result_ptr)")); + assert_eq!(import.matches("FutureLower(value)").count(), 1, "{import}"); + assert_eq!(import.matches("StreamLower(value)").count(), 1, "{import}"); + assert!(!import.contains("cleanup_list")); + } + + #[test] + fn fixed_length_lists_use_checked_fixed_arrays_but_reject_nested_endpoints() { + let files = generate( + r#" + package test:fixed-array; + + interface api { + type pair = list; + type block = list; + accept: func(value: pair) -> pair; + accept-block: func(value: block); + } + + world client { import api; } + "#, + "client", + ); + let top = file(&files, "interface/test/fixed-array/api/top.mbt"); + assert!(top.contains("FixedArray[UInt]"), "{top}"); + assert!(top.contains(".length() != 2"), "{top}"); + assert!(top.contains(".length() != 20"), "{top}"); + + for unsupported in [ + "type unsupported = list, 1>;", + "type unsupported = future>;", + ] { + let wit = format!( + r#" + package test:fixed-endpoints; + + interface api {{ + {unsupported} + accept: func(value: unsupported); + }} + + world client {{ import api; }} + "# + ); + let error = match try_generate(&wit, "client") { + Ok(_) => std::panic!( + "fixed-length list and async endpoint combinations must be rejected" + ), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("combining future or stream types with fixed-length lists"), + "{error:#}" + ); + } + } + + #[test] + fn cancelled_returned_async_import_recursively_cleans_result() { + let files = generate( + r#" + package test:cancelled-result; + + interface api { + resource leaf; + record payload { + label: string, + leaves: list, + ready: future, + } + load: async func() -> payload; + } + + world client { import api; } + "#, + "client", + ); + + let import = file(&files, "interface/test/cancelled-result/api/top.mbt"); + assert!( + import.contains("suspend_for_subtask") + && import.contains("fn() {") + && import.contains(".drop_sync()") + && import.contains(".drop()"), + "{import}" + ); + assert!( + import.contains("mbt_ffi_free(mbt_ffi_load32((_result_ptr) + 0))") + && import.contains("mbt_ffi_free(mbt_ffi_load32((_result_ptr) + 8))") + && import.contains("defer mbt_ffi_free(_result_ptr)"), + "cancelled returned result must free its strings, lists, and result area: {import}" + ); + } + + #[test] + fn async_runtime_traps_unhandled_export_failure() { + let files = generate( + r#" + package test:failing-export; + world service { export run: async func(); } + "#, + "service", + ); + + let event_loop = file(&files, "async-core/async_ev.mbt"); + assert!( + event_loop.contains("abort(\"async export failed before task return\")") + && event_loop.contains("if !(ev.resolved.get(waitable_set) is Some(true))"), + "{event_loop}" + ); + } + + #[test] + fn async_runtime_borrows_waitable_poll_payload() { + let files = generate( + r#" + package test:poll-payload; + world service { export run: async func(); } + "#, + "service", + ); + + let abi = file(&files, "async-core/async_abi.mbt"); + assert!( + abi.contains("#borrow(array)\nextern \"wasm\" fn int_array2ptr"), + "{abi}" + ); + assert!(!abi.contains("#owned(array)"), "{abi}"); + } + + #[test] + fn async_runtime_uses_baseline_subtask_cancel() { + let files = generate( + r#" + package test:subtask-cancel; + world service { export run: async func(); } + "#, + "service", + ); + + let abi = file(&files, "async-core/async_abi.mbt"); + assert!( + abi.contains(r#""$root" "[subtask-cancel]""#) + && !abi.contains("[async-lower][subtask-cancel]"), + "{abi}" + ); + } + + #[test] + fn unit_future_intrinsics_use_wit_parser_unit_name() { + let files = generate( + r#" + package a:b; + + world runner { + import exchange: func(value: future) -> future; + } + "#, + "runner", + ); + + let ffi = file(&files, "world/runner/ffi_import.mbt"); + assert!(ffi.contains(r#""$root" "[future-new-unit]exchange""#)); + assert!(ffi.contains(r#""$root" "[async-lower][future-read-unit]exchange""#)); + assert!(ffi.contains(r#""$root" "[future-cancel-read-unit]exchange""#)); + } +} diff --git a/crates/moonbit/src/pkg.rs b/crates/moonbit/src/pkg.rs index ccae5ac18..b98343f23 100644 --- a/crates/moonbit/src/pkg.rs +++ b/crates/moonbit/src/pkg.rs @@ -9,7 +9,7 @@ use wit_bindgen_core::{ }, }; -pub(crate) const FFI_DIR: &str = "ffi"; +pub(crate) const ASYNC_CORE_DIR: &str = "async-core"; #[derive(Default)] pub(crate) struct Imports { @@ -247,9 +247,9 @@ impl PkgResolver { } TypeDefKind::Future(ty) => { - let qualifier = self.qualify_package(this, FFI_DIR); + let qualifier = self.qualify_package(this, ASYNC_CORE_DIR); format!( - "{}FutureReader[{}]", + "{}Future[{}]", qualifier, ty.as_ref() .map(|t| self.type_name(this, t)) @@ -258,9 +258,9 @@ impl PkgResolver { } TypeDefKind::Stream(ty) => { - let qualifier = self.qualify_package(this, FFI_DIR); + let qualifier = self.qualify_package(this, ASYNC_CORE_DIR); format!( - "{}StreamReader[{}]", + "{}Stream[{}]", qualifier, ty.as_ref() .map(|t| self.type_name(this, t)) diff --git a/crates/test/src/moonbit.rs b/crates/test/src/moonbit.rs index 8811314f5..435bc4ae4 100644 --- a/crates/test/src/moonbit.rs +++ b/crates/test/src/moonbit.rs @@ -33,6 +33,10 @@ impl LanguageMethods for MoonBit { ] } + fn codegen_test_variants(&self) -> &[(&str, &[&str])] { + &[("async", &["--async=all"])] + } + fn prepare(&self, runner: &mut Runner) -> anyhow::Result<()> { println!("Testing if MoonBit toolchain exists..."); if runner @@ -125,9 +129,7 @@ impl LanguageMethods for MoonBit { config: &crate::config::WitConfig, _args: &[String], ) -> bool { - // async-resource-func actually works, but most other async tests - // fail during codegen or verification - config.async_ && name != "async-resource-func.wit" + name == "named-fixed-length-list.wit-async" || config.error_context } fn verify(&self, runner: &Runner, verify: &crate::Verify) -> anyhow::Result<()> { diff --git a/tests/runtime/cancel-import/test.mbt b/tests/runtime/cancel-import/test.mbt new file mode 100644 index 000000000..e24774938 --- /dev/null +++ b/tests/runtime/cancel-import/test.mbt @@ -0,0 +1,19 @@ +//@ [lang] +//@ path = 'gen/interface/my/test/i/stub.mbt' + +///| +pub async fn pending_import( + x : @async-core.Future[Unit], + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + x.get() +} + +///| +pub fn backpressure_set(x : Bool) -> Unit { + if x { + @async-core.backpressure_inc() + } else { + @async-core.backpressure_dec() + } +} diff --git a/tests/runtime/future-cancel-read/test.mbt b/tests/runtime/future-cancel-read/test.mbt index 5354c920e..13d62eaba 100644 --- a/tests/runtime/future-cancel-read/test.mbt +++ b/tests/runtime/future-cancel-read/test.mbt @@ -3,35 +3,35 @@ ///| pub async fn cancel_before_read( - x : @ffi.FutureReader[UInt], -) -> Unit noraise { - let task = @ffi.current_task() - let _ = x.drop() + x : @async-core.Future[UInt], + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + x.drop() } ///| pub async fn cancel_after_read( - x : @ffi.FutureReader[UInt], -) -> Unit noraise { - let task = @ffi.current_task() - task.spawn( - fn() { - let _ = x.read() catch { _ => raise @ffi.Cancelled::Cancelled } + x : @async-core.Future[UInt], + background_group : @async-core.TaskGroup[Unit], +) -> Unit { + background_group.spawn_bg( + async fn() { + let _ = x.get() catch { _ => raise @async-core.Cancelled::Cancelled } } ) - task.cancel_waitable(x) + x.drop() } ///| pub async fn start_read_then_cancel( - data : @ffi.FutureReader[UInt], - signal : @ffi.FutureReader[Unit], -) -> Unit noraise { - let task = @ffi.current_task() - task.spawn( - fn() { let _ = data.read() catch { _ => raise @ffi.Cancelled::Cancelled } }, - ) - task.spawn( - fn() { signal.read() catch { _ => raise @ffi.Cancelled::Cancelled } }, + data : @async-core.Future[UInt], + signal : @async-core.Future[Unit], + background_group : @async-core.TaskGroup[Unit], +) -> Unit { + background_group.spawn_bg( + async fn() { let _ = data.get() catch { _ => raise @async-core.Cancelled::Cancelled } }, + ) + background_group.spawn_bg( + async fn() { signal.get() catch { _ => raise @async-core.Cancelled::Cancelled } }, ) } diff --git a/tests/runtime/future-cancel-write/test.mbt b/tests/runtime/future-cancel-write/test.mbt index ac2349645..faaaa4144 100644 --- a/tests/runtime/future-cancel-write/test.mbt +++ b/tests/runtime/future-cancel-write/test.mbt @@ -2,16 +2,16 @@ //@ path = 'gen/interface/my/test/i/stub.mbt' ///| -pub fn take_then_drop(x : @ffi.FutureReader[String]) -> Unit { - let _ = x.drop() +pub fn take_then_drop(x : @async-core.Future[String]) -> Unit { + x.drop_sync() } ///| pub async fn read_and_drop( - x : @ffi.FutureReader[String], -) -> Unit noraise { - let task = @ffi.current_task() - let _ = task.spawn(fn() { - let _ = x.read() catch { _ => raise @ffi.Cancelled::Cancelled } + x : @async-core.Future[String], + background_group : @async-core.TaskGroup[Unit], +) -> Unit { + background_group.spawn_bg(async fn() { + let _ = x.get() catch { _ => raise @async-core.Cancelled::Cancelled } }) } diff --git a/tests/runtime/future-close-after-coming-back/test.mbt b/tests/runtime/future-close-after-coming-back/test.mbt index a11ed06d6..b6fc91dcf 100644 --- a/tests/runtime/future-close-after-coming-back/test.mbt +++ b/tests/runtime/future-close-after-coming-back/test.mbt @@ -1,6 +1,6 @@ //@ [lang] //@ path = 'gen/interface/a/b/the-test/stub.mbt' -pub fn f(_param : @ffi.FutureReader[Unit]) -> @ffi.FutureReader[Unit] { +pub fn f(_param : @async-core.Future[Unit]) -> @async-core.Future[Unit] { _param } diff --git a/tests/runtime/moonbit/async-import-cancel/gate.mbt b/tests/runtime/moonbit/async-import-cancel/gate.mbt new file mode 100644 index 000000000..d43e29bac --- /dev/null +++ b/tests/runtime/moonbit/async-import-cancel/gate.mbt @@ -0,0 +1,28 @@ +//@ [lang] +//@ path = 'gen/interface/test/async-import-cancel/gate-control/stub.mbt' + +///| +let return_started : Ref[Bool] = Ref(false) + +///| +let return_released : Ref[Bool] = Ref(false) + +///| +pub fn mark_started() -> Unit { + return_started.val = true +} + +///| +pub fn started() -> Bool { + return_started.val +} + +///| +pub fn release() -> Unit { + return_released.val = true +} + +///| +pub fn released() -> Bool { + return_released.val +} diff --git a/tests/runtime/moonbit/async-import-cancel/runner.mbt b/tests/runtime/moonbit/async-import-cancel/runner.mbt new file mode 100644 index 000000000..f7e76b5c8 --- /dev/null +++ b/tests/runtime/moonbit/async-import-cancel/runner.mbt @@ -0,0 +1,313 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' +//@ [lang] +//@ path = 'gen/world/runner/stub.mbt' +//@ pkg_config = """{ "import": [{ "path": "test/async-import-cancel/async-core", "alias": "async-core" }, { "path": "test/async-import-cancel/interface/test/async-import-cancel/pending", "alias": "pending" }, { "path": "test/async-import-cancel/interface/test/async-import-cancel/gate-control", "alias": "gate-control" }] }""" + +///| +pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { + guard @pending.leaf_live_count() == 0U else { panic() } + guard @pending.leaf_drop_count() == 0U else { panic() } + + @pending.backpressure_set(true) + let leaf = @pending.Leaf::leaf() + let future = @async-core.Future::ready_with_cleanup(leaf, fn( + value : @pending.Leaf, + ) { + value.drop() + }) + let (lowered, lowered_sink) = @async-core.Stream::new(capacity=1) + let task = background_group.spawn(async fn() -> Unit { + let signal : FixedArray[Unit] = [()] + guard lowered_sink.write_all(signal[:]) else { panic() } + lowered_sink.close() + defer @pending.backpressure_set(false) + @pending.pending(future) + }) + + guard lowered.read(1) is Some(signal) && signal.length() == 1 else { panic() } + guard !@pending.pending_started() else { panic() } + task.cancel() + @pending.settle() + guard !@pending.pending_started() else { panic() } + + for _ in 0..<32 { + if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 1U { + break + } + @pending.settle() + } + guard @pending.leaf_live_count() == 0U else { panic() } + guard @pending.leaf_drop_count() == 1U else { panic() } + + @pending.backpressure_set(true) + let stream_leaf = @pending.Leaf::leaf() + let stream_producer_ran = Ref(false) + let stream = @async-core.Stream::produce( + async fn(sink) { + stream_producer_ran.val = true + let values : FixedArray[@pending.Leaf] = [stream_leaf] + guard sink.write_all(values[:]) else { panic() } + sink.close() + }, + on_unstarted_drop=fn() { stream_leaf.drop() }, + ) + let (stream_lowered, stream_lowered_sink) = @async-core.Stream::new( + capacity=1, + ) + let stream_task = background_group.spawn(async fn() -> Unit { + let signal : FixedArray[Unit] = [()] + guard stream_lowered_sink.write_all(signal[:]) else { panic() } + stream_lowered_sink.close() + defer @pending.backpressure_set(false) + @pending.pending_stream(stream) + }) + + guard stream_lowered.read(1) is Some(signal) && signal.length() == 1 else { + panic() + } + guard !@pending.pending_stream_started() else { panic() } + stream_task.cancel() + @pending.settle() + guard !@pending.pending_stream_started() else { panic() } + guard !stream_producer_ran.val else { panic() } + + for _ in 0..<32 { + if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 2U { + break + } + @pending.settle() + } + guard @pending.leaf_live_count() == 0U else { panic() } + guard @pending.leaf_drop_count() == 2U else { panic() } + + let returned_leaf = @pending.Leaf::leaf() + let (return_started, return_started_sink) = @async-core.Stream::new( + capacity=0, + ) + let returned_task = background_group.spawn(async fn() -> Unit { + let signal : FixedArray[Unit] = [()] + guard return_started_sink.write_all(signal[:]) else { panic() } + return_started_sink.close() + let payload = @pending.return_after_cancel(returned_leaf) + payload.leaf.drop() + }) + guard return_started.read(1) is Some(signal) && signal.length() == 1 else { + panic() + } + guard @gate-control.started() else { panic() } + returned_task.cancel() + @gate-control.release() + + for _ in 0..<32 { + if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 5U { + break + } + @pending.settle() + } + guard @pending.leaf_live_count() == 0U else { panic() } + guard @pending.leaf_drop_count() == 5U else { panic() } + + let cleanup_leaf = fn(value : @pending.Leaf) { value.drop() } + let stream_values : FixedArray[@async-core.Future[@pending.Leaf]] = [ + @async-core.Future::ready_with_cleanup(@pending.Leaf::leaf(), cleanup_leaf), + @async-core.Future::ready_with_cleanup(@pending.Leaf::leaf(), cleanup_leaf), + @async-core.Future::ready_with_cleanup(@pending.Leaf::leaf(), cleanup_leaf), + ] + let cleanup_stream = @async-core.Stream::produce(async fn(sink) { + guard !sink.write_all(stream_values[:]) else { + abort("peer unexpectedly accepted the future stream") + } + sink.close() + }) + @pending.drop_future_stream(cleanup_stream) + for _ in 0..<32 { + if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 8U { + break + } + @pending.settle() + } + guard @pending.leaf_live_count() == 0U else { panic() } + guard @pending.leaf_drop_count() == 8U else { panic() } + + let after_start_future = @async-core.Future::ready_with_cleanup( + @pending.Leaf::leaf(), + cleanup_leaf, + ) + let after_start_stream_leaf = @pending.Leaf::leaf() + let after_start_stream = @async-core.Stream::produce( + async fn(sink) { + let values : FixedArray[@pending.Leaf] = [after_start_stream_leaf] + guard !sink.write_all(values[:]) else { + abort("cancelled callee unexpectedly consumed its stream") + } + sink.close() + }, + on_unstarted_drop=fn() { after_start_stream_leaf.drop() }, + ) + let after_start_task = background_group.spawn(async fn() -> Unit { + @pending.take_after_start({ + direct: @pending.Leaf::leaf(), + future_value: after_start_future, + stream_value: after_start_stream, + }) + }) + while !@pending.take_after_start_started() { + @pending.settle() + } + after_start_task.cancel() + after_start_task.wait() catch { + _ => () + } + for _ in 0..<32 { + if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 11U { + break + } + @pending.settle() + } + guard @pending.leaf_live_count() == 0U else { panic() } + guard @pending.leaf_drop_count() == 11U else { panic() } + + let race_future = @pending.open_race_future() + let (race_read_started, race_read_started_sink) = @async-core.Stream::new( + capacity=1, + ) + let race_read = background_group.spawn(async fn() -> Bool { + let signal : FixedArray[Unit] = [()] + guard race_read_started_sink.write_all(signal[:]) else { panic() } + race_read_started_sink.close() + let value = race_future.get() + value.drop() + true + }) + guard race_read_started.read(1) is Some(signal) && signal.length() == 1 else { + panic() + } + @pending.complete_race_future() + race_read.cancel() + guard race_read.wait() else { panic() } + for _ in 0..<32 { + if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 12U { + break + } + @pending.settle() + } + guard @pending.leaf_live_count() == 0U else { panic() } + guard @pending.leaf_drop_count() == 12U else { panic() } + + let race_stream = @pending.open_race_stream() + let (race_stream_started, race_stream_started_sink) = @async-core.Stream::new( + capacity=1, + ) + let race_stream_read = background_group.spawn(async fn() -> Bool { + let signal : FixedArray[Unit] = [()] + guard race_stream_started_sink.write_all(signal[:]) else { panic() } + race_stream_started_sink.close() + guard race_stream.read(1) is Some(values) && values.length() == 1 else { + return false + } + values[0].drop() + true + }) + guard race_stream_started.read(1) is Some(signal) && signal.length() == 1 else { + panic() + } + @pending.complete_race_stream() + race_stream_read.cancel() + guard race_stream_read.wait() else { panic() } + for _ in 0..<32 { + if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 13U { + break + } + @pending.settle() + } + guard @pending.leaf_live_count() == 0U else { panic() } + guard @pending.leaf_drop_count() == 13U else { panic() } + + let pending_future = @pending.open_future() + let (future_read_started, future_read_started_sink) = @async-core.Stream::new( + capacity=1, + ) + let future_read = background_group.spawn(async fn() -> Unit { + let signal : FixedArray[Unit] = [()] + guard future_read_started_sink.write_all(signal[:]) else { panic() } + future_read_started_sink.close() + let _ = pending_future.get() catch { _ => return } + }) + guard future_read_started.read(1) is Some(signal) && signal.length() == 1 else { + panic() + } + pending_future.drop() + future_read.wait() + guard @pending.open_future_reader_dropped() else { panic() } + + let cancelled_future = @pending.open_future() + let (future_cancel_started, future_cancel_started_sink) = @async-core.Stream::new( + capacity=1, + ) + let future_cancel = background_group.spawn( + async fn() -> Unit { + let signal : FixedArray[Unit] = [()] + guard future_cancel_started_sink.write_all(signal[:]) else { panic() } + future_cancel_started_sink.close() + let _ = cancelled_future.get() + }, + allow_failure=true, + ) + guard future_cancel_started.read(1) is Some(signal) && signal.length() == 1 else { + panic() + } + future_cancel.cancel() + let future_was_cancelled = try future_cancel.wait() catch { + @async-core.Cancelled::Cancelled => true + _ => false + } noraise { + _ => false + } + guard future_was_cancelled else { panic() } + guard @pending.open_future_reader_dropped() else { panic() } + + let pending_stream = @pending.open_stream() + let (stream_read_started, stream_read_started_sink) = @async-core.Stream::new( + capacity=1, + ) + let stream_read = background_group.spawn(async fn() -> Unit { + let signal : FixedArray[Unit] = [()] + guard stream_read_started_sink.write_all(signal[:]) else { panic() } + stream_read_started_sink.close() + let _ = pending_stream.read(1) catch { _ => return } + }) + guard stream_read_started.read(1) is Some(signal) && signal.length() == 1 else { + panic() + } + pending_stream.drop() + stream_read.wait() + guard @pending.open_stream_reader_dropped() else { panic() } + + let cancelled_stream = @pending.open_stream() + let (stream_cancel_started, stream_cancel_started_sink) = @async-core.Stream::new( + capacity=1, + ) + let stream_cancel = background_group.spawn( + async fn() -> Unit { + let signal : FixedArray[Unit] = [()] + guard stream_cancel_started_sink.write_all(signal[:]) else { panic() } + stream_cancel_started_sink.close() + let _ = cancelled_stream.read(1) + }, + allow_failure=true, + ) + guard stream_cancel_started.read(1) is Some(signal) && signal.length() == 1 else { + panic() + } + stream_cancel.cancel() + let stream_was_cancelled = try stream_cancel.wait() catch { + @async-core.Cancelled::Cancelled => true + _ => false + } noraise { + _ => false + } + guard stream_was_cancelled else { panic() } + guard @pending.open_stream_reader_dropped() else { panic() } + guard @pending.leaf_live_count() == 0U else { panic() } + guard @pending.leaf_drop_count() == 17U else { panic() } +} diff --git a/tests/runtime/moonbit/async-import-cancel/test.rs b/tests/runtime/moonbit/async-import-cancel/test.rs new file mode 100644 index 000000000..a432a3b78 --- /dev/null +++ b/tests/runtime/moonbit/async-import-cancel/test.rs @@ -0,0 +1,181 @@ +include!(env!("BINDINGS")); + +use crate::test::async_import_cancel::gate_control; +use crate::exports::test::async_import_cancel::pending::{ + Guest, GuestLeaf, Leaf, OwnedInput, ReturnedPayload, +}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::Mutex; +use wit_bindgen::{ + FutureReader, FutureWriter, StreamReader, StreamResult, StreamWriter, +}; + +struct Component; + +export!(Component); + +static LEAF_LIVE_COUNT: AtomicU32 = AtomicU32::new(0); +static LEAF_DROP_COUNT: AtomicU32 = AtomicU32::new(0); +static PENDING_STARTED: AtomicBool = AtomicBool::new(false); +static PENDING_STREAM_STARTED: AtomicBool = AtomicBool::new(false); +static TAKE_AFTER_START_STARTED: AtomicBool = AtomicBool::new(false); +static OPEN_FUTURE_WRITER: Mutex>> = Mutex::new(None); +static RACE_FUTURE_WRITER: Mutex>> = Mutex::new(None); +static OPEN_STREAM_WRITER: Mutex>> = Mutex::new(None); +static RACE_STREAM_WRITER: Mutex>> = Mutex::new(None); + +struct MyLeaf; + +impl MyLeaf { + fn tracked() -> Self { + LEAF_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); + Self + } +} + +impl Guest for Component { + type Leaf = MyLeaf; + + async fn pending(value: FutureReader) { + PENDING_STARTED.store(true, Ordering::SeqCst); + let _ = value.await; + } + + fn pending_started() -> bool { + PENDING_STARTED.load(Ordering::SeqCst) + } + + async fn pending_stream(mut value: StreamReader) { + PENDING_STREAM_STARTED.store(true, Ordering::SeqCst); + let _ = value.read(Vec::with_capacity(1)).await; + } + + fn pending_stream_started() -> bool { + PENDING_STREAM_STARTED.load(Ordering::SeqCst) + } + + fn open_future() -> FutureReader { + let (writer, reader) = wit_future::new::(|| Leaf::new(MyLeaf::tracked())); + assert!(OPEN_FUTURE_WRITER.lock().unwrap().replace(writer).is_none()); + reader + } + + async fn open_future_reader_dropped() -> bool { + let writer = OPEN_FUTURE_WRITER.lock().unwrap().take().unwrap(); + match writer.write(Leaf::new(MyLeaf::tracked())).await { + Ok(()) => false, + Err(error) => { + drop(error.value); + true + } + } + } + + fn open_race_future() -> FutureReader { + let (writer, reader) = wit_future::new::(|| Leaf::new(MyLeaf::tracked())); + assert!(RACE_FUTURE_WRITER.lock().unwrap().replace(writer).is_none()); + reader + } + + async fn complete_race_future() { + let writer = RACE_FUTURE_WRITER.lock().unwrap().take().unwrap(); + writer + .write(Leaf::new(MyLeaf::tracked())) + .await + .unwrap(); + } + + fn open_stream() -> StreamReader { + let (writer, reader) = wit_stream::new::(); + assert!(OPEN_STREAM_WRITER.lock().unwrap().replace(writer).is_none()); + reader + } + + async fn open_stream_reader_dropped() -> bool { + let mut writer = OPEN_STREAM_WRITER.lock().unwrap().take().unwrap(); + let (result, remaining) = writer + .write(vec![Leaf::new(MyLeaf::tracked())]) + .await; + let dropped = result == StreamResult::Dropped && remaining.remaining() == 1; + drop(remaining); + dropped + } + + fn open_race_stream() -> StreamReader { + let (writer, reader) = wit_stream::new::(); + assert!(RACE_STREAM_WRITER.lock().unwrap().replace(writer).is_none()); + reader + } + + async fn complete_race_stream() { + let mut writer = RACE_STREAM_WRITER.lock().unwrap().take().unwrap(); + let (result, remaining) = writer + .write(vec![Leaf::new(MyLeaf::tracked())]) + .await; + assert_eq!(result, StreamResult::Complete(1)); + assert_eq!(remaining.remaining(), 0); + } + + async fn drop_future_stream(value: StreamReader>) { + drop(value); + } + + async fn take_after_start(_value: OwnedInput) { + TAKE_AFTER_START_STARTED.store(true, Ordering::SeqCst); + loop { + wit_bindgen::yield_async().await; + } + } + + fn take_after_start_started() -> bool { + TAKE_AFTER_START_STARTED.load(Ordering::SeqCst) + } + + async fn return_after_cancel(value: Leaf) -> ReturnedPayload { + gate_control::mark_started(); + while !gate_control::released() { + let _ = wit_bindgen::yield_blocking(); + } + let (ready_writer, ready) = wit_future::new::(|| Leaf::new(MyLeaf::tracked())); + drop(ready_writer); + ReturnedPayload { + label: "returned after cancellation".into(), + leaf: value, + leaves: vec![Leaf::new(MyLeaf::tracked())], + ready, + } + } + + async fn settle() { + let _ = wit_bindgen::yield_blocking(); + } + + fn backpressure_set(value: bool) { + if value { + wit_bindgen::backpressure_inc(); + } else { + wit_bindgen::backpressure_dec(); + } + } + + fn leaf_live_count() -> u32 { + LEAF_LIVE_COUNT.load(Ordering::SeqCst) + } + + fn leaf_drop_count() -> u32 { + LEAF_DROP_COUNT.load(Ordering::SeqCst) + } +} + +impl GuestLeaf for MyLeaf { + fn new() -> Self { + Self::tracked() + } +} + +impl Drop for MyLeaf { + fn drop(&mut self) { + LEAF_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + LEAF_DROP_COUNT.fetch_add(1, Ordering::SeqCst); + } +} diff --git a/tests/runtime/moonbit/async-import-cancel/test.wit b/tests/runtime/moonbit/async-import-cancel/test.wit new file mode 100644 index 000000000..1589ed5be --- /dev/null +++ b/tests/runtime/moonbit/async-import-cancel/test.wit @@ -0,0 +1,66 @@ +//@ async = true +//@ dependencies = ['test', 'gate'] + +package test:async-import-cancel; + +interface gate-control { + mark-started: func(); + started: func() -> bool; + release: func(); + released: func() -> bool; +} + +interface pending { + resource leaf { + constructor(); + } + + record returned-payload { + label: string, + leaf: leaf, + leaves: list, + ready: future, + } + + record owned-input { + direct: leaf, + future-value: future, + stream-value: stream, + } + + pending: async func(value: future); + pending-started: func() -> bool; + pending-stream: async func(value: stream); + pending-stream-started: func() -> bool; + open-future: func() -> future; + open-future-reader-dropped: async func() -> bool; + open-race-future: func() -> future; + complete-race-future: async func(); + open-stream: func() -> stream; + open-stream-reader-dropped: async func() -> bool; + open-race-stream: func() -> stream; + complete-race-stream: async func(); + drop-future-stream: async func(value: stream>); + take-after-start: async func(value: owned-input); + take-after-start-started: func() -> bool; + return-after-cancel: async func(value: leaf) -> returned-payload; + settle: async func(); + backpressure-set: func(value: bool); + leaf-live-count: func() -> u32; + leaf-drop-count: func() -> u32; +} + +world test { + import gate-control; + export pending; +} + +world gate { + export gate-control; +} + +world runner { + import pending; + import gate-control; + export run: async func(); +} diff --git a/tests/runtime/moonbit/concurrent-export-isolation/runner.mbt b/tests/runtime/moonbit/concurrent-export-isolation/runner.mbt new file mode 100644 index 000000000..e6478cb77 --- /dev/null +++ b/tests/runtime/moonbit/concurrent-export-isolation/runner.mbt @@ -0,0 +1,133 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' +//@ [lang] +//@ path = 'gen/world/runner/stub.mbt' +//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "test/moonbit-concurrent-export-isolation/async-core", "alias": "async-core" }, { "path": "test/moonbit-concurrent-export-isolation/interface/test/moonbit-concurrent-export-isolation/operations", "alias": "operations" }] }""" + +///| +pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { + let isolated = background_group.spawn( + async fn() -> Bool { @operations.check_context_isolation() }, + allow_failure=true, + ) + let isolated_again = background_group.spawn( + async fn() -> Bool { @operations.check_context_isolation() }, + allow_failure=true, + ) + let completed = background_group.spawn( + async fn() -> UInt { @operations.complete_now() }, + allow_failure=true, + ) + guard isolated.wait() else { panic() } + guard isolated_again.wait() else { panic() } + guard completed.wait() == 42U else { panic() } + + let (gate, gate_sink) = @async-core.Stream::new(capacity=0) + let waiting = background_group.spawn( + async fn() -> Unit { + @operations.wait_for_gate( + @async-core.Future::from(async fn() { + guard gate.read(1) is Some(signal) && signal.length() == 1 + }), + ) + }, + allow_failure=true, + ) + let completing = background_group.spawn( + async fn() -> UInt { + let result = @operations.complete_now() + let signal : FixedArray[Unit] = [()] + guard gate_sink.write_all(signal[:]) else { panic() } + gate_sink.close() + result + }, + allow_failure=true, + ) + guard completing.wait() == 42U else { panic() } + waiting.wait() + + let (release_stream, release_sink) = @async-core.Stream::new(capacity=0) + let held_stream = @operations.hold_stream( + @async-core.Future::from(async fn() { + guard release_stream.read(1) is Some(signal) && signal.length() == 1 else { + abort("stream release signal closed early") + } + }), + ) + let completing_while_stream_is_held = background_group.spawn( + async fn() -> UInt { @operations.complete_now() }, + allow_failure=true, + ) + + // Give the imported call several scheduler turns without releasing the + // stream producer. Component backpressure must not be tied to bridge life. + let (turns, turn_sink) = @async-core.Stream::new(capacity=0) + let turn_writer = background_group.spawn( + async fn() -> Bool { + let turn : FixedArray[Unit] = [()] + for _ in 0..<32 { + guard turn_sink.write_all(turn[:]) else { return false } + } + turn_sink.close() + true + }, + allow_failure=true, + ) + for _ in 0..<32 { + guard turns.read(1) is Some(turn) && turn.length() == 1 else { panic() } + } + guard turn_writer.wait() else { panic() } + let completed_before_release = completing_while_stream_is_held.try_wait() == + Some(42U) + + let release : FixedArray[Unit] = [()] + guard release_sink.write_all(release[:]) else { panic() } + release_sink.close() + guard held_stream.read(1) is Some(chunk) && + chunk.length() == 1 && + chunk[0] == b'x' else { + panic() + } + guard held_stream.read(1) is None else { panic() } + guard completing_while_stream_is_held.wait() == 42U else { panic() } + guard completed_before_release else { panic() } + + let (cross_future, cross_promise) = @async-core.Future::new() + @operations.begin_cross_task_future_read(cross_future) + for _ in 0..<32 { + if @operations.cross_task_future_read_state() >= 1 { + break + } + ignore(@operations.complete_now()) + } + guard @operations.cross_task_future_read_state() == 1 else { panic() } + @operations.drop_cross_task_future_read() + guard cross_promise.complete(()) else { panic() } + for _ in 0..<32 { + if @operations.cross_task_future_read_state() == 2 { + break + } + ignore(@operations.complete_now()) + } + guard @operations.cross_task_future_read_state() == 2 else { panic() } + + let (cross_stream, cross_sink) = @async-core.Stream::new(capacity=0) + @operations.begin_cross_task_stream_read(cross_stream) + for _ in 0..<32 { + if @operations.cross_task_stream_read_state() >= 1 { + break + } + ignore(@operations.complete_now()) + } + guard @operations.cross_task_stream_read_state() == 1 else { panic() } + @operations.drop_cross_task_stream_read() + let discarded : FixedArray[Byte] = [7] + guard cross_sink.write_all(discarded[:]) else { panic() } + cross_sink.close() + for _ in 0..<32 { + if @operations.cross_task_stream_read_state() == 2 { + break + } + ignore(@operations.complete_now()) + } + guard @operations.cross_task_stream_read_state() == 2 else { panic() } +} diff --git a/tests/runtime/moonbit/concurrent-export-isolation/test.mbt b/tests/runtime/moonbit/concurrent-export-isolation/test.mbt new file mode 100644 index 000000000..7aa60a0fd --- /dev/null +++ b/tests/runtime/moonbit/concurrent-export-isolation/test.mbt @@ -0,0 +1,127 @@ +//@ [lang] +//@ path = 'gen/interface/test/moonbit-concurrent-export-isolation/operations/stub.mbt' + +///| +let cross_task_future : Ref[@async-core.Future[Unit]?] = Ref(None) + +///| +let cross_task_future_state : Ref[Byte] = Ref(0) + +///| +let cross_task_stream : Ref[@async-core.Stream[Byte]?] = Ref(None) + +///| +let cross_task_stream_state : Ref[Byte] = Ref(0) + +///| +pub async fn check_context_isolation( + background_group : @async-core.TaskGroup[Unit], +) -> Bool { + let (turns, turn_sink) = @async-core.Stream::new(capacity=0) + background_group.spawn_bg(async fn() { + let turn : FixedArray[Unit] = [()] + for _ in 0..<2048 { + guard turn_sink.write_all(turn[:]) else { return } + } + turn_sink.close() + }) + // Exceed one scheduler callback budget with real suspend/resume cycles so + // another export starts while this coroutine is still in flight. + for _ in 0..<2048 { + guard turns.read(1) is Some(turn) && turn.length() == 1 else { + return false + } + } + true +} + +///| +pub async fn wait_for_gate( + gate : @async-core.Future[Unit], + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + let _ = gate.get() +} + +///| +pub async fn hold_stream( + gate : @async-core.Future[Unit], + _background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Stream[Byte] { + @async-core.Stream::produce(async fn(sink) { + let _ = gate.get() + guard sink.write_all_bytes(b"x"[:]) else { return } + sink.close() + }) +} + +///| +pub async fn complete_now( + _background_group : @async-core.TaskGroup[Unit], +) -> UInt { + 42 +} + +///| +pub async fn begin_cross_task_future_read( + value : @async-core.Future[Unit], + background_group : @async-core.TaskGroup[Unit], +) -> Unit { + cross_task_future.val = Some(value) + cross_task_future_state.val = 0 + background_group.spawn_bg(async fn() { + cross_task_future_state.val = 1 + match cross_task_future.val { + Some(value) => value.get() catch { _ => () } + None => panic() + } + cross_task_future_state.val = 2 + }) +} + +///| +pub fn cross_task_future_read_state() -> Byte { + cross_task_future_state.val +} + +///| +pub async fn drop_cross_task_future_read( + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + match cross_task_future.val { + Some(value) => value.drop() + None => panic() + } +} + +///| +pub async fn begin_cross_task_stream_read( + value : @async-core.Stream[Byte], + background_group : @async-core.TaskGroup[Unit], +) -> Unit { + cross_task_stream.val = Some(value) + cross_task_stream_state.val = 0 + background_group.spawn_bg(async fn() { + cross_task_stream_state.val = 1 + match cross_task_stream.val { + Some(value) => ignore(value.read(1)) catch { _ => () } + None => panic() + } + cross_task_stream_state.val = 2 + }) +} + +///| +pub fn cross_task_stream_read_state() -> Byte { + cross_task_stream_state.val +} + +///| +pub async fn drop_cross_task_stream_read( + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + match cross_task_stream.val { + Some(value) => value.drop() + None => panic() + } +} diff --git a/tests/runtime/moonbit/concurrent-export-isolation/test.wit b/tests/runtime/moonbit/concurrent-export-isolation/test.wit new file mode 100644 index 000000000..fed56dd4d --- /dev/null +++ b/tests/runtime/moonbit/concurrent-export-isolation/test.wit @@ -0,0 +1,25 @@ +//@ async = true +package test:moonbit-concurrent-export-isolation; + +interface operations { + check-context-isolation: async func() -> bool; + wait-for-gate: async func(gate: future); + hold-stream: async func(gate: future) -> stream; + complete-now: async func() -> u32; + begin-cross-task-future-read: async func(value: future); + cross-task-future-read-state: func() -> u8; + drop-cross-task-future-read: async func(); + begin-cross-task-stream-read: async func(value: stream); + cross-task-stream-read-state: func() -> u8; + drop-cross-task-stream-read: async func(); +} + +world test { + export operations; +} + +world runner { + import operations; + + export run: async func(); +} diff --git a/tests/runtime/moonbit/http-background-hook/deps/wasi-cli-0.3.0/package.wit b/tests/runtime/moonbit/http-background-hook/deps/wasi-cli-0.3.0/package.wit new file mode 100644 index 000000000..219ca0a55 --- /dev/null +++ b/tests/runtime/moonbit/http-background-hook/deps/wasi-cli-0.3.0/package.wit @@ -0,0 +1,27 @@ +package wasi:cli@0.3.0; + +interface types { + enum error-code { + io, + illegal-byte-sequence, + pipe, + } +} + +interface stdout { + use types.{error-code}; + + write-via-stream: func(data: stream) -> future>; +} + +interface stderr { + use types.{error-code}; + + write-via-stream: func(data: stream) -> future>; +} + +interface stdin { + use types.{error-code}; + + read-via-stream: func() -> tuple, future>>; +} diff --git a/tests/runtime/moonbit/http-background-hook/deps/wasi-clocks-0.3.0/package.wit b/tests/runtime/moonbit/http-background-hook/deps/wasi-clocks-0.3.0/package.wit new file mode 100644 index 000000000..c70cccb01 --- /dev/null +++ b/tests/runtime/moonbit/http-background-hook/deps/wasi-clocks-0.3.0/package.wit @@ -0,0 +1,42 @@ +package wasi:clocks@0.3.0; + +interface types { + type duration = u64; +} + +interface monotonic-clock { + use types.{duration}; + + type mark = u64; + + now: func() -> mark; + + get-resolution: func() -> duration; + + wait-until: async func(when: mark); + + wait-for: async func(how-long: duration); +} + +interface system-clock { + use types.{duration}; + + record instant { + seconds: s64, + nanoseconds: u32, + } + + now: func() -> instant; + + get-resolution: func() -> duration; +} + +interface timezone { + use system-clock.{instant}; + + iana-id: func() -> option; + + utc-offset: func(when: instant) -> option; + + to-debug-string: func() -> string; +} diff --git a/tests/runtime/moonbit/http-background-hook/deps/wasi-http-0.3.0/package.wit b/tests/runtime/moonbit/http-background-hook/deps/wasi-http-0.3.0/package.wit new file mode 100644 index 000000000..08458f7c6 --- /dev/null +++ b/tests/runtime/moonbit/http-background-hook/deps/wasi-http-0.3.0/package.wit @@ -0,0 +1,509 @@ +package wasi:http@0.3.0; + +/// This interface defines all of the types and methods for implementing HTTP +/// Requests and Responses, as well as their headers, trailers, and bodies. +@since(version = 0.3.0) +interface types { + use wasi:clocks/types@0.3.0.{duration}; + + /// This type corresponds to HTTP standard Methods. + @since(version = 0.3.0) + variant method { + get, + head, + post, + put, + delete, + connect, + options, + trace, + patch, + other(string), + } + + /// This type corresponds to HTTP standard Related Schemes. + @since(version = 0.3.0) + variant scheme { + HTTP, + HTTPS, + other(string), + } + + /// Defines the case payload type for `DNS-error` above: + @since(version = 0.3.0) + record DNS-error-payload { + rcode: option, + info-code: option, + } + + /// Defines the case payload type for `TLS-alert-received` above: + @since(version = 0.3.0) + record TLS-alert-received-payload { + alert-id: option, + alert-message: option, + } + + /// Defines the case payload type for `HTTP-response-{header,trailer}-size` above: + @since(version = 0.3.0) + record field-size-payload { + field-name: option, + field-size: option, + } + + /// These cases are inspired by the IANA HTTP Proxy Error Types: + /// + @since(version = 0.3.0) + variant error-code { + DNS-timeout, + DNS-error(DNS-error-payload), + destination-not-found, + destination-unavailable, + destination-IP-prohibited, + destination-IP-unroutable, + connection-refused, + connection-terminated, + connection-timeout, + connection-read-timeout, + connection-write-timeout, + connection-limit-reached, + TLS-protocol-error, + TLS-certificate-error, + TLS-alert-received(TLS-alert-received-payload), + HTTP-request-denied, + HTTP-request-length-required, + HTTP-request-body-size(option), + HTTP-request-method-invalid, + HTTP-request-URI-invalid, + HTTP-request-URI-too-long, + HTTP-request-header-section-size(option), + HTTP-request-header-size(option), + HTTP-request-trailer-section-size(option), + HTTP-request-trailer-size(field-size-payload), + HTTP-response-incomplete, + HTTP-response-header-section-size(option), + HTTP-response-header-size(field-size-payload), + HTTP-response-body-size(option), + HTTP-response-trailer-section-size(option), + HTTP-response-trailer-size(field-size-payload), + HTTP-response-transfer-coding(option), + HTTP-response-content-coding(option), + HTTP-response-timeout, + HTTP-upgrade-failed, + HTTP-protocol-error, + loop-detected, + configuration-error, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. It also includes an optional string for an + /// unstructured description of the error. Users should not depend on the + /// string for diagnosing errors, as it's not required to be consistent + /// between implementations. + internal-error(option), + } + + /// This type enumerates the different kinds of errors that may occur when + /// setting or appending to a `fields` resource. + @since(version = 0.3.0) + variant header-error { + /// This error indicates that a `field-name` or `field-value` was + /// syntactically invalid when used with an operation that sets headers in a + /// `fields`. + invalid-syntax, + /// This error indicates that a forbidden `field-name` was used when trying + /// to set a header in a `fields`. + forbidden, + /// This error indicates that the operation on the `fields` was not + /// permitted because the fields are immutable. + immutable, + /// This error indicates that the operation would exceed an + /// implementation-defined limit on field sizes. This may apply to + /// an individual `field-value`, a single `field-name` plus all its + /// values, or the total aggregate size of all fields. + size-exceeded, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. Implementations can use this to extend the error + /// type without breaking existing code. It also includes an optional + /// string for an unstructured description of the error. Users should not + /// depend on the string for diagnosing errors, as it's not required to be + /// consistent between implementations. + other(option), + } + + /// This type enumerates the different kinds of errors that may occur when + /// setting fields of a `request-options` resource. + @since(version = 0.3.0) + variant request-options-error { + /// Indicates the specified field is not supported by this implementation. + not-supported, + /// Indicates that the operation on the `request-options` was not permitted + /// because it is immutable. + immutable, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. Implementations can use this to extend the error + /// type without breaking existing code. It also includes an optional + /// string for an unstructured description of the error. Users should not + /// depend on the string for diagnosing errors, as it's not required to be + /// consistent between implementations. + other(option), + } + + /// Field names are always strings. + /// + /// Field names should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + @since(version = 0.3.0) + type field-name = string; + + /// Field values should always be ASCII strings. However, in + /// reality, HTTP implementations often have to interpret malformed values, + /// so they are provided as a list of bytes. + @since(version = 0.3.0) + type field-value = list; + + /// This following block defines the `fields` resource which corresponds to + /// HTTP standard Fields. Fields are a common representation used for both + /// Headers and Trailers. + /// + /// A `fields` may be mutable or immutable. A `fields` created using the + /// constructor, `from-list`, or `clone` will be mutable, but a `fields` + /// resource given by other means (including, but not limited to, + /// `request.headers`) might be be immutable. In an immutable fields, the + /// `set`, `append`, and `delete` operations will fail with + /// `header-error.immutable`. + /// + /// A `fields` resource should store `field-name`s and `field-value`s in their + /// original casing used to construct or mutate the `fields` resource. The `fields` + /// resource should use that original casing when serializing the fields for + /// transport or when returning them from a method. + /// + /// Implementations may impose limits on individual field values and on total + /// aggregate field section size. Operations that would exceed these limits + /// fail with `header-error.size-exceeded` + @since(version = 0.3.0) + resource fields { + /// Construct an empty HTTP Fields. + /// + /// The resulting `fields` is mutable. + constructor(); + /// Construct an HTTP Fields. + /// + /// The resulting `fields` is mutable. + /// + /// The list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The tuple is a pair of the field name, represented as a string, and + /// Value, represented as a list of bytes. In a valid Fields, all names + /// and values are valid UTF-8 strings. However, values are not always + /// well-formed, so they are represented as a raw list of bytes. + /// + /// An error result will be returned if any header or value was + /// syntactically invalid, if a header was forbidden, or if the + /// entries would exceed an implementation size limit. + from-list: static func(entries: list>) -> result; + /// Get all of the values corresponding to a name. If the name is not present + /// in this `fields`, an empty list is returned. However, if the name is + /// present but empty, this is represented by a list with one or more + /// empty field-values present. + get: func(name: field-name) -> list; + /// Returns `true` when the name is present in this `fields`. If the name is + /// syntactically invalid, `false` is returned. + has: func(name: field-name) -> bool; + /// Set all of the values for a name. Clears any existing values for that + /// name, if they have been set. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.size-exceeded` if the name or values would + /// exceed an implementation-defined size limit. + set: func(name: field-name, value: list) -> result<_, header-error>; + /// Delete all values for a name. Does nothing if no values for the name + /// exist. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + delete: func(name: field-name) -> result<_, header-error>; + /// Delete all values for a name. Does nothing if no values for the name + /// exist. + /// + /// Returns all values previously corresponding to the name, if any. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + get-and-delete: func(name: field-name) -> result, header-error>; + /// Append a value for a name. Does not change or delete any existing + /// values for that name. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.size-exceeded` if the value would exceed + /// an implementation-defined size limit. + append: func(name: field-name, value: field-value) -> result<_, header-error>; + /// Retrieve the full set of names and values in the Fields. Like the + /// constructor, the list represents each name-value pair. + /// + /// The outer list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The names and values are always returned in the original casing and in + /// the order in which they will be serialized for transport. + copy-all: func() -> list>; + /// Make a deep copy of the Fields. Equivalent in behavior to calling the + /// `fields` constructor on the return value of `copy-all`. The resulting + /// `fields` is mutable. + clone: func() -> fields; + } + + /// Headers is an alias for Fields. + @since(version = 0.3.0) + type headers = fields; + + /// Trailers is an alias for Fields. + @since(version = 0.3.0) + type trailers = fields; + + /// Represents an HTTP Request. + @since(version = 0.3.0) + resource request { + /// Construct a new `request` with a default `method` of `GET`, and + /// `none` values for `path-with-query`, `scheme`, and `authority`. + /// + /// `headers` is the HTTP Headers for the Request. + /// + /// `contents` is the optional body content stream with `none` + /// representing a zero-length content stream. + /// Once it is closed, `trailers` future must resolve to a result. + /// If `trailers` resolves to an error, underlying connection + /// will be closed immediately. + /// + /// `options` is optional `request-options` resource to be used + /// if the request is sent over a network connection. + /// + /// It is possible to construct, or manipulate with the accessor functions + /// below, a `request` with an invalid combination of `scheme` + /// and `authority`, or `headers` which are not permitted to be sent. + /// It is the obligation of the `handler.handle` implementation + /// to reject invalid constructions of `request`. + /// + /// The returned future resolves to result of transmission of this request. + new: static func(headers: headers, contents: option>, trailers: future, error-code>>, options: option) -> tuple>>; + /// Get the Method for the Request. + get-method: func() -> method; + /// Set the Method for the Request. Fails if the string present in a + /// `method.other` argument is not a syntactically valid method. + set-method: func(method: method) -> result; + /// Get the combination of the HTTP Path and Query for the Request. When + /// `none`, this represents an empty Path and empty Query. + get-path-with-query: func() -> option; + /// Set the combination of the HTTP Path and Query for the Request. When + /// `none`, this represents an empty Path and empty Query. Fails is the + /// string given is not a syntactically valid path and query uri component. + set-path-with-query: func(path-with-query: option) -> result; + /// Get the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. + get-scheme: func() -> option; + /// Set the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. Fails if the + /// string given is not a syntactically valid uri scheme. + set-scheme: func(scheme: option) -> result; + /// Get the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. + get-authority: func() -> option; + /// Set the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. Fails if the string given is + /// not a syntactically valid URI authority. + set-authority: func(authority: option) -> result; + /// Get the `request-options` to be associated with this request + /// + /// The returned `request-options` resource is immutable: `set-*` operations + /// will fail if invoked. + /// + /// This `request-options` resource is a child: it must be dropped before + /// the parent `request` is dropped, or its ownership is transferred to + /// another component by e.g. `handler.handle`. + get-options: func() -> option; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + get-headers: func() -> headers; + /// Get body of the Request. + /// + /// Stream returned by this method represents the contents of the body. + /// Once the stream is reported as closed, callers should await the returned + /// future to determine whether the body was received successfully. + /// The future will only resolve after the stream is reported as closed. + /// + /// This function takes a `res` future as a parameter, which can be used to + /// communicate an error in handling of the request. + /// + /// Note that function will move the `request`, but references to headers or + /// request options acquired from it previously will remain valid. + consume-body: static func(this: request, res: future>) -> tuple, future, error-code>>>; + } + + /// Parameters for making an HTTP Request. Each of these parameters is + /// currently an optional timeout applicable to the transport layer of the + /// HTTP protocol. + /// + /// These timeouts are separate from any the user may use to bound an + /// asynchronous call. + @since(version = 0.3.0) + resource request-options { + /// Construct a default `request-options` value. + constructor(); + /// The timeout for the initial connect to the HTTP Server. + get-connect-timeout: func() -> option; + /// Set the timeout for the initial connect to the HTTP Server. An error + /// return value indicates that this timeout is not supported or that this + /// handle is immutable. + set-connect-timeout: func(duration: option) -> result<_, request-options-error>; + /// The timeout for receiving the first byte of the Response body. + get-first-byte-timeout: func() -> option; + /// Set the timeout for receiving the first byte of the Response body. An + /// error return value indicates that this timeout is not supported or that + /// this handle is immutable. + set-first-byte-timeout: func(duration: option) -> result<_, request-options-error>; + /// The timeout for receiving subsequent chunks of bytes in the Response + /// body stream. + get-between-bytes-timeout: func() -> option; + /// Set the timeout for receiving subsequent chunks of bytes in the Response + /// body stream. An error return value indicates that this timeout is not + /// supported or that this handle is immutable. + set-between-bytes-timeout: func(duration: option) -> result<_, request-options-error>; + /// Make a deep copy of the `request-options`. + /// The resulting `request-options` is mutable. + clone: func() -> request-options; + } + + /// This type corresponds to the HTTP standard Status Code. + @since(version = 0.3.0) + type status-code = u16; + + /// Represents an HTTP Response. + @since(version = 0.3.0) + resource response { + /// Construct a new `response`, with a default `status-code` of `200`. + /// If a different `status-code` is needed, it must be set via the + /// `set-status-code` method. + /// + /// `headers` is the HTTP Headers for the Response. + /// + /// `contents` is the optional body content stream with `none` + /// representing a zero-length content stream. + /// Once it is closed, `trailers` future must resolve to a result. + /// If `trailers` resolves to an error, underlying connection + /// will be closed immediately. + /// + /// The returned future resolves to result of transmission of this response. + new: static func(headers: headers, contents: option>, trailers: future, error-code>>) -> tuple>>; + /// Get the HTTP Status Code for the Response. + get-status-code: func() -> status-code; + /// Set the HTTP Status Code for the Response. Fails if the status-code + /// given is not a valid http status code. + set-status-code: func(status-code: status-code) -> result; + /// Get the headers associated with the Response. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + get-headers: func() -> headers; + /// Get body of the Response. + /// + /// Stream returned by this method represents the contents of the body. + /// Once the stream is reported as closed, callers should await the returned + /// future to determine whether the body was received successfully. + /// The future will only resolve after the stream is reported as closed. + /// + /// This function takes a `res` future as a parameter, which can be used to + /// communicate an error in handling of the response. + /// + /// Note that function will move the `response`, but references to headers + /// acquired from it previously will remain valid. + consume-body: static func(this: response, res: future>) -> tuple, future, error-code>>>; + } +} + +/// This interface defines a handler of HTTP Requests. +/// +/// In a `wasi:http/service` this interface is exported to respond to an +/// incoming HTTP Request with a Response. +/// +/// In `wasi:http/middleware` this interface is both exported and imported as +/// the "downstream" and "upstream" directions of the middleware chain. +@since(version = 0.3.0) +interface handler { + use types.{request, response, error-code}; + + /// This function may be called with either an incoming request read from the + /// network or a request synthesized or forwarded by another component. + handle: async func(request: request) -> result; +} + +/// This interface defines an HTTP client for sending "outgoing" requests. +/// +/// Most components are expected to import this interface to provide the +/// capability to send HTTP requests to arbitrary destinations on a network. +/// +/// The type signature of `client.send` is the same as `handler.handle`. This +/// duplication is currently necessary because some Component Model tooling +/// (including WIT itself) is unable to represent a component importing two +/// instances of the same interface. A `client.send` import may be linked +/// directly to a `handler.handle` export to bypass the network. +@since(version = 0.3.0) +interface client { + use types.{request, response, error-code}; + + /// This function may be used to either send an outgoing request over the + /// network or to forward it to another component. + send: async func(request: request) -> result; +} + +/// The `wasi:http/service` world captures a broad category of HTTP services +/// including web applications, API servers, and proxies. It may be `include`d +/// in more specific worlds such as `wasi:http/middleware`. +@since(version = 0.3.0) +world service { + import wasi:cli/types@0.3.0; + import wasi:cli/stdout@0.3.0; + import wasi:cli/stderr@0.3.0; + import wasi:cli/stdin@0.3.0; + import wasi:clocks/types@0.3.0; + import types; + import client; + import wasi:clocks/monotonic-clock@0.3.0; + import wasi:clocks/system-clock@0.3.0; + @unstable(feature = clocks-timezone) + import wasi:clocks/timezone@0.3.0; + import wasi:random/random@0.3.0; + import wasi:random/insecure@0.3.0; + import wasi:random/insecure-seed@0.3.0; + + export handler; +} +/// The `wasi:http/middleware` world captures HTTP services that forward HTTP +/// Requests to another handler. +/// +/// Components may implement this world to allow them to participate in handler +/// "chains" where a `request` flows through handlers on its way to some terminal +/// `service` and corresponding `response` flows in the opposite direction. +@since(version = 0.3.0) +world middleware { + import wasi:clocks/types@0.3.0; + import types; + import handler; + import wasi:cli/types@0.3.0; + import wasi:cli/stdout@0.3.0; + import wasi:cli/stderr@0.3.0; + import wasi:cli/stdin@0.3.0; + import client; + import wasi:clocks/monotonic-clock@0.3.0; + import wasi:clocks/system-clock@0.3.0; + @unstable(feature = clocks-timezone) + import wasi:clocks/timezone@0.3.0; + import wasi:random/random@0.3.0; + import wasi:random/insecure@0.3.0; + import wasi:random/insecure-seed@0.3.0; + + export handler; +} diff --git a/tests/runtime/moonbit/http-background-hook/deps/wasi-random-0.3.0/package.wit b/tests/runtime/moonbit/http-background-hook/deps/wasi-random-0.3.0/package.wit new file mode 100644 index 000000000..ee31d79e8 --- /dev/null +++ b/tests/runtime/moonbit/http-background-hook/deps/wasi-random-0.3.0/package.wit @@ -0,0 +1,17 @@ +package wasi:random@0.3.0; + +interface random { + get-random-bytes: func(max-len: u64) -> list; + + get-random-u64: func() -> u64; +} + +interface insecure { + get-insecure-random-bytes: func(max-len: u64) -> list; + + get-insecure-random-u64: func() -> u64; +} + +interface insecure-seed { + get-insecure-seed: func() -> tuple; +} diff --git a/tests/runtime/moonbit/http-background-hook/runner-invalid.mbt b/tests/runtime/moonbit/http-background-hook/runner-invalid.mbt new file mode 100644 index 000000000..f9c414822 --- /dev/null +++ b/tests/runtime/moonbit/http-background-hook/runner-invalid.mbt @@ -0,0 +1,51 @@ +//@ wasmtime-flags = '-Wcomponent-model-async -S p3 -S http' +//@ [lang] +//@ path = 'gen/world/runner/stub.mbt' +//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "example/background-hook/async-core", "alias": "async-core" }, { "path": "example/background-hook/interface/wasi/http/handler", "alias": "handler" }, { "path": "example/background-hook/interface/wasi/http/types", "alias": "types" }] }""" + +///| +pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { + let request_body = @async-core.Stream::produce(async fn(sink) { + guard sink.write_all_bytes(b"build=debug"[:]) else { + abort("request body closed early") + } + sink.close() + }) + let (request, request_transmitted) = @types.Request::new( + @types.Fields::fields(), + Some(request_body), + @async-core.Future::ready(Ok(None)), + None, + ) + guard request.set_method(@types.Method::Post) is Ok(_) else { panic() } + guard request.set_path_with_query(Some("/hooks/build")) is Ok(_) else { + panic() + } + + let response = match @handler.handle(request) { + Ok(response) => response + Err(_) => panic() + } + guard response.get_status_code() == 202U else { panic() } + + let (response_body, response_trailers) = response.consume_body( + @async-core.Future::ready(Ok(())), + ) + guard response_body.read(1) is None else { panic() } + response_body.drop() + match response_trailers.get() { + Ok(None) => () + Ok(Some(fields)) => { + fields.drop() + panic() + } + Err(_) => panic() + } + + match request_transmitted.get() { + Err(@types.ErrorCode::InternalError(Some(message))) => { + guard message == "unexpected hook payload" else { panic() } + } + _ => panic() + } +} diff --git a/tests/runtime/moonbit/http-background-hook/runner.mbt b/tests/runtime/moonbit/http-background-hook/runner.mbt new file mode 100644 index 000000000..71845d74b --- /dev/null +++ b/tests/runtime/moonbit/http-background-hook/runner.mbt @@ -0,0 +1,49 @@ +//@ wasmtime-flags = '-Wcomponent-model-async -S p3 -S http' +//@ [lang] +//@ path = 'gen/world/runner/stub.mbt' +//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "example/background-hook/async-core", "alias": "async-core" }, { "path": "example/background-hook/interface/wasi/http/handler", "alias": "handler" }, { "path": "example/background-hook/interface/wasi/http/types", "alias": "types" }] }""" + +///| +pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { + let request_body = @async-core.Stream::produce(async fn(sink) { + guard sink.write_all_bytes(b"build=release"[:]) else { + abort("request body closed early") + } + sink.close() + }) + let (request, request_transmitted) = @types.Request::new( + @types.Fields::fields(), + Some(request_body), + @async-core.Future::ready(Ok(None)), + None, + ) + guard request.set_method(@types.Method::Post) is Ok(_) else { panic() } + guard request.set_path_with_query(Some("/hooks/build")) is Ok(_) else { + panic() + } + + let response = match @handler.handle(request) { + Ok(response) => response + Err(_) => panic() + } + guard response.get_status_code() == 202U else { panic() } + + let (response_body, response_trailers) = response.consume_body( + @async-core.Future::ready(Ok(())), + ) + guard response_body.read(1) is None else { panic() } + response_body.drop() + match response_trailers.get() { + Ok(None) => () + Ok(Some(fields)) => { + fields.drop() + panic() + } + Err(_) => panic() + } + + match request_transmitted.get() { + Ok(_) => () + Err(_) => panic() + } +} diff --git a/tests/runtime/moonbit/http-background-hook/test.mbt b/tests/runtime/moonbit/http-background-hook/test.mbt new file mode 100644 index 000000000..b979824bb --- /dev/null +++ b/tests/runtime/moonbit/http-background-hook/test.mbt @@ -0,0 +1,82 @@ +//@ wasmtime-flags = '-Wcomponent-model-async -S p3 -S http' +//@ [lang] +//@ path = 'gen/interface/wasi/http/handler/stub.mbt' +//@ pkg_config = """{ "import": [{ "path": "moonbitlang/core/buffer", "alias": "buffer" }, { "path": "example/background-hook/async-core", "alias": "async-core" }, { "path": "example/background-hook/interface/wasi/http/types", "alias": "types" }] }""" + +///| +pub async fn handle( + request : @types.Request, + background_group : @async-core.TaskGroup[Unit], +) -> Result[@types.Response, @types.ErrorCode] { + guard request.get_method() is @types.Method::Post else { + request.drop() + return Err(@types.ErrorCode::HttpRequestMethodInvalid) + } + guard request.get_path_with_query() == Some("/hooks/build") else { + request.drop() + return Err(@types.ErrorCode::HttpRequestUriInvalid) + } + + let (handling_result, handling_promise) = @async-core.Future::new() + let (request_body, request_trailers) = request.consume_body(handling_result) + let payload = @buffer.Buffer() + for ;; { + match request_body.read(4096) { + Some(chunk) => payload.write_iter(chunk.iter()) + None => break + } + } + request_body.drop() + match request_trailers.get() { + Ok(Some(fields)) => fields.drop() + Ok(None) => () + Err(error) => { + let _ = handling_promise.complete(Err(error)) + return Err(error) + } + } + let payload = payload.to_bytes() + + let (response, response_transmitted) = @types.Response::new( + @types.Fields::fields(), + None, + @async-core.Future::ready(Ok(None)), + ) + guard response.set_status_code(202U) is Ok(_) else { + let error = @types.ErrorCode::InternalError( + Some("failed to set hook response status"), + ) + let _ = handling_promise.complete(Err(error)) + response.drop() + return Err(error) + } + + background_group.spawn_bg(async fn() { + let result = (async fn() -> Result[Unit, @types.ErrorCode] { + match response_transmitted.get() { + Err(error) => return Err(error) + Ok(_) => () + } + let expected = b"build=release" + guard payload.length() == expected.length() else { + return Err( + @types.ErrorCode::InternalError(Some("unexpected hook payload")), + ) + } + for i in 0.. Err( + @types.ErrorCode::InternalError(Some("background hook task failed")), + ) + } + let _ = handling_promise.complete(result) + }) + Ok(response) +} diff --git a/tests/runtime/moonbit/http-background-hook/test.wit b/tests/runtime/moonbit/http-background-hook/test.wit new file mode 100644 index 000000000..04f413b0d --- /dev/null +++ b/tests/runtime/moonbit/http-background-hook/test.wit @@ -0,0 +1,12 @@ +package example:background-hook; + +world test { + include wasi:http/service@0.3.0; +} + +world runner { + import wasi:http/types@0.3.0; + import wasi:http/handler@0.3.0; + + export run: async func(); +} diff --git a/tests/runtime/moonbit/http-background-hook/wkg.lock b/tests/runtime/moonbit/http-background-hook/wkg.lock new file mode 100644 index 000000000..1c6187c52 --- /dev/null +++ b/tests/runtime/moonbit/http-background-hook/wkg.lock @@ -0,0 +1,12 @@ +# This file is automatically generated. +# It is not intended for manual editing. +version = 1 + +[[packages]] +name = "wasi:http" +registry = "wasi.dev" + +[[packages.versions]] +requirement = "=0.3.0" +version = "0.3.0" +digest = "sha256:92cd8f3730c00226dc15626a2e7b21834dd187fc221f09818720d228585bbbf7" diff --git a/tests/runtime/moonbit/http-body-trailers/deps/clocks.wit b/tests/runtime/moonbit/http-body-trailers/deps/clocks.wit new file mode 100644 index 000000000..6522c9fdc --- /dev/null +++ b/tests/runtime/moonbit/http-body-trailers/deps/clocks.wit @@ -0,0 +1,7 @@ +package wasi:clocks@0.3.0; + +@since(version = 0.3.0) +interface types { + @since(version = 0.3.0) + type duration = u64; +} diff --git a/tests/runtime/moonbit/http-body-trailers/deps/http.wit b/tests/runtime/moonbit/http-body-trailers/deps/http.wit new file mode 100644 index 000000000..e453ace5a --- /dev/null +++ b/tests/runtime/moonbit/http-body-trailers/deps/http.wit @@ -0,0 +1,460 @@ +package wasi:http@0.3.0; + +/// This interface defines all of the types and methods for implementing HTTP +/// Requests and Responses, as well as their headers, trailers, and bodies. +@since(version = 0.3.0) +interface types { + use wasi:clocks/types@0.3.0.{duration}; + + /// This type corresponds to HTTP standard Methods. + @since(version = 0.3.0) + variant method { + get, + head, + post, + put, + delete, + connect, + options, + trace, + patch, + other(string), + } + + /// This type corresponds to HTTP standard Related Schemes. + @since(version = 0.3.0) + variant scheme { + HTTP, + HTTPS, + other(string), + } + + /// Defines the case payload type for `DNS-error` above: + @since(version = 0.3.0) + record DNS-error-payload { + rcode: option, + info-code: option, + } + + /// Defines the case payload type for `TLS-alert-received` above: + @since(version = 0.3.0) + record TLS-alert-received-payload { + alert-id: option, + alert-message: option, + } + + /// Defines the case payload type for `HTTP-response-{header,trailer}-size` above: + @since(version = 0.3.0) + record field-size-payload { + field-name: option, + field-size: option, + } + + /// These cases are inspired by the IANA HTTP Proxy Error Types: + /// + @since(version = 0.3.0) + variant error-code { + DNS-timeout, + DNS-error(DNS-error-payload), + destination-not-found, + destination-unavailable, + destination-IP-prohibited, + destination-IP-unroutable, + connection-refused, + connection-terminated, + connection-timeout, + connection-read-timeout, + connection-write-timeout, + connection-limit-reached, + TLS-protocol-error, + TLS-certificate-error, + TLS-alert-received(TLS-alert-received-payload), + HTTP-request-denied, + HTTP-request-length-required, + HTTP-request-body-size(option), + HTTP-request-method-invalid, + HTTP-request-URI-invalid, + HTTP-request-URI-too-long, + HTTP-request-header-section-size(option), + HTTP-request-header-size(option), + HTTP-request-trailer-section-size(option), + HTTP-request-trailer-size(field-size-payload), + HTTP-response-incomplete, + HTTP-response-header-section-size(option), + HTTP-response-header-size(field-size-payload), + HTTP-response-body-size(option), + HTTP-response-trailer-section-size(option), + HTTP-response-trailer-size(field-size-payload), + HTTP-response-transfer-coding(option), + HTTP-response-content-coding(option), + HTTP-response-timeout, + HTTP-upgrade-failed, + HTTP-protocol-error, + loop-detected, + configuration-error, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. It also includes an optional string for an + /// unstructured description of the error. Users should not depend on the + /// string for diagnosing errors, as it's not required to be consistent + /// between implementations. + internal-error(option), + } + + /// This type enumerates the different kinds of errors that may occur when + /// setting or appending to a `fields` resource. + @since(version = 0.3.0) + variant header-error { + /// This error indicates that a `field-name` or `field-value` was + /// syntactically invalid when used with an operation that sets headers in a + /// `fields`. + invalid-syntax, + /// This error indicates that a forbidden `field-name` was used when trying + /// to set a header in a `fields`. + forbidden, + /// This error indicates that the operation on the `fields` was not + /// permitted because the fields are immutable. + immutable, + /// This error indicates that the operation would exceed an + /// implementation-defined limit on field sizes. This may apply to + /// an individual `field-value`, a single `field-name` plus all its + /// values, or the total aggregate size of all fields. + size-exceeded, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. Implementations can use this to extend the error + /// type without breaking existing code. It also includes an optional + /// string for an unstructured description of the error. Users should not + /// depend on the string for diagnosing errors, as it's not required to be + /// consistent between implementations. + other(option), + } + + /// This type enumerates the different kinds of errors that may occur when + /// setting fields of a `request-options` resource. + @since(version = 0.3.0) + variant request-options-error { + /// Indicates the specified field is not supported by this implementation. + not-supported, + /// Indicates that the operation on the `request-options` was not permitted + /// because it is immutable. + immutable, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. Implementations can use this to extend the error + /// type without breaking existing code. It also includes an optional + /// string for an unstructured description of the error. Users should not + /// depend on the string for diagnosing errors, as it's not required to be + /// consistent between implementations. + other(option), + } + + /// Field names are always strings. + /// + /// Field names should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + @since(version = 0.3.0) + type field-name = string; + + /// Field values should always be ASCII strings. However, in + /// reality, HTTP implementations often have to interpret malformed values, + /// so they are provided as a list of bytes. + @since(version = 0.3.0) + type field-value = list; + + /// This following block defines the `fields` resource which corresponds to + /// HTTP standard Fields. Fields are a common representation used for both + /// Headers and Trailers. + /// + /// A `fields` may be mutable or immutable. A `fields` created using the + /// constructor, `from-list`, or `clone` will be mutable, but a `fields` + /// resource given by other means (including, but not limited to, + /// `request.headers`) might be be immutable. In an immutable fields, the + /// `set`, `append`, and `delete` operations will fail with + /// `header-error.immutable`. + /// + /// A `fields` resource should store `field-name`s and `field-value`s in their + /// original casing used to construct or mutate the `fields` resource. The `fields` + /// resource should use that original casing when serializing the fields for + /// transport or when returning them from a method. + /// + /// Implementations may impose limits on individual field values and on total + /// aggregate field section size. Operations that would exceed these limits + /// fail with `header-error.size-exceeded` + @since(version = 0.3.0) + resource fields { + /// Construct an empty HTTP Fields. + /// + /// The resulting `fields` is mutable. + constructor(); + /// Construct an HTTP Fields. + /// + /// The resulting `fields` is mutable. + /// + /// The list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The tuple is a pair of the field name, represented as a string, and + /// Value, represented as a list of bytes. In a valid Fields, all names + /// and values are valid UTF-8 strings. However, values are not always + /// well-formed, so they are represented as a raw list of bytes. + /// + /// An error result will be returned if any header or value was + /// syntactically invalid, if a header was forbidden, or if the + /// entries would exceed an implementation size limit. + from-list: static func(entries: list>) -> result; + /// Get all of the values corresponding to a name. If the name is not present + /// in this `fields`, an empty list is returned. However, if the name is + /// present but empty, this is represented by a list with one or more + /// empty field-values present. + get: func(name: field-name) -> list; + /// Returns `true` when the name is present in this `fields`. If the name is + /// syntactically invalid, `false` is returned. + has: func(name: field-name) -> bool; + /// Set all of the values for a name. Clears any existing values for that + /// name, if they have been set. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.size-exceeded` if the name or values would + /// exceed an implementation-defined size limit. + set: func(name: field-name, value: list) -> result<_, header-error>; + /// Delete all values for a name. Does nothing if no values for the name + /// exist. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + delete: func(name: field-name) -> result<_, header-error>; + /// Delete all values for a name. Does nothing if no values for the name + /// exist. + /// + /// Returns all values previously corresponding to the name, if any. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + get-and-delete: func(name: field-name) -> result, header-error>; + /// Append a value for a name. Does not change or delete any existing + /// values for that name. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.size-exceeded` if the value would exceed + /// an implementation-defined size limit. + append: func(name: field-name, value: field-value) -> result<_, header-error>; + /// Retrieve the full set of names and values in the Fields. Like the + /// constructor, the list represents each name-value pair. + /// + /// The outer list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The names and values are always returned in the original casing and in + /// the order in which they will be serialized for transport. + copy-all: func() -> list>; + /// Make a deep copy of the Fields. Equivalent in behavior to calling the + /// `fields` constructor on the return value of `copy-all`. The resulting + /// `fields` is mutable. + clone: func() -> fields; + } + + /// Headers is an alias for Fields. + @since(version = 0.3.0) + type headers = fields; + + /// Trailers is an alias for Fields. + @since(version = 0.3.0) + type trailers = fields; + + /// Represents an HTTP Request. + @since(version = 0.3.0) + resource request { + /// Construct a new `request` with a default `method` of `GET`, and + /// `none` values for `path-with-query`, `scheme`, and `authority`. + /// + /// `headers` is the HTTP Headers for the Request. + /// + /// `contents` is the optional body content stream with `none` + /// representing a zero-length content stream. + /// Once it is closed, `trailers` future must resolve to a result. + /// If `trailers` resolves to an error, underlying connection + /// will be closed immediately. + /// + /// `options` is optional `request-options` resource to be used + /// if the request is sent over a network connection. + /// + /// It is possible to construct, or manipulate with the accessor functions + /// below, a `request` with an invalid combination of `scheme` + /// and `authority`, or `headers` which are not permitted to be sent. + /// It is the obligation of the `handler.handle` implementation + /// to reject invalid constructions of `request`. + /// + /// The returned future resolves to result of transmission of this request. + new: static func(headers: headers, contents: option>, trailers: future, error-code>>, options: option) -> tuple>>; + /// Get the Method for the Request. + get-method: func() -> method; + /// Set the Method for the Request. Fails if the string present in a + /// `method.other` argument is not a syntactically valid method. + set-method: func(method: method) -> result; + /// Get the combination of the HTTP Path and Query for the Request. When + /// `none`, this represents an empty Path and empty Query. + get-path-with-query: func() -> option; + /// Set the combination of the HTTP Path and Query for the Request. When + /// `none`, this represents an empty Path and empty Query. Fails is the + /// string given is not a syntactically valid path and query uri component. + set-path-with-query: func(path-with-query: option) -> result; + /// Get the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. + get-scheme: func() -> option; + /// Set the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. Fails if the + /// string given is not a syntactically valid uri scheme. + set-scheme: func(scheme: option) -> result; + /// Get the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. + get-authority: func() -> option; + /// Set the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. Fails if the string given is + /// not a syntactically valid URI authority. + set-authority: func(authority: option) -> result; + /// Get the `request-options` to be associated with this request + /// + /// The returned `request-options` resource is immutable: `set-*` operations + /// will fail if invoked. + /// + /// This `request-options` resource is a child: it must be dropped before + /// the parent `request` is dropped, or its ownership is transferred to + /// another component by e.g. `handler.handle`. + get-options: func() -> option; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + get-headers: func() -> headers; + /// Get body of the Request. + /// + /// Stream returned by this method represents the contents of the body. + /// Once the stream is reported as closed, callers should await the returned + /// future to determine whether the body was received successfully. + /// The future will only resolve after the stream is reported as closed. + /// + /// This function takes a `res` future as a parameter, which can be used to + /// communicate an error in handling of the request. + /// + /// Note that function will move the `request`, but references to headers or + /// request options acquired from it previously will remain valid. + consume-body: static func(this: request, res: future>) -> tuple, future, error-code>>>; + } + + /// Parameters for making an HTTP Request. Each of these parameters is + /// currently an optional timeout applicable to the transport layer of the + /// HTTP protocol. + /// + /// These timeouts are separate from any the user may use to bound an + /// asynchronous call. + @since(version = 0.3.0) + resource request-options { + /// Construct a default `request-options` value. + constructor(); + /// The timeout for the initial connect to the HTTP Server. + get-connect-timeout: func() -> option; + /// Set the timeout for the initial connect to the HTTP Server. An error + /// return value indicates that this timeout is not supported or that this + /// handle is immutable. + set-connect-timeout: func(duration: option) -> result<_, request-options-error>; + /// The timeout for receiving the first byte of the Response body. + get-first-byte-timeout: func() -> option; + /// Set the timeout for receiving the first byte of the Response body. An + /// error return value indicates that this timeout is not supported or that + /// this handle is immutable. + set-first-byte-timeout: func(duration: option) -> result<_, request-options-error>; + /// The timeout for receiving subsequent chunks of bytes in the Response + /// body stream. + get-between-bytes-timeout: func() -> option; + /// Set the timeout for receiving subsequent chunks of bytes in the Response + /// body stream. An error return value indicates that this timeout is not + /// supported or that this handle is immutable. + set-between-bytes-timeout: func(duration: option) -> result<_, request-options-error>; + /// Make a deep copy of the `request-options`. + /// The resulting `request-options` is mutable. + clone: func() -> request-options; + } + + /// This type corresponds to the HTTP standard Status Code. + @since(version = 0.3.0) + type status-code = u16; + + /// Represents an HTTP Response. + @since(version = 0.3.0) + resource response { + /// Construct a new `response`, with a default `status-code` of `200`. + /// If a different `status-code` is needed, it must be set via the + /// `set-status-code` method. + /// + /// `headers` is the HTTP Headers for the Response. + /// + /// `contents` is the optional body content stream with `none` + /// representing a zero-length content stream. + /// Once it is closed, `trailers` future must resolve to a result. + /// If `trailers` resolves to an error, underlying connection + /// will be closed immediately. + /// + /// The returned future resolves to result of transmission of this response. + new: static func(headers: headers, contents: option>, trailers: future, error-code>>) -> tuple>>; + /// Get the HTTP Status Code for the Response. + get-status-code: func() -> status-code; + /// Set the HTTP Status Code for the Response. Fails if the status-code + /// given is not a valid http status code. + set-status-code: func(status-code: status-code) -> result; + /// Get the headers associated with the Response. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + get-headers: func() -> headers; + /// Get body of the Response. + /// + /// Stream returned by this method represents the contents of the body. + /// Once the stream is reported as closed, callers should await the returned + /// future to determine whether the body was received successfully. + /// The future will only resolve after the stream is reported as closed. + /// + /// This function takes a `res` future as a parameter, which can be used to + /// communicate an error in handling of the response. + /// + /// Note that function will move the `response`, but references to headers + /// acquired from it previously will remain valid. + consume-body: static func(this: response, res: future>) -> tuple, future, error-code>>>; + } +} + +/// This interface defines a handler of HTTP Requests. +/// +/// In a `wasi:http/service` this interface is exported to respond to an +/// incoming HTTP Request with a Response. +/// +/// In `wasi:http/middleware` this interface is both exported and imported as +/// the "downstream" and "upstream" directions of the middleware chain. +@since(version = 0.3.0) +interface handler { + use types.{request, response, error-code}; + + /// This function may be called with either an incoming request read from the + /// network or a request synthesized or forwarded by another component. + handle: async func(request: request) -> result; +} + +/// This interface defines an HTTP client for sending "outgoing" requests. +/// +/// Most components are expected to import this interface to provide the +/// capability to send HTTP requests to arbitrary destinations on a network. +/// +/// The type signature of `client.send` is the same as `handler.handle`. This +/// duplication is currently necessary because some Component Model tooling +/// (including WIT itself) is unable to represent a component importing two +/// instances of the same interface. A `client.send` import may be linked +/// directly to a `handler.handle` export to bypass the network. +@since(version = 0.3.0) +interface client { + use types.{request, response, error-code}; + + /// This function may be used to either send an outgoing request over the + /// network or to forward it to another component. + send: async func(request: request) -> result; +} diff --git a/tests/runtime/moonbit/http-body-trailers/runner.mbt b/tests/runtime/moonbit/http-body-trailers/runner.mbt new file mode 100644 index 000000000..e18c150a5 --- /dev/null +++ b/tests/runtime/moonbit/http-body-trailers/runner.mbt @@ -0,0 +1,47 @@ +//@ wasmtime-flags = '-Wcomponent-model-async -S p3 -S http' +//@ [lang] +//@ path = 'gen/world/runner/stub.mbt' +//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "my/test/async-core", "alias": "async-core" }, { "path": "my/test/interface/wasi/http/handler", "alias": "handler" }, { "path": "my/test/interface/wasi/http/types", "alias": "types" }] }""" + +///| +pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { + let (request, request_transmitted) = @types.Request::new( + @types.Fields::fields(), + None, + @async-core.Future::ready(Ok(None)), + None, + ) + let response = match @handler.handle(request) { + Ok(response) => response + Err(_) => panic() + } + + // Completing this future tells the handler that the response was transmitted. + let (body, trailers) = @types.Response::consume_body( + response, + @async-core.Future::ready(Ok(())), + ) + let bytes : Array[Byte] = [] + for ;; { + match body.read(4) { + Some(chunk) => + for i = 0; i < chunk.length(); i = i + 1 { + bytes.push(chunk[i]) + } + None => break + } + } + + let expected = b"hello http p3" + guard bytes.length() == expected.length() else { panic() } + for i = 0; i < expected.length(); i = i + 1 { + guard bytes[i] == expected[i] else { panic() } + } + + match trailers.get() { + Ok(Some(fields)) => fields.drop() + Ok(None) | Err(_) => panic() + } + body.drop() + request_transmitted.drop() +} diff --git a/tests/runtime/moonbit/http-body-trailers/test.mbt b/tests/runtime/moonbit/http-body-trailers/test.mbt new file mode 100644 index 000000000..6748a3c5b --- /dev/null +++ b/tests/runtime/moonbit/http-body-trailers/test.mbt @@ -0,0 +1,66 @@ +//@ [lang] +//@ path = 'gen/interface/wasi/http/handler/stub.mbt' +//@ pkg_config = """{ +//@ "import": [ +//@ { "path": "my/test/async-core", "alias": "async-core" }, +//@ { "path": "my/test/interface/wasi/http/types", "alias": "types" } +//@ ] +//@ }""" + +///| +let body_progress : Ref[UInt] = Ref(0) + +///| +pub async fn handle( + request : @types.Request, + background_group : @async-core.TaskGroup[Unit], +) -> Result[@types.Response, @types.ErrorCode] { + request.drop() + body_progress.val = 0 + let transmitted_done = Ref(false) + let (completed, completed_sink) = @async-core.Stream::new(capacity=2) + + let trailers = @async-core.Future::from(async fn() { + for _ in 0..<2 { + guard completed.read(1) is Some(signal) && signal.length() == 1 else { + abort("response completion signal closed early") + } + } + guard body_progress.val >= 3 && transmitted_done.val else { + abort("response completion signal arrived too early") + } + Ok(Some(@types.Fields::fields())) + }) + + let body = @async-core.Stream::produce(async fn(sink) { + body_progress.val = 1 + let _ = sink.write_all_bytes(b"hello "[:]) + body_progress.val = 2 + let _ = sink.write_all_bytes(b"http p3"[:]) + sink.close() + body_progress.val = 3 + let signal : FixedArray[Unit] = [()] + guard completed_sink.write_all(signal[:]) else { + abort("response completion signal closed early") + } + }) + + let (response, transmitted) = @types.Response::new( + @types.Fields::fields(), + Some(body), + trailers, + ) + background_group.spawn_bg(async fn() { + match transmitted.get() { + Ok(_) => { + transmitted_done.val = true + let signal : FixedArray[Unit] = [()] + guard completed_sink.write_all(signal[:]) else { + abort("response completion signal closed early") + } + } + Err(_) => abort("response transmission failed") + } + }) + Ok(response) +} diff --git a/tests/runtime/moonbit/http-body-trailers/test.wit b/tests/runtime/moonbit/http-body-trailers/test.wit new file mode 100644 index 000000000..10365d6a1 --- /dev/null +++ b/tests/runtime/moonbit/http-body-trailers/test.wit @@ -0,0 +1,14 @@ +package my:test; + +world test { + import wasi:http/types@0.3.0; + + export wasi:http/handler@0.3.0; +} + +world runner { + import wasi:http/types@0.3.0; + import wasi:http/handler@0.3.0; + + export run: async func(); +} diff --git a/tests/runtime/moonbit/local-async-primitives/runner.mbt b/tests/runtime/moonbit/local-async-primitives/runner.mbt new file mode 100644 index 000000000..60adaac55 --- /dev/null +++ b/tests/runtime/moonbit/local-async-primitives/runner.mbt @@ -0,0 +1,9 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' +//@ [lang] +//@ path = 'gen/world/runner/stub.mbt' +//@ pkg_config = """{ "import": [{ "path": "test/moonbit-local-async-primitives/async-core", "alias": "async-core" }, { "path": "test/moonbit-local-async-primitives/interface/test/moonbit-local-async-primitives/operations", "alias": "operations" }] }""" + +///| +pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { + guard @operations.exercise() else { panic() } +} diff --git a/tests/runtime/moonbit/local-async-primitives/test.mbt b/tests/runtime/moonbit/local-async-primitives/test.mbt new file mode 100644 index 000000000..3cc39b689 --- /dev/null +++ b/tests/runtime/moonbit/local-async-primitives/test.mbt @@ -0,0 +1,311 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' +//@ [lang] +//@ path = 'gen/interface/test/moonbit-local-async-primitives/operations/stub.mbt' + +///| +suberror ExpectedPromiseFailure derive(Debug) + +///| +priv struct LocalOnlyValue { + marker : Int +} + +///| +pub async fn exercise(background_group : @async-core.TaskGroup[Unit]) -> Bool { + let semaphore = @async-core.Semaphore(1, initial_value=1) + guard semaphore.try_acquire() else { return false } + guard !semaphore.try_acquire() else { return false } + semaphore.release() + semaphore.acquire() + + let mutex = @async-core.Mutex() + guard mutex.try_acquire() else { return false } + guard !mutex.try_acquire() else { return false } + let (mutex_started, mutex_started_promise) = @async-core.Future::new() + let mutex_waiter = background_group.spawn( + async fn() -> Bool { + guard mutex_started_promise.complete(()) else { return false } + mutex.acquire() + mutex.release() + true + }, + allow_failure=true, + ) + mutex_started.get() + mutex.release() + guard mutex_waiter.wait() else { return false } + + let condition = @async-core.CondVar() + let condition_ready = Ref(false) + let (condition_started, condition_started_promise) = @async-core.Future::new() + let condition_waiter = background_group.spawn( + async fn() -> Bool { + guard condition_started_promise.complete(()) else { return false } + while !condition_ready.val { + condition.wait() + } + true + }, + allow_failure=true, + ) + condition_started.get() + condition_ready.val = true + condition.signal() + condition_waiter.cancel() + guard condition_waiter.wait() else { return false } + + let cancellation_condition = @async-core.CondVar() + let (condition_cancel_started, condition_cancel_started_promise) = @async-core.Future::new() + let cancelled_condition_waiter = background_group.spawn( + async fn() -> Unit { + guard condition_cancel_started_promise.complete(()) else { panic() } + cancellation_condition.wait() + }, + allow_failure=true, + ) + condition_cancel_started.get() + cancelled_condition_waiter.cancel() + let condition_wait_was_cancelled = try + cancelled_condition_waiter.wait() + catch { + @async-core.Cancelled::Cancelled => true + _ => false + } noraise { + _ => false + } + guard condition_wait_was_cancelled else { return false } + + let (condition_survivor_started, condition_survivor_started_promise) = @async-core.Future::new() + let condition_survivor = background_group.spawn( + async fn() -> Bool { + guard condition_survivor_started_promise.complete(()) else { + return false + } + cancellation_condition.wait() + true + }, + allow_failure=true, + ) + condition_survivor_started.get() + cancellation_condition.broadcast() + guard condition_survivor.wait() else { return false } + + let (ready_future, ready_promise) = @async-core.Future::new() + guard ready_promise.complete(41) else { return false } + guard ready_future.get() == 41 else { return false } + + let (local_future, local_promise) = @async-core.Future::new() + guard local_promise.complete({ marker: 47 }) else { return false } + guard local_future.get().marker == 47 else { return false } + + let cleaned = Ref(0) + let (discarded_future, discarded_promise) = @async-core.Future::new_with_cleanup(fn( + value : Int, + ) { + cleaned.val = value + }, + ) + guard discarded_promise.complete(42) else { return false } + discarded_future.drop() + discarded_future.drop() + guard cleaned.val == 42 else { return false } + + let (dropped_future, dropped_promise) : ( + @async-core.Future[Int], + @async-core.Promise[Int], + ) = @async-core.Future::new() + dropped_future.drop() + guard !dropped_promise.complete(43) else { return false } + + let (failed_future, failed_promise) : ( + @async-core.Future[Int], + @async-core.Promise[Int], + ) = @async-core.Future::new() + guard failed_promise.fail(ExpectedPromiseFailure) else { return false } + let observed_failure = try failed_future.get() catch { + ExpectedPromiseFailure::ExpectedPromiseFailure => true + _ => false + } noraise { + _ => false + } + guard observed_failure else { return false } + + let (closed_future, closed_promise) : ( + @async-core.Future[Int], + @async-core.Promise[Int], + ) = @async-core.Future::new() + guard closed_promise.close() else { return false } + let observed_close = try closed_future.get() catch { + @async-core.PromiseClosed::PromiseClosed => true + _ => false + } noraise { + _ => false + } + guard observed_close else { return false } + + let (pending_future, pending_promise) = @async-core.Future::new() + let (pending_started, pending_started_promise) = @async-core.Future::new() + let pending_waiter = background_group.spawn( + async fn() -> Int { + guard pending_started_promise.complete(()) else { panic() } + pending_future.get() + }, + allow_failure=true, + ) + pending_started.get() + guard pending_promise.complete(44) else { return false } + guard pending_waiter.wait() == 44 else { return false } + + let (cancelled_future, cancelled_promise) = @async-core.Future::new() + let (cancel_started, cancel_started_promise) = @async-core.Future::new() + let cancelled_waiter = background_group.spawn( + async fn() -> Int { + guard cancel_started_promise.complete(()) else { panic() } + cancelled_future.get() + }, + allow_failure=true, + ) + cancel_started.get() + cancelled_waiter.cancel() + let observed_cancel = try cancelled_waiter.wait() catch { + @async-core.Cancelled::Cancelled => true + _ => false + } noraise { + _ => false + } + guard observed_cancel else { return false } + guard !cancelled_promise.complete(45) else { return false } + + let (dropped_while_reading, dropped_while_reading_promise) = @async-core.Future::new() + let (drop_read_started, drop_read_started_promise) = @async-core.Future::new() + let dropped_reader = background_group.spawn( + async fn() -> Bool { + guard drop_read_started_promise.complete(()) else { panic() } + dropped_while_reading.get() catch { + @async-core.Cancelled::Cancelled => return true + _ => return false + } + false + }, + allow_failure=true, + ) + drop_read_started.get() + dropped_while_reading.drop() + guard dropped_reader.wait() else { return false } + guard !dropped_while_reading_promise.complete(()) else { return false } + + let completion_cleanup = Ref(0) + let (completed_while_reading, completed_while_reading_promise) = @async-core.Future::new_with_cleanup(fn( + value : Int, + ) { + completion_cleanup.val = value + }, + ) + let (completed_read_started, completed_read_started_promise) = @async-core.Future::new() + let completed_reader = background_group.spawn( + async fn() -> Int { + guard completed_read_started_promise.complete(()) else { panic() } + completed_while_reading.get() + }, + allow_failure=true, + ) + completed_read_started.get() + guard completed_while_reading_promise.complete(45) else { return false } + completed_while_reading.drop() + guard completed_reader.wait() == 45 else { return false } + guard completion_cleanup.val == 0 else { return false } + + let (racing_future, racing_promise) = @async-core.Future::new() + let (race_started, race_started_promise) = @async-core.Future::new() + let racing_waiter = background_group.spawn( + async fn() -> Int { + guard race_started_promise.complete(()) else { panic() } + racing_future.get() + }, + allow_failure=true, + ) + race_started.get() + guard racing_promise.complete(46) else { return false } + racing_waiter.cancel() + guard racing_waiter.wait() == 46 else { return false } + + let fifo = @async-core.Semaphore(1, initial_value=0) + let phase = Ref(0) + let (first_started, first_started_promise) = @async-core.Future::new() + let first = background_group.spawn( + async fn() -> Bool { + guard first_started_promise.complete(()) else { return false } + fifo.acquire() + guard phase.val == 0 else { return false } + phase.val = 1 + fifo.release() + true + }, + allow_failure=true, + ) + first_started.get() + let (second_started, second_started_promise) = @async-core.Future::new() + let second = background_group.spawn( + async fn() -> Bool { + guard second_started_promise.complete(()) else { return false } + fifo.acquire() + guard phase.val == 1 else { return false } + phase.val = 2 + true + }, + allow_failure=true, + ) + second_started.get() + fifo.release() + guard first.wait() else { return false } + guard second.wait() else { return false } + guard phase.val == 2 else { return false } + + let cancellable = @async-core.Semaphore(1, initial_value=0) + let (cancelled_acquire_started, cancelled_acquire_started_promise) = @async-core.Future::new() + let cancelled_acquire = background_group.spawn( + async fn() -> Bool { + guard cancelled_acquire_started_promise.complete(()) else { return false } + cancellable.acquire() + false + }, + allow_failure=true, + ) + cancelled_acquire_started.get() + cancelled_acquire.cancel() + let acquire_was_cancelled = try cancelled_acquire.wait() catch { + @async-core.Cancelled::Cancelled => true + _ => false + } noraise { + _ => false + } + guard acquire_was_cancelled else { return false } + + let (survivor_started, survivor_started_promise) = @async-core.Future::new() + let survivor = background_group.spawn( + async fn() -> Bool { + guard survivor_started_promise.complete(()) else { return false } + cancellable.acquire() + true + }, + allow_failure=true, + ) + survivor_started.get() + cancellable.release() + guard survivor.wait() else { return false } + + let assigned = @async-core.Semaphore(1, initial_value=0) + let (assigned_started, assigned_started_promise) = @async-core.Future::new() + let assigned_waiter = background_group.spawn( + async fn() -> Bool { + guard assigned_started_promise.complete(()) else { return false } + assigned.acquire() + true + }, + allow_failure=true, + ) + assigned_started.get() + assigned.release() + assigned_waiter.cancel() + assigned_waiter.wait() +} diff --git a/tests/runtime/moonbit/local-async-primitives/test.wit b/tests/runtime/moonbit/local-async-primitives/test.wit new file mode 100644 index 000000000..8fae0534f --- /dev/null +++ b/tests/runtime/moonbit/local-async-primitives/test.wit @@ -0,0 +1,15 @@ +//@ async = true +package test:moonbit-local-async-primitives; + +interface operations { + exercise: async func() -> bool; +} + +world test { + export operations; +} + +world runner { + import operations; + export run: async func(); +} diff --git a/tests/runtime/moonbit/nested-future-stream/runner.rs b/tests/runtime/moonbit/nested-future-stream/runner.rs new file mode 100644 index 000000000..52842639d --- /dev/null +++ b/tests/runtime/moonbit/nested-future-stream/runner.rs @@ -0,0 +1,144 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' + +include!(env!("BINDINGS")); + +use crate::test::moonbit_nested_future_stream::nested::{ + cancellation_observed, concurrent_writes, post_return_lazy, relay, relay_stream, + resolve_shared, wait_cancellation_observed, wait_cancelled, wait_shared, +}; +use futures::task::noop_waker_ref; +use std::future::Future; +use std::task::Context; +use wit_bindgen::{FutureReader, StreamReader, StreamResult}; + +struct Component; + +export!(Component); + +impl Guest for Component { + async fn run() { + transfers_nested_endpoints().await; + rejects_nested_endpoints().await; + transfers_stream_of_futures().await; + serializes_concurrent_stream_writes().await; + wakes_a_different_component_task().await; + materializes_a_lazy_stream_after_return().await; + cancels_a_moonbit_component_task().await; + } +} + +async fn cancels_a_moonbit_component_task() { + let mut task = Box::pin(wait_cancelled()); + assert!(task + .as_mut() + .poll(&mut Context::from_waker(noop_waker_ref())) + .is_pending()); + drop(task); + wait_cancellation_observed().await; + assert!(cancellation_observed()); +} + +async fn wakes_a_different_component_task() { + let (value, ()) = futures::join!(wait_shared(), resolve_shared(42)); + assert_eq!(value, 42); +} + +async fn materializes_a_lazy_stream_after_return() { + let mut stream = post_return_lazy().await; + let (result, values) = stream.read(Vec::with_capacity(1)).await; + assert_eq!(result, StreamResult::Complete(1)); + assert_eq!(values, [42]); +} + +async fn serializes_concurrent_stream_writes() { + let mut stream = concurrent_writes().await; + for _ in 0..4 { + wit_bindgen::yield_async().await; + } + let (first_result, first) = stream.read(Vec::with_capacity(1)).await; + assert_eq!(first_result, StreamResult::Complete(1)); + let (second_result, second) = stream.read(Vec::with_capacity(1)).await; + assert_eq!(second_result, StreamResult::Complete(1)); + let mut values = vec![first[0], second[0]]; + values.sort(); + assert_eq!(values, [1, 2]); +} + +async fn transfers_stream_of_futures() { + let (mut input_writer, input) = wit_stream::new::>(); + let (value_writer1, value_reader1) = wit_future::new::(|| unreachable!()); + let (value_writer2, value_reader2) = wit_future::new::(|| unreachable!()); + let mut output = relay_stream(input).await; + + let send = async { + let (result, remaining) = input_writer.write(vec![value_reader1]).await; + assert_eq!(result, StreamResult::Complete(1)); + assert_eq!(remaining.remaining(), 0); + value_writer1.write(11).await.unwrap(); + + let (result, remaining) = input_writer.write(vec![value_reader2]).await; + assert_eq!(result, StreamResult::Complete(1)); + assert_eq!(remaining.remaining(), 0); + value_writer2.write(22).await.unwrap(); + drop(input_writer); + }; + let receive = async { + let (result, values) = output.read(Vec::with_capacity(1)).await; + assert_eq!(result, StreamResult::Complete(1)); + assert_eq!(values.into_iter().next().unwrap().await, 11); + + let (result, values) = output.read(Vec::with_capacity(1)).await; + assert_eq!(result, StreamResult::Complete(1)); + assert_eq!(values.into_iter().next().unwrap().await, 22); + }; + let ((), ()) = futures::join!(send, receive); +} + +async fn transfers_nested_endpoints() { + let (mut stream_writer, stream_reader) = wit_stream::new::(); + let (inner_writer, inner_reader) = + wit_future::new::>(|| unreachable!()); + let (outer_writer, outer_reader) = + wit_future::new::>>(|| unreachable!()); + let output = relay(outer_reader).await; + + let send = async { + outer_writer.write(inner_reader).await.unwrap(); + inner_writer.write(stream_reader).await.unwrap(); + let (result, remaining) = stream_writer.write(vec![1, 2, 3]).await; + assert_eq!(result, StreamResult::Complete(3)); + assert_eq!(remaining.remaining(), 0); + drop(stream_writer); + }; + let receive = async { + let mut stream = output.await.await; + let (result, bytes) = stream.read(Vec::with_capacity(3)).await; + assert_eq!(result, StreamResult::Complete(3)); + assert_eq!(bytes, [1, 2, 3]); + }; + let ((), ()) = futures::join!(send, receive); +} + +async fn rejects_nested_endpoints() { + let (mut stream_writer, stream_reader) = wit_stream::new::(); + let (inner_writer, inner_reader) = + wit_future::new::>(|| unreachable!()); + let (outer_writer, outer_reader) = + wit_future::new::>>(|| unreachable!()); + let output = relay(outer_reader).await; + drop(output); + + outer_writer.write(inner_reader).await.unwrap(); + inner_writer.write(stream_reader).await.unwrap(); + let (result, remaining) = stream_writer.write(vec![9]).await; + match result { + StreamResult::Dropped => assert_eq!(remaining.remaining(), 1), + StreamResult::Complete(1) => { + assert_eq!(remaining.remaining(), 0); + let (result, remaining) = stream_writer.write(vec![10]).await; + assert_eq!(result, StreamResult::Dropped); + assert_eq!(remaining.remaining(), 1); + } + result => panic!("unexpected stream write result: {result:?}"), + } +} diff --git a/tests/runtime/moonbit/nested-future-stream/test.mbt b/tests/runtime/moonbit/nested-future-stream/test.mbt new file mode 100644 index 000000000..6dde2fd03 --- /dev/null +++ b/tests/runtime/moonbit/nested-future-stream/test.mbt @@ -0,0 +1,149 @@ +//@ [lang] +//@ path = 'gen/interface/test/moonbit-nested-future-stream/nested/stub.mbt' + +///| +let shared : (@async-core.Future[UInt], @async-core.Promise[UInt]) = @async-core.Future::new() + +///| +let never : (@async-core.Future[Unit], @async-core.Promise[Unit]) = @async-core.Future::new() + +///| +let cancellation_seen : Ref[Bool] = { val: false } + +///| +let cancellation_done : (@async-core.Future[Unit], @async-core.Promise[Unit]) = @async-core.Future::new() + +///| +pub async fn relay( + value : @async-core.Future[@async-core.Future[@async-core.Stream[Byte]]], + _background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Future[@async-core.Future[@async-core.Stream[Byte]]] { + @async-core.Future::from(async fn() { + let inner = value.get() + @async-core.Future::from(async fn() { inner.get() }) + }) +} + +///| +pub async fn relay_stream( + value : @async-core.Stream[@async-core.Future[Byte]], + _background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Stream[@async-core.Future[Byte]] { + value +} + +///| +pub async fn concurrent_writes( + background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Stream[Byte] { + let (first_sinks, first_sinks_sink) : ( + @async-core.Stream[@async-core.Sink[Byte]], + @async-core.Sink[@async-core.Sink[Byte]], + ) = @async-core.Stream::new(capacity=1) + let (second_sinks, second_sinks_sink) : ( + @async-core.Stream[@async-core.Sink[Byte]], + @async-core.Sink[@async-core.Sink[Byte]], + ) = @async-core.Stream::new(capacity=1) + let (first_started, first_started_sink) = @async-core.Stream::new(capacity=0) + let first = background_group.spawn( + async fn() -> Bool { + guard first_sinks.read(1) is Some(sinks) && sinks.length() == 1 else { + return false + } + let signal : FixedArray[Unit] = [()] + guard first_started_sink.write_all(signal[:]) else { return false } + first_started_sink.close() + let data : FixedArray[Byte] = [1] + sinks[0].write_all(data[:]) + }, + allow_failure=true, + ) + let second = background_group.spawn( + async fn() -> Bool { + guard second_sinks.read(1) is Some(sinks) && sinks.length() == 1 else { + return false + } + guard first_started.read(1) is Some(signal) && signal.length() == 1 else { + return false + } + let data : FixedArray[Byte] = [2] + sinks[0].write_all(data[:]) + }, + allow_failure=true, + ) + @async-core.Stream::produce(async fn(sink) { + let sinks : FixedArray[@async-core.Sink[Byte]] = [sink] + guard first_sinks_sink.write_all(sinks[:]) else { + abort("failed to start first component stream writer") + } + first_sinks_sink.close() + guard second_sinks_sink.write_all(sinks[:]) else { + abort("failed to start second component stream writer") + } + second_sinks_sink.close() + guard first.wait() else { abort("first component stream write failed") } + guard second.wait() else { abort("second component stream write failed") } + sink.close() + }) +} + +///| +pub async fn post_return_lazy( + _background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Stream[Byte] { + @async-core.Stream::produce(async fn(sink) { + let nested = @async-core.Stream::produce(async fn(nested_sink) { + let data : FixedArray[Byte] = [42] + guard nested_sink.write_all(data[:]) else { + abort("nested stream closed before accepting its value") + } + nested_sink.close() + }) + guard nested.read(1) is Some(data) && data.length() == 1 else { + abort("nested stream did not produce its value") + } + guard sink.write_all(data[:]) else { + abort("component stream closed before accepting its value") + } + nested.drop() + sink.close() + }) +} + +///| +pub async fn wait_shared( + _background_group : @async-core.TaskGroup[Unit], +) -> UInt { + shared.0.get() +} + +///| +pub async fn resolve_shared( + value : UInt, + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + guard shared.1.complete(value) else { panic() } +} + +///| +pub async fn wait_cancelled( + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + defer { + cancellation_seen.val = true + ignore(cancellation_done.1.complete(())) + } + never.0.get() +} + +///| +pub async fn wait_cancellation_observed( + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + cancellation_done.0.get() +} + +///| +pub fn cancellation_observed() -> Bool { + cancellation_seen.val +} diff --git a/tests/runtime/moonbit/nested-future-stream/test.wit b/tests/runtime/moonbit/nested-future-stream/test.wit new file mode 100644 index 000000000..53720d9b9 --- /dev/null +++ b/tests/runtime/moonbit/nested-future-stream/test.wit @@ -0,0 +1,30 @@ +//@ async = true + +package test:moonbit-nested-future-stream; + +interface nested { + relay: async func( + value: future>>, + ) -> future>>; + + relay-stream: async func( + value: stream>, + ) -> stream>; + + concurrent-writes: async func() -> stream; + post-return-lazy: async func() -> stream; + wait-shared: async func() -> u32; + resolve-shared: async func(value: u32); + wait-cancelled: async func(); + wait-cancellation-observed: async func(); + cancellation-observed: func() -> bool; +} + +world test { + export nested; +} + +world runner { + import nested; + export run: async func(); +} diff --git a/tests/runtime/moonbit/resource-payloads/leaf.mbt b/tests/runtime/moonbit/resource-payloads/leaf.mbt new file mode 100644 index 000000000..b7c1c28a1 --- /dev/null +++ b/tests/runtime/moonbit/resource-payloads/leaf.mbt @@ -0,0 +1,213 @@ +//@ [lang] +//@ path = 'gen/interface/my/test/leaf-interface/stub.mbt' +//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "my/test/async-core", "alias": "async-core" }, { "path": "moonbitlang/core/encoding/utf8", "alias": "utf8" }] }""" + +///| +priv struct BodyState { + mut contents : @async-core.Stream[Byte]? + mut trailers : @async-core.Future[Fields]? +} + +///| +priv struct ResponseState { + mut body : BodyState? +} + +///| +let leaf_values : Map[Int, String] = Map([]) + +///| +let fields_values : Map[Int, String] = Map([]) + +///| +let body_values : Map[Int, BodyState] = Map([]) + +///| +let response_values : Map[Int, ResponseState] = Map([]) + +///| +let next_resource_rep : Ref[Int] = Ref(1) + +///| +let leaf_drops : Ref[UInt] = { val: 0 } + +///| +let leaf_live : Ref[UInt] = { val: 0 } + +///| +let fields_live : Ref[UInt] = { val: 0 } + +///| +let body_live : Ref[UInt] = { val: 0 } + +///| +let response_live : Ref[UInt] = { val: 0 } + +///| +pub fn LeafThing::leaf_thing(value : String) -> LeafThing { + let rep = next_resource_rep.val + next_resource_rep.val += 1 + leaf_values[rep] = value + leaf_live.val += 1 + LeafThing::new(rep) +} + +///| +pub fn LeafThing::get(self : LeafThing) -> String { + leaf_values[self.0] +} + +///| +pub fn LeafThing::dtor(self : LeafThing) -> Unit { + leaf_values.remove(self.0) + leaf_drops.val += 1 + leaf_live.val -= 1 +} + +///| +pub fn leaf_drop_count() -> UInt { + leaf_drops.val +} + +///| +pub fn leaf_live_count() -> UInt { + leaf_live.val +} + +///| +pub fn Fields::fields(value : String) -> Fields { + let rep = next_resource_rep.val + next_resource_rep.val += 1 + fields_values[rep] = value + fields_live.val += 1 + Fields::new(rep) +} + +///| +pub fn Fields::get(self : Fields) -> String { + fields_values[self.0] +} + +///| +pub fn Fields::dtor(self : Fields) -> Unit { + fields_values.remove(self.0) + fields_live.val -= 1 +} + +///| +pub fn Body::body( + contents : @async-core.Stream[Byte], + trailers : @async-core.Future[Fields]?, +) -> Body { + let rep = next_resource_rep.val + next_resource_rep.val += 1 + body_values[rep] = { contents: Some(contents), trailers } + body_live.val += 1 + Body::new(rep) +} + +///| +async fn collect_body_state(state : BodyState) -> (String, String?) { + guard state.contents is Some(contents) else { panic() } + state.contents = None + + let bytes : Array[Byte] = [] + for ;; { + match contents.read(16) { + Some(chunk) => + for byte in chunk { + bytes.push(byte) + } + None => break + } + } + contents.drop() + + let trailers = match state.trailers { + Some(future) => { + state.trailers = None + let fields = future.get() + let value = fields_values[fields.rep()] + fields.drop() + future.drop() + Some(value) + } + None => None + } + (@utf8.decode_lossy(Bytes::from_array(bytes[:])[:]), trailers) +} + +///| +pub async fn Body::collect( + self : Body, + _background_group : @async-core.TaskGroup[Unit], +) -> (String, String?) { + collect_body_state(body_values[self.0]) +} + +///| +pub fn Body::dtor(self : Body) -> Unit { + match body_values.get(self.0) { + Some(state) => { + body_values.remove(self.0) + guard state.contents is None && state.trailers is None else { + abort("test body dropped before collect") + } + body_live.val -= 1 + } + None => () + } +} + +///| +pub fn Response::response(body : Body) -> Response { + // Move the owned body's representation into the response before releasing + // its handle. The body remains live until `Response::collect` consumes it. + let body_rep = body.rep() + guard body_values.get(body_rep) is Some(body_state) else { panic() } + body_values.remove(body_rep) + body.drop() + let rep = next_resource_rep.val + next_resource_rep.val += 1 + response_values[rep] = { body: Some(body_state) } + response_live.val += 1 + Response::new(rep) +} + +///| +pub async fn Response::collect( + self : Response, + _background_group : @async-core.TaskGroup[Unit], +) -> (String, String?) { + let state = response_values[self.0] + guard state.body is Some(body_state) else { panic() } + state.body = None + let result = collect_body_state(body_state) + body_live.val -= 1 + result +} + +///| +pub fn Response::dtor(self : Response) -> Unit { + let state = response_values[self.0] + guard state.body is None else { + abort("test response dropped before collect") + } + response_values.remove(self.0) + response_live.val -= 1 +} + +///| +pub fn fields_live_count() -> UInt { + fields_live.val +} + +///| +pub fn body_live_count() -> UInt { + body_live.val +} + +///| +pub fn response_live_count() -> UInt { + response_live.val +} diff --git a/tests/runtime/moonbit/resource-payloads/leaf.rs b/tests/runtime/moonbit/resource-payloads/leaf.rs new file mode 100644 index 000000000..33e3d3d9c --- /dev/null +++ b/tests/runtime/moonbit/resource-payloads/leaf.rs @@ -0,0 +1,144 @@ +include!(env!("BINDINGS")); + +use crate::exports::my::test::leaf_interface::{ + Body, Fields, Guest, GuestBody, GuestFields, GuestLeafThing, GuestResponse, +}; +use std::cell::RefCell; +use std::sync::atomic::{AtomicU32, Ordering}; +use wit_bindgen::{FutureReader, StreamReader}; + +struct Component; + +export!(Component); + +static LEAF_DROP_COUNT: AtomicU32 = AtomicU32::new(0); +static LEAF_LIVE_COUNT: AtomicU32 = AtomicU32::new(0); +static FIELDS_LIVE_COUNT: AtomicU32 = AtomicU32::new(0); +static BODY_LIVE_COUNT: AtomicU32 = AtomicU32::new(0); +static RESPONSE_LIVE_COUNT: AtomicU32 = AtomicU32::new(0); + +struct MyLeafThing { + value: String, +} + +struct MyFields { + value: String, +} + +struct MyBody { + contents: RefCell>>, + trailers: RefCell>>, +} + +struct MyResponse { + body: RefCell>, +} + +impl Guest for Component { + type LeafThing = MyLeafThing; + type Fields = MyFields; + type Body = MyBody; + type Response = MyResponse; + + fn leaf_drop_count() -> u32 { + LEAF_DROP_COUNT.load(Ordering::SeqCst) + } + + fn leaf_live_count() -> u32 { + LEAF_LIVE_COUNT.load(Ordering::SeqCst) + } + + fn fields_live_count() -> u32 { + FIELDS_LIVE_COUNT.load(Ordering::SeqCst) + } + + fn body_live_count() -> u32 { + BODY_LIVE_COUNT.load(Ordering::SeqCst) + } + + fn response_live_count() -> u32 { + RESPONSE_LIVE_COUNT.load(Ordering::SeqCst) + } +} + +impl GuestLeafThing for MyLeafThing { + fn new(s: String) -> Self { + LEAF_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); + Self { value: s } + } + + fn get(&self) -> String { + self.value.clone() + } +} + +impl Drop for MyLeafThing { + fn drop(&mut self) { + LEAF_DROP_COUNT.fetch_add(1, Ordering::SeqCst); + LEAF_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + } +} + +impl GuestFields for MyFields { + fn new(s: String) -> Self { + FIELDS_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); + Self { value: s } + } + + fn get(&self) -> String { + self.value.clone() + } +} + +impl Drop for MyFields { + fn drop(&mut self) { + FIELDS_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + } +} + +impl GuestBody for MyBody { + fn new(contents: StreamReader, trailers: Option>) -> Self { + BODY_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); + Self { + contents: RefCell::new(Some(contents)), + trailers: RefCell::new(trailers), + } + } + + async fn collect(&self) -> (String, Option) { + let contents = self.contents.borrow_mut().take().unwrap(); + let bytes = contents.collect().await; + let body = String::from_utf8(bytes).unwrap(); + let trailers = match self.trailers.borrow_mut().take() { + Some(trailers) => Some(trailers.await.get::().get()), + None => None, + }; + (body, trailers) + } +} + +impl Drop for MyBody { + fn drop(&mut self) { + BODY_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + } +} + +impl GuestResponse for MyResponse { + fn new(body: Body) -> Self { + RESPONSE_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); + Self { + body: RefCell::new(Some(body)), + } + } + + async fn collect(&self) -> (String, Option) { + let body = self.body.borrow_mut().take().unwrap(); + body.get::().collect().await + } +} + +impl Drop for MyResponse { + fn drop(&mut self) { + RESPONSE_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + } +} diff --git a/tests/runtime/moonbit/resource-payloads/runner.mbt b/tests/runtime/moonbit/resource-payloads/runner.mbt new file mode 100644 index 000000000..e7770fae1 --- /dev/null +++ b/tests/runtime/moonbit/resource-payloads/runner.mbt @@ -0,0 +1,321 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' +//@ [lang] +//@ path = 'gen/world/runner/stub.mbt' +//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "my/test/async-core", "alias": "async-core" }, { "path": "my/test/interface/my/test/leaf-interface", "alias": "leaf-interface" }, { "path": "my/test/interface/my/test/test-interface", "alias": "test-interface" }] }""" + +///| +async fn read_one_leaf( + stream : @async-core.Stream[@leaf-interface.LeafThing], + expected : String, +) -> Unit { + match stream.read(1) { + Some(things) => { + if things.length() != 1 { + for thing in things { + thing.drop() + } + panic() + } + let thing = things[0] + let value = thing.get() + thing.drop() + guard value == expected else { panic() } + } + None => panic() + } +} + +///| +async fn wait_for_leaf_drop_count(expected : UInt) -> Unit { + for _ in 0..<32 { + if @leaf-interface.leaf_drop_count() == expected { + return + } + @test-interface.settle() + } + guard @leaf-interface.leaf_drop_count() == expected else { panic() } +} + +///| +async fn assert_no_live_resources() -> Unit { + for _ in 0..<32 { + if @leaf-interface.leaf_live_count() == 0U && + @leaf-interface.fields_live_count() == 0U && + @leaf-interface.body_live_count() == 0U && + @leaf-interface.response_live_count() == 0U { + return + } + @test-interface.settle() + } + guard @leaf-interface.leaf_live_count() == 0U else { panic() } + guard @leaf-interface.fields_live_count() == 0U else { panic() } + guard @leaf-interface.body_live_count() == 0U else { panic() } + guard @leaf-interface.response_live_count() == 0U else { panic() } +} + +///| +fn nested_leaf_stream( + prefix : String, +) -> @async-core.Stream[@leaf-interface.LeafThing] { + @async-core.Stream::produce(async fn(sink) { + let values : FixedArray[@leaf-interface.LeafThing] = [ + @leaf-interface.LeafThing::leaf_thing(prefix + "-a"), + @leaf-interface.LeafThing::leaf_thing(prefix + "-b"), + ] + let _ = sink.write_all(values[:]) + sink.close() + }) +} + +///| +fn nested_leaf_future( + prefix : String, +) -> @async-core.Future[ + @async-core.Future[@async-core.Stream[@leaf-interface.LeafThing]], +] { + @async-core.Future::ready( + @async-core.Future::ready(nested_leaf_stream(prefix)), + ) +} + +///| +pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { + { + let before = @leaf-interface.leaf_drop_count() + let output = @test-interface.relay_nested_leaf( + nested_leaf_future("nested-transfer"), + ) + let stream = output.get().get() + read_one_leaf(stream, "nested-transfer-a") + read_one_leaf(stream, "nested-transfer-b") + guard stream.read(1) is None else { panic() } + stream.drop() + output.drop() + wait_for_leaf_drop_count(before + 2U) + } + { + let before = @leaf-interface.leaf_drop_count() + let output = @test-interface.relay_nested_leaf( + nested_leaf_future("nested-reject"), + ) + output.drop() + wait_for_leaf_drop_count(before + 2U) + } + { + let before = @leaf-interface.leaf_drop_count() + let input = @async-core.Stream::produce(async fn(sink) { + let values : FixedArray[@async-core.Future[@leaf-interface.LeafThing]] = [ + @async-core.Future::ready( + @leaf-interface.LeafThing::leaf_thing("future-stream-a"), + ), + @async-core.Future::ready( + @leaf-interface.LeafThing::leaf_thing("future-stream-b"), + ), + ] + let _ = sink.write_all(values[:]) + sink.close() + }) + let output = @test-interface.relay_leaf_futures(input) + guard output.read(1) is Some(values) && values.length() == 1 else { + panic() + } + let leaf = values[0].get() + guard leaf.get() == "future-stream-a" else { panic() } + leaf.drop() + output.drop() + wait_for_leaf_drop_count(before + 2U) + } + { + let (input, sink) = @async-core.Stream::new_with_cleanup( + fn(value : @leaf-interface.LeafThing) { value.drop() }, + capacity=3, + ) + let values : FixedArray[@leaf-interface.LeafThing] = [ + @leaf-interface.LeafThing::leaf_thing("a"), + @leaf-interface.LeafThing::leaf_thing("b"), + @leaf-interface.LeafThing::leaf_thing("c"), + ] + guard sink.write_all(values[:]) else { panic() } + sink.close() + + let stream = @test-interface.short_reads_leaf(input) + read_one_leaf(stream, "a") + read_one_leaf(stream, "b") + read_one_leaf(stream, "c") + stream.drop() + } + { + let before = @leaf-interface.leaf_drop_count() + let (input, sink) = @async-core.Stream::new_with_cleanup( + fn(value : @leaf-interface.LeafThing) { value.drop() }, + capacity=3, + ) + let values : FixedArray[@leaf-interface.LeafThing] = [ + @leaf-interface.LeafThing::leaf_thing("drop-a"), + @leaf-interface.LeafThing::leaf_thing("drop-b"), + @leaf-interface.LeafThing::leaf_thing("drop-c"), + ] + guard sink.write_all(values[:]) else { panic() } + sink.close() + + let stream = @test-interface.short_reads_leaf(input) + read_one_leaf(stream, "drop-a") + stream.drop() + wait_for_leaf_drop_count(before + 3U) + } + { + let (release_first, release_first_sink) = @async-core.Stream::new( + capacity=1, + ) + let first_payload = @async-core.Future::ready_with_cleanup( + @leaf-interface.LeafThing::leaf_thing("a"), + fn(value : @leaf-interface.LeafThing) { value.drop() }, + ) + let first = @async-core.Future::from(async fn() { + guard release_first.read(1) is Some(signal) && signal.length() == 1 else { + abort("future release signal closed early") + } + first_payload.get() + }) + let second = @async-core.Future::ready_with_cleanup( + @leaf-interface.LeafThing::leaf_thing("a"), + fn(value : @leaf-interface.LeafThing) { value.drop() }, + ) + let (out1, out2) = @test-interface.dropped_reader_leaf(first, second) + + out1.drop() + let thing = out2.get() + let value = thing.get() + thing.drop() + out2.drop() + guard value == "a" else { panic() } + + // `out2` is only produced after the peer has dropped `first`. + let signal : FixedArray[Unit] = [()] + guard release_first_sink.write_all(signal[:]) else { panic() } + release_first_sink.close() + assert_no_live_resources() + } + { + let before = @leaf-interface.leaf_drop_count() + let (release, release_sink) = @async-core.Stream::new(capacity=1) + let payload = @async-core.Future::ready_with_cleanup( + @leaf-interface.LeafThing::leaf_thing("cancelled-future-read"), + fn(value : @leaf-interface.LeafThing) { value.drop() }, + ) + let future = @async-core.Future::from(async fn() { + guard release.read(1) is Some(signal) && signal.length() == 1 else { + abort("future release signal closed early") + } + payload.get() + }) + guard @test-interface.cancel_future_read(future) else { panic() } + + let signal : FixedArray[Unit] = [()] + guard release_sink.write_all(signal[:]) else { panic() } + release_sink.close() + wait_for_leaf_drop_count(before + 1U) + } + { + let before = @leaf-interface.leaf_drop_count() + let (stream, sink) = @async-core.Stream::new_with_cleanup( + fn(value : @leaf-interface.LeafThing) { value.drop() }, + capacity=0, + ) + guard @test-interface.cancel_stream_read(stream) else { panic() } + + let values : FixedArray[@leaf-interface.LeafThing] = [ + @leaf-interface.LeafThing::leaf_thing("cancelled-stream-read"), + ] + let _ = sink.write_all(values[:]) + sink.close() + wait_for_leaf_drop_count(before + 1U) + } + { + let before = @leaf-interface.leaf_drop_count() + let stream = @test-interface.make_leaf_stream() + read_one_leaf(stream, "stream-a") + stream.drop() + wait_for_leaf_drop_count(before + 4U) + } + { + let stream = @test-interface.make_local_leaf_stream() + read_one_leaf(stream, "local-a") + read_one_leaf(stream, "local-b") + read_one_leaf(stream, "local-c") + match stream.read(1) { + None => () + Some(things) => { + for thing in things { + thing.drop() + } + panic() + } + } + stream.drop() + } + { + let response = @test-interface.make_response() + let (body, trailers) = response.collect() + response.drop() + guard body == "hello http p3" else { panic() } + guard trailers == Some("done") else { panic() } + } + { + let response = @test-interface.make_response_post_work() + let before = @test-interface.post_response_count() + guard before < 3U else { panic() } + + let (body, trailers) = response.collect() + response.drop() + guard body == "post work" else { panic() } + guard trailers == Some("after-body") else { panic() } + guard @test-interface.post_response_count() >= 3U else { panic() } + } + { + let response = @test-interface.make_response_background() + guard @test-interface.background_count() == 0U else { panic() } + + let (body, trailers) = response.collect() + response.drop() + guard body == "background" else { panic() } + guard trailers == Some("background-done") else { panic() } + guard @test-interface.background_count() == 1U else { panic() } + } + { + let future = @test-interface.delayed_leaf_future() + let thing = future.get() + let value = thing.get() + thing.drop() + future.drop() + guard value == "delayed" else { panic() } + } + + guard @test-interface.local_future_drop_cleanup() == 1U else { panic() } + guard @test-interface.local_future_drop_resource_cleanup() == 1U else { + panic() + } + guard @test-interface.local_lazy_future_drop_resource_cleanup() == 1U else { + panic() + } + guard @test-interface.local_stream_drop_resource_cleanup() == 4U else { + panic() + } + guard @test-interface.local_lazy_stream_drop_resource_cleanup() == 1U else { + panic() + } + guard @test-interface.local_lazy_stream_partial_drop_resource_cleanup() == 2U else { + panic() + } + guard @test-interface.local_stream_cancelled_write_resource_cleanup() == 3U else { + panic() + } + guard @test-interface.local_stream_rendezvous() else { panic() } + guard @test-interface.local_stream_bounded_backpressure() else { panic() } + guard @test-interface.local_stream_cancelled_waiters() else { panic() } + guard @test-interface.local_stream_produce_read() else { panic() } + assert_no_live_resources() + + // The export ABI supplies this group even when this runner has no detached work. + ignore(background_group) +} diff --git a/tests/runtime/moonbit/resource-payloads/test.mbt b/tests/runtime/moonbit/resource-payloads/test.mbt new file mode 100644 index 000000000..477223aae --- /dev/null +++ b/tests/runtime/moonbit/resource-payloads/test.mbt @@ -0,0 +1,714 @@ +//@ [lang] +//@ path = 'gen/interface/my/test/test-interface/stub.mbt' + +///| +let post_response_steps : Ref[UInt] = Ref(0) + +///| +fn record_post_response_step() -> Unit { + post_response_steps.val += 1 +} + +///| +let background_steps : Ref[UInt] = Ref(0) + +///| +fn reset_background_work() -> Unit { + background_steps.val = 0 +} + +///| +pub async fn settle(_background_group : @async-core.TaskGroup[Unit]) -> Unit { + +} + +///| +pub async fn short_reads_leaf( + s : @async-core.Stream[@leaf-interface.LeafThing], + _background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Stream[@leaf-interface.LeafThing] { + @async-core.Stream::produce(async fn(sink) { + let things : Array[@leaf-interface.LeafThing] = [] + for ;; { + match s.read(1) { + Some(chunk) => + for i in 0.. break + } + } + let data = FixedArray::from_array(things) + let _ = sink.write_all(data[:]) + sink.close() + }) +} + +///| +pub async fn dropped_reader_leaf( + f1 : @async-core.Future[@leaf-interface.LeafThing], + f2 : @async-core.Future[@leaf-interface.LeafThing], + _background_group : @async-core.TaskGroup[Unit], +) -> ( + @async-core.Future[@leaf-interface.LeafThing], + @async-core.Future[@leaf-interface.LeafThing], +) { + let value : Ref[String?] = Ref(None) + let (value_ready, value_ready_sink) = @async-core.Stream::new(capacity=1) + let out1 = @async-core.Future::from(async fn() { + f1.drop() + let thing = f2.get() + value.val = Some(thing.get()) + let signal : FixedArray[Unit] = [()] + guard value_ready_sink.write_all(signal[:]) else { + abort("future value signal closed early") + } + value_ready_sink.close() + thing + }) + let out2 = @async-core.Future::from(async fn() { + guard value_ready.read(1) is Some(signal) && signal.length() == 1 else { + abort("future value signal closed early") + } + @leaf-interface.LeafThing::leaf_thing(value.val.unwrap()) + }) + (out1, out2) +} + +///| +pub async fn make_leaf_stream( + _background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Stream[@leaf-interface.LeafThing] { + @async-core.Stream::produce(async fn(sink) { + let data : FixedArray[@leaf-interface.LeafThing] = [ + @leaf-interface.LeafThing::leaf_thing("stream-a"), + @leaf-interface.LeafThing::leaf_thing("stream-b"), + @leaf-interface.LeafThing::leaf_thing("stream-c"), + @leaf-interface.LeafThing::leaf_thing("stream-d"), + ] + let _ = sink.write_all(data[:]) + sink.close() + }) +} + +///| +pub async fn make_local_leaf_stream( + background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Stream[@leaf-interface.LeafThing] { + let (stream, sink) = @async-core.Stream::new_with_cleanup( + fn(value : @leaf-interface.LeafThing) { value.drop() }, + capacity=1, + ) + background_group.spawn_bg(async fn() { + let data : FixedArray[@leaf-interface.LeafThing] = [ + @leaf-interface.LeafThing::leaf_thing("local-a"), + @leaf-interface.LeafThing::leaf_thing("local-b"), + @leaf-interface.LeafThing::leaf_thing("local-c"), + ] + let _ = sink.write_all(data[:]) + sink.close() + }) + stream +} + +///| +pub async fn make_response( + _background_group : @async-core.TaskGroup[Unit], +) -> @leaf-interface.Response { + let trailers = @leaf-interface.Fields::fields("done") + let body = @leaf-interface.Body::body( + @async-core.Stream::produce(async fn(sink) { + let _ = sink.write_all_bytes(b"hello "[:]) + let _ = sink.write_all_bytes(b"http p3"[:]) + sink.close() + }), + Some(@async-core.Future::ready(trailers)), + ) + @leaf-interface.Response::response(body) +} + +///| +pub async fn make_response_post_work( + _background_group : @async-core.TaskGroup[Unit], +) -> @leaf-interface.Response { + let (body_done, body_done_sink) = @async-core.Stream::new(capacity=1) + let trailers = @async-core.Future::from(async fn() { + guard body_done.read(1) is Some(signal) && signal.length() == 1 else { + abort("body completion signal closed early") + } + guard post_response_steps.val >= 3 else { + abort("body completion signal arrived too early") + } + @leaf-interface.Fields::fields("after-body") + }) + let body = @leaf-interface.Body::body( + @async-core.Stream::produce(async fn(sink) { + record_post_response_step() + let _ = sink.write_all_bytes(b"post "[:]) + record_post_response_step() + let _ = sink.write_all_bytes(b"work"[:]) + sink.close() + record_post_response_step() + let signal : FixedArray[Unit] = [()] + guard body_done_sink.write_all(signal[:]) else { + abort("body completion signal closed early") + } + body_done_sink.close() + }), + Some(trailers), + ) + @leaf-interface.Response::response(body) +} + +///| +pub fn post_response_count() -> UInt { + post_response_steps.val +} + +///| +pub async fn make_response_background( + background_group : @async-core.TaskGroup[Unit], +) -> @leaf-interface.Response { + reset_background_work() + let (body_done, body_done_sink) = @async-core.Stream::new(capacity=1) + let (background_done, background_done_sink) = @async-core.Stream::new( + capacity=1, + ) + background_group.spawn_bg(async fn() { + guard body_done.read(1) is Some(signal) && signal.length() == 1 else { + abort("body completion signal closed early") + } + background_steps.val += 1 + let signal : FixedArray[Unit] = [()] + guard background_done_sink.write_all(signal[:]) else { + abort("background completion signal closed early") + } + background_done_sink.close() + }) + let trailers = @async-core.Future::from(async fn() { + guard background_done.read(1) is Some(signal) && signal.length() == 1 else { + abort("background completion signal closed early") + } + @leaf-interface.Fields::fields("background-done") + }) + let body = @leaf-interface.Body::body( + @async-core.Stream::produce(async fn(sink) { + let _ = sink.write_all_bytes(b"background"[:]) + sink.close() + let signal : FixedArray[Unit] = [()] + guard body_done_sink.write_all(signal[:]) else { + abort("body completion signal closed early") + } + body_done_sink.close() + }), + Some(trailers), + ) + @leaf-interface.Response::response(body) +} + +///| +pub fn background_count() -> UInt { + background_steps.val +} + +///| +pub async fn delayed_leaf_future( + background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Future[@leaf-interface.LeafThing] { + let (ready, ready_sink) = @async-core.Stream::new(capacity=0) + background_group.spawn_bg(async fn() { + let signal : FixedArray[Unit] = [()] + guard ready_sink.write_all(signal[:]) else { return } + ready_sink.close() + }) + @async-core.Future::from(async fn() { + guard ready.read(1) is Some(signal) && signal.length() == 1 else { + abort("future readiness signal closed early") + } + @leaf-interface.LeafThing::leaf_thing("delayed") + }) +} + +///| +pub async fn local_future_drop_cleanup( + _background_group : @async-core.TaskGroup[Unit], +) -> UInt { + let counts : Array[UInt] = [0] + let f = @async-core.Future::ready_with_cleanup("owned", fn(_value) { + counts[0] = counts[0] + 1 + }) + f.drop() + let unstarted_cleanup_ran = Ref(false) + let started = @async-core.Future::from(async fn() { 42 }, on_unstarted_drop=fn() { + unstarted_cleanup_ran.val = true + }) + guard started.get() == 42 else { + abort("future producer returned wrong value") + } + started.drop() + guard !unstarted_cleanup_ran.val else { + abort("started future ran its unstarted cleanup") + } + counts[0] +} + +///| +pub async fn local_future_drop_resource_cleanup( + _background_group : @async-core.TaskGroup[Unit], +) -> UInt { + let before = @leaf-interface.leaf_drop_count() + let thing = @leaf-interface.LeafThing::leaf_thing("cleanup") + let f = @async-core.Future::ready_with_cleanup(thing, fn(value) { + value.drop() + }) + f.drop() + @leaf-interface.leaf_drop_count() - before +} + +///| +pub async fn local_lazy_future_drop_resource_cleanup( + _background_group : @async-core.TaskGroup[Unit], +) -> UInt { + let before = @leaf-interface.leaf_drop_count() + let producer_ran = Ref(false) + let thing = @leaf-interface.LeafThing::leaf_thing("lazy-future-cleanup") + let future = @async-core.Future::from( + async fn() { + producer_ran.val = true + thing + }, + on_unstarted_drop=fn() { thing.drop() }, + ) + future.drop() + guard !producer_ran.val else { + abort("dropping an unstarted future ran its producer") + } + @leaf-interface.leaf_drop_count() - before +} + +///| +pub async fn local_stream_drop_resource_cleanup( + _background_group : @async-core.TaskGroup[Unit], +) -> UInt { + let before = @leaf-interface.leaf_drop_count() + let (stream, sink) = @async-core.Stream::new_with_cleanup( + fn(value : @leaf-interface.LeafThing) { value.drop() }, + capacity=4, + ) + let data : FixedArray[@leaf-interface.LeafThing] = [ + @leaf-interface.LeafThing::leaf_thing("buffered-a"), + @leaf-interface.LeafThing::leaf_thing("buffered-b"), + @leaf-interface.LeafThing::leaf_thing("buffered-c"), + @leaf-interface.LeafThing::leaf_thing("buffered-d"), + ] + let _ = sink.write_all(data[:]) + sink.close() + match stream.read(1) { + Some(chunk) => chunk[0].drop() + None => panic() + } + stream.drop() + @leaf-interface.leaf_drop_count() - before +} + +///| +pub async fn local_lazy_stream_drop_resource_cleanup( + _background_group : @async-core.TaskGroup[Unit], +) -> UInt { + let before = @leaf-interface.leaf_drop_count() + let producer_ran = Ref(false) + let thing = @leaf-interface.LeafThing::leaf_thing("lazy-stream-cleanup") + let stream : @async-core.Stream[@leaf-interface.LeafThing] = @async-core.Stream::produce( + async fn(sink) { + producer_ran.val = true + let values : FixedArray[@leaf-interface.LeafThing] = [thing] + guard sink.write_all(values[:]) else { return } + sink.close() + }, + on_unstarted_drop=fn() { thing.drop() }, + ) + stream.drop() + guard !producer_ran.val else { + abort("dropping an unstarted stream ran its producer") + } + @leaf-interface.leaf_drop_count() - before +} + +///| +pub async fn local_lazy_stream_partial_drop_resource_cleanup( + _background_group : @async-core.TaskGroup[Unit], +) -> UInt { + let before = @leaf-interface.leaf_drop_count() + let (producer_done, producer_done_promise) = @async-core.Future::new() + let stream : @async-core.Stream[@leaf-interface.LeafThing] = @async-core.Stream::produce( + async fn(sink) { + let values : FixedArray[@leaf-interface.LeafThing] = [ + @leaf-interface.LeafThing::leaf_thing("partial-a"), + @leaf-interface.LeafThing::leaf_thing("partial-b"), + ] + ignore(producer_done_promise.complete(sink.write_all(values[:]))) + }, + cleanup=fn(value) { value.drop() }, + ) + guard stream.read(1) is Some(values) && values.length() == 1 else { panic() } + values[0].drop() + stream.drop() + guard !producer_done.get() else { panic() } + @leaf-interface.leaf_drop_count() - before +} + +///| +pub async fn local_stream_cancelled_write_resource_cleanup( + background_group : @async-core.TaskGroup[Unit], +) -> UInt { + let before = @leaf-interface.leaf_drop_count() + let (stream, sink) = @async-core.Stream::new_with_cleanup( + fn(value : @leaf-interface.LeafThing) { value.drop() }, + capacity=1, + ) + let (write_blocked, write_blocked_sink) = @async-core.Stream::new(capacity=1) + let writer = background_group.spawn( + async fn() -> Unit { + let data : FixedArray[@leaf-interface.LeafThing] = [ + @leaf-interface.LeafThing::leaf_thing("cancelled-a"), + @leaf-interface.LeafThing::leaf_thing("cancelled-b"), + @leaf-interface.LeafThing::leaf_thing("cancelled-c"), + ] + let accepted = sink.write(data[:]) + guard accepted == 1 else { panic() } + let signal : FixedArray[Unit] = [()] + guard write_blocked_sink.write_all(signal[:]) else { panic() } + write_blocked_sink.close() + let _ = sink.write_all(data[accepted:]) + }, + allow_failure=true, + ) + guard write_blocked.read(1) is Some(signal) && signal.length() == 1 else { + panic() + } + writer.cancel() + writer.wait() catch { + _ => () + } + guard @leaf-interface.leaf_drop_count() - before == 2 else { panic() } + stream.drop() + @leaf-interface.leaf_drop_count() - before +} + +///| +pub async fn cancel_future_read( + future : @async-core.Future[@leaf-interface.LeafThing], + background_group : @async-core.TaskGroup[Unit], +) -> Bool { + let (read_started, read_started_sink) = @async-core.Stream::new(capacity=1) + let reader = background_group.spawn( + async fn() -> Bool { + let signal : FixedArray[Unit] = [()] + guard read_started_sink.write_all(signal[:]) else { panic() } + read_started_sink.close() + let value = future.get() + value.drop() + false + }, + allow_failure=true, + ) + guard read_started.read(1) is Some(signal) && signal.length() == 1 else { + panic() + } + reader.cancel() + try reader.wait() catch { + @async-core.Cancelled::Cancelled => true + _ => false + } noraise { + _ => false + } +} + +///| +pub async fn cancel_stream_read( + stream : @async-core.Stream[@leaf-interface.LeafThing], + background_group : @async-core.TaskGroup[Unit], +) -> Bool { + let (read_started, read_started_sink) = @async-core.Stream::new(capacity=1) + let reader = background_group.spawn( + async fn() -> Bool { + let signal : FixedArray[Unit] = [()] + guard read_started_sink.write_all(signal[:]) else { panic() } + read_started_sink.close() + match stream.read(1) { + Some(chunk) => + for value in chunk { + value.drop() + } + None => () + } + false + }, + allow_failure=true, + ) + guard read_started.read(1) is Some(signal) && signal.length() == 1 else { + panic() + } + reader.cancel() + try reader.wait() catch { + @async-core.Cancelled::Cancelled => true + _ => false + } noraise { + _ => false + } +} + +///| +pub async fn relay_nested_leaf( + value : @async-core.Future[ + @async-core.Future[@async-core.Stream[@leaf-interface.LeafThing]], + ], + _background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Future[ + @async-core.Future[@async-core.Stream[@leaf-interface.LeafThing]], +] { + @async-core.Future::from(async fn() { + let inner = value.get() + @async-core.Future::from(async fn() { inner.get() }) + }) +} + +///| +pub async fn relay_leaf_futures( + value : @async-core.Stream[@async-core.Future[@leaf-interface.LeafThing]], + _background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Stream[@async-core.Future[@leaf-interface.LeafThing]] { + value +} + +///| +pub async fn local_stream_rendezvous( + background_group : @async-core.TaskGroup[Unit], +) -> Bool { + let (stream, sink) = @async-core.Stream::new(capacity=0) + let phase = Ref(0) + let (writer_started, writer_started_sink) = @async-core.Stream::new( + capacity=1, + ) + let writer = background_group.spawn(async fn() -> Bool { + let data : FixedArray[Int] = [42] + phase.val = 1 + let signal : FixedArray[Unit] = [()] + guard writer_started_sink.write_all(signal[:]) else { return false } + writer_started_sink.close() + guard sink.write_all(data[:]) else { return false } + phase.val = 2 + sink.close() + true + }) + guard writer_started.read(1) is Some(signal) && signal.length() == 1 else { + return false + } + guard phase.val == 1 else { return false } + guard stream.read(1) is Some(chunk) && chunk.length() == 1 else { + return false + } + writer.wait() && phase.val == 2 && stream.read(1) is None +} + +///| +pub async fn local_stream_bounded_backpressure( + background_group : @async-core.TaskGroup[Unit], +) -> Bool { + let (stream, sink) = @async-core.Stream::new(capacity=2) + let phase = Ref(0) + let (write_blocked, write_blocked_sink) = @async-core.Stream::new(capacity=1) + let writer = background_group.spawn( + async fn() -> Bool { + let data : FixedArray[Int] = [1, 2, 3] + phase.val = 1 + let first = sink.write(data[:]) + guard first == 2 else { return false } + phase.val = 2 + let signal : FixedArray[Unit] = [()] + guard write_blocked_sink.write_all(signal[:]) else { return false } + write_blocked_sink.close() + let second = sink.write(data[first:]) + guard second == 1 else { return false } + phase.val = 3 + sink.close() + true + }, + allow_failure=true, + ) + guard write_blocked.read(1) is Some(signal) && signal.length() == 1 else { + return false + } + guard phase.val == 2 else { return false } + guard stream.read(1) is Some(first) else { return false } + guard first.length() == 1 && first[0] == 1 else { return false } + guard writer.wait() else { return false } + guard phase.val == 3 else { return false } + guard stream.read(2) is Some(second) else { return false } + guard second.length() == 1 && second[0] == 2 else { return false } + guard stream.read(2) is Some(third) else { return false } + guard third.length() == 1 && third[0] == 3 else { return false } + stream.read(1) is None +} + +///| +pub async fn local_stream_cancelled_waiters( + background_group : @async-core.TaskGroup[Unit], +) -> Bool { + let pre_start_cleanup = Ref(false) + let (never, never_sink) = @async-core.Stream::new(capacity=0) + let pre_start = background_group.spawn(async fn() -> Unit { + defer { + pre_start_cleanup.val = true + } + let _ = never.read(1) + }) + pre_start.cancel() + pre_start.wait() catch { + _ => () + } + ignore(never_sink) + guard pre_start_cleanup.val else { return false } + + let (stream, sink) = @async-core.Stream::new(capacity=0) + + let (writer_started, writer_started_sink) = @async-core.Stream::new( + capacity=1, + ) + let blocked_writer = background_group.spawn(async fn() -> Int { + let data : FixedArray[Int] = [1] + let signal : FixedArray[Unit] = [()] + guard writer_started_sink.write_all(signal[:]) else { return -1 } + writer_started_sink.close() + sink.write(data[:]) + }) + guard writer_started.read(1) is Some(signal) && signal.length() == 1 else { + return false + } + blocked_writer.cancel() + let blocked_writer_result = blocked_writer.wait() catch { _ => -1 } + guard blocked_writer_result == -1 else { return false } + + let (reader_started, reader_started_sink) = @async-core.Stream::new( + capacity=1, + ) + let blocked_reader = background_group.spawn( + async fn() -> Int { + let signal : FixedArray[Unit] = [()] + guard reader_started_sink.write_all(signal[:]) else { return -1 } + reader_started_sink.close() + match stream.read(1) { + Some(_) => 1 + None => 0 + } + }, + allow_failure=true, + ) + guard reader_started.read(1) is Some(signal) && signal.length() == 1 else { + return false + } + blocked_reader.cancel() + let blocked_reader_result = blocked_reader.wait() catch { _ => -1 } + guard blocked_reader_result == -1 else { return false } + + let writer = background_group.spawn( + async fn() -> Int { + let data : FixedArray[Int] = [7] + sink.write(data[:]) + }, + allow_failure=true, + ) + guard stream.read(1) is Some(chunk) else { return false } + guard chunk.length() == 1 && chunk[0] == 7 else { return false } + writer.cancel() + guard writer.wait() == 1 else { return false } + + let (finishing_reader_started, finishing_reader_started_sink) = @async-core.Stream::new( + capacity=1, + ) + let finishing_reader = background_group.spawn( + async fn() -> Int { + let signal : FixedArray[Unit] = [()] + guard finishing_reader_started_sink.write_all(signal[:]) else { + return -1 + } + finishing_reader_started_sink.close() + match stream.read(1) { + Some(data) if data.length() == 1 => data[0] + _ => -1 + } + }, + allow_failure=true, + ) + guard finishing_reader_started.read(1) is Some(signal) && signal.length() == 1 else { + return false + } + let data : FixedArray[Int] = [9] + guard sink.write(data[:]) == 1 else { return false } + finishing_reader.cancel() + guard finishing_reader.wait() == 9 else { return false } + + sink.close() + guard stream.read(1) is None else { return false } + + let (closed_stream, closed_sink) = @async-core.Stream::new(capacity=0) + let (close_writer_started, close_writer_started_sink) = @async-core.Stream::new( + capacity=1, + ) + let close_writer = background_group.spawn(async fn() -> Int { + let signal : FixedArray[Unit] = [()] + guard close_writer_started_sink.write_all(signal[:]) else { return -1 } + close_writer_started_sink.close() + let data : FixedArray[Int] = [11] + closed_sink.write(data[:]) + }) + guard close_writer_started.read(1) is Some(signal) && signal.length() == 1 else { + return false + } + closed_sink.close() + close_writer.cancel() + guard close_writer.wait() == 0 && !closed_sink.is_open() else { return false } + guard closed_stream.read(1) is None else { return false } + + let (dropped_stream, dropped_sink) = @async-core.Stream::new(capacity=0) + let (drop_writer_started, drop_writer_started_sink) = @async-core.Stream::new( + capacity=1, + ) + let drop_writer = background_group.spawn(async fn() -> Int { + let signal : FixedArray[Unit] = [()] + guard drop_writer_started_sink.write_all(signal[:]) else { return -1 } + drop_writer_started_sink.close() + let data : FixedArray[Int] = [13] + dropped_sink.write(data[:]) + }) + guard drop_writer_started.read(1) is Some(signal) && signal.length() == 1 else { + return false + } + dropped_stream.drop() + drop_writer.cancel() + drop_writer.wait() == 0 && !dropped_sink.is_open() +} + +///| +pub async fn local_stream_produce_read( + _background_group : @async-core.TaskGroup[Unit], +) -> Bool { + let unstarted_cleanup_ran = Ref(false) + let stream = @async-core.Stream::produce( + async fn(sink) { + let data : FixedArray[Int] = [1, 2, 3] + let _ = sink.write_all(data[:]) + sink.close() + }, + on_unstarted_drop=fn() { unstarted_cleanup_ran.val = true }, + ) + guard stream.read(2) is Some(first) else { return false } + guard first.length() == 2 && first[0] == 1 && first[1] == 2 else { + return false + } + guard stream.read(2) is Some(second) else { return false } + guard second.length() == 1 && second[0] == 3 else { return false } + guard stream.read(1) is None else { return false } + stream.drop() + !unstarted_cleanup_ran.val +} diff --git a/tests/runtime/moonbit/resource-payloads/test.wit b/tests/runtime/moonbit/resource-payloads/test.wit new file mode 100644 index 000000000..ea6ec9e2a --- /dev/null +++ b/tests/runtime/moonbit/resource-payloads/test.wit @@ -0,0 +1,75 @@ +//@ dependencies = ['test', 'leaf'] + +package my:test; + +interface leaf-interface { + resource leaf-thing { + constructor(s: string); + get: func() -> string; + } + leaf-drop-count: func() -> u32; + leaf-live-count: func() -> u32; + + resource fields { + constructor(s: string); + get: func() -> string; + } + + resource body { + constructor(contents: stream, trailers: option>); + collect: async func() -> tuple>; + } + + resource response { + constructor(body: body); + collect: async func() -> tuple>; + } + + fields-live-count: func() -> u32; + body-live-count: func() -> u32; + response-live-count: func() -> u32; +} + +interface test-interface { + use leaf-interface.{leaf-thing, response}; + + settle: async func(); + short-reads-leaf: async func(s: stream) -> stream; + dropped-reader-leaf: async func(f1: future, f2: future) -> tuple, future>; + make-leaf-stream: async func() -> stream; + make-local-leaf-stream: async func() -> stream; + make-response: async func() -> response; + make-response-post-work: async func() -> response; + post-response-count: func() -> u32; + make-response-background: async func() -> response; + background-count: func() -> u32; + delayed-leaf-future: async func() -> future; + local-future-drop-cleanup: async func() -> u32; + local-future-drop-resource-cleanup: async func() -> u32; + local-lazy-future-drop-resource-cleanup: async func() -> u32; + local-stream-drop-resource-cleanup: async func() -> u32; + local-lazy-stream-drop-resource-cleanup: async func() -> u32; + local-lazy-stream-partial-drop-resource-cleanup: async func() -> u32; + local-stream-cancelled-write-resource-cleanup: async func() -> u32; + cancel-future-read: async func(f: future) -> bool; + cancel-stream-read: async func(s: stream) -> bool; + relay-nested-leaf: async func(value: future>>) -> future>>; + relay-leaf-futures: async func(value: stream>) -> stream>; + local-stream-rendezvous: async func() -> bool; + local-stream-bounded-backpressure: async func() -> bool; + local-stream-cancelled-waiters: async func() -> bool; + local-stream-produce-read: async func() -> bool; +} + +world leaf { + export leaf-interface; +} + +world test { + export test-interface; +} + +world runner { + import test-interface; + export run: async func(); +} diff --git a/tests/runtime/moonbit/stream-write-cancel/holder-service.rs b/tests/runtime/moonbit/stream-write-cancel/holder-service.rs new file mode 100644 index 000000000..5d1057fb5 --- /dev/null +++ b/tests/runtime/moonbit/stream-write-cancel/holder-service.rs @@ -0,0 +1,90 @@ +include!(env!("BINDINGS")); + +use crate::exports::test::moonbit_stream_write_cancel::holder::{ + Guest, GuestLeaf, Leaf, +}; +use std::cell::RefCell; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::Mutex; +use std::task::Waker; +use wit_bindgen::{StreamReader, StreamResult}; + +struct Component; + +export!(Component); + +static WRITE_STARTED: AtomicBool = AtomicBool::new(false); +static WRITE_STARTED_WAKER: Mutex> = Mutex::new(None); +static LEAF_LIVE_COUNT: AtomicU32 = AtomicU32::new(0); +static LEAF_DROP_COUNT: AtomicU32 = AtomicU32::new(0); + +thread_local! { + static HELD_STREAM: RefCell>> = const { RefCell::new(None) }; +} + +struct MyLeaf; + +impl Guest for Component { + type Leaf = MyLeaf; + + async fn hold(value: StreamReader) { + HELD_STREAM.with(|stream| assert!(stream.borrow_mut().replace(value).is_none())); + } + + fn mark_write_started() { + WRITE_STARTED.store(true, Ordering::SeqCst); + if let Some(waker) = WRITE_STARTED_WAKER.lock().unwrap().take() { + waker.wake(); + } + } + + fn write_started() -> bool { + WRITE_STARTED.load(Ordering::SeqCst) + } + + async fn wait_write_started() { + std::future::poll_fn(|cx| { + if WRITE_STARTED.load(Ordering::SeqCst) { + std::task::Poll::Ready(()) + } else { + *WRITE_STARTED_WAKER.lock().unwrap() = Some(cx.waker().clone()); + std::task::Poll::Pending + } + }) + .await + } + + async fn writable_dropped() -> bool { + let mut stream = HELD_STREAM.with(|stream| stream.borrow_mut().take().unwrap()); + let (result, values) = stream.read(Vec::with_capacity(1)).await; + result == StreamResult::Dropped && values.is_empty() + } + + async fn read_one_and_drop() -> bool { + let mut stream = HELD_STREAM.with(|stream| stream.borrow_mut().take().unwrap()); + let (result, values) = stream.read(Vec::with_capacity(1)).await; + result == StreamResult::Complete(1) && values.len() == 1 + } + + fn leaf_live_count() -> u32 { + LEAF_LIVE_COUNT.load(Ordering::SeqCst) + } + + fn leaf_drop_count() -> u32 { + LEAF_DROP_COUNT.load(Ordering::SeqCst) + } +} + +impl GuestLeaf for MyLeaf { + fn new() -> Self { + LEAF_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); + Self + } +} + +impl Drop for MyLeaf { + fn drop(&mut self) { + LEAF_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + LEAF_DROP_COUNT.fetch_add(1, Ordering::SeqCst); + } +} diff --git a/tests/runtime/moonbit/stream-write-cancel/runner.rs b/tests/runtime/moonbit/stream-write-cancel/runner.rs new file mode 100644 index 000000000..df0a16e7f --- /dev/null +++ b/tests/runtime/moonbit/stream-write-cancel/runner.rs @@ -0,0 +1,43 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' + +include!(env!("BINDINGS")); + +use crate::test::moonbit_stream_write_cancel::controller::{ + cancellation_observed, producer_finished, run_until_cancelled, second_write_started, + start_peer_drop, wait_cancellation_observed, wait_producer_finished, +}; +use crate::test::moonbit_stream_write_cancel::holder; +use futures::task::noop_waker_ref; +use std::future::Future; +use std::task::Context; + +struct Component; + +export!(Component); + +impl Guest for Component { + async fn run() { + let mut task = Box::pin(run_until_cancelled()); + assert!(task + .as_mut() + .poll(&mut Context::from_waker(noop_waker_ref())) + .is_pending()); + holder::wait_write_started().await; + assert!(holder::write_started()); + + drop(task); + wait_cancellation_observed().await; + assert!(cancellation_observed()); + assert!(holder::writable_dropped().await); + assert_eq!(holder::leaf_live_count(), 0); + assert_eq!(holder::leaf_drop_count(), 1); + + start_peer_drop().await; + assert!(holder::read_one_and_drop().await); + wait_producer_finished().await; + assert!(second_write_started()); + assert!(producer_finished()); + assert_eq!(holder::leaf_live_count(), 0); + assert_eq!(holder::leaf_drop_count(), 5); + } +} diff --git a/tests/runtime/moonbit/stream-write-cancel/test.mbt b/tests/runtime/moonbit/stream-write-cancel/test.mbt new file mode 100644 index 000000000..18c01d6ee --- /dev/null +++ b/tests/runtime/moonbit/stream-write-cancel/test.mbt @@ -0,0 +1,96 @@ +//@ [lang] +//@ path = 'gen/interface/test/moonbit-stream-write-cancel/controller/stub.mbt' +//@ pkg_config = """{ "import": [{ "path": "test/moonbit-stream-write-cancel/async-core", "alias": "async-core" }, { "path": "test/moonbit-stream-write-cancel/interface/test/moonbit-stream-write-cancel/holder", "alias": "holder" }] }""" + +///| +let never : (@async-core.Future[Unit], @async-core.Promise[Unit]) = + @async-core.Future::new() + +///| +let cancellation_seen : Ref[Bool] = { val: false } + +///| +let cancellation_done : (@async-core.Future[Unit], @async-core.Promise[Unit]) = + @async-core.Future::new() + +///| +let second_write_seen : Ref[Bool] = { val: false } + +///| +let peer_drop_producer_finished : Ref[Bool] = { val: false } + +///| +let producer_done : (@async-core.Future[Unit], @async-core.Promise[Unit]) = + @async-core.Future::new() + +///| +pub async fn run_until_cancelled( + _background_group : @async-core.TaskGroup[Unit] +) -> Unit { + let stream = @async-core.Stream::produce(async fn(sink) { + defer { + cancellation_seen.val = true + ignore(cancellation_done.1.complete(())) + } + @holder.mark_write_started() + let data : FixedArray[@holder.Leaf] = [@holder.Leaf::leaf()] + let _ = sink.write_all(data[:]) + }) + @holder.hold(stream) + never.0.get() +} + +///| +pub fn cancellation_observed() -> Bool { + cancellation_seen.val +} + +///| +pub async fn wait_cancellation_observed( + _background_group : @async-core.TaskGroup[Unit] +) -> Unit { + cancellation_done.0.get() +} + +///| +pub async fn start_peer_drop( + _background_group : @async-core.TaskGroup[Unit] +) -> Unit { + let stream = @async-core.Stream::produce(async fn(sink) { + defer { + peer_drop_producer_finished.val = true + ignore(producer_done.1.complete(())) + } + let first : FixedArray[@holder.Leaf] = [@holder.Leaf::leaf()] + guard sink.write_all(first[:]) else { panic() } + let second : FixedArray[@holder.Leaf] = [@holder.Leaf::leaf()] + guard sink.write_all(second[:]) else { panic() } + second_write_seen.val = true + let third : FixedArray[@holder.Leaf] = [@holder.Leaf::leaf()] + guard !sink.write_all(third[:]) else { panic() } + let fourth : FixedArray[@holder.Leaf] = [@holder.Leaf::leaf()] + guard !sink.write_all(fourth[:]) else { panic() } + }, cleanup=fn(value) { value.drop() }) + guard stream.read(1) is Some(priming) && priming.length() == 1 else { + panic() + } + priming[0].drop() + @holder.hold(stream) +} + +///| +pub fn second_write_started() -> Bool { + second_write_seen.val +} + +///| +pub fn producer_finished() -> Bool { + peer_drop_producer_finished.val +} + +///| +pub async fn wait_producer_finished( + _background_group : @async-core.TaskGroup[Unit] +) -> Unit { + producer_done.0.get() +} diff --git a/tests/runtime/moonbit/stream-write-cancel/test.wit b/tests/runtime/moonbit/stream-write-cancel/test.wit new file mode 100644 index 000000000..cf084aafa --- /dev/null +++ b/tests/runtime/moonbit/stream-write-cancel/test.wit @@ -0,0 +1,44 @@ +//@ async = true +//@ dependencies = ['test', 'holder-service'] + +package test:moonbit-stream-write-cancel; + +interface holder { + resource leaf { + constructor(); + } + + hold: async func(value: stream); + mark-write-started: func(); + write-started: func() -> bool; + wait-write-started: async func(); + writable-dropped: async func() -> bool; + read-one-and-drop: async func() -> bool; + leaf-live-count: func() -> u32; + leaf-drop-count: func() -> u32; +} + +interface controller { + run-until-cancelled: async func(); + cancellation-observed: func() -> bool; + wait-cancellation-observed: async func(); + start-peer-drop: async func(); + second-write-started: func() -> bool; + producer-finished: func() -> bool; + wait-producer-finished: async func(); +} + +world test { + import holder; + export controller; +} + +world holder-service { + export holder; +} + +world runner { + import controller; + import holder; + export run: async func(); +} diff --git a/tests/runtime/moonbit/wasi-cli-p3-stdout/deps/cli.wit b/tests/runtime/moonbit/wasi-cli-p3-stdout/deps/cli.wit new file mode 100644 index 000000000..2eb2bc408 --- /dev/null +++ b/tests/runtime/moonbit/wasi-cli-p3-stdout/deps/cli.wit @@ -0,0 +1,26 @@ +package wasi:cli@0.3.0; + +interface types { + enum error-code { + io, + illegal-byte-sequence, + pipe, + } +} + +interface stdout { + use types.{error-code}; + + write-via-stream: func(data: stream) -> future>; +} + +interface run { + run: async func() -> result; +} + +world command { + import types; + import stdout; + + export run; +} diff --git a/tests/runtime/moonbit/wasi-cli-p3-stdout/runner.mbt b/tests/runtime/moonbit/wasi-cli-p3-stdout/runner.mbt new file mode 100644 index 000000000..86d82c972 --- /dev/null +++ b/tests/runtime/moonbit/wasi-cli-p3-stdout/runner.mbt @@ -0,0 +1,9 @@ +//@ wasmtime-flags = '-Wcomponent-model-async -S p3' +//@ [lang] +//@ path = 'gen/world/runner/stub.mbt' +//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "my/test/async-core", "alias": "async-core" }, { "path": "my/test/interface/my/test/i", "alias": "i" }] }""" + +///| +pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { + guard @i.write_stdout() is Ok(_) else { panic() } +} diff --git a/tests/runtime/moonbit/wasi-cli-p3-stdout/test.mbt b/tests/runtime/moonbit/wasi-cli-p3-stdout/test.mbt new file mode 100644 index 000000000..753229c10 --- /dev/null +++ b/tests/runtime/moonbit/wasi-cli-p3-stdout/test.mbt @@ -0,0 +1,28 @@ +//@ [lang] +//@ path = 'gen/interface/my/test/i/stub.mbt' +//@ pkg_config = """{ +//@ "import": [ +//@ { "path": "my/test/async-core", "alias": "async-core" }, +//@ { "path": "my/test/interface/wasi/cli/stdout", "alias": "stdout" } +//@ ] +//@ }""" + +///| +pub async fn write_stdout( + _background_group : @async-core.TaskGroup[Unit], +) -> Result[Unit, Unit] { + let data : FixedArray[Byte] = [ + b'h', b'e', b'l', b'l', b'o', b' ', b'p', b'3', b'\n', + ] + let done = @stdout.write_via_stream( + @async-core.Stream::produce(async fn(sink) { + let _ = sink.write_all(data[:]) + sink.close() + }), + ) + let result = done.get() catch { _ => return Err(()) } + match result { + Ok(_) => Ok(()) + Err(_) => Err(()) + } +} diff --git a/tests/runtime/moonbit/wasi-cli-p3-stdout/test.wit b/tests/runtime/moonbit/wasi-cli-p3-stdout/test.wit new file mode 100644 index 000000000..39cad09e4 --- /dev/null +++ b/tests/runtime/moonbit/wasi-cli-p3-stdout/test.wit @@ -0,0 +1,17 @@ +package my:test; + +interface i { + write-stdout: async func() -> result; +} + +world test { + import wasi:cli/stdout@0.3.0; + + export i; +} + +world runner { + import i; + + export run: async func(); +} diff --git a/tests/runtime/simple-future/test.mbt b/tests/runtime/simple-future/test.mbt index d4fc4b7a0..a30e62511 100644 --- a/tests/runtime/simple-future/test.mbt +++ b/tests/runtime/simple-future/test.mbt @@ -3,19 +3,16 @@ ///| pub async fn read_future( - x : @ffi.FutureReader[Unit], -) -> Unit noraise { - let task = @ffi.current_task() - task.spawn(fn() { - let _ = x.read() catch { _ => raise @ffi.Cancelled::Cancelled } - - }) + x : @async-core.Future[Unit], + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + let _ = x.get() catch { _ => () } } ///| pub async fn drop_future( - x : @ffi.FutureReader[Unit], -) -> Unit noraise { - let task = @ffi.current_task() - let _ = x.drop() + x : @async-core.Future[Unit], + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + x.drop() } diff --git a/tests/runtime/simple-import-params-results/test.mbt b/tests/runtime/simple-import-params-results/test.mbt index ddaaac29a..2661cbc0f 100644 --- a/tests/runtime/simple-import-params-results/test.mbt +++ b/tests/runtime/simple-import-params-results/test.mbt @@ -2,29 +2,43 @@ //@ path = 'gen/interface/a/b/i/stub.mbt' ///| -pub async fn one_argument(x : UInt) -> Unit { +pub async fn one_argument( + x : UInt, + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { assert_eq(x, 1) } ///| -pub async fn one_result() -> UInt noraise { +pub async fn one_result(_background_group : @async-core.TaskGroup[Unit]) -> UInt { 2 } ///| -pub async fn one_argument_and_result(x : UInt) -> UInt { +pub async fn one_argument_and_result( + x : UInt, + _background_group : @async-core.TaskGroup[Unit], +) -> UInt { assert_eq(x, 3) 4 } ///| -pub async fn two_arguments(x : UInt, y : UInt) -> Unit { +pub async fn two_arguments( + x : UInt, + y : UInt, + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { assert_eq(x, 5) assert_eq(y, 6) } ///| -pub async fn two_arguments_and_result(x : UInt, y : UInt) -> UInt { +pub async fn two_arguments_and_result( + x : UInt, + y : UInt, + _background_group : @async-core.TaskGroup[Unit], +) -> UInt { assert_eq(x, 7) assert_eq(y, 8) 9 diff --git a/tests/runtime/simple-stream-payload/test.mbt b/tests/runtime/simple-stream-payload/test.mbt index ef38dd6a6..48a18ef5d 100644 --- a/tests/runtime/simple-stream-payload/test.mbt +++ b/tests/runtime/simple-stream-payload/test.mbt @@ -1,20 +1,26 @@ //@ [lang] //@ path = 'gen/interface/my/test/i/stub.mbt' -pub async fn read_stream(x : @ffi.StreamReader[Byte]) -> Unit raise { - let task = @ffi.current_task() - let buffer = FixedArray::make(10, Byte::default()); +pub async fn read_stream( + x : @async-core.Stream[Byte], + _background_group : @async-core.TaskGroup[Unit], +) -> Unit raise { + let chunk0 = x.read(1).unwrap() + assert_eq(chunk0.length(), 1) + assert_eq(chunk0[0], b'\x00') - task.wait(fn(){ - let _ = x.read(buffer, 1) catch { _ => raise @ffi.Cancelled::Cancelled } - }) - task.wait(fn(){ - let _ = x.read(buffer, 2, offset=1) catch { _ => raise @ffi.Cancelled::Cancelled } - }) - task.wait(fn(){ - let _ = x.read(buffer, 1, offset=3) catch { _ => raise @ffi.Cancelled::Cancelled } - }) - task.wait(fn(){ - let _ = x.read(buffer, 1, offset=4) catch { _ => raise @ffi.Cancelled::Cancelled } - }) + let chunk1 = x.read(2).unwrap() + assert_eq(chunk1.length(), 2) + assert_eq(chunk1[0], b'\x01') + assert_eq(chunk1[1], b'\x02') + + let chunk2 = x.read(1).unwrap() + assert_eq(chunk2.length(), 1) + assert_eq(chunk2[0], b'\x03') + + let chunk3 = x.read(1).unwrap() + assert_eq(chunk3.length(), 1) + assert_eq(chunk3[0], b'\x04') + + x.drop() } diff --git a/tests/runtime/simple-stream/test.mbt b/tests/runtime/simple-stream/test.mbt index c2f38c7a6..2a10b58f4 100644 --- a/tests/runtime/simple-stream/test.mbt +++ b/tests/runtime/simple-stream/test.mbt @@ -1,14 +1,11 @@ //@ [lang] //@ path = 'gen/interface/my/test/i/stub.mbt' -pub async fn read_stream(x : @ffi.StreamReader[Unit]) -> Unit noraise { - let task = @ffi.current_task() - let buffer = FixedArray::make(10, Unit::default()); - - task.spawn(fn(){ - let _ = x.read(buffer, 1) catch { _ => raise @ffi.Cancelled::Cancelled } - }) - task.spawn(fn(){ - let _ = x.read(buffer, 2) catch { _ => raise @ffi.Cancelled::Cancelled } - }) +pub async fn read_stream( + x : @async-core.Stream[Unit], + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + let _ = x.read(1) catch { _ => None } + let _ = x.read(2) catch { _ => None } + let _ = x.drop() catch { _ => () } } From 09193bc1329b94eb6371fefe51b0c0d65adc5440 Mon Sep 17 00:00:00 2001 From: zihang Date: Mon, 20 Jul 2026 10:48:52 +0800 Subject: [PATCH 04/10] test(moonbit): add Rust async fixture pairings --- .../moonbit/nested-future-stream/test.rs | 150 ++++++++++++++++++ .../moonbit/stream-write-cancel/test.rs | 113 +++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 tests/runtime/moonbit/nested-future-stream/test.rs create mode 100644 tests/runtime/moonbit/stream-write-cancel/test.rs diff --git a/tests/runtime/moonbit/nested-future-stream/test.rs b/tests/runtime/moonbit/nested-future-stream/test.rs new file mode 100644 index 000000000..7dec15953 --- /dev/null +++ b/tests/runtime/moonbit/nested-future-stream/test.rs @@ -0,0 +1,150 @@ +include!(env!("BINDINGS")); + +use crate::exports::test::moonbit_nested_future_stream::nested::Guest; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::task::{Poll, Waker}; +use wit_bindgen::{FutureReader, StreamReader, StreamResult}; + +struct Component; + +export!(Component); + +static SHARED: Mutex> = Mutex::new(None); +static SHARED_WAKER: Mutex> = Mutex::new(None); +static CANCELLATION_OBSERVED: AtomicBool = AtomicBool::new(false); +static CANCELLATION_WAKER: Mutex> = Mutex::new(None); + +struct CancellationGuard; + +impl Drop for CancellationGuard { + fn drop(&mut self) { + CANCELLATION_OBSERVED.store(true, Ordering::SeqCst); + if let Some(waker) = CANCELLATION_WAKER.lock().unwrap().take() { + waker.wake(); + } + } +} + +impl Guest for Component { + async fn relay( + value: FutureReader>>, + ) -> FutureReader>> { + let (outer_writer, outer_reader) = wit_future::new(|| unreachable!()); + wit_bindgen::spawn_local(async move { + let input_inner = value.await; + let (inner_writer, inner_reader) = wit_future::new(|| unreachable!()); + let outer_open = outer_writer.write(inner_reader).await.is_ok(); + + // Continue consuming the input chain after an outer rejection so + // that its producer observes the rejection at the innermost stream. + let mut input_stream = input_inner.await; + let (mut output_writer, output_reader) = wit_stream::new(); + let inner_open = inner_writer.write(output_reader).await.is_ok(); + if !outer_open || !inner_open { + return; + } + + loop { + let (result, values) = input_stream.read(Vec::with_capacity(16)).await; + if !values.is_empty() && !output_writer.write_all(values).await.is_empty() { + return; + } + match result { + StreamResult::Complete(_) => {} + StreamResult::Dropped => return, + StreamResult::Cancelled => unreachable!(), + } + } + }); + outer_reader + } + + async fn relay_stream(value: StreamReader>) -> StreamReader> { + let (mut output_writer, output_reader) = wit_stream::new(); + wit_bindgen::spawn_local(async move { + let mut input = value; + loop { + let (result, values) = input.read(Vec::with_capacity(1)).await; + for input_value in values { + let (value_writer, value_reader) = wit_future::new(|| unreachable!()); + if output_writer.write_one(value_reader).await.is_some() { + let _ = input_value.await; + return; + } + let value = input_value.await; + let _ = value_writer.write(value).await; + } + match result { + StreamResult::Complete(_) => {} + StreamResult::Dropped => return, + StreamResult::Cancelled => unreachable!(), + } + } + }); + output_reader + } + + async fn concurrent_writes() -> StreamReader { + let (mut writer, reader) = wit_stream::new(); + wit_bindgen::spawn_local(async move { + assert!(writer.write_all(vec![1, 2]).await.is_empty()); + }); + reader + } + + async fn post_return_lazy() -> StreamReader { + let (mut writer, reader) = wit_stream::new(); + wit_bindgen::spawn_local(async move { + assert!(writer.write_one(42).await.is_none()); + }); + reader + } + + async fn wait_shared() -> u32 { + std::future::poll_fn(|cx| { + if let Some(value) = SHARED.lock().unwrap().take() { + return Poll::Ready(value); + } + + *SHARED_WAKER.lock().unwrap() = Some(cx.waker().clone()); + match SHARED.lock().unwrap().take() { + Some(value) => Poll::Ready(value), + None => Poll::Pending, + } + }) + .await + } + + async fn resolve_shared(value: u32) { + assert!(SHARED.lock().unwrap().replace(value).is_none()); + if let Some(waker) = SHARED_WAKER.lock().unwrap().take() { + waker.wake(); + } + } + + async fn wait_cancelled() { + let _guard = CancellationGuard; + std::future::pending::<()>().await; + } + + async fn wait_cancellation_observed() { + std::future::poll_fn(|cx| { + if CANCELLATION_OBSERVED.load(Ordering::SeqCst) { + return Poll::Ready(()); + } + + *CANCELLATION_WAKER.lock().unwrap() = Some(cx.waker().clone()); + if CANCELLATION_OBSERVED.load(Ordering::SeqCst) { + Poll::Ready(()) + } else { + Poll::Pending + } + }) + .await + } + + fn cancellation_observed() -> bool { + CANCELLATION_OBSERVED.load(Ordering::SeqCst) + } +} diff --git a/tests/runtime/moonbit/stream-write-cancel/test.rs b/tests/runtime/moonbit/stream-write-cancel/test.rs new file mode 100644 index 000000000..3529557a0 --- /dev/null +++ b/tests/runtime/moonbit/stream-write-cancel/test.rs @@ -0,0 +1,113 @@ +include!(env!("BINDINGS")); + +use crate::exports::test::moonbit_stream_write_cancel::controller::Guest; +use crate::test::moonbit_stream_write_cancel::holder; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::task::{Poll, Waker}; + +struct Component; + +export!(Component); + +static CANCELLATION_OBSERVED: AtomicBool = AtomicBool::new(false); +static CANCELLATION_WAKER: Mutex> = Mutex::new(None); +static SECOND_WRITE_STARTED: AtomicBool = AtomicBool::new(false); +static PRODUCER_FINISHED: AtomicBool = AtomicBool::new(false); +static PRODUCER_WAKER: Mutex> = Mutex::new(None); + +struct CancellationGuard; + +impl Drop for CancellationGuard { + fn drop(&mut self) { + CANCELLATION_OBSERVED.store(true, Ordering::SeqCst); + if let Some(waker) = CANCELLATION_WAKER.lock().unwrap().take() { + waker.wake(); + } + } +} + +struct ProducerGuard; + +impl Drop for ProducerGuard { + fn drop(&mut self) { + PRODUCER_FINISHED.store(true, Ordering::SeqCst); + if let Some(waker) = PRODUCER_WAKER.lock().unwrap().take() { + waker.wake(); + } + } +} + +impl Guest for Component { + async fn run_until_cancelled() { + let _guard = CancellationGuard; + let (mut writer, reader) = wit_stream::new::(); + holder::hold(reader).await; + holder::mark_write_started(); + let _ = writer.write_one(holder::Leaf::new()).await; + std::future::pending::<()>().await; + } + + fn cancellation_observed() -> bool { + CANCELLATION_OBSERVED.load(Ordering::SeqCst) + } + + async fn wait_cancellation_observed() { + std::future::poll_fn(|cx| { + if CANCELLATION_OBSERVED.load(Ordering::SeqCst) { + return Poll::Ready(()); + } + + *CANCELLATION_WAKER.lock().unwrap() = Some(cx.waker().clone()); + if CANCELLATION_OBSERVED.load(Ordering::SeqCst) { + Poll::Ready(()) + } else { + Poll::Pending + } + }) + .await + } + + async fn start_peer_drop() { + // The MoonBit implementation primes its language-level queue locally. + // A CM stream cannot be read and written by the same component when + // its payload contains resources, so account for that local item here. + drop(holder::Leaf::new()); + + let (mut writer, reader) = wit_stream::new::(); + wit_bindgen::spawn_local(async move { + let _guard = ProducerGuard; + + assert!(writer.write_one(holder::Leaf::new()).await.is_none()); + SECOND_WRITE_STARTED.store(true, Ordering::SeqCst); + assert!(writer.write_one(holder::Leaf::new()).await.is_some()); + assert!(writer.write_one(holder::Leaf::new()).await.is_some()); + }); + + holder::hold(reader).await; + } + + fn second_write_started() -> bool { + SECOND_WRITE_STARTED.load(Ordering::SeqCst) + } + + fn producer_finished() -> bool { + PRODUCER_FINISHED.load(Ordering::SeqCst) + } + + async fn wait_producer_finished() { + std::future::poll_fn(|cx| { + if PRODUCER_FINISHED.load(Ordering::SeqCst) { + return Poll::Ready(()); + } + + *PRODUCER_WAKER.lock().unwrap() = Some(cx.waker().clone()); + if PRODUCER_FINISHED.load(Ordering::SeqCst) { + Poll::Ready(()) + } else { + Poll::Pending + } + }) + .await + } +} From 0eb0d53ae7ae83a40e01d64b50cebccb33568a12 Mon Sep 17 00:00:00 2001 From: zihang Date: Mon, 20 Jul 2026 15:18:51 +0800 Subject: [PATCH 05/10] refactor(moonbit): remove async duplication --- crates/moonbit/CONTEXT.md | 40 +- .../adr/0001-async-ffi-boundary-conversion.md | 172 ++--- crates/moonbit/docs/async-glossary.md | 626 ++++-------------- crates/moonbit/src/async/async_abi.mbt | 11 - crates/moonbit/src/async/ev.mbt | 11 - crates/moonbit/src/lib.rs | 143 +--- .../moonbit/async-import-cancel/runner.mbt | 76 +-- .../http-background-hook/runner-invalid.mbt | 51 -- .../moonbit/http-background-hook/runner.mbt | 49 +- .../moonbit/http-background-hook/test.mbt | 22 +- .../moonbit/http-background-hook/wkg.lock | 12 - .../http-body-trailers/deps/clocks.wit | 7 - .../moonbit/http-body-trailers/deps/http.wit | 460 ------------- .../moonbit/http-body-trailers/runner.mbt | 47 -- .../moonbit/http-body-trailers/test.mbt | 66 -- .../moonbit/http-body-trailers/test.wit | 14 - 16 files changed, 286 insertions(+), 1521 deletions(-) delete mode 100644 tests/runtime/moonbit/http-background-hook/runner-invalid.mbt delete mode 100644 tests/runtime/moonbit/http-background-hook/wkg.lock delete mode 100644 tests/runtime/moonbit/http-body-trailers/deps/clocks.wit delete mode 100644 tests/runtime/moonbit/http-body-trailers/deps/http.wit delete mode 100644 tests/runtime/moonbit/http-body-trailers/runner.mbt delete mode 100644 tests/runtime/moonbit/http-body-trailers/test.mbt delete mode 100644 tests/runtime/moonbit/http-body-trailers/test.wit diff --git a/crates/moonbit/CONTEXT.md b/crates/moonbit/CONTEXT.md index 8add75ee5..1d55a4488 100644 --- a/crates/moonbit/CONTEXT.md +++ b/crates/moonbit/CONTEXT.md @@ -1,34 +1,16 @@ # MoonBit Bindings Context -This context records shared language for the MoonBit binding generator. Async -future and stream vocabulary is split into [Async Glossary](docs/async-glossary.md). +MoonBit emits core wasm and `wasm-tools` adapts it into a component. Generated +bindings expose WIT imports and adapt MoonBit exports to the component ABI. -## Language +Async design references: -**MoonBit binding**: -Generated MoonBit source that exposes WIT imports to MoonBit code or adapts -MoonBit exports to the component ABI. +- [Design contract](docs/async-design.md) +- [Terminology](docs/async-glossary.md) +- [FFI-boundary conversion decision](docs/adr/0001-async-ffi-boundary-conversion.md) +- [Local Future/Promise decision](docs/adr/0002-local-future-promise.md) -**Component adapter path**: -The current implementation path where MoonBit emits core wasm and `wasm-tools` -converts it into a component using adapter imports and exports. -_Avoid_: direct component generation - -## Async Design - -- [Async design contract](docs/async-design.md) -- [Async glossary](docs/async-glossary.md) -- [ADR 0001: FFI-boundary conversion](docs/adr/0001-async-ffi-boundary-conversion.md) -- [ADR 0002: local Future/Promise pair](docs/adr/0002-local-future-promise.md) - -The implementation targets the official upstream generator architecture. WIT -`future` and `stream` remain distinct from local MoonBit `Future` and `Stream`; -generated code converts them only at concrete FFI positions whose intrinsic -names are supplied by `wit-parser`. Local `Future::new()` returns a -MoonBit-only Future/Promise pair, and local `Semaphore` coordinates coroutines; -neither can select or own a component endpoint from `T`. Async support is always -available in the MoonBit generator, but endpoint-free synchronous worlds do not -emit its runtime or wrappers. Component endpoint wrappers are -generated-code-only, enforce a single in-flight operation, and retain operation -buffers until cancellation or completion is observed. Each top-level component -task has its own waitable set and scheduler state. +Lowercase `future` and `stream` refer to Component Model types. Uppercase +`Future` and `Stream` refer to local MoonBit types. Generated code converts +between them only at concrete WIT positions whose intrinsic names come from +`wit-parser`. diff --git a/crates/moonbit/docs/adr/0001-async-ffi-boundary-conversion.md b/crates/moonbit/docs/adr/0001-async-ffi-boundary-conversion.md index 1cc18ec12..161db8c45 100644 --- a/crates/moonbit/docs/adr/0001-async-ffi-boundary-conversion.md +++ b/crates/moonbit/docs/adr/0001-async-ffi-boundary-conversion.md @@ -1,141 +1,43 @@ -# Keep MoonBit async values detached from component futures and streams +# Keep local async values separate from component endpoints -Status: accepted; implemented in the current branch +Status: accepted; implemented -MoonBit `Future[T]` handles and local `Stream[T]` values are over arbitrary -MoonBit types, while component `future` and `stream` are ABI values over -WIT-representable payloads. MoonBit async bindings will keep those concepts -separate: generated FFI-boundary code converts between local async values and -component endpoints only at concrete WIT positions where the endpoint operations -and payload lift/lower code are known. +## Context -For MVP, component `future` maps to a one-shot `Future[T]` handle. The handle -represents a ready value or an owned local source computation with -value-discarding drop cleanup. For an incoming component future, generated code -supplies a source closure that captures the raw readable handle and directly -binds the concrete WIT position's read/cancel/drop intrinsics. The generic local -type has no CM-specific variant or operation table. A public local `Promise[T]` -may settle the same Future state, but it is not a component writable endpoint. +MoonBit `Future[T]` and `Stream[T]` must support arbitrary MoonBit values. A +Component Model `future` or `stream` is instead a transferable readable +endpoint whose operations and payload representation belong to a concrete WIT +function position. + +The previous implementation exposed generic component endpoint wrappers backed +by operation vtables. That tied local construction and recursive conversion to +position-specific ABI machinery. + +## Decision + +`Future[T]`, `Promise[T]`, `Stream[T]`, and `Sink[T]` are local coordination +types and never contain component handles or operation tables. + +For each endpoint occurrence, generated FFI code obtains intrinsic names from +`wit-parser`, owns the raw endpoint, and recursively lifts or lowers the payload. +Nested endpoints are converted one layer at a time when their containing value +crosses or is read at the boundary. ## Consequences -- `Stream::new()` creates local MoonBit values only. -- `Future::new()` creates a local `Future[T]` / `Promise[T]` pair only. It does - not select or invoke a component `future.new` intrinsic. -- Endpoint operation tables are not part of the target design. Generated - position-specific helpers call canonical intrinsics directly and keep raw - handle state inside generated source closures or producer tasks. -- Component intrinsic module and field names are generated through - `wit-parser`'s `WasmImport::{FutureIntrinsic, StreamIntrinsic}` API. The - MoonBit generator does not reproduce position indices, export prefixes, - unit-payload names, or async-lower prefixes by string formatting. -- The Rust generator boundary owns a recursive conversion plan for each - function. Ordinary lift/lower code requests position-specific helpers from - that plan instead of naming runtime endpoint types or type-shaped tables. -- The existing async generator boundary is retained. The static recursive - rewrite happens behind it without changing ordinary lift/lower ownership or - the endpoint-free sync path. -- User-facing bindings expose local `Future[T]` handles and local `Stream[T]` - for ordinary async composition. -- A nested WIT shape such as `future>>` maps to - `Future[Future[Stream[T]]]`. Only the current layer's readable handle appears - at each canonical payload stage. Generated recursive lift/lower functions bind - each layer to its own function-position intrinsic and apply generated - commit/reject dispositions at every transfer boundary. -- Recursive lower retains its canonical buffer and prepared producer state until - the transfer reports a disposition. Commit starts producer work only after - ownership transfers. Stream progress commits the accepted prefix and retries - the same lowered suffix; an abandoned suffix is rejected exactly once. -- A freshly-created component future is not fully rollbackable. Canonical ABI - permits its writable end to be dropped only after a write succeeds or a write - reports that the reader was dropped. If an outer transfer rejects a nested - future readable, generated code drops that readable but must still drive the - paired writer with the local future value until its write observes `dropped`. - A cancelled write is not settlement and is retried while the component task - remains alive. -- Stream batches whose element type recursively contains a future use a - one-element staging window for MVP. This bounds settlement obligations created - before downstream acceptance without changing the public stream API. -- Generated stream producers remain prepared when their component pair is - created. Commit starts normal pumping. Parent rejection drops both - untransferred component ends and performs state-aware local rejection: incoming - component sources close immediately, buffered values use configured cleanup or - generated cleanup as fallback, and an unstarted local producer is discarded - without executing user code. `Stream::produce` accepts an optional - `on_unstarted_drop` callback for resources captured by that branch and a - separate per-element cleanup for values written after it starts. -- Canonical `backpressure.inc/dec` controls admission of new async component - tasks. It is not tied to future/stream bridge lifetime and is not called - implicitly by the MoonBit runtime. Endpoint read/write suspension provides - data-flow backpressure independently. -- Async import argument settlement distinguishes `cancelled-before-started` - from every state in which the callee may have started. The former recursively - rejects owned resources and endpoints; the latter only reclaims guest-owned - canonical list allocations. -- Local future MVP has consuming `Future::get()` and value-discarding async - `Future::drop()`. Strong cancellation belongs to `Task` and `TaskGroup`, not - to a future-specific result type. `Future::drop()` is explicit cleanup, not a - direct alias for component `future.drop-*`, and futures that may discard - completed WIT payloads carry generated payload cleanup logic. -- Outgoing component `future` producer tasks must settle their raw writable - handle by writing a real value or by attempting the write and observing reader - drop. The MVP does not fabricate default values to satisfy unwritten futures. - If user code produces a ready `T`, generated code may write immediately; if - user code returns a pending `Future[T]`, the bridge exists before the CM - `future.write` operation starts because the component boundary already needs a - readable end to return. -- After an outgoing component `future` readable end is committed, or after a - parent transfer rejects and locally drops it, the bridge shields producer work - from ordinary task/subtask cancellation. Component-task cancellation is - cooperative: it may resolve the cancelled call with `task.cancel`, but it does - not forcibly destroy shielded settlement work. Only instance teardown or a - trap can abandon the writer without settlement. Peer reader drop is - loss-of-interest, not task cancellation; before `future.write` has started, - the bridge does nothing special for it. If a later write reports `dropped`, - the bridge cleans the value and settles the writer. -- If that local future never produces `T`, the Component Model provides no - generic close-without-value operation. This is an explicit liveness limit; the - binding does not fabricate a default value or pretend an idle writer can be - dropped safely. The same limit applies when the paired readable was created - for a nested payload but the outer transfer rejected it before ownership - crossed the boundary. -- Local stream MVP has `Sink::close()` for graceful producer close and async - `Stream::drop()` or `Stream[T]` drop for consumer loss-of-interest. It does - not expose public `Sink::cancel()` because component `stream` has no distinct - generic producer-failure signal to preserve across the boundary. -- Local stream state keeps producer close separate from reader drop. Producer - close preserves buffered `FixedArray[T]` chunks for draining; reader drop - discards unread chunks and uses an explicit `Stream::new_with_cleanup` or - `Stream::produce(cleanup=...)` callback for payloads that need resource - cleanup. -- Local stream capacity is measured in elements. Zero is strict rendezvous and - a positive value is a hard bound on accepted unread values; local streams are - never implicitly unbounded. Waiting readers and writers use direct FIFO - handoff with completion-versus-cancellation race handling. -- An incoming component stream uses a generated demand-driven source whose - reads directly call its concrete site intrinsics. It does not start an eager - pump into a local stream pipe. -- Forwarding component endpoints without reading them is not the default path and - needs an explicit advanced API if we decide to support it. -- Runtime validation includes real `wasi:cli@0.3.0` stream output and a real - `wasi:http@0.3.0` handler response whose body stream, trailers future, and - transmission completion continue after the handler returns the response. -- Local stream validation covers strict rendezvous, bounded buffering, cancelled - waiter removal, completion-versus-cancellation ownership races, local - producer reads, and unread resource cleanup. -- Async export stubs intentionally expose - `background_group : @async-core.TaskGroup[Unit]`. It adapts the mismatch - between MoonBit structured completion and component task return. Generated code - publishes component task return when the user function produces its result, - then keeps the underlying MoonBit task group alive for hook-style post-return - work. That work remains structurally owned and bounded by the component task - or instance lifetime, but cannot change the already-published export result. -- Lowering local `Future[T]` and `Stream[T]` values requires an active component - async task scope, including recursive occurrences inside structured payloads. - A sync WIT import called in that scope prepares its endpoint arguments and - commits those same handles immediately after the core call returns. Sync export - results, and sync imports called without an active scope, remain unsupported. - Incoming lift remains lazy and does not require a producer task until user code - later reads in an async scope. Scope-free sync lowering requires cooperative - component-thread support and must not be faked by using the stackless callback - ABI for a non-async WIT function. +- `Future::new()` creates a local Future/Promise pair; `Stream::new()` creates a + local Stream/Sink pair. +- Incoming endpoints become lazy generated sources. Outgoing local values are + bridged through newly created component endpoint pairs. +- Lowering uses explicit prepare, commit, and reject paths so resources and + partial stream prefixes transfer or clean exactly once. +- Once a component future readable end is exposed, its writer must eventually + write a real value or observe that the reader was dropped. The binding cannot + fabricate a default value or close it without a value. +- Position-specific endpoint traversal remains generator data, not a MoonBit + runtime vtable. +- Producing a component future or stream requires an active component async task + scope. Scope-free synchronous lowering remains unsupported. + +Detailed API and lifecycle invariants live in the +[async design contract](../async-design.md). diff --git a/crates/moonbit/docs/async-glossary.md b/crates/moonbit/docs/async-glossary.md index 3807c883f..14fedcd8e 100644 --- a/crates/moonbit/docs/async-glossary.md +++ b/crates/moonbit/docs/async-glossary.md @@ -1,488 +1,138 @@ -# MoonBit Async Glossary - -This glossary records the vocabulary used by the MoonBit async future and stream -design notes. The naming rule is: - -- lowercase code-form names, `future` and `stream`, mean component-model WIT - types and operations; -- uppercase code-form names, `Future` and `Stream`, mean MoonBit-owned local - async types; -- `CM` describes component-model concepts in prose. The target runtime does not - expose reusable generic `CMFuture*` or `CMStream*` wrapper types; raw endpoint - handles and their direct operations stay inside generated site helpers. - -## Component Model Language - -**Component `future`**: -The component-model `future` WIT type. In a WIT signature it transfers -ownership of a readable end, not a MoonBit-local computation. -_Avoid_: `Future`, local Future - -**Component `stream`**: -The component-model `stream` WIT type. In a WIT signature it transfers -ownership of a readable end, not a MoonBit-owned `Stream` buffer. -_Avoid_: `Stream`, local Stream - -**Readable end**: -The endpoint of a component `future` or component `stream` that can be read and -transferred through WIT. - -**Writable end**: -The endpoint created with `future.new` or `stream.new` that stays in the -producing component and writes values to the paired readable end. - -**Async task scope**: -The component-model task context that lets stackless MoonBit async export code -join waitables, receive callback events, and resume suspended bridge -continuations. -_Avoid_: global event loop - -**Background group**: -The `background_group : @async-core.TaskGroup[Unit]` parameter intentionally -exposed by an async MoonBit export. The generated adapter publishes component -task return when the user function produces its result, then keeps the -underlying MoonBit task group alive for this hook-style work. The work remains -structurally owned inside MoonBit and bounded by the component task or instance -lifetime, but its later outcome cannot change the already-published export -result. -_Avoid_: leaked task group, detached global task - -**Async core package**: -The generated `@async-core` MoonBit package. It exposes the local `Future[T]`, -`Promise[T]`, `Stream[T]`, `Sink[T]`, `Task[T]`, `TaskGroup[T]`, `Semaphore`, -`Mutex`, and `CondVar` API while keeping component-model endpoint handles and -scheduler machinery private. -Interface-specific `ffi.mbt` files remain generated boundary implementations -and are not this package. - -**Sync lower producer gap**: -The unsupported case where boundary code without an active component async task -scope must lower a local Future or Stream, including recursive occurrences in a -sync import parameter or sync export result. The producer must continue after -the call returns, but the stackless callback ABI cannot resume it. This requires -cooperative component-thread support. Incoming lift is not this case because it -can remain lazy until a later async read. -_Avoid_: hidden async export - -## MoonBit Binding Language - -**CM future endpoint**: -A conceptual component `future` readable or writable handle owned by generated -boundary code. Its operations are statically bound to a concrete function site, -not stored in a generic MoonBit operation table. -_Avoid_: `Future`, local Future - -**CM stream endpoint**: -A conceptual component `stream` readable or writable handle owned by generated -boundary code. Its operations are statically bound to a concrete function site, -not stored in a generic MoonBit operation table. -_Avoid_: `Stream`, local Stream - -**Endpoint site**: -One component `future` or component `stream` node in a concrete WIT function's -canonical type traversal. A site binds its payload plan and intrinsic names to -that function context. It is generator metadata, not a runtime value. -_Avoid_: payload type alone - -**Recursive boundary plan**: -The generator-owned tree that connects an endpoint site to the endpoint sites -inside its payload. It drives direct lift/lower code and recursive cleanup for -records, variants, collections, nested futures, and nested streams. -_Avoid_: endpoint vtable, flat type lookup - -**Generated readable source**: -A private source closure produced when lifting an incoming component endpoint. -It exclusively owns one raw readable handle and directly calls that site's -read, cancel-read, and drop-readable intrinsics. A future source plugs into local -`Future[T]`; a stream source implements demand-driven pulls for local -`Stream[T]`. -_Avoid_: `CMFutureReader[T]`, `CMStreamReader[T]` - -**Generated writable state**: -A private prepared producer state created when lowering a local Future or Stream. -It exclusively owns one raw writable handle and binds directly to that site's -write/drop intrinsics. Lowering does not start user computation. Commit starts a -normal producer task; rejection starts future writer settlement when required or -discards a prepared stream without transferring its readable end. A future state -carries a settlement obligation from the moment `future.new` succeeds. -_Avoid_: `CMFutureWriter[T]`, `CMStreamWriter[T]` - -**Staged nested endpoint payload**: -The rule that only the current endpoint layer's readable handle appears in its -canonical payload. For `future>>`, the function boundary carries -the outer future handle, reading it carries the inner future handle, and reading -that carries the stream handle. -_Avoid_: flattened endpoint graph - -**Transfer commit/reject**: -Generated ownership handling after lowering a payload containing resources or -nested endpoints. A successful transfer commits the transferred values to the -peer. A rejected ordinary resource is cleaned. A prepared stream drops its -untransferred component pair and rejects guest-owned local state without -pretending the peer accepted values. A rejected future drops its readable end, -but its paired writer and local source remain owned until a later write reaches -settlement. A partial stream transfer commits the accepted prefix. Generated -code retries the remainder of the current staging window while the peer remains -open; peer drop or write cancellation rejects that staged remainder exactly -once. Values beyond the staging window remain caller-owned. -_Avoid_: unconditional cleanup - -**Future writer settlement obligation**: -The non-rollbackable obligation created by `future.new`. The writable end can be -dropped only after a successful write or after a write reports that the readable -end was dropped. Dropping a still-guest-owned readable end does not settle its -paired writer; the generated producer must still obtain a real payload and -attempt the write. A `cancelled` write has returned its ABI buffer but has not -settled the writer. -_Avoid_: future rollback - -**Prepared lower payload**: -The private combination of a canonical ABI value or buffer, unstarted generated -producer state, and recursive `commit`/`reject` paths. Commit starts producer work -only after ownership transfers. The same lowered buffer is retained across -blocked or partial writes. For a staging window, the accepted prefix is -committed and the staged suffix is retried from the same buffer or rejected. -Both portions are consumed from the public `Sink` input; only the unstaged tail -remains caller-owned. It is not a user-facing MoonBit type. -_Avoid_: lowered value alone - -**Local Future**: -A MoonBit-only one-shot handle, expected to be spelled `Future[T]`, that wraps an -owned source computation plus cancellation/drop state. Its generic state has no -CM endpoint variant and does not require `T` to be representable in WIT. A -generated readable source may capture a raw handle inside its private closure, -but the local runtime cannot inspect or forward it. -_Avoid_: component `future`, future endpoint - -**One-shot async thunk**: -A MoonBit `async () -> T` value wrapped by local `Future[T]` or generated bridge -code to read or produce exactly one value. It is the computation inside the -handle, not the public component `future` representation by itself. -_Avoid_: future handle - -**Future state cell**: -The local mutable runtime state owned by a `Future[T]` handle. Depending on the -constructor, it records a ready value, an owned source, or a shared local -Future/Promise settlement cell. It does not contain CM-specific states or own -writable ends. -_Avoid_: endpoint table - -**Owned Future source**: -The generic pending source stored by local `Future[T]`. It has a one-shot async -`run` operation and may have an explicit unstarted cleanup action. Ordinary -MoonBit futures use a local thunk; generated incoming futures use a readable -source whose closure owns the endpoint. -_Avoid_: pending CM state - -**Unstarted producer cleanup**: -The optional `on_unstarted_drop : () -> Unit` action attached to a lazy local -`Future::from` or `Stream::produce`. It owns resources captured by the producer -only until the producer starts. Drop or prepared-transfer rejection invokes it -without executing the producer; get, read, or commit clears it before producer -execution. It is not a cancellation handler for an already-running task. -_Avoid_: producer finalizer, implicit cancellation - -**Bridge-owned future write**: -The separate generated task state that drives a local `Future[T]` computation -into an outgoing component `future`. This state owns the raw writable handle; -the local `Future[T]` does not. -_Avoid_: local Promise, component future writer - -**Future writer settlement obligation**: -The rule that once an outgoing component future pair has exposed its readable -end, the generated task owning the writable handle must eventually write one -real value or attempt the write and observe that the reader was dropped before -the writable end can be dropped. It cannot be satisfied by silently dropping -the writer. -_Avoid_: writer close - -**In-flight future write**: -A generated writable operation that has already called component `future.write` -and is waiting for the `FUTURE_WRITE` waitable event. If the peer -drops the readable end while this operation is in flight, the event reports -`dropped` and returns ownership of the lowered payload buffer to the writer. -_Avoid_: bridge task in flight - -**Pre-write future writer**: -A generated task that owns the writable end but has not started component -`future.write` because the payload value is not available yet. The -bridge task may be running or suspended on local work in this phase, but there -is no CM write operation waiting in a waitable set. Reader drop is observed when -the later `future.write` attempt returns or reports `dropped`. -_Avoid_: idle writer, in-flight future write - -**Bridge-shielded future task**: -An outgoing future bridge task that is protected from ordinary task -cancellation after its readable end has crossed the component boundary. Peer -loss-of-interest does not cancel it; the bridge keeps running until it satisfies -the future writer settlement obligation, unless the whole component instance is -trapping or being torn down. -_Avoid_: cancellable bridge - -**Generated future site helper**: -A generated function that directly calls `future.new` and the other future -intrinsics for one concrete WIT function site. It returns or consumes raw handles -only within generated boundary code. -_Avoid_: local `Future::new()`, generic component endpoint factory - -**Local Promise**: -A MoonBit-only producer side paired with a local `Future[T]` by -`Future::new()` or `Future::new_with_cleanup()`. `complete` transfers a value -only if the reader still exists; `fail` and `close` settle local waiters without -a value. It never creates or owns a component `future` writable end. If its -paired Future later crosses an FFI boundary, generated code creates and owns the -separate component endpoint pair. -_Avoid_: future writer - -**Local Semaphore**: -A MoonBit-only counting semaphore used for coroutine coordination. Waiters are -FIFO. If a release assigns a permit before cancellation resumes the waiter, the -permit wins that race and acquire succeeds so the permit is not lost. It is not -a component-model waitable or backpressure counter. -_Avoid_: waitable, `backpressure.inc` - -**Local Mutex**: -A MoonBit-only FIFO mutex implemented by a one-permit Local Semaphore. Its -acquire operation has the same cancellation race semantics as the semaphore. -Generated stream writers use it to serialize writes and close without polling -the scheduler. -_Avoid_: component stream lock - -**Local Condition Variable**: -A MoonBit-only `CondVar` used for direct task notification. If a signal has -already been assigned when cancellation resumes a waiter, the signal wins so -it cannot be lost. Generated future and stream sources use it to wait for -in-flight component read cancellation to return the operation buffer. -_Avoid_: waitable-set event, predicate polling - -**Local Stream**: -A MoonBit-only stream type, expected to be spelled `Stream[T]`, that coordinates -MoonBit coroutines or wraps a generic demand-driven source without a CM-specific -state variant. A generated readable source may capture a raw component handle, -but the local runtime cannot inspect or forward it, and `T` need not be -representable in WIT. -_Avoid_: component `stream`, stream endpoint - -**Local Sink**: -The producer side of a local Stream. It is not a component `stream` writable end. -_Avoid_: stream writer - -**Producer close**: -The graceful local-stream terminal action performed by `Sink::close()`. It means -no more values will be written, while already-buffered values remain readable. -_Avoid_: producer cancel - -**Consumer stream drop**: -The local-stream terminal action performed when the `Stream[T]` side is dropped -or explicitly consumed by async `Stream::drop()`. It means the consumer has lost -interest, so buffered values still owned by the local stream are cleaned and -producer-side waiters are woken. Bridge internals may cancel in-flight endpoint -copy operations to recover buffers, but the user-facing operation is not hard -cancellation of a producer. -_Avoid_: `Stream::cancel`, stream close - -**Producer failure**: -A possible future local-stream operation where the producer ends the stream with -a failure state instead of graceful EOF. It is not `unreachable`, a wasm trap, or -process abort. It is not part of the MVP public `Sink[T]` interface because -component `stream` does not carry a distinct generic producer-failure signal. -_Avoid_: producer abort, sink close, trap - -**Stream pipe**: -The shared runtime state behind a local `Stream[T]` and `Sink[T]` pair. It owns -bounded local chunk storage, FIFO waiting readers and writers, and separate -writer-close and reader-drop state. Capacity zero means strict rendezvous; -positive capacity is the maximum number of accepted unread elements. It does -not own CM stream endpoints. -_Avoid_: stream endpoint - -**Owned stream chunk**: -An owned, exact-length batch of stream items returned by local `Stream[T]` -reads. The expected representation is `FixedArray[T]`; local stream -implementation should pass chunks by ownership instead of assembling them in a -growable `Array[T]`. -_Avoid_: ABI buffer - -**Borrowed stream chunk**: -A temporary view of stream items, such as `ArrayView[T]`, accepted by the MVP -local `Sink[T]` write API. Before a write operation suspends, the stream runtime -must materialize the relevant view window into owned stream storage or an owned -canonical ABI buffer. -_Avoid_: stream storage - -**Staged write window**: -The bounded part of a borrowed stream chunk that one awaited write operation -materializes into owned storage. MVP `Sink[T]` writes stage at most one window -per awaited operation; `write_all` loops over windows. -_Avoid_: whole input buffer - -**CM operation buffer**: -The canonical ABI buffer supplied to an in-flight component `future` or -component `stream` read/write operation. The buffer is not reusable by MoonBit -until the operation completes or cancellation/drop is observed through the -terminal waitable event. -_Avoid_: local stream buffer - -**Accepted stream prefix**: -The prefix length reported by a completed or terminal stream copy event. Values -inside this prefix have semantically transferred across the stream operation. -For runtime-owned buffers, the source side must not clean them again. For -caller-owned MoonBit values, this is a best-effort API contract rather than a -type-system-enforced lifetime. Values outside the prefix remain owned by the -side that staged the buffer. -_Avoid_: whole chunk - -**Stream cleanup invariant**: -After every stream read, write, close, cancel, peer drop, or partial transfer, -each value is caller-owned, stream-owned, bridge-operation-owned, transferred to -the component peer, or cleaned exactly once. -_Avoid_: best-effort cleanup - -**Local producer close**: -`Sink::close()` stops local writes and marks graceful EOF. Buffered chunks remain -stream-owned and can still be drained before `Stream::read` returns `None`. -_Avoid_: reader drop, cancellation - -**Local reader drop**: -`Stream::drop()` records local consumer loss-of-interest. It wakes blocked -writers, discards unread chunks, and invokes the cleanup operation supplied by -`Stream::new_with_cleanup` or `Stream::produce(cleanup=...)` for each unread -owned value. A suspended -`Sink::write` owns one bounded staged `FixedArray`; `write_all` uses the same -cleanup for any remaining suffix that never entered the stream buffer. -_Avoid_: producer close, hard cancellation - -**Generated stream adapter**: -Boundary-generated state connecting local and component stream semantics. An -incoming adapter is a lazy readable source and starts no pump task. An outgoing -adapter is a producer task that owns the raw writable handle and pulls from a -local `Stream[T]`. -_Avoid_: local stream state - -**Incoming stream demand policy**: -The rule that a generated incoming stream source issues one component read only -for a corresponding local read demand. It does not prefetch into a local pipe. -_Avoid_: unbounded prefetch - -**Source cleanup hook**: -An owned cleanup action stored with a generic local source. Explicit local -cleanup, such as async `Future::drop` or `Stream::drop`, runs the generated hook -so an idle endpoint is dropped directly or an in-flight copy is cancelled and -observed before buffers and handles are released. -_Avoid_: destructor - -**BYOB stream read**: -A "bring your own buffer" read shape where the caller supplies storage to fill. -This is useful for component ABI buffers and specialized fast paths, but should -not be part of the MVP local `Stream[T]` interface for arbitrary `T`. - -**Local-component bridge**: -Generated FFI-boundary code that adapts between a one-shot async thunk or local -Stream and component future/stream endpoints. It exists only for a concrete WIT -site whose payload has generated recursive lift/lower operations. - -**Async generator boundary**: -The Rust generator layer that decides which MoonBit async runtime package, -endpoint bridge helpers, and public async type names to emit. This boundary -lets the old async runtime be peeled out and the replacement runtime added back -without scattering runtime-specific decisions across ordinary lift/lower code. -_Avoid_: inline async codegen - -**Mergeable prototype**: -A production-intended implementation slice built after the async generator -boundary exists. It is allowed to be experimental in scope, but it must use the -real generator boundary, carry tests, and be suitable to harden in place if the -shape proves correct. It is not throwaway code. -_Avoid_: throwaway prototype - -**ABI-compatible payload**: -A MoonBit type that is the generated representation of a WIT type and has the -payload lift/lower operations needed at a specific FFI boundary. -_Avoid_: arbitrary `T` - -**Bridge task**: -Runtime-owned async work that drives a local-component bridge after a readable -end has been returned or passed to another component. - -**Bridge continuation**: -The suspended MoonBit coroutine captured while a local-component bridge is -waiting on a component-model waitable operation. Reader drop, writer drop, and -task cancellation must be routed through this continuation rather than modeled -only as ordinary value destruction. -_Avoid_: destructor - -**Endpoint copy cancellation**: -Cancellation of one in-flight component `future` or component `stream` read or -write operation. This returns ownership of the operation's buffer when the -cancelled event is observed. It is not the same thing as cancelling the MoonBit -coroutine that requested the operation. -_Avoid_: task cancellation - -**Hard cancellation**: -A MoonBit-side request to stop local async work with strong semantics. Examples -include task/subtask cancellation and component teardown or failure. Hard -cancellation may cancel a producer coroutine or cancel and observe an in-flight -endpoint copy operation. It is not the same as the peer dropping a component -endpoint. -_Avoid_: peer drop, consumer loss-of-interest - -**Peer loss-of-interest**: -The opposite component endpoint was dropped. When an endpoint operation observes -this, the component result is `dropped`, not `cancelled`. If there is no endpoint -operation that can observe it yet, such as a pre-write outgoing future writer, -the bridge must not infer hard cancellation from it. -_Avoid_: task cancellation - -**Bridge cancellation**: -A hard cancellation of the MoonBit bridge continuation that is driving -conversion between local async work and a component endpoint. For streams and -in-flight future operations, this may cancel endpoint copy work. For a pre-write -outgoing component future writer, peer readable-end drop is not bridge -cancellation; it is observed only if a later `future.write` reports `dropped`. -_Avoid_: endpoint copy cancellation - -**Future drop**: -The async user-facing operation that consumes a local `Future[T]` handle and -runs its explicit cleanup protocol. If the handle is backed by an unread component -readable end and no read is in flight, this cleanup may call -`future.drop-readable`; otherwise it may need to cancel and observe cleanup -first. If cancellation races with completion, `Future::drop` cleans the produced -value using the future's payload cleanup operation. It is not a direct alias for -component `future.drop-*`, and it is not an automatic destructor. -_Avoid_: read cancellation - -**Payload cleanup operation**: -A generated or stored function that cleans a produced payload value when a -future or stream operation owns that value but will not return it to user code. -For WIT payloads, this operation is generated from lift/lower cleanup logic. A -generic local `Future[T]` or `Stream[T]` cannot invent this operation for -arbitrary `T`; local constructors therefore accept it explicitly through -`Future::ready_with_cleanup`, `Stream::new_with_cleanup`, and -`Stream::produce(cleanup=...)` when needed. -_Avoid_: generic destructor - -## Adapter Generation Language - -**Adapter-generated intrinsic**: -A canonical ABI import generated by the core-wasm-to-component adapter path. For -component `future` and component `stream`, these intrinsics are identified by -the containing function and a discovered component `future` or component -`stream` position. - -**Function-position index**: -The enumeration index assigned to a component `future` or component `stream` -discovered in one concrete WIT function. It is not derivable from the MoonBit -payload type alone. -_Avoid_: parameter index, type index - -**Endpoint operation table**: -The rejected design where adapter intrinsics and payload callbacks are stored in -a runtime record selected for a generic endpoint wrapper. The target design emits -direct site-specific calls instead. -_Avoid_: vtable - -**Endpoint factory**: -A generated site helper that directly creates a component future or stream pair -because it is tied to a concrete WIT function position and therefore knows the -adapter-generated intrinsic names. -_Avoid_: local `Future::new()`, local `Stream::new()`, generic endpoint factory +# MoonBit Async Terminology + +Use lowercase `future` and `stream` for Component Model types. Use uppercase +`Future` and `Stream` for local MoonBit types. `CM` may qualify a Component +Model concept in prose, but there are no public generic `CMFuture` or `CMStream` +wrapper types. + +## Component Model + +**Component `future`** +: A WIT `future`. Its canonical value transfers a readable endpoint, not a + MoonBit computation. + +**Component `stream`** +: A WIT `stream`. Its canonical value transfers a readable endpoint, not a + local MoonBit buffer. + +**Readable end / writable end** +: The receiving and producing halves of a component future or stream. Generated + `future.new` and `stream.new` calls create the pair; WIT values carry the + readable end. + +**Endpoint site** +: One future or stream occurrence in the canonical traversal of a concrete WIT + function. A site binds payload conversion to intrinsic names supplied by + `wit-parser`. + +**Async task scope** +: The component task context that owns a waitable set and can suspend generated + bridge work. It is not a global event loop. + +## Local MoonBit + +**Local `Future[T]`** +: A consuming one-shot local value. It may hold a ready value or a lazy source, + but its generic state contains no component endpoint or intrinsic table. + +**Local `Promise[T]`** +: The producer paired with a local Future. It can complete, fail, or close local + waiters. It is not a component future writer. + +**Local `Stream[T]` / `Sink[T]`** +: A bounded local pipe. Consumers pull owned `FixedArray[T]` chunks; producers + push immutable `ArrayView[T]` values. Capacity zero is strict rendezvous. + +**Producer close** +: `Sink::close()` marks graceful EOF while preserving buffered values for + readers. + +**Consumer drop** +: `Stream::drop()` records loss of interest, wakes writers, and cleans unread + values through the configured payload cleanup operation. + +**Background group** +: The generated `background_group : @async-core.TaskGroup[Unit]` export + parameter. Work spawned into it may continue after the component result is + published, but cannot alter that result. + +**Unstarted producer cleanup** +: An optional callback owned by a lazy Future or Stream until its producer + starts. It releases explicitly captured resources when the unstarted value is + rejected or dropped. + +## Boundary Conversion + +**Recursive boundary plan** +: Generator metadata connecting every endpoint site to endpoint sites nested in + its payload. It drives position-specific lift, lower, commit, reject, and + cleanup code. + +**Generated readable source** +: A private lazy source that owns an incoming raw readable handle and calls that + site's read, cancel-read, and drop-readable intrinsics. + +**Generated writable state** +: Private producer state that owns an outgoing raw writable handle. It starts + bridge work only after the paired readable end is committed to the peer. + +**Staged nested endpoint** +: An endpoint revealed only at its containing transfer layer. For + `future>>`, reading each layer reveals the next handle; the + graph is not flattened at the function boundary. + +**Prepared lower payload** +: A canonical value or buffer together with unstarted producer state and + recursive commit/reject actions. It stays owned until the transfer outcome is + known. + +**Commit / reject** +: Commit transfers the accepted payload and starts its producer work. Reject + cleans values and untransferred streams. A rejected future still drives its + paired writer until a write observes reader drop. + +**Future writer settlement obligation** +: After `future.new`, the writable end may be dropped only after a successful + write or a write that reports the readable end was dropped. Cancellation does + not settle it. + +**Staged write window** +: The bounded portion of a local stream chunk lowered for one component write. + Nested future payloads use a one-element window in the MVP. + +**Accepted stream prefix** +: The count reported by a component stream operation. Accepted values belong to + the peer; the rejected staged suffix is cleaned exactly once; unstaged values + remain with the local stream. + +**Operation buffer** +: Canonical ABI memory borrowed by an in-flight endpoint operation. It remains + live until completion or a terminal cancellation event returns ownership. + +## Cancellation And Limits + +**Endpoint copy cancellation** +: Cancellation of one component read or write so its operation buffer can be + recovered. It is separate from cancelling the MoonBit coroutine. + +**Hard cancellation** +: A request to stop local task work, such as `Task::cancel` or component task + cancellation. + +**Peer loss of interest** +: The opposite endpoint was dropped. Operations report `dropped`; this does not + imply hard cancellation of the producing coroutine. + +**Sync lower producer gap** +: Lowering a newly produced Future or Stream without an active component async + scope. The stackless callback ABI cannot resume such work after a synchronous + call returns, so the MVP does not support it. + +**BYOB / `read_into`** +: A deferred read API where callers provide destination storage to reduce + copying. It is not part of the MVP local Stream interface. + +**Endpoint operation table** +: The rejected vtable design that selected position-specific intrinsics and + payload callbacks at runtime. The implemented design emits direct site helpers + instead. diff --git a/crates/moonbit/src/async/async_abi.mbt b/crates/moonbit/src/async/async_abi.mbt index e15b80413..373cded54 100644 --- a/crates/moonbit/src/async/async_abi.mbt +++ b/crates/moonbit/src/async/async_abi.mbt @@ -212,17 +212,6 @@ fn CallbackCode::encode(self : Self) -> Int { } } -///| -fn CallbackCode::_decode(int : Int) -> CallbackCode { - let id = int >> 4 - match int & 0xf { - 0 => Completed - 1 => Yield - 2 => Wait(id) - _ => panic() - } -} - // #endregion // #region Component async primitives diff --git a/crates/moonbit/src/async/ev.mbt b/crates/moonbit/src/async/ev.mbt index e08383271..c1b669ca7 100644 --- a/crates/moonbit/src/async/ev.mbt +++ b/crates/moonbit/src/async/ev.mbt @@ -663,16 +663,6 @@ pub async fn suspend_for_future_write_terminal(idx : Int, val : Int) -> Bool? { } } -///| -#internal(wit_bindgen, "generated binding code only") -#doc(hidden) -pub async fn suspend_for_future_write(idx : Int, val : Int) -> Bool { - match suspend_for_future_write_terminal(idx, val) { - Some(transferred) => transferred - None => raise FutureWriteCancelled - } -} - ///| #internal(wit_bindgen, "generated binding code only") #doc(hidden) @@ -718,7 +708,6 @@ pub async fn suspend_for_stream_write(idx : Int, val : Int) -> (Int, Bool) { pub suberror OpCancelled { SubTaskCancelled(before_started~ : Bool) StreamReadCancelled - FutureWriteCancelled } ///| diff --git a/crates/moonbit/src/lib.rs b/crates/moonbit/src/lib.rs index 99189d9f8..8810db305 100644 --- a/crates/moonbit/src/lib.rs +++ b/crates/moonbit/src/lib.rs @@ -14,14 +14,12 @@ use wit_bindgen_core::{ uwrite, uwriteln, wit_parser::{ Alignment, ArchitectureSize, Docs, Enum, Flags, FlagsRepr, Function, Int, InterfaceId, - LiftLowerAbi, ManglingAndAbi, Param, Record, Resolve, ResourceIntrinsic, Result_, - SizeAlign, Tuple, Type, TypeDefKind, TypeId, Variant, WasmExport, WasmExportKind, + LiftLowerAbi, LiveTypes, ManglingAndAbi, Param, Record, Resolve, ResourceIntrinsic, + Result_, SizeAlign, Tuple, Type, TypeDefKind, TypeId, Variant, WasmExport, WasmExportKind, WasmImport, WorldId, WorldKey, }, }; -use wit_bindgen_core::wit_parser::WorldItem; - use crate::async_support::{AsyncFunctionState, AsyncSupport}; use crate::pkg::{Imports, MoonbitSignature, PkgResolver, ToMoonBitIdent, ToMoonBitTypeIdent}; @@ -2955,44 +2953,14 @@ fn flags_repr(flags: &Flags) -> Int { } fn type_contains_future_or_stream(resolve: &Resolve, ty: &Type) -> bool { - let Type::Id(id) = ty else { - return false; - }; - - match &resolve.types[*id].kind { - TypeDefKind::Future(_) | TypeDefKind::Stream(_) => true, - TypeDefKind::Record(record) => record - .fields - .iter() - .any(|field| type_contains_future_or_stream(resolve, &field.ty)), - TypeDefKind::Tuple(tuple) => tuple - .types - .iter() - .any(|ty| type_contains_future_or_stream(resolve, ty)), - TypeDefKind::Variant(variant) => variant - .cases - .iter() - .filter_map(|case| case.ty.as_ref()) - .any(|ty| type_contains_future_or_stream(resolve, ty)), - TypeDefKind::Option(ty) - | TypeDefKind::List(ty) - | TypeDefKind::FixedLengthList(ty, _) - | TypeDefKind::Type(ty) => type_contains_future_or_stream(resolve, ty), - TypeDefKind::Map(key, value) => { - type_contains_future_or_stream(resolve, key) - || type_contains_future_or_stream(resolve, value) - } - TypeDefKind::Result(result) => result - .ok - .iter() - .chain(result.err.iter()) - .any(|ty| type_contains_future_or_stream(resolve, ty)), - TypeDefKind::Resource - | TypeDefKind::Handle(_) - | TypeDefKind::Flags(_) - | TypeDefKind::Enum(_) => false, - TypeDefKind::Unknown => unreachable!(), - } + let mut live = LiveTypes::default(); + live.add_type(resolve, ty); + live.iter().any(|id| { + matches!( + resolve.types[id].kind, + TypeDefKind::Future(_) | TypeDefKind::Stream(_) + ) + }) } fn type_contains_endpoint_fixed_length_list_combination( @@ -3085,85 +3053,22 @@ fn world_contains_endpoint_fixed_length_list_combination( resolve: &Resolve, world: WorldId, ) -> bool { - let item_contains_endpoint = |item: &WorldItem| match item { - WorldItem::Function(func) => { - func.params.iter().any(|param| { - type_contains_endpoint_fixed_length_list_combination( - resolve, ¶m.ty, false, false, - ) - }) || func.result.as_ref().is_some_and(|ty| { - type_contains_endpoint_fixed_length_list_combination(resolve, ty, false, false) - }) - } - WorldItem::Interface { id, .. } => { - let interface = &resolve.interfaces[*id]; - interface.types.values().any(|id| { - type_contains_endpoint_fixed_length_list_combination( - resolve, - &Type::Id(*id), - false, - false, - ) - }) || interface.functions.values().any(|func| { - func.params.iter().any(|param| { - type_contains_endpoint_fixed_length_list_combination( - resolve, ¶m.ty, false, false, - ) - }) || func.result.as_ref().is_some_and(|ty| { - type_contains_endpoint_fixed_length_list_combination(resolve, ty, false, false) - }) - }) - } - WorldItem::Type { id, .. } => type_contains_endpoint_fixed_length_list_combination( - resolve, - &Type::Id(*id), - false, - false, - ), - }; - let world = &resolve.worlds[world]; - world - .imports - .values() - .chain(world.exports.values()) - .any(item_contains_endpoint) + let mut live = LiveTypes::default(); + live.add_world(resolve, world); + live.iter().any(|id| { + type_contains_endpoint_fixed_length_list_combination(resolve, &Type::Id(id), false, false) + }) } fn world_contains_future_or_stream(resolve: &Resolve, world: WorldId) -> bool { - let item_contains_endpoint = |item: &WorldItem| match item { - WorldItem::Function(func) => { - func.params - .iter() - .any(|param| type_contains_future_or_stream(resolve, ¶m.ty)) - || func - .result - .as_ref() - .is_some_and(|ty| type_contains_future_or_stream(resolve, ty)) - } - WorldItem::Interface { id, .. } => { - let interface = &resolve.interfaces[*id]; - interface - .types - .values() - .any(|id| type_contains_future_or_stream(resolve, &Type::Id(*id))) - || interface.functions.values().any(|func| { - func.params - .iter() - .any(|param| type_contains_future_or_stream(resolve, ¶m.ty)) - || func - .result - .as_ref() - .is_some_and(|ty| type_contains_future_or_stream(resolve, ty)) - }) - } - WorldItem::Type { id, .. } => type_contains_future_or_stream(resolve, &Type::Id(*id)), - }; - let world = &resolve.worlds[world]; - world - .imports - .values() - .chain(world.exports.values()) - .any(item_contains_endpoint) + let mut live = LiveTypes::default(); + live.add_world(resolve, world); + live.iter().any(|id| { + matches!( + resolve.types[id].kind, + TypeDefKind::Future(_) | TypeDefKind::Stream(_) + ) + }) } fn indent(code: &str) -> Source { @@ -3609,7 +3514,7 @@ mod tests { "#doc(hidden)\npub fn has_component_task_scope", "#doc(hidden)\npub async fn suspend_for_subtask", "#doc(hidden)\npub async fn suspend_for_future_read", - "#doc(hidden)\npub async fn suspend_for_future_write", + "#doc(hidden)\npub async fn suspend_for_future_write_terminal", "#doc(hidden)\npub async fn suspend_for_stream_read", "#doc(hidden)\npub async fn suspend_for_stream_write", ] { diff --git a/tests/runtime/moonbit/async-import-cancel/runner.mbt b/tests/runtime/moonbit/async-import-cancel/runner.mbt index f7e76b5c8..03d87d85d 100644 --- a/tests/runtime/moonbit/async-import-cancel/runner.mbt +++ b/tests/runtime/moonbit/async-import-cancel/runner.mbt @@ -3,6 +3,19 @@ //@ path = 'gen/world/runner/stub.mbt' //@ pkg_config = """{ "import": [{ "path": "test/async-import-cancel/async-core", "alias": "async-core" }, { "path": "test/async-import-cancel/interface/test/async-import-cancel/pending", "alias": "pending" }, { "path": "test/async-import-cancel/interface/test/async-import-cancel/gate-control", "alias": "gate-control" }] }""" +///| +async fn settle_leaf_counts(expected_drops : UInt) -> Unit { + for _ in 0..<32 { + if @pending.leaf_live_count() == 0U && + @pending.leaf_drop_count() == expected_drops { + break + } + @pending.settle() + } + guard @pending.leaf_live_count() == 0U else { panic() } + guard @pending.leaf_drop_count() == expected_drops else { panic() } +} + ///| pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { guard @pending.leaf_live_count() == 0U else { panic() } @@ -30,14 +43,7 @@ pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { @pending.settle() guard !@pending.pending_started() else { panic() } - for _ in 0..<32 { - if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 1U { - break - } - @pending.settle() - } - guard @pending.leaf_live_count() == 0U else { panic() } - guard @pending.leaf_drop_count() == 1U else { panic() } + settle_leaf_counts(1U) @pending.backpressure_set(true) let stream_leaf = @pending.Leaf::leaf() @@ -71,14 +77,7 @@ pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { guard !@pending.pending_stream_started() else { panic() } guard !stream_producer_ran.val else { panic() } - for _ in 0..<32 { - if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 2U { - break - } - @pending.settle() - } - guard @pending.leaf_live_count() == 0U else { panic() } - guard @pending.leaf_drop_count() == 2U else { panic() } + settle_leaf_counts(2U) let returned_leaf = @pending.Leaf::leaf() let (return_started, return_started_sink) = @async-core.Stream::new( @@ -98,14 +97,7 @@ pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { returned_task.cancel() @gate-control.release() - for _ in 0..<32 { - if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 5U { - break - } - @pending.settle() - } - guard @pending.leaf_live_count() == 0U else { panic() } - guard @pending.leaf_drop_count() == 5U else { panic() } + settle_leaf_counts(5U) let cleanup_leaf = fn(value : @pending.Leaf) { value.drop() } let stream_values : FixedArray[@async-core.Future[@pending.Leaf]] = [ @@ -120,14 +112,7 @@ pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { sink.close() }) @pending.drop_future_stream(cleanup_stream) - for _ in 0..<32 { - if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 8U { - break - } - @pending.settle() - } - guard @pending.leaf_live_count() == 0U else { panic() } - guard @pending.leaf_drop_count() == 8U else { panic() } + settle_leaf_counts(8U) let after_start_future = @async-core.Future::ready_with_cleanup( @pending.Leaf::leaf(), @@ -158,14 +143,7 @@ pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { after_start_task.wait() catch { _ => () } - for _ in 0..<32 { - if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 11U { - break - } - @pending.settle() - } - guard @pending.leaf_live_count() == 0U else { panic() } - guard @pending.leaf_drop_count() == 11U else { panic() } + settle_leaf_counts(11U) let race_future = @pending.open_race_future() let (race_read_started, race_read_started_sink) = @async-core.Stream::new( @@ -185,14 +163,7 @@ pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { @pending.complete_race_future() race_read.cancel() guard race_read.wait() else { panic() } - for _ in 0..<32 { - if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 12U { - break - } - @pending.settle() - } - guard @pending.leaf_live_count() == 0U else { panic() } - guard @pending.leaf_drop_count() == 12U else { panic() } + settle_leaf_counts(12U) let race_stream = @pending.open_race_stream() let (race_stream_started, race_stream_started_sink) = @async-core.Stream::new( @@ -214,14 +185,7 @@ pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { @pending.complete_race_stream() race_stream_read.cancel() guard race_stream_read.wait() else { panic() } - for _ in 0..<32 { - if @pending.leaf_live_count() == 0U && @pending.leaf_drop_count() == 13U { - break - } - @pending.settle() - } - guard @pending.leaf_live_count() == 0U else { panic() } - guard @pending.leaf_drop_count() == 13U else { panic() } + settle_leaf_counts(13U) let pending_future = @pending.open_future() let (future_read_started, future_read_started_sink) = @async-core.Stream::new( diff --git a/tests/runtime/moonbit/http-background-hook/runner-invalid.mbt b/tests/runtime/moonbit/http-background-hook/runner-invalid.mbt deleted file mode 100644 index f9c414822..000000000 --- a/tests/runtime/moonbit/http-background-hook/runner-invalid.mbt +++ /dev/null @@ -1,51 +0,0 @@ -//@ wasmtime-flags = '-Wcomponent-model-async -S p3 -S http' -//@ [lang] -//@ path = 'gen/world/runner/stub.mbt' -//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "example/background-hook/async-core", "alias": "async-core" }, { "path": "example/background-hook/interface/wasi/http/handler", "alias": "handler" }, { "path": "example/background-hook/interface/wasi/http/types", "alias": "types" }] }""" - -///| -pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { - let request_body = @async-core.Stream::produce(async fn(sink) { - guard sink.write_all_bytes(b"build=debug"[:]) else { - abort("request body closed early") - } - sink.close() - }) - let (request, request_transmitted) = @types.Request::new( - @types.Fields::fields(), - Some(request_body), - @async-core.Future::ready(Ok(None)), - None, - ) - guard request.set_method(@types.Method::Post) is Ok(_) else { panic() } - guard request.set_path_with_query(Some("/hooks/build")) is Ok(_) else { - panic() - } - - let response = match @handler.handle(request) { - Ok(response) => response - Err(_) => panic() - } - guard response.get_status_code() == 202U else { panic() } - - let (response_body, response_trailers) = response.consume_body( - @async-core.Future::ready(Ok(())), - ) - guard response_body.read(1) is None else { panic() } - response_body.drop() - match response_trailers.get() { - Ok(None) => () - Ok(Some(fields)) => { - fields.drop() - panic() - } - Err(_) => panic() - } - - match request_transmitted.get() { - Err(@types.ErrorCode::InternalError(Some(message))) => { - guard message == "unexpected hook payload" else { panic() } - } - _ => panic() - } -} diff --git a/tests/runtime/moonbit/http-background-hook/runner.mbt b/tests/runtime/moonbit/http-background-hook/runner.mbt index 71845d74b..d01bff8bc 100644 --- a/tests/runtime/moonbit/http-background-hook/runner.mbt +++ b/tests/runtime/moonbit/http-background-hook/runner.mbt @@ -4,9 +4,9 @@ //@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "example/background-hook/async-core", "alias": "async-core" }, { "path": "example/background-hook/interface/wasi/http/handler", "alias": "handler" }, { "path": "example/background-hook/interface/wasi/http/types", "alias": "types" }] }""" ///| -pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { +async fn run_hook(payload : Bytes, expected_error : String?) -> Unit { let request_body = @async-core.Stream::produce(async fn(sink) { - guard sink.write_all_bytes(b"build=release"[:]) else { + guard sink.write_all_bytes(payload[:]) else { abort("request body closed early") } sink.close() @@ -31,19 +31,44 @@ pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { let (response_body, response_trailers) = response.consume_body( @async-core.Future::ready(Ok(())), ) - guard response_body.read(1) is None else { panic() } + let expected_response = b"accepted" + let received = Ref(0) + for ;; { + match response_body.read(4) { + Some(chunk) => + for byte in chunk { + guard received.val < expected_response.length() else { panic() } + guard byte == expected_response[received.val] else { panic() } + received.val += 1 + } + None => break + } + } + guard received.val == expected_response.length() else { panic() } response_body.drop() match response_trailers.get() { - Ok(None) => () - Ok(Some(fields)) => { - fields.drop() - panic() - } - Err(_) => panic() + Ok(Some(fields)) => fields.drop() + Ok(None) | Err(_) => panic() } - match request_transmitted.get() { - Ok(_) => () - Err(_) => panic() + match expected_error { + None => + match request_transmitted.get() { + Ok(_) => () + Err(_) => panic() + } + Some(expected) => + match request_transmitted.get() { + Err(@types.ErrorCode::InternalError(Some(message))) => { + guard message == expected else { panic() } + } + _ => panic() + } } } + +///| +pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { + run_hook(b"build=release", None) + run_hook(b"build=debug", Some("unexpected hook payload")) +} diff --git a/tests/runtime/moonbit/http-background-hook/test.mbt b/tests/runtime/moonbit/http-background-hook/test.mbt index b979824bb..ed1114014 100644 --- a/tests/runtime/moonbit/http-background-hook/test.mbt +++ b/tests/runtime/moonbit/http-background-hook/test.mbt @@ -37,10 +37,24 @@ pub async fn handle( } let payload = payload.to_bytes() + let (body_done, body_done_promise) = @async-core.Future::new() + let response_body = @async-core.Stream::produce(async fn(sink) { + guard sink.write_all_bytes(b"accepted"[:]) else { + abort("response body closed early") + } + sink.close() + let _ = body_done_promise.complete(()) + }) + let (transmitted_done, transmitted_done_promise) = @async-core.Future::new() + let response_trailers = @async-core.Future::from(async fn() { + body_done.get() + transmitted_done.get() + Ok(Some(@types.Fields::fields())) + }) let (response, response_transmitted) = @types.Response::new( @types.Fields::fields(), - None, - @async-core.Future::ready(Ok(None)), + Some(response_body), + response_trailers, ) guard response.set_status_code(202U) is Ok(_) else { let error = @types.ErrorCode::InternalError( @@ -53,7 +67,9 @@ pub async fn handle( background_group.spawn_bg(async fn() { let result = (async fn() -> Result[Unit, @types.ErrorCode] { - match response_transmitted.get() { + let transmission_result = response_transmitted.get() + let _ = transmitted_done_promise.complete(()) + match transmission_result { Err(error) => return Err(error) Ok(_) => () } diff --git a/tests/runtime/moonbit/http-background-hook/wkg.lock b/tests/runtime/moonbit/http-background-hook/wkg.lock deleted file mode 100644 index 1c6187c52..000000000 --- a/tests/runtime/moonbit/http-background-hook/wkg.lock +++ /dev/null @@ -1,12 +0,0 @@ -# This file is automatically generated. -# It is not intended for manual editing. -version = 1 - -[[packages]] -name = "wasi:http" -registry = "wasi.dev" - -[[packages.versions]] -requirement = "=0.3.0" -version = "0.3.0" -digest = "sha256:92cd8f3730c00226dc15626a2e7b21834dd187fc221f09818720d228585bbbf7" diff --git a/tests/runtime/moonbit/http-body-trailers/deps/clocks.wit b/tests/runtime/moonbit/http-body-trailers/deps/clocks.wit deleted file mode 100644 index 6522c9fdc..000000000 --- a/tests/runtime/moonbit/http-body-trailers/deps/clocks.wit +++ /dev/null @@ -1,7 +0,0 @@ -package wasi:clocks@0.3.0; - -@since(version = 0.3.0) -interface types { - @since(version = 0.3.0) - type duration = u64; -} diff --git a/tests/runtime/moonbit/http-body-trailers/deps/http.wit b/tests/runtime/moonbit/http-body-trailers/deps/http.wit deleted file mode 100644 index e453ace5a..000000000 --- a/tests/runtime/moonbit/http-body-trailers/deps/http.wit +++ /dev/null @@ -1,460 +0,0 @@ -package wasi:http@0.3.0; - -/// This interface defines all of the types and methods for implementing HTTP -/// Requests and Responses, as well as their headers, trailers, and bodies. -@since(version = 0.3.0) -interface types { - use wasi:clocks/types@0.3.0.{duration}; - - /// This type corresponds to HTTP standard Methods. - @since(version = 0.3.0) - variant method { - get, - head, - post, - put, - delete, - connect, - options, - trace, - patch, - other(string), - } - - /// This type corresponds to HTTP standard Related Schemes. - @since(version = 0.3.0) - variant scheme { - HTTP, - HTTPS, - other(string), - } - - /// Defines the case payload type for `DNS-error` above: - @since(version = 0.3.0) - record DNS-error-payload { - rcode: option, - info-code: option, - } - - /// Defines the case payload type for `TLS-alert-received` above: - @since(version = 0.3.0) - record TLS-alert-received-payload { - alert-id: option, - alert-message: option, - } - - /// Defines the case payload type for `HTTP-response-{header,trailer}-size` above: - @since(version = 0.3.0) - record field-size-payload { - field-name: option, - field-size: option, - } - - /// These cases are inspired by the IANA HTTP Proxy Error Types: - /// - @since(version = 0.3.0) - variant error-code { - DNS-timeout, - DNS-error(DNS-error-payload), - destination-not-found, - destination-unavailable, - destination-IP-prohibited, - destination-IP-unroutable, - connection-refused, - connection-terminated, - connection-timeout, - connection-read-timeout, - connection-write-timeout, - connection-limit-reached, - TLS-protocol-error, - TLS-certificate-error, - TLS-alert-received(TLS-alert-received-payload), - HTTP-request-denied, - HTTP-request-length-required, - HTTP-request-body-size(option), - HTTP-request-method-invalid, - HTTP-request-URI-invalid, - HTTP-request-URI-too-long, - HTTP-request-header-section-size(option), - HTTP-request-header-size(option), - HTTP-request-trailer-section-size(option), - HTTP-request-trailer-size(field-size-payload), - HTTP-response-incomplete, - HTTP-response-header-section-size(option), - HTTP-response-header-size(field-size-payload), - HTTP-response-body-size(option), - HTTP-response-trailer-section-size(option), - HTTP-response-trailer-size(field-size-payload), - HTTP-response-transfer-coding(option), - HTTP-response-content-coding(option), - HTTP-response-timeout, - HTTP-upgrade-failed, - HTTP-protocol-error, - loop-detected, - configuration-error, - /// This is a catch-all error for anything that doesn't fit cleanly into a - /// more specific case. It also includes an optional string for an - /// unstructured description of the error. Users should not depend on the - /// string for diagnosing errors, as it's not required to be consistent - /// between implementations. - internal-error(option), - } - - /// This type enumerates the different kinds of errors that may occur when - /// setting or appending to a `fields` resource. - @since(version = 0.3.0) - variant header-error { - /// This error indicates that a `field-name` or `field-value` was - /// syntactically invalid when used with an operation that sets headers in a - /// `fields`. - invalid-syntax, - /// This error indicates that a forbidden `field-name` was used when trying - /// to set a header in a `fields`. - forbidden, - /// This error indicates that the operation on the `fields` was not - /// permitted because the fields are immutable. - immutable, - /// This error indicates that the operation would exceed an - /// implementation-defined limit on field sizes. This may apply to - /// an individual `field-value`, a single `field-name` plus all its - /// values, or the total aggregate size of all fields. - size-exceeded, - /// This is a catch-all error for anything that doesn't fit cleanly into a - /// more specific case. Implementations can use this to extend the error - /// type without breaking existing code. It also includes an optional - /// string for an unstructured description of the error. Users should not - /// depend on the string for diagnosing errors, as it's not required to be - /// consistent between implementations. - other(option), - } - - /// This type enumerates the different kinds of errors that may occur when - /// setting fields of a `request-options` resource. - @since(version = 0.3.0) - variant request-options-error { - /// Indicates the specified field is not supported by this implementation. - not-supported, - /// Indicates that the operation on the `request-options` was not permitted - /// because it is immutable. - immutable, - /// This is a catch-all error for anything that doesn't fit cleanly into a - /// more specific case. Implementations can use this to extend the error - /// type without breaking existing code. It also includes an optional - /// string for an unstructured description of the error. Users should not - /// depend on the string for diagnosing errors, as it's not required to be - /// consistent between implementations. - other(option), - } - - /// Field names are always strings. - /// - /// Field names should always be treated as case insensitive by the `fields` - /// resource for the purposes of equality checking. - @since(version = 0.3.0) - type field-name = string; - - /// Field values should always be ASCII strings. However, in - /// reality, HTTP implementations often have to interpret malformed values, - /// so they are provided as a list of bytes. - @since(version = 0.3.0) - type field-value = list; - - /// This following block defines the `fields` resource which corresponds to - /// HTTP standard Fields. Fields are a common representation used for both - /// Headers and Trailers. - /// - /// A `fields` may be mutable or immutable. A `fields` created using the - /// constructor, `from-list`, or `clone` will be mutable, but a `fields` - /// resource given by other means (including, but not limited to, - /// `request.headers`) might be be immutable. In an immutable fields, the - /// `set`, `append`, and `delete` operations will fail with - /// `header-error.immutable`. - /// - /// A `fields` resource should store `field-name`s and `field-value`s in their - /// original casing used to construct or mutate the `fields` resource. The `fields` - /// resource should use that original casing when serializing the fields for - /// transport or when returning them from a method. - /// - /// Implementations may impose limits on individual field values and on total - /// aggregate field section size. Operations that would exceed these limits - /// fail with `header-error.size-exceeded` - @since(version = 0.3.0) - resource fields { - /// Construct an empty HTTP Fields. - /// - /// The resulting `fields` is mutable. - constructor(); - /// Construct an HTTP Fields. - /// - /// The resulting `fields` is mutable. - /// - /// The list represents each name-value pair in the Fields. Names - /// which have multiple values are represented by multiple entries in this - /// list with the same name. - /// - /// The tuple is a pair of the field name, represented as a string, and - /// Value, represented as a list of bytes. In a valid Fields, all names - /// and values are valid UTF-8 strings. However, values are not always - /// well-formed, so they are represented as a raw list of bytes. - /// - /// An error result will be returned if any header or value was - /// syntactically invalid, if a header was forbidden, or if the - /// entries would exceed an implementation size limit. - from-list: static func(entries: list>) -> result; - /// Get all of the values corresponding to a name. If the name is not present - /// in this `fields`, an empty list is returned. However, if the name is - /// present but empty, this is represented by a list with one or more - /// empty field-values present. - get: func(name: field-name) -> list; - /// Returns `true` when the name is present in this `fields`. If the name is - /// syntactically invalid, `false` is returned. - has: func(name: field-name) -> bool; - /// Set all of the values for a name. Clears any existing values for that - /// name, if they have been set. - /// - /// Fails with `header-error.immutable` if the `fields` are immutable. - /// - /// Fails with `header-error.size-exceeded` if the name or values would - /// exceed an implementation-defined size limit. - set: func(name: field-name, value: list) -> result<_, header-error>; - /// Delete all values for a name. Does nothing if no values for the name - /// exist. - /// - /// Fails with `header-error.immutable` if the `fields` are immutable. - delete: func(name: field-name) -> result<_, header-error>; - /// Delete all values for a name. Does nothing if no values for the name - /// exist. - /// - /// Returns all values previously corresponding to the name, if any. - /// - /// Fails with `header-error.immutable` if the `fields` are immutable. - get-and-delete: func(name: field-name) -> result, header-error>; - /// Append a value for a name. Does not change or delete any existing - /// values for that name. - /// - /// Fails with `header-error.immutable` if the `fields` are immutable. - /// - /// Fails with `header-error.size-exceeded` if the value would exceed - /// an implementation-defined size limit. - append: func(name: field-name, value: field-value) -> result<_, header-error>; - /// Retrieve the full set of names and values in the Fields. Like the - /// constructor, the list represents each name-value pair. - /// - /// The outer list represents each name-value pair in the Fields. Names - /// which have multiple values are represented by multiple entries in this - /// list with the same name. - /// - /// The names and values are always returned in the original casing and in - /// the order in which they will be serialized for transport. - copy-all: func() -> list>; - /// Make a deep copy of the Fields. Equivalent in behavior to calling the - /// `fields` constructor on the return value of `copy-all`. The resulting - /// `fields` is mutable. - clone: func() -> fields; - } - - /// Headers is an alias for Fields. - @since(version = 0.3.0) - type headers = fields; - - /// Trailers is an alias for Fields. - @since(version = 0.3.0) - type trailers = fields; - - /// Represents an HTTP Request. - @since(version = 0.3.0) - resource request { - /// Construct a new `request` with a default `method` of `GET`, and - /// `none` values for `path-with-query`, `scheme`, and `authority`. - /// - /// `headers` is the HTTP Headers for the Request. - /// - /// `contents` is the optional body content stream with `none` - /// representing a zero-length content stream. - /// Once it is closed, `trailers` future must resolve to a result. - /// If `trailers` resolves to an error, underlying connection - /// will be closed immediately. - /// - /// `options` is optional `request-options` resource to be used - /// if the request is sent over a network connection. - /// - /// It is possible to construct, or manipulate with the accessor functions - /// below, a `request` with an invalid combination of `scheme` - /// and `authority`, or `headers` which are not permitted to be sent. - /// It is the obligation of the `handler.handle` implementation - /// to reject invalid constructions of `request`. - /// - /// The returned future resolves to result of transmission of this request. - new: static func(headers: headers, contents: option>, trailers: future, error-code>>, options: option) -> tuple>>; - /// Get the Method for the Request. - get-method: func() -> method; - /// Set the Method for the Request. Fails if the string present in a - /// `method.other` argument is not a syntactically valid method. - set-method: func(method: method) -> result; - /// Get the combination of the HTTP Path and Query for the Request. When - /// `none`, this represents an empty Path and empty Query. - get-path-with-query: func() -> option; - /// Set the combination of the HTTP Path and Query for the Request. When - /// `none`, this represents an empty Path and empty Query. Fails is the - /// string given is not a syntactically valid path and query uri component. - set-path-with-query: func(path-with-query: option) -> result; - /// Get the HTTP Related Scheme for the Request. When `none`, the - /// implementation may choose an appropriate default scheme. - get-scheme: func() -> option; - /// Set the HTTP Related Scheme for the Request. When `none`, the - /// implementation may choose an appropriate default scheme. Fails if the - /// string given is not a syntactically valid uri scheme. - set-scheme: func(scheme: option) -> result; - /// Get the authority of the Request's target URI. A value of `none` may be used - /// with Related Schemes which do not require an authority. The HTTP and - /// HTTPS schemes always require an authority. - get-authority: func() -> option; - /// Set the authority of the Request's target URI. A value of `none` may be used - /// with Related Schemes which do not require an authority. The HTTP and - /// HTTPS schemes always require an authority. Fails if the string given is - /// not a syntactically valid URI authority. - set-authority: func(authority: option) -> result; - /// Get the `request-options` to be associated with this request - /// - /// The returned `request-options` resource is immutable: `set-*` operations - /// will fail if invoked. - /// - /// This `request-options` resource is a child: it must be dropped before - /// the parent `request` is dropped, or its ownership is transferred to - /// another component by e.g. `handler.handle`. - get-options: func() -> option; - /// Get the headers associated with the Request. - /// - /// The returned `headers` resource is immutable: `set`, `append`, and - /// `delete` operations will fail with `header-error.immutable`. - get-headers: func() -> headers; - /// Get body of the Request. - /// - /// Stream returned by this method represents the contents of the body. - /// Once the stream is reported as closed, callers should await the returned - /// future to determine whether the body was received successfully. - /// The future will only resolve after the stream is reported as closed. - /// - /// This function takes a `res` future as a parameter, which can be used to - /// communicate an error in handling of the request. - /// - /// Note that function will move the `request`, but references to headers or - /// request options acquired from it previously will remain valid. - consume-body: static func(this: request, res: future>) -> tuple, future, error-code>>>; - } - - /// Parameters for making an HTTP Request. Each of these parameters is - /// currently an optional timeout applicable to the transport layer of the - /// HTTP protocol. - /// - /// These timeouts are separate from any the user may use to bound an - /// asynchronous call. - @since(version = 0.3.0) - resource request-options { - /// Construct a default `request-options` value. - constructor(); - /// The timeout for the initial connect to the HTTP Server. - get-connect-timeout: func() -> option; - /// Set the timeout for the initial connect to the HTTP Server. An error - /// return value indicates that this timeout is not supported or that this - /// handle is immutable. - set-connect-timeout: func(duration: option) -> result<_, request-options-error>; - /// The timeout for receiving the first byte of the Response body. - get-first-byte-timeout: func() -> option; - /// Set the timeout for receiving the first byte of the Response body. An - /// error return value indicates that this timeout is not supported or that - /// this handle is immutable. - set-first-byte-timeout: func(duration: option) -> result<_, request-options-error>; - /// The timeout for receiving subsequent chunks of bytes in the Response - /// body stream. - get-between-bytes-timeout: func() -> option; - /// Set the timeout for receiving subsequent chunks of bytes in the Response - /// body stream. An error return value indicates that this timeout is not - /// supported or that this handle is immutable. - set-between-bytes-timeout: func(duration: option) -> result<_, request-options-error>; - /// Make a deep copy of the `request-options`. - /// The resulting `request-options` is mutable. - clone: func() -> request-options; - } - - /// This type corresponds to the HTTP standard Status Code. - @since(version = 0.3.0) - type status-code = u16; - - /// Represents an HTTP Response. - @since(version = 0.3.0) - resource response { - /// Construct a new `response`, with a default `status-code` of `200`. - /// If a different `status-code` is needed, it must be set via the - /// `set-status-code` method. - /// - /// `headers` is the HTTP Headers for the Response. - /// - /// `contents` is the optional body content stream with `none` - /// representing a zero-length content stream. - /// Once it is closed, `trailers` future must resolve to a result. - /// If `trailers` resolves to an error, underlying connection - /// will be closed immediately. - /// - /// The returned future resolves to result of transmission of this response. - new: static func(headers: headers, contents: option>, trailers: future, error-code>>) -> tuple>>; - /// Get the HTTP Status Code for the Response. - get-status-code: func() -> status-code; - /// Set the HTTP Status Code for the Response. Fails if the status-code - /// given is not a valid http status code. - set-status-code: func(status-code: status-code) -> result; - /// Get the headers associated with the Response. - /// - /// The returned `headers` resource is immutable: `set`, `append`, and - /// `delete` operations will fail with `header-error.immutable`. - get-headers: func() -> headers; - /// Get body of the Response. - /// - /// Stream returned by this method represents the contents of the body. - /// Once the stream is reported as closed, callers should await the returned - /// future to determine whether the body was received successfully. - /// The future will only resolve after the stream is reported as closed. - /// - /// This function takes a `res` future as a parameter, which can be used to - /// communicate an error in handling of the response. - /// - /// Note that function will move the `response`, but references to headers - /// acquired from it previously will remain valid. - consume-body: static func(this: response, res: future>) -> tuple, future, error-code>>>; - } -} - -/// This interface defines a handler of HTTP Requests. -/// -/// In a `wasi:http/service` this interface is exported to respond to an -/// incoming HTTP Request with a Response. -/// -/// In `wasi:http/middleware` this interface is both exported and imported as -/// the "downstream" and "upstream" directions of the middleware chain. -@since(version = 0.3.0) -interface handler { - use types.{request, response, error-code}; - - /// This function may be called with either an incoming request read from the - /// network or a request synthesized or forwarded by another component. - handle: async func(request: request) -> result; -} - -/// This interface defines an HTTP client for sending "outgoing" requests. -/// -/// Most components are expected to import this interface to provide the -/// capability to send HTTP requests to arbitrary destinations on a network. -/// -/// The type signature of `client.send` is the same as `handler.handle`. This -/// duplication is currently necessary because some Component Model tooling -/// (including WIT itself) is unable to represent a component importing two -/// instances of the same interface. A `client.send` import may be linked -/// directly to a `handler.handle` export to bypass the network. -@since(version = 0.3.0) -interface client { - use types.{request, response, error-code}; - - /// This function may be used to either send an outgoing request over the - /// network or to forward it to another component. - send: async func(request: request) -> result; -} diff --git a/tests/runtime/moonbit/http-body-trailers/runner.mbt b/tests/runtime/moonbit/http-body-trailers/runner.mbt deleted file mode 100644 index e18c150a5..000000000 --- a/tests/runtime/moonbit/http-body-trailers/runner.mbt +++ /dev/null @@ -1,47 +0,0 @@ -//@ wasmtime-flags = '-Wcomponent-model-async -S p3 -S http' -//@ [lang] -//@ path = 'gen/world/runner/stub.mbt' -//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "my/test/async-core", "alias": "async-core" }, { "path": "my/test/interface/wasi/http/handler", "alias": "handler" }, { "path": "my/test/interface/wasi/http/types", "alias": "types" }] }""" - -///| -pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { - let (request, request_transmitted) = @types.Request::new( - @types.Fields::fields(), - None, - @async-core.Future::ready(Ok(None)), - None, - ) - let response = match @handler.handle(request) { - Ok(response) => response - Err(_) => panic() - } - - // Completing this future tells the handler that the response was transmitted. - let (body, trailers) = @types.Response::consume_body( - response, - @async-core.Future::ready(Ok(())), - ) - let bytes : Array[Byte] = [] - for ;; { - match body.read(4) { - Some(chunk) => - for i = 0; i < chunk.length(); i = i + 1 { - bytes.push(chunk[i]) - } - None => break - } - } - - let expected = b"hello http p3" - guard bytes.length() == expected.length() else { panic() } - for i = 0; i < expected.length(); i = i + 1 { - guard bytes[i] == expected[i] else { panic() } - } - - match trailers.get() { - Ok(Some(fields)) => fields.drop() - Ok(None) | Err(_) => panic() - } - body.drop() - request_transmitted.drop() -} diff --git a/tests/runtime/moonbit/http-body-trailers/test.mbt b/tests/runtime/moonbit/http-body-trailers/test.mbt deleted file mode 100644 index 6748a3c5b..000000000 --- a/tests/runtime/moonbit/http-body-trailers/test.mbt +++ /dev/null @@ -1,66 +0,0 @@ -//@ [lang] -//@ path = 'gen/interface/wasi/http/handler/stub.mbt' -//@ pkg_config = """{ -//@ "import": [ -//@ { "path": "my/test/async-core", "alias": "async-core" }, -//@ { "path": "my/test/interface/wasi/http/types", "alias": "types" } -//@ ] -//@ }""" - -///| -let body_progress : Ref[UInt] = Ref(0) - -///| -pub async fn handle( - request : @types.Request, - background_group : @async-core.TaskGroup[Unit], -) -> Result[@types.Response, @types.ErrorCode] { - request.drop() - body_progress.val = 0 - let transmitted_done = Ref(false) - let (completed, completed_sink) = @async-core.Stream::new(capacity=2) - - let trailers = @async-core.Future::from(async fn() { - for _ in 0..<2 { - guard completed.read(1) is Some(signal) && signal.length() == 1 else { - abort("response completion signal closed early") - } - } - guard body_progress.val >= 3 && transmitted_done.val else { - abort("response completion signal arrived too early") - } - Ok(Some(@types.Fields::fields())) - }) - - let body = @async-core.Stream::produce(async fn(sink) { - body_progress.val = 1 - let _ = sink.write_all_bytes(b"hello "[:]) - body_progress.val = 2 - let _ = sink.write_all_bytes(b"http p3"[:]) - sink.close() - body_progress.val = 3 - let signal : FixedArray[Unit] = [()] - guard completed_sink.write_all(signal[:]) else { - abort("response completion signal closed early") - } - }) - - let (response, transmitted) = @types.Response::new( - @types.Fields::fields(), - Some(body), - trailers, - ) - background_group.spawn_bg(async fn() { - match transmitted.get() { - Ok(_) => { - transmitted_done.val = true - let signal : FixedArray[Unit] = [()] - guard completed_sink.write_all(signal[:]) else { - abort("response completion signal closed early") - } - } - Err(_) => abort("response transmission failed") - } - }) - Ok(response) -} diff --git a/tests/runtime/moonbit/http-body-trailers/test.wit b/tests/runtime/moonbit/http-body-trailers/test.wit deleted file mode 100644 index 10365d6a1..000000000 --- a/tests/runtime/moonbit/http-body-trailers/test.wit +++ /dev/null @@ -1,14 +0,0 @@ -package my:test; - -world test { - import wasi:http/types@0.3.0; - - export wasi:http/handler@0.3.0; -} - -world runner { - import wasi:http/types@0.3.0; - import wasi:http/handler@0.3.0; - - export run: async func(); -} From 81fcf84f6aee56dc4fbb5d122720bfd961966e89 Mon Sep 17 00:00:00 2001 From: zihang Date: Tue, 21 Jul 2026 10:32:51 +0800 Subject: [PATCH 06/10] fix(moonbit): serialize stream writer teardown --- crates/moonbit/src/async_support.rs | 19 ++++++++---- crates/moonbit/src/lib.rs | 5 ++++ .../stream-write-cancel/holder-service.rs | 7 +++++ .../moonbit/stream-write-cancel/runner.rs | 5 ++-- .../moonbit/stream-write-cancel/test.mbt | 29 +++++++++++++------ .../moonbit/stream-write-cancel/test.rs | 4 ++- .../moonbit/stream-write-cancel/test.wit | 1 + 7 files changed, 52 insertions(+), 18 deletions(-) diff --git a/crates/moonbit/src/async_support.rs b/crates/moonbit/src/async_support.rs index 128d41afd..61c2976fa 100644 --- a/crates/moonbit/src/async_support.rs +++ b/crates/moonbit/src/async_support.rs @@ -1984,7 +1984,11 @@ fn wasm{symbol_name}StreamCommit(handle: Int) -> Unit {{ wasmImport{symbol_name}DropWritable(writer) }} }} - defer close_writer() + let close_writer_serialized = async fn() -> Unit {{ + writer_lock.acquire() + defer writer_lock.release() + close_writer() + }} let cleanup_value = fn(value : {result}) -> Unit {{ let ptr = wasm{symbol_name}Malloc(1) wasm{symbol_name}Lower(value, ptr) @@ -2054,11 +2058,7 @@ fn wasm{symbol_name}StreamCommit(handle: Int) -> Unit {{ // consumed the rest of this staging window. data_len }}, - () => {{ - writer_lock.acquire() - defer writer_lock.release() - close_writer() - }}, + () => close_writer_serialized(), () => !writer_closed.val, Some(cleanup_value), ) @@ -2096,6 +2096,13 @@ fn wasm{symbol_name}StreamCommit(handle: Int) -> Unit {{ }} }} }} + // A retained Sink may still own an in-flight canonical write after its + // producer returns. Do not drop the writable endpoint until that write + // has reached a terminal event and released its staging buffer. + {ffi}protect_from_cancel( + () => close_writer_serialized(), + resume_on_cancel=true, + ) }}) }} diff --git a/crates/moonbit/src/lib.rs b/crates/moonbit/src/lib.rs index 8810db305..d4013efb7 100644 --- a/crates/moonbit/src/lib.rs +++ b/crates/moonbit/src/lib.rs @@ -3585,8 +3585,13 @@ mod tests { assert!(ffi.contains("suspend_for_future_write_terminal")); assert!(ffi.contains("let data_len = if data.length() < 1")); assert!(ffi.contains("let writer_lock = @async-core.Mutex()")); + assert!(ffi.contains("let close_writer_serialized = async fn()")); assert!(ffi.contains("writer_lock.acquire()")); assert!(ffi.contains("defer writer_lock.release()")); + assert!( + ffi.contains("() => close_writer_serialized(),\n resume_on_cancel=true,") + ); + assert!(!ffi.contains("defer close_writer()")); assert!(ffi.contains("read_cleanup : @async-core.CondVar")); assert!(ffi.contains("self.read_cleanup.broadcast()")); assert!(ffi.contains("@async-core.cancel_future_read(")); diff --git a/tests/runtime/moonbit/stream-write-cancel/holder-service.rs b/tests/runtime/moonbit/stream-write-cancel/holder-service.rs index 5d1057fb5..31fb6668c 100644 --- a/tests/runtime/moonbit/stream-write-cancel/holder-service.rs +++ b/tests/runtime/moonbit/stream-write-cancel/holder-service.rs @@ -60,6 +60,13 @@ impl Guest for Component { result == StreamResult::Dropped && values.is_empty() } + async fn read_one_and_keep() -> bool { + let mut stream = HELD_STREAM.with(|stream| stream.borrow_mut().take().unwrap()); + let (result, values) = stream.read(Vec::with_capacity(1)).await; + HELD_STREAM.with(|held| assert!(held.borrow_mut().replace(stream).is_none())); + result == StreamResult::Complete(1) && values.len() == 1 + } + async fn read_one_and_drop() -> bool { let mut stream = HELD_STREAM.with(|stream| stream.borrow_mut().take().unwrap()); let (result, values) = stream.read(Vec::with_capacity(1)).await; diff --git a/tests/runtime/moonbit/stream-write-cancel/runner.rs b/tests/runtime/moonbit/stream-write-cancel/runner.rs index df0a16e7f..5a09a586a 100644 --- a/tests/runtime/moonbit/stream-write-cancel/runner.rs +++ b/tests/runtime/moonbit/stream-write-cancel/runner.rs @@ -24,13 +24,14 @@ impl Guest for Component { .is_pending()); holder::wait_write_started().await; assert!(holder::write_started()); + assert!(holder::read_one_and_keep().await); drop(task); wait_cancellation_observed().await; assert!(cancellation_observed()); assert!(holder::writable_dropped().await); assert_eq!(holder::leaf_live_count(), 0); - assert_eq!(holder::leaf_drop_count(), 1); + assert_eq!(holder::leaf_drop_count(), 2); start_peer_drop().await; assert!(holder::read_one_and_drop().await); @@ -38,6 +39,6 @@ impl Guest for Component { assert!(second_write_started()); assert!(producer_finished()); assert_eq!(holder::leaf_live_count(), 0); - assert_eq!(holder::leaf_drop_count(), 5); + assert_eq!(holder::leaf_drop_count(), 6); } } diff --git a/tests/runtime/moonbit/stream-write-cancel/test.mbt b/tests/runtime/moonbit/stream-write-cancel/test.mbt index 18c01d6ee..630427372 100644 --- a/tests/runtime/moonbit/stream-write-cancel/test.mbt +++ b/tests/runtime/moonbit/stream-write-cancel/test.mbt @@ -25,17 +25,28 @@ let producer_done : (@async-core.Future[Unit], @async-core.Promise[Unit]) = ///| pub async fn run_until_cancelled( - _background_group : @async-core.TaskGroup[Unit] + background_group : @async-core.TaskGroup[Unit] ) -> Unit { + let writer_started : (@async-core.Future[Unit], @async-core.Promise[Unit]) = + @async-core.Future::new() let stream = @async-core.Stream::produce(async fn(sink) { - defer { - cancellation_seen.val = true - ignore(cancellation_done.1.complete(())) - } - @holder.mark_write_started() - let data : FixedArray[@holder.Leaf] = [@holder.Leaf::leaf()] - let _ = sink.write_all(data[:]) - }) + // Return while a child owns a partially accepted write, forcing generated + // finalization to wait for the child's canonical buffer cleanup. + background_group.spawn_bg(async fn() { + defer { + cancellation_seen.val = true + ignore(cancellation_done.1.complete(())) + } + @holder.mark_write_started() + ignore(writer_started.1.complete(())) + let data : FixedArray[@holder.Leaf] = [ + @holder.Leaf::leaf(), + @holder.Leaf::leaf(), + ] + let _ = sink.write_all(data[:]) + }) + writer_started.0.get() + }, cleanup=fn(value) { value.drop() }) @holder.hold(stream) never.0.get() } diff --git a/tests/runtime/moonbit/stream-write-cancel/test.rs b/tests/runtime/moonbit/stream-write-cancel/test.rs index 3529557a0..53a6ea7cd 100644 --- a/tests/runtime/moonbit/stream-write-cancel/test.rs +++ b/tests/runtime/moonbit/stream-write-cancel/test.rs @@ -44,7 +44,9 @@ impl Guest for Component { let (mut writer, reader) = wit_stream::new::(); holder::hold(reader).await; holder::mark_write_started(); - let _ = writer.write_one(holder::Leaf::new()).await; + let _ = writer + .write(vec![holder::Leaf::new(), holder::Leaf::new()]) + .await; std::future::pending::<()>().await; } diff --git a/tests/runtime/moonbit/stream-write-cancel/test.wit b/tests/runtime/moonbit/stream-write-cancel/test.wit index cf084aafa..38644f172 100644 --- a/tests/runtime/moonbit/stream-write-cancel/test.wit +++ b/tests/runtime/moonbit/stream-write-cancel/test.wit @@ -13,6 +13,7 @@ interface holder { write-started: func() -> bool; wait-write-started: async func(); writable-dropped: async func() -> bool; + read-one-and-keep: async func() -> bool; read-one-and-drop: async func() -> bool; leaf-live-count: func() -> u32; leaf-drop-count: func() -> u32; From 20d591e2ba160b7a991ca095d1b0cf44d2e56da6 Mon Sep 17 00:00:00 2001 From: zihang Date: Tue, 21 Jul 2026 11:01:29 +0800 Subject: [PATCH 07/10] fix(moonbit): close stream test wakeup race --- .../moonbit/stream-write-cancel/holder-service.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/runtime/moonbit/stream-write-cancel/holder-service.rs b/tests/runtime/moonbit/stream-write-cancel/holder-service.rs index 31fb6668c..1fc2aadfc 100644 --- a/tests/runtime/moonbit/stream-write-cancel/holder-service.rs +++ b/tests/runtime/moonbit/stream-write-cancel/holder-service.rs @@ -47,8 +47,14 @@ impl Guest for Component { if WRITE_STARTED.load(Ordering::SeqCst) { std::task::Poll::Ready(()) } else { - *WRITE_STARTED_WAKER.lock().unwrap() = Some(cx.waker().clone()); - std::task::Poll::Pending + let mut waker = WRITE_STARTED_WAKER.lock().unwrap(); + *waker = Some(cx.waker().clone()); + if WRITE_STARTED.load(Ordering::SeqCst) { + waker.take(); + std::task::Poll::Ready(()) + } else { + std::task::Poll::Pending + } } }) .await From 8162df06874a87f972f0db86c6cffb895e932be1 Mon Sep 17 00:00:00 2001 From: zihang Date: Tue, 21 Jul 2026 11:01:57 +0800 Subject: [PATCH 08/10] refactor(moonbit): clean up async binding code --- crates/moonbit/src/async/async_abi.mbt | 14 +- crates/moonbit/src/async/ev.mbt | 4 +- crates/moonbit/src/async/promise.mbt | 2 +- crates/moonbit/src/async/trait.mbt | 50 ++- crates/moonbit/src/async_support.rs | 360 +++++++++--------- crates/moonbit/src/lib.rs | 32 +- .../moonbit/async-import-cancel/runner.mbt | 2 +- .../concurrent-export-isolation/runner.mbt | 2 +- .../moonbit/http-background-hook/runner.mbt | 2 +- .../moonbit/http-background-hook/test.mbt | 2 +- .../moonbit/local-async-primitives/runner.mbt | 2 +- .../moonbit/resource-payloads/leaf.mbt | 2 +- .../moonbit/resource-payloads/runner.mbt | 2 +- .../moonbit/stream-write-cancel/test.mbt | 2 +- .../moonbit/wasi-cli-p3-stdout/runner.mbt | 2 +- .../moonbit/wasi-cli-p3-stdout/test.mbt | 1 + 16 files changed, 268 insertions(+), 213 deletions(-) diff --git a/crates/moonbit/src/async/async_abi.mbt b/crates/moonbit/src/async/async_abi.mbt index 373cded54..c13492612 100644 --- a/crates/moonbit/src/async/async_abi.mbt +++ b/crates/moonbit/src/async/async_abi.mbt @@ -57,13 +57,13 @@ priv enum EventCode { ///| fn EventCode::from(int : Int) -> EventCode { match int { - 0 => EventCode::None - 1 => EventCode::SubTask - 2 => EventCode::StreamRead - 3 => EventCode::StreamWrite - 4 => EventCode::FutureRead - 5 => EventCode::FutureWrite - 6 => EventCode::TaskCancelled + 0 => None + 1 => SubTask + 2 => StreamRead + 3 => StreamWrite + 4 => FutureRead + 5 => FutureWrite + 6 => TaskCancelled _ => panic() } } diff --git a/crates/moonbit/src/async/ev.mbt b/crates/moonbit/src/async/ev.mbt index c1b669ca7..d5ea97263 100644 --- a/crates/moonbit/src/async/ev.mbt +++ b/crates/moonbit/src/async/ev.mbt @@ -354,7 +354,7 @@ fn arm_task_wakeup(waitable_set : WaitableSet) -> Unit { guard result == -1 let subscribers = subscribers_for(waitable_set) guard subscribers.get(wakeup.reader) is None - subscribers.set(wakeup.reader, { event: None, coro: @set.Set([]) }) + subscribers.set(wakeup.reader, { event: None, coro: Set([]) }) waitable_join(wakeup.reader, waitable_set.0) wakeup.reading = true wakeup.signaled = false @@ -447,7 +447,7 @@ async fn waitable_event(waitable_id : Int, immediate : Events?) -> Events { let subscriber = if subscribers.get(waitable_id) is Some(subscriber) { subscriber } else { - let subscriber = { event: None, coro: @set.Set([]) } + let subscriber = { event: None, coro: Set([]) } subscribers.set(waitable_id, subscriber) subscriber } diff --git a/crates/moonbit/src/async/promise.mbt b/crates/moonbit/src/async/promise.mbt index ec72b21b7..cce7b5cc2 100644 --- a/crates/moonbit/src/async/promise.mbt +++ b/crates/moonbit/src/async/promise.mbt @@ -52,7 +52,7 @@ fn[X] new_future_promise(cleanup : ((X) -> Unit)?) -> (Future[X], Promise[X]) { reading: false, }, } - (Future::{ inner: Promised(cell) }, Promise::{ cell, }) + ({ inner: Promised(cell) }, { cell, }) } ///| diff --git a/crates/moonbit/src/async/trait.mbt b/crates/moonbit/src/async/trait.mbt index 1413502d0..516a3a0e1 100644 --- a/crates/moonbit/src/async/trait.mbt +++ b/crates/moonbit/src/async/trait.mbt @@ -14,6 +14,7 @@ let sink_write_window_size : Int = 64 /// The write callback must either raise before consuming any value or return /// the number consumed. It must not raise after partial consumption because /// `ArrayView` cannot carry an ownership offset through an exception. +/// The close callback must be idempotent. #internal(wit_bindgen, "generated binding code only") pub fn[X] Sink::from_callbacks( write : async (ArrayView[X]) -> Int, @@ -162,6 +163,10 @@ pub async fn Sink::write_all_bytes(self : Sink[Byte], data : BytesView) -> Bool } ///| +/// Closes the writable end of the stream. +/// +/// Closing is idempotent. A peer waiting for values observes the end of the +/// stream after any values already accepted by the sink have been read. pub async fn[X] Sink::close(self : Sink[X]) -> Unit { (self.close)() } @@ -540,7 +545,7 @@ pub fn[X] Future::ready_with_cleanup( let state : Ref[LocalFutureState[X]] = { val: { value: Some(value), closed: false, cleanup: Some(cleanup) }, } - Future::{ inner: Local(state) } + { inner: Local(state) } } ///| @@ -548,7 +553,7 @@ pub fn[X] Future::ready(value : X) -> Future[X] { let state : Ref[LocalFutureState[X]] = { val: { value: Some(value), closed: false, cleanup: None }, } - Future::{ inner: Local(state) } + { inner: Local(state) } } ///| @@ -561,9 +566,7 @@ pub fn[X] Future::from( producer : async () -> X, on_unstarted_drop? : () -> Unit, ) -> Future[X] { - Future::{ - inner: Pending({ val: { producer: Some(producer), on_unstarted_drop } }), - } + { inner: Pending({ val: { producer: Some(producer), on_unstarted_drop } }) } } ///| @@ -573,10 +576,14 @@ pub fn[X] Future::from_source( close : async () -> Unit, close_sync : () -> Unit, ) -> Future[X] { - Future::{ inner: Source({ get, close, close_sync }) } + { inner: Source({ get, close, close_sync }) } } ///| +/// Waits for and consumes the future's single value. +/// +/// A `Future` is a one-shot value. Calling `get` again, or calling it after the +/// readable end has been dropped, fails instead of replaying the value. pub async fn[X] Future::get(self : Future[X]) -> X { match self.inner { Source(source) => (source.get)() @@ -595,6 +602,11 @@ pub async fn[X] Future::get(self : Future[X]) -> X { } ///| +/// Releases the readable end without consuming its value. +/// +/// If the value is already locally available, its registered cleanup callback +/// runs. An unstarted lazy producer is discarded and runs +/// `on_unstarted_drop`, when provided. pub async fn[X] Future::drop(self : Future[X]) -> Unit noraise { match self.inner { Source(source) => (source.close)() catch { _ => () } @@ -669,22 +681,22 @@ fn[X] new_stream_pipe( { val: { capacity, - chunks: @deque.Deque([]), + chunks: Deque([]), buffered: 0, writer_closed: false, reader_dropped: false, cleanup, head: None, head_pos: 0, - readers: @deque.Deque([]), - writers: @deque.Deque([]), + readers: Deque([]), + writers: Deque([]), }, } } ///| fn[X] stream_pipe_sink(pipe : Ref[StreamPipe[X]]) -> Sink[X] { - Sink::{ + { write: (data : ArrayView[X]) => stream_pipe_write(pipe, data), close: () => stream_pipe_close_writer(pipe), is_open: () => !pipe.val.writer_closed && !pipe.val.reader_dropped, @@ -714,9 +726,7 @@ fn spawn_stream_producer(task : async () -> Unit) -> Unit { let current = current_coroutine() match current.spawner { Some(spawner) => spawner(task) - None => { - let _ = spawn(task) - } + None => ignore(spawn(task)) } } @@ -785,7 +795,7 @@ pub fn[X] Stream::produce( cleanup? : (X) -> Unit, on_unstarted_drop? : () -> Unit, ) -> Stream[X] { - Stream::{ + { inner: Outgoing({ val: { producer: Some(producer), cleanup, on_unstarted_drop, pipe: None }, }), @@ -799,10 +809,16 @@ pub fn[X] Stream::from_source( close : async () -> Unit, close_sync : () -> Unit, ) -> Stream[X] { - Stream::{ inner: Source({ read, close, close_sync }) } + { inner: Source({ read, close, close_sync }) } } ///| +/// Reads at most `count` values from the stream. +/// +/// `Some(values)` contains the next owned chunk and may be empty when the peer +/// reports no progress. `None` means the writable end has closed and no +/// buffered values remain. A non-positive count returns an empty chunk without +/// waiting. pub async fn[X] Stream::read(self : Stream[X], count : Int) -> FixedArray[X]? { match self.inner { Source(source) => (source.read)(count) @@ -813,6 +829,10 @@ pub async fn[X] Stream::read(self : Stream[X], count : Int) -> FixedArray[X]? { } ///| +/// Releases the readable end and discards unread values. +/// +/// Registered cleanup callbacks run for locally buffered values. An unstarted +/// lazy producer is discarded and runs `on_unstarted_drop`, when provided. pub async fn[X] Stream::drop(self : Stream[X]) -> Unit noraise { match self.inner { Source(source) => (source.close)() catch { _ => () } diff --git a/crates/moonbit/src/async_support.rs b/crates/moonbit/src/async_support.rs index 61c2976fa..52ce72a12 100644 --- a/crates/moonbit/src/async_support.rs +++ b/crates/moonbit/src/async_support.rs @@ -34,6 +34,13 @@ impl PayloadFor { PayloadFor::Stream => "stream", } } + + fn type_name(self) -> &'static str { + match self { + PayloadFor::Future => "Future", + PayloadFor::Stream => "Stream", + } + } } #[derive(Clone, Debug, Default)] @@ -110,6 +117,17 @@ struct AsyncEndpointIntrinsics { drop_writable: AsyncIntrinsicName, } +struct EndpointPayloadFragments { + lift: String, + lift_result: String, + lower: String, + malloc: String, + lift_list: String, + commit: String, + reject: String, + free_outer: String, +} + #[derive(Clone, Debug)] pub(crate) struct AsyncFunctionPlan { endpoint_sites: Vec, @@ -355,6 +373,10 @@ impl AsyncEndpointNames { } impl AsyncSupport { + pub(crate) fn is_required(&self) -> bool { + self.runtime_required || !self.endpoints.is_empty() + } + pub(crate) fn import_plan( &mut self, async_filter: &mut AsyncFilterSet, @@ -401,7 +423,7 @@ impl AsyncSupport { } pub(crate) fn emit_runtime_files(&self, files: &mut Files, version: &str) { - if !self.runtime_required && self.endpoints.is_empty() { + if !self.is_required() { return; } @@ -710,7 +732,7 @@ impl<'a> InterfaceGenerator<'a> { r#" #doc(hidden) pub fn {func_name}({params}) -> {result_type} {{ - return {ffi}with_waitableset(async fn() {{ + {ffi}with_waitableset(async fn() {{ {ffi}with_task_group(async fn({background_group}) {{ {cleanup_list} {body} @@ -909,7 +931,7 @@ impl<'a> InterfaceGenerator<'a> { self.ffi, r#" #doc(hidden) - pub fn {export_func_name}(event_raw: Int, waitable: Int, code: Int) -> Int {{ + pub fn {export_func_name}(event_raw : Int, waitable : Int, code : Int) -> Int {{ {ffi}cb(event_raw, waitable, code) }} "# @@ -923,7 +945,7 @@ impl<'a> InterfaceGenerator<'a> { let export = format!( r#" #doc(hidden) - pub fn {export_func_name}(event_raw: Int, waitable: Int, code: Int) -> Int {{ + pub fn {export_func_name}(event_raw : Int, waitable : Int, code : Int) -> Int {{ {package}{export_func_name}(event_raw, waitable, code) }} "#, @@ -949,6 +971,10 @@ impl<'a> InterfaceGenerator<'a> { let mut lower_results = Vec::new(); let mut async_state = endpoint_plan.state(); let mut needs_cleanup_list = false; + let mut local_names = Ns::default(); + for (name, _) in &mbt_sig.params { + local_names.tmp(name); + } self.ffi_imports.insert(ffi::FREE); let ffi = self .world_gen @@ -956,19 +982,20 @@ impl<'a> InterfaceGenerator<'a> { .qualify_package(self.name, ASYNC_CORE_DIR); if sig.indirect_params { + let lower_ptr = local_names.tmp("lower_ptr"); match &func.params[..] { [] => {} [Param { name, ty, .. }] => { - body.push_str(&self.malloc_memory("_lower_ptr", "1", ty, self.name)); - body.push_str("\ndefer mbt_ffi_free(_lower_ptr)\n"); + body.push_str(&self.malloc_memory(&lower_ptr, "1", ty)); + body.push_str(&format!("\ndefer mbt_ffi_free({lower_ptr})\n")); body.push_str(&self.lower_to_memory( - "_lower_ptr", + &lower_ptr, &name.to_moonbit_ident(), ty, self.name, &mut async_state, )); - lower_params.push("_lower_ptr".into()); + lower_params.push(lower_ptr); } multiple_params => { let params = multiple_params.iter().map(|Param { ty, .. }| ty); @@ -977,8 +1004,8 @@ impl<'a> InterfaceGenerator<'a> { self.ffi_imports.insert(ffi::MALLOC); body.push_str(&format!( r#" - let _lower_ptr : Int = mbt_ffi_malloc({}) - defer mbt_ffi_free(_lower_ptr) + let {lower_ptr} : Int = mbt_ffi_malloc({}) + defer mbt_ffi_free({lower_ptr}) "#, elem_info.size.size_wasm32(), )); @@ -989,7 +1016,7 @@ impl<'a> InterfaceGenerator<'a> { .map(|Param { name, .. }| name.to_moonbit_ident()), ) { let result = self.lower_to_memory( - &format!("_lower_ptr + {}", offset.size_wasm32()), + &format!("{lower_ptr} + {}", offset.size_wasm32()), &name, ty, self.name, @@ -998,7 +1025,7 @@ impl<'a> InterfaceGenerator<'a> { body.push_str(&result); } - lower_params.push("_lower_ptr".into()); + lower_params.push(lower_ptr); } } } else { @@ -1022,9 +1049,8 @@ impl<'a> InterfaceGenerator<'a> { let mut stable_params = Vec::with_capacity(lower_params.len()); let bindings = lower_params .iter() - .enumerate() - .map(|(index, value)| { - let name = format!("_lower_arg{index}"); + .map(|value| { + let name = local_names.tmp("lower_arg"); stable_params.push(name.clone()); format!("let {name} = {value}") }) @@ -1093,12 +1119,13 @@ impl<'a> InterfaceGenerator<'a> { ); let func_name = func.name.to_upper_camel_case(); - let call_import = |params: &Vec, drop_returned_result: &str| { + let subtask_code = local_names.tmp("subtask_code"); + let call_import = |params: &[String], drop_returned_result: &str| { format!( r#" - let _subtask_code = wasmImport{func_name}({}) + let {subtask_code} = wasmImport{func_name}({}) {ffi}suspend_for_subtask( - _subtask_code, + {subtask_code}, {settle_arguments}, fn() {{ {drop_returned_result} @@ -1111,11 +1138,12 @@ impl<'a> InterfaceGenerator<'a> { }; match &func.result { Some(ty) => { - lower_params.push("_result_ptr".into()); + let result_ptr = local_names.tmp("result_ptr"); + lower_params.push(result_ptr.clone()); let (drop_returned_result, drop_result_state) = self .deallocate_lists_and_own_with_state( std::slice::from_ref(ty), - &["_result_ptr".into()], + std::slice::from_ref(&result_ptr), true, self.name, endpoint_plan.endpoint_sites.clone(), @@ -1130,18 +1158,18 @@ impl<'a> InterfaceGenerator<'a> { } let call_import = call_import(&lower_params, &drop_returned_result); let (lift, lift_result) = - &self.lift_from_memory("_result_ptr", ty, self.name, &mut async_state); + &self.lift_from_memory(&result_ptr, ty, self.name, &mut async_state); body.push_str(&format!( r#" {} {} - defer mbt_ffi_free(_result_ptr) + defer mbt_ffi_free({result_ptr}) {call_import} {lift} {lift_result} "#, lower_results.join("\n"), - &self.malloc_memory("_result_ptr", "1", ty, self.name) + &self.malloc_memory(&result_ptr, "1", ty) )); } None => { @@ -1180,7 +1208,7 @@ impl<'a> InterfaceGenerator<'a> { let symbol_name = &site.symbol_name; let payload_len_arg = match site.kind { PayloadFor::Future => "", - PayloadFor::Stream => " ,length : Int", + PayloadFor::Stream => ", length : Int", }; let new_module = &site.intrinsics.new.module; let new_field = &site.intrinsics.new.field; @@ -1204,75 +1232,81 @@ impl<'a> InterfaceGenerator<'a> { let elem_size = result_type .map(|ty| self.world_gen.sizes.size(ty).size_wasm32()) .unwrap_or(0); - let read_chunk_owns_buffer = - result_type.is_some_and(|ty| self.is_list_canonical(self.resolve, ty)); + let read_chunk_owns_buffer = result_type.is_some_and(|ty| self.is_list_canonical(ty)); let staging_window = if payload_sites.is_empty() { 64 } else { 1 }; - let (lift, lift_result, lower, malloc, lift_list, commit, reject, free_outer) = - if let Some(result_type) = result_type { - let mut payload_state = AsyncFunctionState::from_sites(payload_sites.clone()); - let (lift, lift_result) = - self.lift_from_memory("ptr", result_type, &helper_package, &mut payload_state); - let mut payload_state = AsyncFunctionState::from_sites(payload_sites.clone()); - let lower = self.lower_to_memory( - "ptr", - "value", - result_type, - &helper_package, - &mut payload_state, - ); - let (commit, _) = self.commit_lists_and_endpoints_with_state( - std::slice::from_ref(result_type), - &[String::from("elem_ptr")], - true, - &helper_package, - payload_sites.clone(), - ); - let reject = self.deallocate_lists_and_own( - std::slice::from_ref(result_type), - &[String::from("elem_ptr")], - true, - &helper_package, - payload_sites.clone(), - ); - let lift_list = self.list_lift_from_memory( - "ptr", - "length", - &format!("wasm{symbol_name}Lift"), - result_type, - &helper_package, - ); - let malloc = self.malloc_memory("ptr", "length", result_type, &helper_package); - self.ffi_imports.insert(ffi::FREE); - ( - lift, - lift_result, - lower, - malloc, - lift_list, - commit, - reject, - "mbt_ffi_free(ptr)".to_string(), - ) - } else { - ( - "let _ = ptr".into(), - "".into(), - "let _ = (ptr, value)".into(), - "let ptr = 0".into(), - "FixedArray::make(length, Unit::default())".into(), - String::new(), - String::new(), - "let _ = ptr".into(), - ) - }; + let EndpointPayloadFragments { + lift, + lift_result, + lower, + malloc, + lift_list, + commit, + reject, + free_outer, + } = if let Some(result_type) = result_type { + let mut payload_state = AsyncFunctionState::from_sites(payload_sites.clone()); + let (lift, lift_result) = + self.lift_from_memory("ptr", result_type, &helper_package, &mut payload_state); + let mut payload_state = AsyncFunctionState::from_sites(payload_sites.clone()); + let lower = self.lower_to_memory( + "ptr", + "value", + result_type, + &helper_package, + &mut payload_state, + ); + let (commit, _) = self.commit_lists_and_endpoints_with_state( + std::slice::from_ref(result_type), + &[String::from("elem_ptr")], + true, + &helper_package, + payload_sites.clone(), + ); + let reject = self.deallocate_lists_and_own( + std::slice::from_ref(result_type), + &[String::from("elem_ptr")], + true, + &helper_package, + payload_sites.clone(), + ); + let lift_list = self.list_lift_from_memory( + "ptr", + "length", + &format!("wasm{symbol_name}Lift"), + result_type, + ); + let malloc = self.malloc_memory("ptr", "length", result_type); + self.ffi_imports.insert(ffi::FREE); + EndpointPayloadFragments { + lift, + lift_result, + lower, + malloc, + lift_list, + commit, + reject, + free_outer: "mbt_ffi_free(ptr)".to_string(), + } + } else { + EndpointPayloadFragments { + lift: "ignore(ptr)".into(), + lift_result: String::new(), + lower: "ignore((ptr, value))".into(), + malloc: "let ptr = 0".into(), + lift_list: "FixedArray::make(length, Unit::default())".into(), + commit: String::new(), + reject: String::new(), + free_outer: "ignore(ptr)".into(), + } + }; let commit = if commit.trim().is_empty() { - "let _ = (ptr, start, length)".to_string() + "ignore((ptr, start, length))".to_string() } else { format!( r#" - for i = start; i < start + length; i = i + 1 {{ + for i in start..<(start + length) {{ let elem_ptr = ptr + i * {elem_size} {commit} }} @@ -1280,11 +1314,11 @@ impl<'a> InterfaceGenerator<'a> { ) }; let reject = if reject.trim().is_empty() { - "let _ = (ptr, start, length)".to_string() + "ignore((ptr, start, length))".to_string() } else { format!( r#" - for i = start; i < start + length; i = i + 1 {{ + for i in start..<(start + length) {{ let elem_ptr = ptr + i * {elem_size} {reject} }} @@ -1297,7 +1331,7 @@ impl<'a> InterfaceGenerator<'a> { .then(|| { format!( r#" - fn wasm{symbol_name}Lift(ptr: Int) -> {result} {{ + fn wasm{symbol_name}Lift(ptr : Int) -> {result} {{ {lift} {lift_result} }} @@ -1310,7 +1344,7 @@ impl<'a> InterfaceGenerator<'a> { .then(|| { format!( r#" - fn wasm{symbol_name}Lower(value: {result}, ptr: Int) -> Unit {{ + fn wasm{symbol_name}Lower(value : {result}, ptr : Int) -> Unit {{ {lower} }} "# @@ -1322,8 +1356,8 @@ impl<'a> InterfaceGenerator<'a> { format!( r#" fn wasm{symbol_name}ListLift( - ptr: Int, - length: Int, + ptr : Int, + length : Int, ) -> FixedArray[{result}] {{ {lift_list} }} @@ -1382,9 +1416,9 @@ impl<'a> InterfaceGenerator<'a> { format!( r#" fn wasm{symbol_name}Commit( - ptr: Int, - start: Int, - length: Int, + ptr : Int, + start : Int, + length : Int, ) -> Unit {{ {commit} }} @@ -1413,7 +1447,7 @@ impl<'a> InterfaceGenerator<'a> { .then(|| { format!( r#" -fn wasm{symbol_name}FutureRejectPrepared(handle: Int) -> Bool {{ +fn wasm{symbol_name}FutureRejectPrepared(handle : Int) -> Bool {{ guard wasm{symbol_name}FutureProducers.get(handle) is Some(producer) else {{ return false }} @@ -1601,7 +1635,7 @@ async fn Wasm{symbol_name}FutureSource::read( value }} -fn wasm{symbol_name}FutureLift(handle: Int) -> {ffi}Future[{result}] {{ +fn wasm{symbol_name}FutureLift(handle : Int) -> {ffi}Future[{result}] {{ let source = Wasm{symbol_name}FutureSource::{{ handle, closed: false, @@ -1610,7 +1644,7 @@ fn wasm{symbol_name}FutureLift(handle: Int) -> {ffi}Future[{result}] {{ read_buffer: 0, read_discarding: false, read_cleanup_done: false, - read_cleanup: {ffi}CondVar(), + read_cleanup: CondVar(), }} {ffi}Future::from_source( () => source.read(), @@ -1627,7 +1661,7 @@ fn wasm{symbol_name}FutureLift(handle: Int) -> {ffi}Future[{result}] {{ .then(|| { format!( r#" -fn wasm{symbol_name}FutureLowerCommitted(future: {ffi}Future[{result}]) -> Int {{ +fn wasm{symbol_name}FutureLowerCommitted(future : {ffi}Future[{result}]) -> Int {{ let reader = wasm{symbol_name}FutureLower(future) wasm{symbol_name}FutureCommit(reader) reader @@ -1648,7 +1682,7 @@ let wasm{symbol_name}FutureProducers : Map[Int, Wasm{symbol_name}FutureProducer] {reject_prepared_func} -fn wasm{symbol_name}FutureCommit(handle: Int) -> Unit {{ +fn wasm{symbol_name}FutureCommit(handle : Int) -> Unit {{ guard wasm{symbol_name}FutureProducers.get(handle) is Some(producer) else {{ return }} @@ -1686,7 +1720,7 @@ fn wasm{symbol_name}FutureCommit(handle: Int) -> Unit {{ }}) }} -fn wasm{symbol_name}FutureLower(future: {ffi}Future[{result}]) -> Int {{ +fn wasm{symbol_name}FutureLower(future : {ffi}Future[{result}]) -> Int {{ if !{ffi}has_component_task_scope() {{ abort("component future producer requires an async task scope") }} @@ -1722,7 +1756,7 @@ fn wasm{symbol_name}FutureLower(future: {ffi}Future[{result}]) -> Int {{ .then(|| { format!( r#" -fn wasm{symbol_name}StreamRejectPrepared(handle: Int) -> Bool {{ +fn wasm{symbol_name}StreamRejectPrepared(handle : Int) -> Bool {{ guard wasm{symbol_name}StreamProducers.get(handle) is Some(prepared) else {{ return false }} @@ -1917,7 +1951,7 @@ async fn Wasm{symbol_name}StreamSource::read( Some(values) }} -fn wasm{symbol_name}StreamLift(handle: Int) -> {ffi}Stream[{result}] {{ +fn wasm{symbol_name}StreamLift(handle : Int) -> {ffi}Stream[{result}] {{ let source = Wasm{symbol_name}StreamSource::{{ handle, closed: false, @@ -1926,7 +1960,7 @@ fn wasm{symbol_name}StreamLift(handle: Int) -> {ffi}Stream[{result}] {{ read_buffer: 0, read_discarding: false, read_cleanup_done: false, - read_cleanup: {ffi}CondVar(), + read_cleanup: CondVar(), }} {ffi}Stream::from_source( (count) => source.read(count), @@ -1943,7 +1977,7 @@ fn wasm{symbol_name}StreamLift(handle: Int) -> {ffi}Stream[{result}] {{ .then(|| { format!( r#" -fn wasm{symbol_name}StreamLowerCommitted(stream: {ffi}Stream[{result}]) -> Int {{ +fn wasm{symbol_name}StreamLowerCommitted(stream : {ffi}Stream[{result}]) -> Int {{ let reader = wasm{symbol_name}StreamLower(stream) wasm{symbol_name}StreamCommit(reader) reader @@ -1964,7 +1998,7 @@ let wasm{symbol_name}StreamProducers : Map[Int, Wasm{symbol_name}StreamProducer] {reject_prepared_func} -fn wasm{symbol_name}StreamCommit(handle: Int) -> Unit {{ +fn wasm{symbol_name}StreamCommit(handle : Int) -> Unit {{ guard wasm{symbol_name}StreamProducers.get(handle) is Some(prepared) else {{ return }} @@ -2008,11 +2042,11 @@ fn wasm{symbol_name}StreamCommit(handle: Int) -> Unit {{ {staging_window} }} let ptr = wasm{symbol_name}Malloc(data_len) - for i = 0; i < data_len; i = i + 1 {{ + for i in 0.. Unit {{ + let settle_staging = fn(transferred : Int) -> Unit {{ wasm{symbol_name}Commit(ptr, 0, transferred) if transferred < data_len {{ wasm{symbol_name}Reject( @@ -2106,7 +2140,7 @@ fn wasm{symbol_name}StreamCommit(handle: Int) -> Unit {{ }}) }} -fn wasm{symbol_name}StreamLower(stream: {ffi}Stream[{result}]) -> Int {{ +fn wasm{symbol_name}StreamLower(stream : {ffi}Stream[{result}]) -> Int {{ if !{ffi}has_component_task_scope() {{ abort("component stream producer requires an async task scope") }} @@ -2134,7 +2168,7 @@ fn wasm{symbol_name}StreamLower(stream: {ffi}Stream[{result}]) -> Int {{ {drop_readable_intrinsic} {lower_intrinsics} -fn wasm{symbol_name}Malloc(length: Int) -> Int {{ +fn wasm{symbol_name}Malloc(length : Int) -> Int {{ {malloc} ptr }} @@ -2142,9 +2176,9 @@ fn wasm{symbol_name}Malloc(length: Int) -> Int {{ {commit_func} fn wasm{symbol_name}Reject( - ptr: Int, - start: Int, - length: Int, + ptr : Int, + start : Int, + length : Int, ) -> Unit {{ {reject} }} @@ -2252,14 +2286,13 @@ fn wasm{symbol_name}Reject( f.src } - fn malloc_memory(&mut self, address: &str, length: &str, ty: &Type, package: &str) -> String { + fn malloc_memory(&mut self, address: &str, length: &str, ty: &Type) -> String { let size = self.world_gen.sizes.size(ty).size_wasm32(); - let _ = package; self.ffi_imports.insert(ffi::MALLOC); - format!("let {address} = mbt_ffi_malloc({size} * {length});") + format!("let {address} = mbt_ffi_malloc({size} * {length})") } - fn is_list_canonical(&self, _resolve: &Resolve, element: &Type) -> bool { + fn is_list_canonical(&self, element: &Type) -> bool { matches!( element, Type::U8 | Type::U32 | Type::U64 | Type::S32 | Type::S64 | Type::F32 | Type::F64 @@ -2272,10 +2305,8 @@ fn wasm{symbol_name}Reject( length: &str, lift_func: &str, ty: &Type, - package: &str, ) -> String { - let _ = package; - if self.is_list_canonical(self.resolve, ty) { + if self.is_list_canonical(ty) { if ty == &Type::U8 { self.ffi_imports.insert(ffi::PTR2BYTES); return format!("mbt_ffi_ptr2bytes({address}, {length})"); @@ -2299,7 +2330,7 @@ fn wasm{symbol_name}Reject( FixedArray::makei( {length}, (index) => {{ - let ptr = ({address}) + (index * {size}); + let ptr = ({address}) + (index * {size}) {lift_func}(ptr) }} ) @@ -2447,8 +2478,9 @@ impl<'a, 'b> FunctionBindgen<'a, 'b> { }; } - pub(super) fn emit_future_lift( + fn emit_endpoint_lift( &mut self, + kind: PayloadFor, ty: TypeId, operands: &[String], results: &mut Vec, @@ -2457,23 +2489,25 @@ impl<'a, 'b> FunctionBindgen<'a, 'b> { let op = &operands[0]; let site = self .async_state - .next_site(PayloadFor::Future, ty, EndpointOperation::Lift); - let lift_name = format!("wasm{}FutureLift", site.symbol_name); + .next_site(kind, ty, EndpointOperation::Lift); + let type_name = kind.type_name(); + let lift_name = format!("wasm{}{type_name}Lift", site.symbol_name); if self.commit_endpoints { - let commit_name = format!("wasm{}FutureCommit", site.symbol_name); - uwriteln!(self.src, r#"{commit_name}({op});"#); + let commit_name = format!("wasm{}{type_name}Commit", site.symbol_name); + uwriteln!(self.src, r#"{commit_name}({op})"#); results.push("()".into()); return; } - uwriteln!(self.src, r#"let {result} = {lift_name}({op});"#,); + uwriteln!(self.src, r#"let {result} = {lift_name}({op})"#,); results.push(result); } - pub(super) fn emit_future_lower( + fn emit_endpoint_lower( &mut self, + kind: PayloadFor, ty: TypeId, operands: &[String], results: &mut Vec, @@ -2487,19 +2521,32 @@ impl<'a, 'b> FunctionBindgen<'a, 'b> { } else { EndpointOperation::Lower }; - let site = self - .async_state - .next_site(PayloadFor::Future, ty, operation); - let suffix = if committed { - "FutureLowerCommitted" - } else { - "FutureLower" - }; - let lower_name = format!("wasm{}{suffix}", site.symbol_name); + let site = self.async_state.next_site(kind, ty, operation); + let type_name = kind.type_name(); + let suffix = if committed { "LowerCommitted" } else { "Lower" }; + let lower_name = format!("wasm{}{type_name}{suffix}", site.symbol_name); let op = &operands[0]; results.push(format!("{lower_name}({op})")); } + pub(super) fn emit_future_lift( + &mut self, + ty: TypeId, + operands: &[String], + results: &mut Vec, + ) { + self.emit_endpoint_lift(PayloadFor::Future, ty, operands, results); + } + + pub(super) fn emit_future_lower( + &mut self, + ty: TypeId, + operands: &[String], + results: &mut Vec, + ) { + self.emit_endpoint_lower(PayloadFor::Future, ty, operands, results); + } + pub(super) fn capture_task_return(&mut self, params: &[WasmType], operands: &[String]) { let (body, needs_cleanup_list, return_param, return_value) = match &mut self.async_state.task_return { @@ -2541,26 +2588,7 @@ impl<'a, 'b> FunctionBindgen<'a, 'b> { operands: &[String], results: &mut Vec, ) { - let committed = matches!( - self.async_state.task_return, - AsyncTaskReturnState::Generating { .. } - ); - let operation = if committed { - EndpointOperation::LowerCommitted - } else { - EndpointOperation::Lower - }; - let site = self - .async_state - .next_site(PayloadFor::Stream, ty, operation); - let suffix = if committed { - "StreamLowerCommitted" - } else { - "StreamLower" - }; - let lower_name = format!("wasm{}{suffix}", site.symbol_name); - let op = &operands[0]; - results.push(format!("{lower_name}({op})")); + self.emit_endpoint_lower(PayloadFor::Stream, ty, operands, results); } pub(super) fn emit_stream_lift( @@ -2569,23 +2597,7 @@ impl<'a, 'b> FunctionBindgen<'a, 'b> { operands: &[String], results: &mut Vec, ) { - let result = self.locals.tmp("result"); - let op = &operands[0]; - let site = self - .async_state - .next_site(PayloadFor::Stream, ty, EndpointOperation::Lift); - let lift_name = format!("wasm{}StreamLift", site.symbol_name); - - if self.commit_endpoints { - let commit_name = format!("wasm{}StreamCommit", site.symbol_name); - uwriteln!(self.src, r#"{commit_name}({op});"#); - results.push("()".into()); - return; - } - - uwriteln!(self.src, r#"let {result} = {lift_name}({op});"#,); - - results.push(result); + self.emit_endpoint_lift(PayloadFor::Stream, ty, operands, results); } } diff --git a/crates/moonbit/src/lib.rs b/crates/moonbit/src/lib.rs index d4013efb7..bd377de42 100644 --- a/crates/moonbit/src/lib.rs +++ b/crates/moonbit/src/lib.rs @@ -21,7 +21,9 @@ use wit_bindgen_core::{ }; use crate::async_support::{AsyncFunctionState, AsyncSupport}; -use crate::pkg::{Imports, MoonbitSignature, PkgResolver, ToMoonBitIdent, ToMoonBitTypeIdent}; +use crate::pkg::{ + ASYNC_CORE_DIR, Imports, MoonbitSignature, PkgResolver, ToMoonBitIdent, ToMoonBitTypeIdent, +}; mod async_support; mod ffi; @@ -177,6 +179,11 @@ impl MoonBit { moon_pkg.deindent(1); moon_pkg.push_str("\n]"); } + let imports_async_core = + imports.is_some_and(|imports| imports.packages.contains_key(ASYNC_CORE_DIR)); + if imports_async_core || (link && self.async_support.is_required()) { + moon_pkg.push_str(",\n\"supported-targets\": \"+wasm\""); + } // Link target if link { let memory_name = self.pkg_resolver.resolve.wasm_export_name( @@ -3236,6 +3243,8 @@ mod tests { .iter() .all(|(name, _)| !name.starts_with("async-core/async_")) ); + let package = file(&files, "gen/moon.pkg.json"); + assert!(!package.contains("supported-targets"), "{package}"); } #[test] @@ -3307,6 +3316,16 @@ mod tests { let source = file(&files, "interface/a/b/types/top.mbt"); assert!(source.contains("@async-core.Future[UInt]"), "{source}"); + let package = file(&files, "interface/a/b/types/moon.pkg.json"); + assert!( + package.contains("\"supported-targets\": \"+wasm\""), + "{package}" + ); + let link_package = file(&files, "gen/moon.pkg.json"); + assert!( + link_package.contains("\"supported-targets\": \"+wasm\""), + "{link_package}" + ); file(&files, "async-core/moon.pkg.json"); file(&files, "async-core/async_trait.mbt"); } @@ -3644,7 +3663,10 @@ mod tests { let import = file(&imports, "interface/test/moonbit-nested/nested/top.mbt"); assert!(import.contains("if before_started")); assert!(import.contains(".drop_sync()")); - assert!(import.contains("defer mbt_ffi_free(_result_ptr)")); + assert!(import.contains("defer mbt_ffi_free(result_ptr)")); + for misleading_name in ["_lower_ptr", "_lower_arg", "_subtask_code", "_result_ptr"] { + assert!(!import.contains(misleading_name), "{import}"); + } assert_eq!(import.matches("FutureLower(value)").count(), 1, "{import}"); assert_eq!(import.matches("StreamLower(value)").count(), 1, "{import}"); assert!(!import.contains("cleanup_list")); @@ -3733,9 +3755,9 @@ mod tests { "{import}" ); assert!( - import.contains("mbt_ffi_free(mbt_ffi_load32((_result_ptr) + 0))") - && import.contains("mbt_ffi_free(mbt_ffi_load32((_result_ptr) + 8))") - && import.contains("defer mbt_ffi_free(_result_ptr)"), + import.contains("mbt_ffi_free(mbt_ffi_load32((result_ptr) + 0))") + && import.contains("mbt_ffi_free(mbt_ffi_load32((result_ptr) + 8))") + && import.contains("defer mbt_ffi_free(result_ptr)"), "cancelled returned result must free its strings, lists, and result area: {import}" ); } diff --git a/tests/runtime/moonbit/async-import-cancel/runner.mbt b/tests/runtime/moonbit/async-import-cancel/runner.mbt index 03d87d85d..a847b733b 100644 --- a/tests/runtime/moonbit/async-import-cancel/runner.mbt +++ b/tests/runtime/moonbit/async-import-cancel/runner.mbt @@ -1,7 +1,7 @@ //@ wasmtime-flags = '-Wcomponent-model-async' //@ [lang] //@ path = 'gen/world/runner/stub.mbt' -//@ pkg_config = """{ "import": [{ "path": "test/async-import-cancel/async-core", "alias": "async-core" }, { "path": "test/async-import-cancel/interface/test/async-import-cancel/pending", "alias": "pending" }, { "path": "test/async-import-cancel/interface/test/async-import-cancel/gate-control", "alias": "gate-control" }] }""" +//@ pkg_config = """{ "supported-targets": "+wasm", "import": [{ "path": "test/async-import-cancel/async-core", "alias": "async-core" }, { "path": "test/async-import-cancel/interface/test/async-import-cancel/pending", "alias": "pending" }, { "path": "test/async-import-cancel/interface/test/async-import-cancel/gate-control", "alias": "gate-control" }] }""" ///| async fn settle_leaf_counts(expected_drops : UInt) -> Unit { diff --git a/tests/runtime/moonbit/concurrent-export-isolation/runner.mbt b/tests/runtime/moonbit/concurrent-export-isolation/runner.mbt index e6478cb77..9d184a3c2 100644 --- a/tests/runtime/moonbit/concurrent-export-isolation/runner.mbt +++ b/tests/runtime/moonbit/concurrent-export-isolation/runner.mbt @@ -1,7 +1,7 @@ //@ wasmtime-flags = '-Wcomponent-model-async' //@ [lang] //@ path = 'gen/world/runner/stub.mbt' -//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "test/moonbit-concurrent-export-isolation/async-core", "alias": "async-core" }, { "path": "test/moonbit-concurrent-export-isolation/interface/test/moonbit-concurrent-export-isolation/operations", "alias": "operations" }] }""" +//@ pkg_config = """{ "warn-list": "-44", "supported-targets": "+wasm", "import": [{ "path": "test/moonbit-concurrent-export-isolation/async-core", "alias": "async-core" }, { "path": "test/moonbit-concurrent-export-isolation/interface/test/moonbit-concurrent-export-isolation/operations", "alias": "operations" }] }""" ///| pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { diff --git a/tests/runtime/moonbit/http-background-hook/runner.mbt b/tests/runtime/moonbit/http-background-hook/runner.mbt index d01bff8bc..7fe4bf046 100644 --- a/tests/runtime/moonbit/http-background-hook/runner.mbt +++ b/tests/runtime/moonbit/http-background-hook/runner.mbt @@ -1,7 +1,7 @@ //@ wasmtime-flags = '-Wcomponent-model-async -S p3 -S http' //@ [lang] //@ path = 'gen/world/runner/stub.mbt' -//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "example/background-hook/async-core", "alias": "async-core" }, { "path": "example/background-hook/interface/wasi/http/handler", "alias": "handler" }, { "path": "example/background-hook/interface/wasi/http/types", "alias": "types" }] }""" +//@ pkg_config = """{ "warn-list": "-44", "supported-targets": "+wasm", "import": [{ "path": "example/background-hook/async-core", "alias": "async-core" }, { "path": "example/background-hook/interface/wasi/http/handler", "alias": "handler" }, { "path": "example/background-hook/interface/wasi/http/types", "alias": "types" }] }""" ///| async fn run_hook(payload : Bytes, expected_error : String?) -> Unit { diff --git a/tests/runtime/moonbit/http-background-hook/test.mbt b/tests/runtime/moonbit/http-background-hook/test.mbt index ed1114014..8e490d36b 100644 --- a/tests/runtime/moonbit/http-background-hook/test.mbt +++ b/tests/runtime/moonbit/http-background-hook/test.mbt @@ -1,7 +1,7 @@ //@ wasmtime-flags = '-Wcomponent-model-async -S p3 -S http' //@ [lang] //@ path = 'gen/interface/wasi/http/handler/stub.mbt' -//@ pkg_config = """{ "import": [{ "path": "moonbitlang/core/buffer", "alias": "buffer" }, { "path": "example/background-hook/async-core", "alias": "async-core" }, { "path": "example/background-hook/interface/wasi/http/types", "alias": "types" }] }""" +//@ pkg_config = """{ "supported-targets": "+wasm", "import": [{ "path": "moonbitlang/core/buffer", "alias": "buffer" }, { "path": "example/background-hook/async-core", "alias": "async-core" }, { "path": "example/background-hook/interface/wasi/http/types", "alias": "types" }] }""" ///| pub async fn handle( diff --git a/tests/runtime/moonbit/local-async-primitives/runner.mbt b/tests/runtime/moonbit/local-async-primitives/runner.mbt index 60adaac55..e45b4e32e 100644 --- a/tests/runtime/moonbit/local-async-primitives/runner.mbt +++ b/tests/runtime/moonbit/local-async-primitives/runner.mbt @@ -1,7 +1,7 @@ //@ wasmtime-flags = '-Wcomponent-model-async' //@ [lang] //@ path = 'gen/world/runner/stub.mbt' -//@ pkg_config = """{ "import": [{ "path": "test/moonbit-local-async-primitives/async-core", "alias": "async-core" }, { "path": "test/moonbit-local-async-primitives/interface/test/moonbit-local-async-primitives/operations", "alias": "operations" }] }""" +//@ pkg_config = """{ "supported-targets": "+wasm", "import": [{ "path": "test/moonbit-local-async-primitives/async-core", "alias": "async-core" }, { "path": "test/moonbit-local-async-primitives/interface/test/moonbit-local-async-primitives/operations", "alias": "operations" }] }""" ///| pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { diff --git a/tests/runtime/moonbit/resource-payloads/leaf.mbt b/tests/runtime/moonbit/resource-payloads/leaf.mbt index b7c1c28a1..69ec8cabb 100644 --- a/tests/runtime/moonbit/resource-payloads/leaf.mbt +++ b/tests/runtime/moonbit/resource-payloads/leaf.mbt @@ -1,6 +1,6 @@ //@ [lang] //@ path = 'gen/interface/my/test/leaf-interface/stub.mbt' -//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "my/test/async-core", "alias": "async-core" }, { "path": "moonbitlang/core/encoding/utf8", "alias": "utf8" }] }""" +//@ pkg_config = """{ "warn-list": "-44", "supported-targets": "+wasm", "import": [{ "path": "my/test/async-core", "alias": "async-core" }, { "path": "moonbitlang/core/encoding/utf8", "alias": "utf8" }] }""" ///| priv struct BodyState { diff --git a/tests/runtime/moonbit/resource-payloads/runner.mbt b/tests/runtime/moonbit/resource-payloads/runner.mbt index e7770fae1..f951cfd9b 100644 --- a/tests/runtime/moonbit/resource-payloads/runner.mbt +++ b/tests/runtime/moonbit/resource-payloads/runner.mbt @@ -1,7 +1,7 @@ //@ wasmtime-flags = '-Wcomponent-model-async' //@ [lang] //@ path = 'gen/world/runner/stub.mbt' -//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "my/test/async-core", "alias": "async-core" }, { "path": "my/test/interface/my/test/leaf-interface", "alias": "leaf-interface" }, { "path": "my/test/interface/my/test/test-interface", "alias": "test-interface" }] }""" +//@ pkg_config = """{ "warn-list": "-44", "supported-targets": "+wasm", "import": [{ "path": "my/test/async-core", "alias": "async-core" }, { "path": "my/test/interface/my/test/leaf-interface", "alias": "leaf-interface" }, { "path": "my/test/interface/my/test/test-interface", "alias": "test-interface" }] }""" ///| async fn read_one_leaf( diff --git a/tests/runtime/moonbit/stream-write-cancel/test.mbt b/tests/runtime/moonbit/stream-write-cancel/test.mbt index 630427372..87b633d87 100644 --- a/tests/runtime/moonbit/stream-write-cancel/test.mbt +++ b/tests/runtime/moonbit/stream-write-cancel/test.mbt @@ -1,6 +1,6 @@ //@ [lang] //@ path = 'gen/interface/test/moonbit-stream-write-cancel/controller/stub.mbt' -//@ pkg_config = """{ "import": [{ "path": "test/moonbit-stream-write-cancel/async-core", "alias": "async-core" }, { "path": "test/moonbit-stream-write-cancel/interface/test/moonbit-stream-write-cancel/holder", "alias": "holder" }] }""" +//@ pkg_config = """{ "supported-targets": "+wasm", "import": [{ "path": "test/moonbit-stream-write-cancel/async-core", "alias": "async-core" }, { "path": "test/moonbit-stream-write-cancel/interface/test/moonbit-stream-write-cancel/holder", "alias": "holder" }] }""" ///| let never : (@async-core.Future[Unit], @async-core.Promise[Unit]) = diff --git a/tests/runtime/moonbit/wasi-cli-p3-stdout/runner.mbt b/tests/runtime/moonbit/wasi-cli-p3-stdout/runner.mbt index 86d82c972..c35e88179 100644 --- a/tests/runtime/moonbit/wasi-cli-p3-stdout/runner.mbt +++ b/tests/runtime/moonbit/wasi-cli-p3-stdout/runner.mbt @@ -1,7 +1,7 @@ //@ wasmtime-flags = '-Wcomponent-model-async -S p3' //@ [lang] //@ path = 'gen/world/runner/stub.mbt' -//@ pkg_config = """{ "warn-list": "-44", "import": [{ "path": "my/test/async-core", "alias": "async-core" }, { "path": "my/test/interface/my/test/i", "alias": "i" }] }""" +//@ pkg_config = """{ "warn-list": "-44", "supported-targets": "+wasm", "import": [{ "path": "my/test/async-core", "alias": "async-core" }, { "path": "my/test/interface/my/test/i", "alias": "i" }] }""" ///| pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { diff --git a/tests/runtime/moonbit/wasi-cli-p3-stdout/test.mbt b/tests/runtime/moonbit/wasi-cli-p3-stdout/test.mbt index 753229c10..89a72002d 100644 --- a/tests/runtime/moonbit/wasi-cli-p3-stdout/test.mbt +++ b/tests/runtime/moonbit/wasi-cli-p3-stdout/test.mbt @@ -1,6 +1,7 @@ //@ [lang] //@ path = 'gen/interface/my/test/i/stub.mbt' //@ pkg_config = """{ +//@ "supported-targets": "+wasm", //@ "import": [ //@ { "path": "my/test/async-core", "alias": "async-core" }, //@ { "path": "my/test/interface/wasi/cli/stdout", "alias": "stdout" } From b62422a7b62f9af01bb14c39bf9713dd64396148 Mon Sep 17 00:00:00 2001 From: zihang Date: Tue, 21 Jul 2026 11:58:10 +0800 Subject: [PATCH 09/10] fix(moonbit): correct async future rejection --- crates/moonbit/src/async/promise.mbt | 11 ++++- crates/moonbit/src/async/trait.mbt | 45 ++++++++++++++++--- crates/moonbit/src/async_support.rs | 18 ++++---- crates/moonbit/src/lib.rs | 22 +++++++++ .../moonbit/async-import-cancel/runner.mbt | 23 +++++++--- .../moonbit/async-import-cancel/test.rs | 4 ++ .../moonbit/async-import-cancel/test.wit | 1 + 7 files changed, 101 insertions(+), 23 deletions(-) diff --git a/crates/moonbit/src/async/promise.mbt b/crates/moonbit/src/async/promise.mbt index cce7b5cc2..e1be2004f 100644 --- a/crates/moonbit/src/async/promise.mbt +++ b/crates/moonbit/src/async/promise.mbt @@ -172,7 +172,10 @@ async fn[X] promised_future_get(cell : Ref[PromiseCell[X]]) -> X { } ///| -fn[X] promised_future_drop(cell : Ref[PromiseCell[X]]) -> Unit { +fn[X] promised_future_drop( + cell : Ref[PromiseCell[X]], + fallback_cleanup : ((X) -> Unit)?, +) -> Unit { if cell.val.reading { match cell.val.state { Pending => { @@ -191,7 +194,11 @@ fn[X] promised_future_drop(cell : Ref[PromiseCell[X]]) -> Unit { cell.val.state = Consumed match cell.val.cleanup { Some(cleanup) => cleanup(value) - None => () + None => + match fallback_cleanup { + Some(cleanup) => cleanup(value) + None => () + } } } Failed(_) | Closed => cell.val.state = Consumed diff --git a/crates/moonbit/src/async/trait.mbt b/crates/moonbit/src/async/trait.mbt index 516a3a0e1..e219e6695 100644 --- a/crates/moonbit/src/async/trait.mbt +++ b/crates/moonbit/src/async/trait.mbt @@ -179,7 +179,10 @@ priv struct LocalFutureState[X] { } ///| -fn[X] local_future_close(state : Ref[LocalFutureState[X]]) -> Unit { +fn[X] local_future_close( + state : Ref[LocalFutureState[X]], + fallback_cleanup : ((X) -> Unit)?, +) -> Unit { if state.val.closed { return } @@ -188,7 +191,11 @@ fn[X] local_future_close(state : Ref[LocalFutureState[X]]) -> Unit { state.val.value = None match state.val.cleanup { Some(cleanup) => cleanup(value) - None => () + None => + match fallback_cleanup { + Some(cleanup) => cleanup(value) + None => () + } } } None => () @@ -610,7 +617,7 @@ pub async fn[X] Future::get(self : Future[X]) -> X { pub async fn[X] Future::drop(self : Future[X]) -> Unit noraise { match self.inner { Source(source) => (source.close)() catch { _ => () } - Local(state) => local_future_close(state) + Local(state) => local_future_close(state, None) Pending(state) => { let cleanup = state.val.on_unstarted_drop state.val.producer = None @@ -620,7 +627,7 @@ pub async fn[X] Future::drop(self : Future[X]) -> Unit noraise { None => () } } - Promised(cell) => promised_future_drop(cell) + Promised(cell) => promised_future_drop(cell, None) } } @@ -629,7 +636,33 @@ pub async fn[X] Future::drop(self : Future[X]) -> Unit noraise { pub fn[X] Future::drop_sync(self : Future[X]) -> Unit { match self.inner { Source(source) => (source.close_sync)() - Local(state) => local_future_close(state) + Local(state) => local_future_close(state, None) + Pending(state) => { + let cleanup = state.val.on_unstarted_drop + state.val.producer = None + state.val.on_unstarted_drop = None + match cleanup { + Some(cleanup) => cleanup() + None => () + } + } + Promised(cell) => promised_future_drop(cell, None) + } +} + +///| +/// Rejects a future that was prepared for a component-model transfer but was +/// never transferred. A locally available value uses its registered cleanup, +/// falling back to the generated canonical-ABI cleanup. An unstarted producer +/// is discarded without running it. +#internal(wit_bindgen, "generated binding code only") +pub async fn[X] Future::reject( + self : Future[X], + cleanup : (X) -> Unit, +) -> Unit noraise { + match self.inner { + Source(source) => (source.close)() catch { _ => () } + Local(state) => local_future_close(state, Some(cleanup)) Pending(state) => { let cleanup = state.val.on_unstarted_drop state.val.producer = None @@ -639,7 +672,7 @@ pub fn[X] Future::drop_sync(self : Future[X]) -> Unit { None => () } } - Promised(cell) => promised_future_drop(cell) + Promised(cell) => promised_future_drop(cell, Some(cleanup)) } } diff --git a/crates/moonbit/src/async_support.rs b/crates/moonbit/src/async_support.rs index 52ce72a12..8b3c0420f 100644 --- a/crates/moonbit/src/async_support.rs +++ b/crates/moonbit/src/async_support.rs @@ -1458,9 +1458,14 @@ fn wasm{symbol_name}FutureRejectPrepared(handle : Int) -> Bool {{ defer wasmImport{symbol_name}DropWritable(writer) {ffi}protect_from_cancel( () => {{ - let value = producer.future.get() + producer.future.reject((value : {result}) => {{ + let ptr = wasm{symbol_name}Malloc(1) + wasm{symbol_name}Lower(value, ptr) + wasm{symbol_name}Reject(ptr, 0, 1) + {free_outer} + }}) let ptr = wasm{symbol_name}Malloc(1) - wasm{symbol_name}Lower(value, ptr) + defer {{ {free_outer} }} let terminal : Ref[Bool?] = {{ val: None }} while terminal.val is None {{ terminal.val = {ffi}suspend_for_future_write_terminal( @@ -1470,15 +1475,12 @@ fn wasm{symbol_name}FutureRejectPrepared(handle : Int) -> Bool {{ }} guard terminal.val is Some(transferred) if transferred {{ - wasm{symbol_name}Commit(ptr, 0, 1) - }} else {{ - wasm{symbol_name}Reject(ptr, 0, 1) + abort("rejected component future unexpectedly transferred a value") }} - {free_outer} }}, resume_on_cancel=true, ) catch {{ - _ => abort("rejected component future producer ended without a value") + _ => abort("failed to reject component future producer") }} }}) true @@ -2271,7 +2273,7 @@ fn wasm{symbol_name}Reject( package: &str, async_state: &mut AsyncFunctionState, ) -> String { - let mut f = FunctionBindgen::new(self, Box::new([])) + let mut f = FunctionBindgen::new(self, Box::new([address.to_string(), value.to_string()])) .with_type_context(package) .with_async_state(mem::take(async_state)) .without_block_cleanup(); diff --git a/crates/moonbit/src/lib.rs b/crates/moonbit/src/lib.rs index bd377de42..820274d00 100644 --- a/crates/moonbit/src/lib.rs +++ b/crates/moonbit/src/lib.rs @@ -3299,6 +3299,26 @@ mod tests { assert!(ffi.contains("extern \"wasm\" fn mbt_ffi_malloc"), "{ffi}"); } + #[test] + fn endpoint_payload_lowering_reserves_destination_pointer() { + let files = generate( + r#" + package a:b; + + interface api { + consume: async func(value: future); + } + + world client { import api; } + "#, + "client", + ); + + let ffi = file(&files, "interface/a/b/api/ffi.mbt"); + assert!(ffi.contains("let ptr0 = mbt_ffi_str2ptr(value)"), "{ffi}"); + assert!(ffi.contains("mbt_ffi_store32((ptr) + 0, ptr0)"), "{ffi}"); + } + #[test] fn type_only_endpoints_emit_async_runtime() { let files = generate( @@ -3621,6 +3641,8 @@ mod tests { assert!(!ffi.contains("wait_until")); assert!(ffi.contains("RelayFuture5FutureLowerCommitted")); assert!(ffi.contains("RelayFuture4FutureRejectPrepared")); + assert!(ffi.contains("producer.future.reject((value :")); + assert!(ffi.contains("rejected component future unexpectedly transferred a value")); assert!(ffi.contains("RelayStream3StreamRejectPrepared")); assert!(!ffi.contains("RelayFuture5FutureRejectPrepared")); diff --git a/tests/runtime/moonbit/async-import-cancel/runner.mbt b/tests/runtime/moonbit/async-import-cancel/runner.mbt index a847b733b..86cbc7b3b 100644 --- a/tests/runtime/moonbit/async-import-cancel/runner.mbt +++ b/tests/runtime/moonbit/async-import-cancel/runner.mbt @@ -20,14 +20,20 @@ async fn settle_leaf_counts(expected_drops : UInt) -> Unit { pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { guard @pending.leaf_live_count() == 0U else { panic() } guard @pending.leaf_drop_count() == 0U else { panic() } + guard @pending.consume_future_string(@async-core.Future::ready("hello")) else { + panic() + } @pending.backpressure_set(true) let leaf = @pending.Leaf::leaf() - let future = @async-core.Future::ready_with_cleanup(leaf, fn( - value : @pending.Leaf, - ) { - value.drop() - }) + let future_producer_ran = Ref(false) + let future = @async-core.Future::from( + async fn() { + future_producer_ran.val = true + leaf + }, + on_unstarted_drop=fn() { leaf.drop() }, + ) let (lowered, lowered_sink) = @async-core.Stream::new(capacity=1) let task = background_group.spawn(async fn() -> Unit { let signal : FixedArray[Unit] = [()] @@ -42,6 +48,7 @@ pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { task.cancel() @pending.settle() guard !@pending.pending_started() else { panic() } + guard !future_producer_ran.val else { panic() } settle_leaf_counts(1U) @@ -100,9 +107,11 @@ pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { settle_leaf_counts(5U) let cleanup_leaf = fn(value : @pending.Leaf) { value.drop() } + let (promised_future, promised_writer) = @async-core.Future::new() + guard promised_writer.complete(@pending.Leaf::leaf()) else { panic() } let stream_values : FixedArray[@async-core.Future[@pending.Leaf]] = [ - @async-core.Future::ready_with_cleanup(@pending.Leaf::leaf(), cleanup_leaf), - @async-core.Future::ready_with_cleanup(@pending.Leaf::leaf(), cleanup_leaf), + @async-core.Future::ready(@pending.Leaf::leaf()), + promised_future, @async-core.Future::ready_with_cleanup(@pending.Leaf::leaf(), cleanup_leaf), ] let cleanup_stream = @async-core.Stream::produce(async fn(sink) { diff --git a/tests/runtime/moonbit/async-import-cancel/test.rs b/tests/runtime/moonbit/async-import-cancel/test.rs index a432a3b78..2cb0513c2 100644 --- a/tests/runtime/moonbit/async-import-cancel/test.rs +++ b/tests/runtime/moonbit/async-import-cancel/test.rs @@ -41,6 +41,10 @@ impl Guest for Component { let _ = value.await; } + async fn consume_future_string(value: FutureReader) -> bool { + value.await == "hello" + } + fn pending_started() -> bool { PENDING_STARTED.load(Ordering::SeqCst) } diff --git a/tests/runtime/moonbit/async-import-cancel/test.wit b/tests/runtime/moonbit/async-import-cancel/test.wit index 1589ed5be..a1dd30ada 100644 --- a/tests/runtime/moonbit/async-import-cancel/test.wit +++ b/tests/runtime/moonbit/async-import-cancel/test.wit @@ -29,6 +29,7 @@ interface pending { } pending: async func(value: future); + consume-future-string: async func(value: future) -> bool; pending-started: func() -> bool; pending-stream: async func(value: stream); pending-stream-started: func() -> bool; From 9d5806eede1476712f03124d259f5aca4a8cebf3 Mon Sep 17 00:00:00 2001 From: zihang Date: Tue, 21 Jul 2026 19:27:20 +0800 Subject: [PATCH 10/10] fix(moonbit): harden async endpoint boundaries --- crates/moonbit/docs/async-design.md | 6 ++++ crates/moonbit/src/async_support.rs | 11 +++++-- crates/moonbit/src/lib.rs | 1 + tests/runtime/cancel-import/test.mbt | 10 ++++-- .../future-close-after-coming-back/test.mbt | 6 ---- .../moonbit/nested-future-stream/runner.rs | 6 +++- .../moonbit/nested-future-stream/test.mbt | 5 +-- .../moonbit/resource-payloads/runner.mbt | 24 +++++++++----- .../moonbit/resource-payloads/test.mbt | 5 +-- tests/runtime/simple-stream-payload/test.mbt | 31 ++++++++++--------- 10 files changed, 63 insertions(+), 42 deletions(-) delete mode 100644 tests/runtime/future-close-after-coming-back/test.mbt diff --git a/crates/moonbit/docs/async-design.md b/crates/moonbit/docs/async-design.md index 589a10515..1563f9a7e 100644 --- a/crates/moonbit/docs/async-design.md +++ b/crates/moonbit/docs/async-design.md @@ -163,6 +163,9 @@ background_group : @async-core.TaskGroup[Unit] The generated adapter publishes component task return when the implementation returns its result, then allows this group to finish hook-style work. The work remains structurally owned in MoonBit but cannot alter the published result. +If the implementation raises before publishing task return, the adapter traps: +WIT does not encode arbitrary MoonBit errors, and generic component +`error-context` integration is outside this MVP. ## Boundary Conversion @@ -355,6 +358,9 @@ Known limits: - explicitly captured resources require `on_unstarted_drop` before lazy production; lazy streams of managed values also require per-element cleanup after production starts; +- MoonBit cannot enforce linear ownership, so synchronous drop during an + aliased in-flight read is best effort; the active read retains responsibility + for eventual cleanup; - generated glue and user implementations still share a MoonBit package, so `#internal` is documentation control rather than an access boundary. diff --git a/crates/moonbit/src/async_support.rs b/crates/moonbit/src/async_support.rs index 8b3c0420f..02c25a040 100644 --- a/crates/moonbit/src/async_support.rs +++ b/crates/moonbit/src/async_support.rs @@ -1234,6 +1234,7 @@ impl<'a> InterfaceGenerator<'a> { .unwrap_or(0); let read_chunk_owns_buffer = result_type.is_some_and(|ty| self.is_list_canonical(ty)); let staging_window = if payload_sites.is_empty() { 64 } else { 1 }; + let max_read_count = 64; let EndpointPayloadFragments { lift, @@ -1898,7 +1899,13 @@ async fn Wasm{symbol_name}StreamSource::read( if self.reading {{ raise {ffi}EndpointBusy::Read }} - let ptr = wasm{symbol_name}Malloc(count) + // `Stream.read` promises at most `count`; bound one canonical allocation. + let read_count = if count < {max_read_count} {{ + count + }} else {{ + {max_read_count} + }} + let ptr = wasm{symbol_name}Malloc(read_count) let mut owns_buffer = false defer {{ if !owns_buffer {{ @@ -1912,7 +1919,7 @@ async fn Wasm{symbol_name}StreamSource::read( self.read_cleanup_done = false let (progress, end) = {ffi}suspend_for_stream_read( self.handle, - wasmImport{symbol_name}Read(self.handle, ptr, count), + wasmImport{symbol_name}Read(self.handle, ptr, read_count), ) catch {{ err => {{ if self.read_discarding {{ diff --git a/crates/moonbit/src/lib.rs b/crates/moonbit/src/lib.rs index 820274d00..802c804c2 100644 --- a/crates/moonbit/src/lib.rs +++ b/crates/moonbit/src/lib.rs @@ -3632,6 +3632,7 @@ mod tests { ); assert!(!ffi.contains("defer close_writer()")); assert!(ffi.contains("read_cleanup : @async-core.CondVar")); + assert!(ffi.contains("let read_count = if count < 64")); assert!(ffi.contains("self.read_cleanup.broadcast()")); assert!(ffi.contains("@async-core.cancel_future_read(")); assert!(ffi.contains("@async-core.cancel_stream_read(")); diff --git a/tests/runtime/cancel-import/test.mbt b/tests/runtime/cancel-import/test.mbt index e24774938..cf48531d2 100644 --- a/tests/runtime/cancel-import/test.mbt +++ b/tests/runtime/cancel-import/test.mbt @@ -1,6 +1,12 @@ //@ [lang] //@ path = 'gen/interface/my/test/i/stub.mbt' +///| +fn wasmBackpressureInc() = "$root" "[backpressure-inc]" + +///| +fn wasmBackpressureDec() = "$root" "[backpressure-dec]" + ///| pub async fn pending_import( x : @async-core.Future[Unit], @@ -12,8 +18,8 @@ pub async fn pending_import( ///| pub fn backpressure_set(x : Bool) -> Unit { if x { - @async-core.backpressure_inc() + wasmBackpressureInc() } else { - @async-core.backpressure_dec() + wasmBackpressureDec() } } diff --git a/tests/runtime/future-close-after-coming-back/test.mbt b/tests/runtime/future-close-after-coming-back/test.mbt deleted file mode 100644 index b6fc91dcf..000000000 --- a/tests/runtime/future-close-after-coming-back/test.mbt +++ /dev/null @@ -1,6 +0,0 @@ -//@ [lang] -//@ path = 'gen/interface/a/b/the-test/stub.mbt' - -pub fn f(_param : @async-core.Future[Unit]) -> @async-core.Future[Unit] { - _param -} diff --git a/tests/runtime/moonbit/nested-future-stream/runner.rs b/tests/runtime/moonbit/nested-future-stream/runner.rs index 52842639d..7087fc699 100644 --- a/tests/runtime/moonbit/nested-future-stream/runner.rs +++ b/tests/runtime/moonbit/nested-future-stream/runner.rs @@ -129,7 +129,11 @@ async fn rejects_nested_endpoints() { drop(output); outer_writer.write(inner_reader).await.unwrap(); - inner_writer.write(stream_reader).await.unwrap(); + if let Err(error) = inner_writer.write(stream_reader).await { + // A relay may stop at this rejected nested endpoint instead of + // consuming the returned stream. Release the returned reader here. + drop(error.value); + } let (result, remaining) = stream_writer.write(vec![9]).await; match result { StreamResult::Dropped => assert_eq!(remaining.remaining(), 1), diff --git a/tests/runtime/moonbit/nested-future-stream/test.mbt b/tests/runtime/moonbit/nested-future-stream/test.mbt index 6dde2fd03..cff97da26 100644 --- a/tests/runtime/moonbit/nested-future-stream/test.mbt +++ b/tests/runtime/moonbit/nested-future-stream/test.mbt @@ -18,10 +18,7 @@ pub async fn relay( value : @async-core.Future[@async-core.Future[@async-core.Stream[Byte]]], _background_group : @async-core.TaskGroup[Unit], ) -> @async-core.Future[@async-core.Future[@async-core.Stream[Byte]]] { - @async-core.Future::from(async fn() { - let inner = value.get() - @async-core.Future::from(async fn() { inner.get() }) - }) + value } ///| diff --git a/tests/runtime/moonbit/resource-payloads/runner.mbt b/tests/runtime/moonbit/resource-payloads/runner.mbt index f951cfd9b..6e39b9aca 100644 --- a/tests/runtime/moonbit/resource-payloads/runner.mbt +++ b/tests/runtime/moonbit/resource-payloads/runner.mbt @@ -57,14 +57,22 @@ async fn assert_no_live_resources() -> Unit { fn nested_leaf_stream( prefix : String, ) -> @async-core.Stream[@leaf-interface.LeafThing] { - @async-core.Stream::produce(async fn(sink) { - let values : FixedArray[@leaf-interface.LeafThing] = [ - @leaf-interface.LeafThing::leaf_thing(prefix + "-a"), - @leaf-interface.LeafThing::leaf_thing(prefix + "-b"), - ] - let _ = sink.write_all(values[:]) - sink.close() - }) + let values : FixedArray[@leaf-interface.LeafThing] = [ + @leaf-interface.LeafThing::leaf_thing(prefix + "-a"), + @leaf-interface.LeafThing::leaf_thing(prefix + "-b"), + ] + @async-core.Stream::produce( + async fn(sink) { + let _ = sink.write_all(values[:]) + sink.close() + }, + cleanup=fn(value) { value.drop() }, + on_unstarted_drop=fn() { + for value in values { + value.drop() + } + }, + ) } ///| diff --git a/tests/runtime/moonbit/resource-payloads/test.mbt b/tests/runtime/moonbit/resource-payloads/test.mbt index 477223aae..49330d123 100644 --- a/tests/runtime/moonbit/resource-payloads/test.mbt +++ b/tests/runtime/moonbit/resource-payloads/test.mbt @@ -467,10 +467,7 @@ pub async fn relay_nested_leaf( ) -> @async-core.Future[ @async-core.Future[@async-core.Stream[@leaf-interface.LeafThing]], ] { - @async-core.Future::from(async fn() { - let inner = value.get() - @async-core.Future::from(async fn() { inner.get() }) - }) + value } ///| diff --git a/tests/runtime/simple-stream-payload/test.mbt b/tests/runtime/simple-stream-payload/test.mbt index 48a18ef5d..6fd4086c1 100644 --- a/tests/runtime/simple-stream-payload/test.mbt +++ b/tests/runtime/simple-stream-payload/test.mbt @@ -1,26 +1,27 @@ //@ [lang] //@ path = 'gen/interface/my/test/i/stub.mbt' +///| pub async fn read_stream( x : @async-core.Stream[Byte], _background_group : @async-core.TaskGroup[Unit], -) -> Unit raise { - let chunk0 = x.read(1).unwrap() - assert_eq(chunk0.length(), 1) - assert_eq(chunk0[0], b'\x00') +) -> Unit { + let chunk0 = x.read(1).unwrap() + assert_eq(chunk0.length(), 1) + assert_eq(chunk0[0], b'\x00') - let chunk1 = x.read(2).unwrap() - assert_eq(chunk1.length(), 2) - assert_eq(chunk1[0], b'\x01') - assert_eq(chunk1[1], b'\x02') + let chunk1 = x.read(2).unwrap() + assert_eq(chunk1.length(), 2) + assert_eq(chunk1[0], b'\x01') + assert_eq(chunk1[1], b'\x02') - let chunk2 = x.read(1).unwrap() - assert_eq(chunk2.length(), 1) - assert_eq(chunk2[0], b'\x03') + let chunk2 = x.read(1).unwrap() + assert_eq(chunk2.length(), 1) + assert_eq(chunk2[0], b'\x03') - let chunk3 = x.read(1).unwrap() - assert_eq(chunk3.length(), 1) - assert_eq(chunk3[0], b'\x04') + let chunk3 = x.read(0x7fffffff).unwrap() + assert_eq(chunk3.length(), 1) + assert_eq(chunk3[0], b'\x04') - x.drop() + x.drop() }