From 13b4a88853f55cad6a54fc02529fbd92ab9ee069 Mon Sep 17 00:00:00 2001 From: zihang Date: Mon, 12 Oct 2026 15:17:43 +0800 Subject: [PATCH 1/5] fix(moonbit)!: separate exported resource objects from handles --- crates/moonbit/docs/resources.md | 140 ++++++++ crates/moonbit/src/async_support.rs | 2 +- crates/moonbit/src/lib.rs | 319 +++++++++--------- crates/moonbit/src/pkg.rs | 77 +++-- crates/moonbit/src/resources.rs | 295 ++++++++++++++++ .../resource-payload-name-collision.wit | 44 +++ .../moonbit/managed-resources/runner.rs | 42 +++ .../moonbit/managed-resources/test.mbt | 108 ++++++ .../moonbit/managed-resources/test.wit | 35 ++ .../moonbit/resource-payloads/leaf.mbt | 110 ++---- tests/runtime/resources/resources.mbt | 77 ++--- 11 files changed, 945 insertions(+), 304 deletions(-) create mode 100644 crates/moonbit/docs/resources.md create mode 100644 crates/moonbit/src/resources.rs create mode 100644 tests/codegen/resource-payload-name-collision.wit create mode 100644 tests/runtime/moonbit/managed-resources/runner.rs create mode 100644 tests/runtime/moonbit/managed-resources/test.mbt create mode 100644 tests/runtime/moonbit/managed-resources/test.wit diff --git a/crates/moonbit/docs/resources.md b/crates/moonbit/docs/resources.md new file mode 100644 index 000000000..f486b844d --- /dev/null +++ b/crates/moonbit/docs/resources.md @@ -0,0 +1,140 @@ +# Resource implementations and handles + +The resource author implements the original WIT type name. For an exported +`counter`, this is `Counter`; the bindings generate `CounterHandle` for owning, +transferring, and dropping the resource. An importing component sees just +`Counter`, a generated proxy whose methods call the implementing component. + +Implement the declared type and resource functions in a separate file in the +generated interface package. The bindings retain objects in a private table; +applications do not allocate representation integers or maintain resource maps. + +For this WIT: + +```wit +resource counter { + constructor(start: u32); + get: func() -> u32; +} +``` + +The generated requirements are: + +```moonbit +declare type Counter +declare pub fn Counter::counter(start : UInt) -> Counter +declare pub fn Counter::get(self : Counter) -> UInt +declare fn Counter::dtor(self : Counter) -> Unit +``` + +An implementation is: + +```moonbit +///| +struct Counter { + value : UInt +} + +///| +pub fn Counter::counter(start : UInt) -> Counter { + { value: start } +} + +///| +pub fn Counter::get(self : Counter) -> UInt { + self.value +} + +///| +fn Counter::dtor(_self : Counter) -> Unit { + () +} +``` + +The WIT constructor adapter registers the returned object and transfers the +resulting owned resource to its caller. A fallible constructor returns +`Result[Counter, E]`; only `Ok` is wrapped, and `Err` is passed through unchanged. +Constructing a `Counter` directly in +MoonBit only creates the application object. To create a locally owned component +resource, use `CounterHandle::counter(start)` or wrap an existing object with +`CounterHandle::new(counter)`. + +## Methods and ownership + +- WIT constructors, methods, and static functions are implemented on `Counter`. + Generated counterparts on `CounterHandle` forward to those implementations. + Thus `counter.get()` and `handle.get()` operate on the same object, while the + latter first checks that the handle is still owned. +- An exported `own` parameter or result uses `CounterHandle`. The WIT + constructor implementation is the exception: it returns `Counter`, which the + bindings wrap automatically. Other functions returning an owned resource + return a handle, preserving the ability to transfer an existing resource. +- An exported `borrow` parameter uses `Counter`, including inside + records, variants, and lists. Resource methods receive this object as `self`. +- `handle.payload()` accesses the object of a locally owned resource. +- Transferring ownership invalidates the wrapper and its aliases. Calling a + forwarded method, accessing the object, or transferring the same wrapper + again traps. If the resource returns, the bindings create a new owning + wrapper referring to the same stored object. +- `handle.drop()` releases a locally owned resource. Repeating it, or dropping + a wrapper whose ownership was transferred, does nothing. +- Async forwarding methods accept the same background task group as their + implementation methods. + +`Counter` is an ordinary MoonBit value. The bindings do not enforce a borrow +lifetime on references to that value. Keeping an object reference does not +extend the component resource's lifetime or keep application resources open +after its destructor runs. Imported resources retain their existing ownership +behavior and constructor/method names. + +## Visibility and generated names + +Use a plain `struct Counter` (or another abstract type definition) to keep the +representation hidden from other MoonBit packages. The type name remains +accessible so other exported interfaces can accept a borrowed counter. +`pub struct` exposes its fields for reading, while `priv struct` is unsuitable +when the type appears in public signatures. The destructor can be a private +function because its ABI adapter lives in the same package. + +Generated handle representations are abstract on both the exporting and +importing sides. Storage and resource intrinsics are private. Raw conversion +helpers needed by generated adapters in other packages are marked `#doc(hidden)`; +these public helpers are not application APIs. MoonBit package visibility does +not change the WIT interface exposed to other components. + +WIT names take precedence over generated names. If `counter-handle` is already +a WIT type, the owning wrapper receives a numeric suffix. Likewise, if a WIT +method uses a helper name such as `drop`, `new`, or `payload`, the generated +helper receives a suffix and the WIT method keeps its original name. Consult the +generated declarations for the allocated names. + +## Destruction + +The generated destructor removes the object from storage before calling +`Counter::dtor`. The application destructor receives the actual object and can +release files, endpoints, or other resources it owns. Memory-only objects can +use an empty destructor. + +Storage slots are reused through array indexing and a free-slot stack. Cleanup +may create or destroy other resources, including reusing the released slot. +Owned handles still require explicit transfer or `drop()`; MoonBit garbage +collection does not release a component resource automatically. + +## Migrating integer-based implementations + +This changes the generated MoonBit interface for exported resources. The WIT +interface and component ABI are unchanged. + +1. Define the actual resource state under its original name, such as `Counter`. +2. Return that object from the WIT constructor implementation. +3. Implement borrowed methods directly on the object, replacing map lookups. +4. Use `CounterHandle` for other owned parameters and results. Create resources + with `CounterHandle::new(object)` or `CounterHandle::counter(arguments)`. +5. Replace `value.rep()` and subsequent map lookups with `value.payload()`, or + call a forwarded WIT method on the handle. +6. Keep `Counter::dtor` for application cleanup and remove map deletion and ID + allocation logic. + +Generated value derives are omitted for aggregates containing borrowed +implementation objects: those objects need not implement `Debug`, `Show`, or +`Eq`. The application can supply these implementations when needed. diff --git a/crates/moonbit/src/async_support.rs b/crates/moonbit/src/async_support.rs index 11a1bc572..0a5abd047 100644 --- a/crates/moonbit/src/async_support.rs +++ b/crates/moonbit/src/async_support.rs @@ -703,7 +703,7 @@ fn moonbit_identifier_stem(input: &str) -> String { .to_snake_case() } -fn background_group_name(func: &Function) -> String { +pub(super) fn background_group_name(func: &Function) -> String { let mut names = Ns::default(); for param in &func.params { names.tmp(¶m.name.to_moonbit_ident()); diff --git a/crates/moonbit/src/lib.rs b/crates/moonbit/src/lib.rs index 9c3142b89..5fbf7c8b2 100644 --- a/crates/moonbit/src/lib.rs +++ b/crates/moonbit/src/lib.rs @@ -13,10 +13,10 @@ use wit_bindgen_core::{ abi::{self, AbiVariant, Bindgen, Bitcast, Instruction, LiftLower, WasmType}, uwrite, uwriteln, wit_parser::{ - Alignment, ArchitectureSize, Docs, Enum, Flags, FlagsRepr, Function, Int, InterfaceId, - LiftLowerAbi, LiveTypes, ManglingAndAbi, Param, Record, Resolve, ResourceIntrinsic, - Result_, SizeAlign, Tuple, Type, TypeDefKind, TypeId, Variant, WasmExport, WasmExportKind, - WasmImport, WorldId, WorldKey, + Alignment, ArchitectureSize, Docs, Enum, Flags, FlagsRepr, Function, FunctionKind, Handle, + Int, InterfaceId, LiftLowerAbi, LiveTypes, ManglingAndAbi, Param, Record, Resolve, + ResourceIntrinsic, Result_, SizeAlign, Tuple, Type, TypeDefKind, TypeId, Variant, + WasmExport, WasmExportKind, WasmImport, WorldId, WorldKey, }, }; @@ -28,6 +28,7 @@ use crate::pkg::{ mod async_support; mod ffi; mod pkg; +mod resources; // Assumptions: // - Data: u8 -> Byte, s8 | s16 | s32 -> Int, u16 | u32 -> UInt, s64 -> Int64, u64 -> UInt64, f32 | f64 -> Double, address -> Int @@ -419,6 +420,19 @@ impl WorldGenerator for MoonBit { .export_interface_names .insert(id, name.clone()); + // Preserve the WIT name for the implementation type. Allocate a separate + // owning wrapper name without colliding with other WIT types. + let mut type_names = Ns::default(); + for (name, _) in &resolve.interfaces[id].types { + let _ = type_names.insert(&name.to_moonbit_type_ident()); + } + for (name, ty) in &resolve.interfaces[id].types { + if matches!(resolve.types[*ty].kind, TypeDefKind::Resource) { + let handle = type_names.tmp(&format!("{}Handle", name.to_moonbit_type_ident())); + self.pkg_resolver.resource_handle_names.insert(*ty, handle); + } + } + let mut r#gen = self.interface(resolve, &name, Direction::Export, Some(key)); r#gen.types(id); @@ -596,6 +610,20 @@ struct InterfaceGenerator<'a> { } impl InterfaceGenerator<'_> { + fn type_omits_value_derives(&self, ty: &Type) -> bool { + let mut live = LiveTypes::default(); + live.add_type(self.resolve, ty); + live.iter().any(|id| match self.resolve.types[id].kind { + TypeDefKind::Future(_) | TypeDefKind::Stream(_) => true, + TypeDefKind::Handle(Handle::Borrow(resource)) => self + .world_gen + .pkg_resolver + .resource_handle_names + .contains_key(&wit_bindgen_core::dealias(self.resolve, resource)), + _ => false, + }) + } + fn emit_derived_methods(&mut self, name: &str, traits: &[&str]) { // MoonBit used to attach trait methods to types automatically through // `impl`, including derived implementations. During the transition to @@ -732,6 +760,8 @@ impl InterfaceGenerator<'_> { "# ); + self.export_resource_forwarder(func, &mbt_sig, async_plan.signature_is_async()); + let endpoint_plan = self.export_async_function_plan(self.interface, func); let mut bindgen = FunctionBindgen::new( self, @@ -919,9 +949,28 @@ impl InterfaceGenerator<'_> { self.add_async_export_stub_parameter(func, async_, &mut params); let params = params.join(", "); - let result_type = match &sig.result_type { - None => "Unit".into(), - Some(ty) => self.world_gen.pkg_resolver.type_name(self.name, ty), + let result_type = match func.kind { + FunctionKind::Constructor(id) if self.direction == Direction::Export => { + let resolver = &mut self.world_gen.pkg_resolver; + let object = resolver.resource_impl_name(self.name, id).unwrap(); + let Some(Type::Id(result)) = sig.result_type else { + unreachable!() + }; + if let TypeDefKind::Result(result) = &self.resolve.types[result].kind { + let err = result + .err + .as_ref() + .map(|ty| resolver.type_name(self.name, ty)) + .unwrap_or_else(|| "Unit".into()); + format!("Result[{object}, {err}]") + } else { + object + } + } + _ => match &sig.result_type { + None => "Unit".into(), + Some(ty) => self.world_gen.pkg_resolver.type_name(self.name, ty), + }, }; format!( "pub {}fn {}({params}) -> {}", @@ -955,18 +1004,18 @@ impl<'a> wit_bindgen_core::InterfaceGenerator<'a> for InterfaceGenerator<'a> { .collect::>() .join("; "); - let contains_endpoint = record + let omit_derives = record .fields .iter() - .any(|field| type_contains_future_or_stream(self.resolve, &field.ty)); + .any(|field| self.type_omits_value_derives(&field.ty)); let mut deriviation: Vec<_> = Vec::new(); - if self.derive_opts.derive_debug && !contains_endpoint { + if self.derive_opts.derive_debug && !omit_derives { deriviation.push("Debug") } - if self.derive_opts.derive_show && !contains_endpoint { + if self.derive_opts.derive_show && !omit_derives { deriviation.push("Show") } - if self.derive_opts.derive_eq && !contains_endpoint { + if self.derive_opts.derive_eq && !omit_derives { deriviation.push("Eq") } @@ -989,7 +1038,11 @@ impl<'a> wit_bindgen_core::InterfaceGenerator<'a> for InterfaceGenerator<'a> { fn type_resource(&mut self, id: TypeId, name: &str, docs: &Docs) { print_docs(&mut self.src, docs); - let name = name.to_moonbit_type_ident(); + let name = if self.direction == Direction::Export { + self.world_gen.pkg_resolver.resource_handle_names[&id].clone() + } else { + name.to_moonbit_type_ident() + }; let mut deriviation: Vec<_> = Vec::new(); if self.derive_opts.derive_debug { @@ -998,25 +1051,41 @@ impl<'a> wit_bindgen_core::InterfaceGenerator<'a> for InterfaceGenerator<'a> { if self.derive_opts.derive_show { deriviation.push("Show") } - if self.derive_opts.derive_eq { + if self.derive_opts.derive_eq && self.direction == Direction::Import { deriviation.push("Eq") } + let handle_type = if self.direction == Direction::Export { + "Ref[Int?]" + } else { + "Int" + }; let declaration = if self.derive_opts.derive_error && name.contains("Error") { - format!("suberror {name} {{ {name}(Int) }}") + format!("suberror {name} {{ {name}({handle_type}) }}") + } else if self.direction == Direction::Export { + format!("struct {name} {{ handle : Ref[Int?] }}") } else { - format!("struct {name}(Int)") + format!("struct {name}({handle_type})") }; uwrite!( self.src, r#" - pub(all) {declaration} derive({}) + {declaration} derive({}) "#, deriviation.join(", "), ); self.emit_derived_methods(&name, &deriviation); if self.direction == Direction::Import { + let drop = self.world_gen.pkg_resolver.resource_helper_name(id, "drop"); + let from = self + .world_gen + .pkg_resolver + .resource_helper_name(id, "wasm_from_handle"); + let get = self + .world_gen + .pkg_resolver + .resource_helper_name(id, "wasm_handle"); let (drop_module, drop_name) = self.resolve.wasm_import_name( ManglingAndAbi::Legacy(LiftLowerAbi::Sync), WasmImport::ResourceIntrinsic { @@ -1030,124 +1099,35 @@ impl<'a> wit_bindgen_core::InterfaceGenerator<'a> for InterfaceGenerator<'a> { r#" ///| /// Drops a resource handle. - pub fn {name}::drop(self : {name}) -> Unit {{ + pub fn {name}::{drop}(self : {name}) -> Unit {{ let {name}(resource) = self wasmImportResourceDrop{name}(resource) }} - "#, - ); - uwrite!( - &mut self.ffi, - r#" ///| - fn wasmImportResourceDrop{name}(resource : Int) = "{drop_module}" "{drop_name}" - "#, - ) - } else { - let (drop_module, drop_name) = self.resolve.wasm_import_name( - ManglingAndAbi::Legacy(LiftLowerAbi::Sync), - WasmImport::ResourceIntrinsic { - resource: id, - interface: self.interface, - intrinsic: ResourceIntrinsic::ExportedDrop, - }, - ); - let (new_module, new_name) = self.resolve.wasm_import_name( - ManglingAndAbi::Legacy(LiftLowerAbi::Sync), - WasmImport::ResourceIntrinsic { - resource: id, - interface: self.interface, - intrinsic: ResourceIntrinsic::ExportedNew, - }, - ); - let (rep_module, rep_name) = self.resolve.wasm_import_name( - ManglingAndAbi::Legacy(LiftLowerAbi::Sync), - WasmImport::ResourceIntrinsic { - resource: id, - interface: self.interface, - intrinsic: ResourceIntrinsic::ExportedRep, - }, - ); - uwrite!( - &mut self.src, - r#" - ///| - /// Creates a new resource with the given `rep` as its representation and returning the handle to this resource. - pub fn {name}::new(rep : Int) -> {name} {{ - {name}::{name}(wasmExportResourceNew{name}(rep)) - }} - ///| - fn wasmExportResourceNew{name}(rep : Int) -> Int = "{new_module}" "{new_name}" - - ///| - /// Drops a resource handle. - pub fn {name}::drop(self : Self) -> Unit {{ - let {name}(resource) = self - wasmExportResourceDrop{name}(resource) - }} - ///| - fn wasmExportResourceDrop{name}(resource : Int) = "{drop_module}" "{drop_name}" - - ///| - /// Gets the `Int` representation of the resource pointed to the given handle. - pub fn {name}::rep(self : Self) -> Int {{ - let {name}(resource) = self - wasmExportResourceRep{name}(resource) + #doc(hidden) + pub fn {name}::{from}(handle : Int) -> {name} {{ + {name}(handle) }} - ///| - fn wasmExportResourceRep{name}(resource : Int) -> Int = "{rep_module}" "{rep_name}" - "#, - ); - - uwrite!( - &mut self.src, - r#" - ///| - /// Destructor of the resource. - declare pub fn {name}::dtor(_self : {name}) -> Unit - "# - ); - let func_name = self - .world_gen - .export_ns - .tmp(&format!("wasmExport{name}Dtor")); - - uwrite!( - self.ffi, - r#" ///| #doc(hidden) - pub fn {func_name}(handle : Int) -> Unit {{ - {name}::dtor({name}(handle)) + pub fn {name}::{get}(self : {name}) -> Int {{ + let {name}(handle) = self + handle }} "#, ); - let export_name = self.resolve.wasm_export_name( - ManglingAndAbi::Legacy(LiftLowerAbi::Sync), - WasmExport::ResourceDtor { - interface: self.interface.unwrap(), - resource: id, - }, - ); - - let export = format!( + uwrite!( + &mut self.ffi, r#" ///| - #doc(hidden) - pub fn {func_name}(handle : Int) -> Unit {{ - {}{func_name}(handle) - }} + fn wasmImportResourceDrop{name}(resource : Int) = "{drop_module}" "{drop_name}" "#, - self.world_gen - .pkg_resolver - .qualify_package(self.world_gen.opts.gen_dir.as_str(), self.name), - ); - self.world_gen - .export - .insert(export_name, (func_name, export)); + ) + } else { + self.export_resource(id, &name); } } @@ -1276,19 +1256,19 @@ impl<'a> wit_bindgen_core::InterfaceGenerator<'a> for InterfaceGenerator<'a> { .collect::>() .join("\n "); - let contains_endpoint = variant + let omit_derives = variant .cases .iter() .filter_map(|case| case.ty.as_ref()) - .any(|ty| type_contains_future_or_stream(self.resolve, ty)); + .any(|ty| self.type_omits_value_derives(ty)); let mut deriviation: Vec<_> = Vec::new(); - if self.derive_opts.derive_debug && !contains_endpoint { + if self.derive_opts.derive_debug && !omit_derives { deriviation.push("Debug") } - if self.derive_opts.derive_show && !contains_endpoint { + if self.derive_opts.derive_show && !omit_derives { deriviation.push("Show") } - if self.derive_opts.derive_eq && !contains_endpoint { + if self.derive_opts.derive_eq && !omit_derives { deriviation.push("Eq") } let declaration = if self.derive_opts.derive_error && name.contains("Error") { @@ -1842,31 +1822,69 @@ impl Bindgen for FunctionBindgen<'_, '_> { } }, - Instruction::HandleLower { ty, .. } => { + Instruction::HandleLower { handle, .. } => { let op = &operands[0]; - let handle = self.locals.tmp("handle"); - let ty = self.resolve_constructor(&Type::Id(*ty)); - uwrite!( - self.src, - r#" - let {ty}({handle}) = {op} - "# - ); - results.push(handle); + let (Handle::Own(resource) | Handle::Borrow(resource)) = handle; + let resource = wit_bindgen_core::dealias(self.interface_gen.resolve, *resource); + let managed = self + .interface_gen + .world_gen + .pkg_resolver + .resource_handle_names + .contains_key(&resource); + if managed { + // A locally implemented borrow only arrives as an export argument; + // WIT prohibits returning borrows. Imported resources retain handles. + assert!(matches!(handle, Handle::Own(_))); + let handle = self.locals.tmp("handle"); + let take = self + .interface_gen + .world_gen + .pkg_resolver + .resource_helper_name(resource, "wasm_take_handle"); + uwriteln!(self.src, "let {handle} = {op}.{take}()"); + results.push(handle); + } else { + let handle = self.locals.tmp("handle"); + let get = self + .interface_gen + .world_gen + .pkg_resolver + .resource_helper_name(resource, "wasm_handle"); + uwriteln!(self.src, "let {handle} = {op}.{get}()"); + results.push(handle); + } } - Instruction::HandleLift { ty, .. } => { + Instruction::HandleLift { ty, handle, .. } => { let op = &operands[0]; + let (Handle::Own(resource) | Handle::Borrow(resource)) = handle; + let resource = wit_bindgen_core::dealias(self.interface_gen.resolve, *resource); + let managed = self + .interface_gen + .world_gen + .pkg_resolver + .resource_handle_names + .contains_key(&resource); let ty = self.resolve_constructor(&Type::Id(*ty)); - results.push(format!( - "{}::{}({})", - ty, - if ty.starts_with("@") { - ty.split('.').next_back().unwrap() - } else { - &ty - }, - op - )); + if managed { + let method = match handle { + Handle::Own(_) => "wasm_from_handle", + Handle::Borrow(_) => "wasm_borrow", + }; + let method = self + .interface_gen + .world_gen + .pkg_resolver + .resource_helper_name(resource, method); + results.push(format!("{ty}::{method}({op})")); + } else { + let from = self + .interface_gen + .world_gen + .pkg_resolver + .resource_helper_name(resource, "wasm_from_handle"); + results.push(format!("{ty}::{from}({op})")); + } } Instruction::RecordLower { record, .. } => { @@ -3015,17 +3033,6 @@ fn flags_repr(flags: &Flags) -> Int { } } -fn type_contains_future_or_stream(resolve: &Resolve, ty: &Type) -> bool { - 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( resolve: &Resolve, ty: &Type, diff --git a/crates/moonbit/src/pkg.rs b/crates/moonbit/src/pkg.rs index 2f01db0e8..bfdaafd75 100644 --- a/crates/moonbit/src/pkg.rs +++ b/crates/moonbit/src/pkg.rs @@ -5,7 +5,7 @@ use wit_bindgen_core::{ Ns, dealias, wit_parser::{ Function, FunctionKind, Handle, InterfaceId, Param, Resolve, Type, TypeDef, TypeDefKind, - TypeOwner, WorldId, WorldKey, + TypeId, TypeOwner, WorldId, WorldKey, }, }; @@ -30,9 +30,39 @@ pub(crate) struct PkgResolver { pub package_import: HashMap, pub import_interface_names: HashMap, pub export_interface_names: HashMap, + pub resource_handle_names: HashMap, } impl PkgResolver { + pub(crate) fn resource_impl_name(&mut self, this: &str, id: TypeId) -> Option { + let id = dealias(&self.resolve, id); + self.resource_handle_names.get(&id)?; + let ty = self.resolve.types[id].clone(); + let name = ty.name.as_ref()?.to_moonbit_type_ident(); + Some(format!("{}{name}", self.qualifier(this, &ty))) + } + + // WIT methods keep their names. Lifecycle and ABI helpers get a suffix when + // necessary, so a resource may itself have a method named `drop` or `new`. + pub(crate) fn resource_helper_name(&self, id: TypeId, helper: &str) -> String { + let id = dealias(&self.resolve, id); + let mut names = Ns::default(); + if let TypeOwner::Interface(interface) = self.resolve.types[id].owner { + for func in self.resolve.interfaces[interface].functions.values() { + if func.kind.resource() == Some(id) { + let name = match func.kind { + FunctionKind::Constructor(_) => { + func.name.replace("[constructor]", "").to_moonbit_ident() + } + _ => func.name.split('.').next_back().unwrap().to_moonbit_ident(), + }; + let _ = names.insert(&name); + } + } + } + names.tmp(helper) + } + pub(crate) fn qualify_package(&mut self, this: &str, name: &str) -> String { if name != this { let imports = self.package_import.entry(this.to_string()).or_default(); @@ -105,7 +135,9 @@ impl PkgResolver { | FunctionKind::Static(ty) | FunctionKind::AsyncMethod(ty) | FunctionKind::AsyncStatic(ty) => { - let name = self.type_constructor(this, &Type::Id(ty)); + let name = self + .resource_impl_name(this, ty) + .unwrap_or_else(|| self.type_constructor(this, &Type::Id(ty))); format!( "{}::{}", name, @@ -125,7 +157,11 @@ impl PkgResolver { match ty { Type::ErrorContext => unimplemented!("moonbit error context type name"), Type::Id(id) => { - let ty = self.resolve.types[dealias(&self.resolve, *id)].clone(); + let id = dealias(&self.resolve, *id); + let ty = self.resolve.types[id].clone(); + if let Some(name) = self.resource_handle_names.get(&id).cloned() { + return format!("{}{name}", self.qualifier(this, &ty)); + } match ty.kind { TypeDefKind::Type(ty) => self.type_constructor(this, &ty), TypeDefKind::Handle(handle) => { @@ -133,16 +169,7 @@ impl PkgResolver { Handle::Own(ty) => ty, Handle::Borrow(ty) => ty, }; - let ty = self.resolve.types[dealias(&self.resolve, ty)].clone(); - if let Some(name) = &ty.name { - format!( - "{}{}", - self.qualifier(this, &ty), - name.to_moonbit_type_ident() - ) - } else { - unreachable!() - } + self.type_constructor(this, &Type::Id(ty)) } TypeDefKind::Enum(_) | TypeDefKind::Resource @@ -179,6 +206,13 @@ impl PkgResolver { } pub(crate) fn type_name(&mut self, this: &str, ty: &Type) -> String { + if let Type::Id(id) = ty + && let TypeDefKind::Handle(Handle::Borrow(resource)) = + self.resolve.types[dealias(&self.resolve, *id)].kind + && let Some(name) = self.resource_impl_name(this, resource) + { + return name; + } match ty { Type::Bool => "Bool".into(), Type::U8 => "Byte".into(), @@ -240,17 +274,9 @@ impl PkgResolver { Handle::Own(ty) => ty, Handle::Borrow(ty) => ty, }; - let ty = self.resolve.types[dealias(&self.resolve, ty)].clone(); - if let Some(name) = &ty.name { - format!( - "{}{}", - self.qualifier(this, &ty), - name.to_moonbit_type_ident() - ) - } else { - unreachable!() - } + self.type_constructor(this, &Type::Id(ty)) } + TypeDefKind::Resource => self.type_constructor(this, &Type::Id(*id)), TypeDefKind::Future(ty) => { let qualifier = self.qualify_package(this, ASYNC_CORE_DIR); @@ -330,7 +356,10 @@ impl PkgResolver { }; let type_name = match func.kind.resource() { Some(ty) => { - format!("{}::", self.type_constructor(this, &Type::Id(ty))) + let name = self + .resource_impl_name(this, ty) + .unwrap_or_else(|| self.type_constructor(this, &Type::Id(ty))); + format!("{name}::") } None => "".into(), }; diff --git a/crates/moonbit/src/resources.rs b/crates/moonbit/src/resources.rs new file mode 100644 index 000000000..820da9791 --- /dev/null +++ b/crates/moonbit/src/resources.rs @@ -0,0 +1,295 @@ +use std::fmt::Write; + +use wit_bindgen_core::{ + uwrite, uwriteln, + wit_parser::{ + Function, FunctionKind, LiftLowerAbi, ManglingAndAbi, ResourceIntrinsic, Type, TypeDefKind, + TypeId, WasmExport, WasmImport, + }, +}; + +use crate::{ + InterfaceGenerator, + async_support::background_group_name, + pkg::{MoonbitSignature, ToMoonBitTypeIdent}, +}; + +impl InterfaceGenerator<'_> { + // The implementation receives the object; callers of an owned handle get + // the same WIT methods after a checked lookup. Constructors register the + // returned object exactly once before the ABI transfers ownership. + pub(super) fn export_resource_forwarder( + &mut self, + func: &Function, + sig: &MoonbitSignature, + async_: bool, + ) { + let Some(id) = func.kind.resource() else { + return; + }; + if !self + .world_gen + .pkg_resolver + .resource_handle_names + .contains_key(&id) + { + return; + } + let resolver = &mut self.world_gen.pkg_resolver; + let handle = resolver.type_constructor(self.name, &Type::Id(id)); + let payload = resolver.resource_helper_name(id, "payload"); + let new = resolver.resource_helper_name(id, "new"); + let (_, method) = sig.name.rsplit_once("::").unwrap(); + let is_method = matches!( + func.kind, + FunctionKind::Method(_) | FunctionKind::AsyncMethod(_) + ); + let mut params = Vec::new(); + let mut args = Vec::new(); + for (i, (name, ty)) in sig.params.iter().enumerate() { + let (ty, arg) = if i == 0 && is_method { + (handle.clone(), format!("{name}.{payload}()")) + } else { + (resolver.type_name(self.name, ty), name.clone()) + }; + params.push(format!("{name} : {ty}")); + args.push(arg); + } + let result = sig + .result_type + .as_ref() + .map(|ty| resolver.type_name(self.name, ty)) + .unwrap_or_else(|| "Unit".into()); + self.add_async_export_stub_parameter(func, async_, &mut params); + if async_ { + args.push(background_group_name(func)); + } + let call = format!("{}({})", sig.name, args.join(", ")); + let call = if matches!(func.kind, FunctionKind::Constructor(_)) { + let Some(Type::Id(result)) = sig.result_type else { + unreachable!() + }; + if matches!(self.resolve.types[result].kind, TypeDefKind::Result(_)) { + format!( + "match {call} {{ Ok(value) => Ok({handle}::{new}(value)); Err(error) => Err(error) }}" + ) + } else { + format!("{handle}::{new}({call})") + } + } else { + call + }; + let async_keyword = if async_ { "async " } else { "" }; + let params = params.join(", "); + uwrite!( + self.src, + r#" + ///| + pub {async_keyword}fn {handle}::{method}({params}) -> {result} {{ + {call} + }} + "# + ); + } + + pub(super) fn export_resource(&mut self, id: TypeId, name: &str) { + let data = self.resolve.types[id] + .name + .as_ref() + .unwrap() + .to_moonbit_type_ident(); + let resolver = &self.world_gen.pkg_resolver; + let new = resolver.resource_helper_name(id, "new"); + let payload = resolver.resource_helper_name(id, "payload"); + let drop = resolver.resource_helper_name(id, "drop"); + let dtor = resolver.resource_helper_name(id, "dtor"); + let from = resolver.resource_helper_name(id, "wasm_from_handle"); + let take = resolver.resource_helper_name(id, "wasm_take_handle"); + let borrow = resolver.resource_helper_name(id, "wasm_borrow"); + let is_error = self.derive_opts.derive_error && name.contains("Error"); + let get_state = if is_error { + format!("let {name}(state) = self") + } else { + "let state = self.handle".into() + }; + let wrap_handle = if is_error { + format!("{name}(Ref(Some(handle)))") + } else { + "{ handle: Ref(Some(handle)) }".into() + }; + if self.derive_opts.derive_eq { + let other_state = if is_error { + format!("let {name}(other_state) = other") + } else { + "let other_state = other.handle".into() + }; + uwriteln!( + self.src, + r#" + ///| + pub impl Eq for {name} with equal(self, other) {{ + {get_state} + {other_state} + physical_equal(state, other_state) + }} + "# + ); + self.emit_derived_methods(name, &["Eq"]); + } + let (drop_module, drop_name) = self.resolve.wasm_import_name( + ManglingAndAbi::Legacy(LiftLowerAbi::Sync), + WasmImport::ResourceIntrinsic { + resource: id, + interface: self.interface, + intrinsic: ResourceIntrinsic::ExportedDrop, + }, + ); + let (new_module, new_name) = self.resolve.wasm_import_name( + ManglingAndAbi::Legacy(LiftLowerAbi::Sync), + WasmImport::ResourceIntrinsic { + resource: id, + interface: self.interface, + intrinsic: ResourceIntrinsic::ExportedNew, + }, + ); + let (rep_module, rep_name) = self.resolve.wasm_import_name( + ManglingAndAbi::Legacy(LiftLowerAbi::Sync), + WasmImport::ResourceIntrinsic { + resource: id, + interface: self.interface, + intrinsic: ResourceIntrinsic::ExportedRep, + }, + ); + + uwrite!( + self.src, + r#" + ///| + /// Application-defined payload for `{name}`. Implement this type in + /// a separate file in this package. Exported borrows receive this + /// payload directly; owned values use `{name}`. + declare type {data} + + ///| + /// Releases application resources when the component resource is destroyed. + /// The generated bindings remove the payload from their storage first. + declare fn {data}::{dtor}(_self : {data}) -> Unit + + ///| + let wasm{name}Payloads : Array[{data}?] = [] + + ///| + let wasm{name}FreeReps : Array[Int] = [] + + ///| + /// Creates an owned resource and retains its payload until destruction. + pub fn {name}::{new}(payload : {data}) -> {name} {{ + let rep = match wasm{name}FreeReps.pop() {{ + Some(rep) => {{ + wasm{name}Payloads[rep] = Some(payload) + rep + }} + None => {{ + let rep = wasm{name}Payloads.length() + wasm{name}Payloads.push(Some(payload)) + rep + }} + }} + {name}::{from}(wasmExportResourceNew{name}(rep)) + }} + + ///| + /// Accesses the payload of a locally owned resource. + /// Traps if ownership has already been transferred or dropped. + pub fn {name}::{payload}(self : {name}) -> {data} {{ + {get_state} + guard state.val is Some(handle) else {{ abort("resource is no longer owned") }} + {name}::{borrow}(wasmExportResourceRep{name}(handle)) + }} + + ///| + /// Releases ownership and invokes the resource destructor once. + pub fn {name}::{drop}(self : {name}) -> Unit {{ + {get_state} + if state.val is Some(handle) {{ + // Invalidate aliases before calling potentially reentrant cleanup. + state.val = None + wasmExportResourceDrop{name}(handle) + }} + }} + + ///| + #doc(hidden) + pub fn {name}::{from}(handle : Int) -> {name} {{ + {wrap_handle} + }} + + ///| + #doc(hidden) + pub fn {name}::{take}(self : {name}) -> Int {{ + {get_state} + guard state.val is Some(handle) else {{ abort("resource is no longer owned") }} + state.val = None + handle + }} + + ///| + #doc(hidden) + pub fn {name}::{borrow}(rep : Int) -> {data} {{ + wasm{name}Payloads[rep].unwrap() + }} + + ///| + fn wasmExportResourceNew{name}(rep : Int) -> Int = "{new_module}" "{new_name}" + + ///| + fn wasmExportResourceDrop{name}(handle : Int) = "{drop_module}" "{drop_name}" + + ///| + fn wasmExportResourceRep{name}(handle : Int) -> Int = "{rep_module}" "{rep_name}" + "#, + ); + + let func_name = self + .world_gen + .export_ns + .tmp(&format!("wasmExport{name}Dtor")); + uwrite!( + self.ffi, + r#" + ///| + #doc(hidden) + pub fn {func_name}(rep : Int) -> Unit {{ + let payload = wasm{name}Payloads[rep].unwrap() + // Free the slot before user cleanup, which may create another resource. + wasm{name}Payloads[rep] = None + wasm{name}FreeReps.push(rep) + {data}::{dtor}(payload) + }} + "#, + ); + let export_name = self.resolve.wasm_export_name( + ManglingAndAbi::Legacy(LiftLowerAbi::Sync), + WasmExport::ResourceDtor { + interface: self.interface.unwrap(), + resource: id, + }, + ); + let qualifier = self + .world_gen + .pkg_resolver + .qualify_package(self.world_gen.opts.gen_dir.as_str(), self.name); + let export = format!( + r#" + ///| + #doc(hidden) + pub fn {func_name}(rep : Int) -> Unit {{ + {qualifier}{func_name}(rep) + }} + "#, + ); + self.world_gen + .export + .insert(export_name, (func_name, export)); + } +} diff --git a/tests/codegen/resource-payload-name-collision.wit b/tests/codegen/resource-payload-name-collision.wit new file mode 100644 index 000000000..0965c8785 --- /dev/null +++ b/tests/codegen/resource-payload-name-collision.wit @@ -0,0 +1,44 @@ +package test:resource-payloads; + +interface api { + resource counter { + constructor(value: u32); + get: func() -> u32; + new: static func(value: u32) -> counter; + payload: func() -> u32; + drop: func(); + dtor: func(); + wasm-from-handle: func(); + wasm-take-handle: func(); + wasm-borrow: func(); + } + record counter-handle { + value: u32, + } + + // Implementation types are user-defined, so aggregates must not require derives. + variant borrowed { + one(borrow), + many(list>), + } + read: func(value: borrowed, data: counter-handle); + + // Exercise the MoonBit option that represents error resources as suberrors. + resource example-error { + message: func() -> string; + } +} + +interface other { + use api.{counter, borrowed, example-error}; + read: func(value: borrow, nested: borrowed) -> u32; + bounce: func(value: counter) -> counter; + error: func(value: example-error) -> example-error; +} + +world test { + import api; + import other; + export api; + export other; +} diff --git a/tests/runtime/moonbit/managed-resources/runner.rs b/tests/runtime/moonbit/managed-resources/runner.rs new file mode 100644 index 000000000..3a5c70643 --- /dev/null +++ b/tests/runtime/moonbit/managed-resources/runner.rs @@ -0,0 +1,42 @@ +include!(env!("BINDINGS")); + +use test::managed_resources::api::*; + +struct Component; +export!(Component); + +impl Guest for Component { + fn run() { + assert_eq!(CheckedCounter::new(0).unwrap_err(), "zero"); + assert_eq!(checked_drop_count(), 0); + let checked = CheckedCounter::new(42).unwrap(); + assert_eq!(checked.get(), 42); + drop(checked); + assert_eq!(checked_drop_count(), 1); + + let first = Counter::new(10); + let second = Counter::new(20); + assert_eq!(first.get(), 10); + first.add(5); + assert_eq!(read(&first), 15); + assert_eq!(read_record(&Borrowed { value: &second }), 20); + let first = bounce(first); + drop_saved_alias(); + assert_eq!(first.get(), 25); + assert_eq!(drop_count(), 0); + consume(first); + assert_eq!(drop_count(), 1); + drop(second); + assert_eq!(drop_count(), 2); + + // Repeated allocation must not confuse reused representations and handles. + for i in 0..32 { + let counter = Counter::new(i); + assert_eq!(counter.get(), i); + drop(counter); + assert_eq!(drop_count(), 3 + i); + } + drop(Counter::new(999)); + assert_eq!(drop_count(), 36); + } +} diff --git a/tests/runtime/moonbit/managed-resources/test.mbt b/tests/runtime/moonbit/managed-resources/test.mbt new file mode 100644 index 000000000..14d881344 --- /dev/null +++ b/tests/runtime/moonbit/managed-resources/test.mbt @@ -0,0 +1,108 @@ +//@ [lang] +//@ path = 'gen/interface/test/managed-resources/api/impl.mbt' + +///| +struct Counter { + mut value : UInt +} + +///| +let drops : Ref[UInt] = Ref(0) + +///| +let saved_alias : Ref[CounterHandle?] = Ref(None) + +///| +pub fn Counter::counter(value : UInt) -> Counter { + { value, } +} + +///| +pub fn Counter::get(self : Counter) -> UInt { + self.value +} + +///| +pub fn Counter::add(self : Counter, delta : UInt) -> Unit { + self.value += delta +} + +///| +fn Counter::dtor(self : Counter) -> Unit { + drops.val += 1 + if self.value == 999 { + // Destruction can reenter storage allocation and reuse the released slot. + CounterHandle::counter(1).drop() + } +} + +///| +pub fn read(value : Counter) -> UInt { + value.value +} + +///| +pub fn read_record(value : Borrowed) -> UInt { + value.value.value +} + +///| +pub fn bounce(value : CounterHandle) -> CounterHandle { + value.add(10) + saved_alias.val = Some(value) + value +} + +///| +pub fn consume(value : CounterHandle) -> Unit { + let other = value + value.drop() + other.drop() +} + +///| +pub fn drop_saved_alias() -> Unit { + if saved_alias.val is Some(value) { + value.drop() + saved_alias.val = None + } +} + +///| +pub fn drop_count() -> UInt { + drops.val +} + +///| +struct CheckedCounter { + value : UInt +} + +///| +let checked_drops : Ref[UInt] = Ref(0) + +///| +pub fn CheckedCounter::checked_counter( + value : UInt, +) -> Result[CheckedCounter, String] { + if value == 0 { + Err("zero") + } else { + Ok({ value, }) + } +} + +///| +pub fn CheckedCounter::get(self : CheckedCounter) -> UInt { + self.value +} + +///| +fn CheckedCounter::dtor(_self : CheckedCounter) -> Unit { + checked_drops.val += 1 +} + +///| +pub fn checked_drop_count() -> UInt { + checked_drops.val +} diff --git a/tests/runtime/moonbit/managed-resources/test.wit b/tests/runtime/moonbit/managed-resources/test.wit new file mode 100644 index 000000000..5c8e28970 --- /dev/null +++ b/tests/runtime/moonbit/managed-resources/test.wit @@ -0,0 +1,35 @@ +package test:managed-resources; + +interface api { + resource counter { + constructor(value: u32); + get: func() -> u32; + add: func(delta: u32); + } + + resource checked-counter { + constructor(value: u32) -> result; + get: func() -> u32; + } + checked-drop-count: func() -> u32; + + record borrowed { + value: borrow, + } + + read: func(value: borrow) -> u32; + read-record: func(value: borrowed) -> u32; + bounce: func(value: counter) -> counter; + consume: func(value: counter); + drop-saved-alias: func(); + drop-count: func() -> u32; +} + +world test { + export api; +} + +world runner { + import api; + export run: func(); +} diff --git a/tests/runtime/moonbit/resource-payloads/leaf.mbt b/tests/runtime/moonbit/resource-payloads/leaf.mbt index ab58d0c4a..c37ca16be 100644 --- a/tests/runtime/moonbit/resource-payloads/leaf.mbt +++ b/tests/runtime/moonbit/resource-payloads/leaf.mbt @@ -12,63 +12,50 @@ //@ ''' ///| -priv struct BodyState { +struct Body { mut contents : @async-core.Stream[Byte]? - mut trailers : @async-core.Future[Fields]? + mut trailers : @async-core.Future[FieldsHandle]? } ///| -priv struct ResponseState { - mut body : BodyState? +struct Response { + mut body : BodyHandle? } ///| -let leaf_values : Map[Int, String] = Map([]) +struct LeafThing(String) ///| -let fields_values : Map[Int, String] = Map([]) +struct Fields(String) ///| -let body_values : Map[Int, BodyState] = Map([]) +let leaf_drops : Ref[UInt] = { val: 0, } ///| -let response_values : Map[Int, ResponseState] = Map([]) +let leaf_live : Ref[UInt] = { val: 0, } ///| -let next_resource_rep : Ref[Int] = Ref(1) +let fields_live : Ref[UInt] = { val: 0, } ///| -let leaf_drops : Ref[UInt] = { val: 0 } +let body_live : 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 } +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) + LeafThing(value) } ///| pub fn LeafThing::get(self : LeafThing) -> String { - leaf_values[self.0] + self.0 } ///| -pub fn LeafThing::dtor(self : LeafThing) -> Unit { - leaf_values.remove(self.0) +fn LeafThing::dtor(_self : LeafThing) -> Unit { leaf_drops.val += 1 leaf_live.val -= 1 } @@ -85,38 +72,31 @@ pub fn leaf_live_count() -> UInt { ///| 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) + Fields(value) } ///| pub fn Fields::get(self : Fields) -> String { - fields_values[self.0] + self.0 } ///| -pub fn Fields::dtor(self : Fields) -> Unit { - fields_values.remove(self.0) +fn Fields::dtor(_self : Fields) -> Unit { fields_live.val -= 1 } ///| pub fn Body::body( contents : @async-core.Stream[Byte], - trailers : @async-core.Future[Fields]?, + trailers : @async-core.Future[FieldsHandle]?, ) -> 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) + { contents: Some(contents), trailers, } } ///| -async fn collect_body_state(state : BodyState) -> (String, String?) { +async fn collect_body_state(state : Body) -> (String, String?) { guard state.contents is Some(contents) else { panic() } state.contents = None @@ -136,7 +116,7 @@ async fn collect_body_state(state : BodyState) -> (String, String?) { Some(future) => { state.trailers = None let fields = future.get() - let value = fields_values[fields.rep()] + let value = fields.get() fields.drop() future.drop() Some(value) @@ -151,58 +131,38 @@ pub async fn Body::collect( self : Body, _background_group : @async-core.TaskGroup[Unit], ) -> (String, String?) { - collect_body_state(body_values[self.0]) + collect_body_state(self) } ///| -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 => () +fn Body::dtor(self : Body) -> Unit { + guard self.contents is None && self.trailers is None else { + abort("test body dropped before collect") } + body_live.val -= 1 } ///| -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) } +pub fn Response::response(body : BodyHandle) -> Response { response_live.val += 1 - Response::new(rep) + { body: Some(body), } } ///| pub async fn Response::collect( self : Response, - _background_group : @async-core.TaskGroup[Unit], + 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 + guard self.body is Some(body) else { panic() } + self.body = None + let result = body.collect(background_group) + body.drop() 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) +fn Response::dtor(self : Response) -> Unit { + guard self.body is None else { abort("test response dropped before collect") } response_live.val -= 1 } diff --git a/tests/runtime/resources/resources.mbt b/tests/runtime/resources/resources.mbt index 095712170..f0e801fba 100644 --- a/tests/runtime/resources/resources.mbt +++ b/tests/runtime/resources/resources.mbt @@ -9,80 +9,67 @@ //@ ''' ///| -let x : Map[Int, Int] = Map([]) - -///| -let x_counter : Ref[Int] = { val: 1 } - -///| -let z : Map[Int, Int] = Map([]) - -///| -let z_counter : Ref[Int] = { val: 1 } +struct X { + mut value : Int +} ///| -let z_dropped : Ref[UInt] = { val: 1 } +struct Z { + value : Int +} ///| -let k : Map[Int, UInt] = Map([]) +struct KebabCase { + value : UInt +} ///| -let k_counter : Ref[Int] = { val: 1 } +let z_dropped : Ref[UInt] = Ref(1) ///| -/// Destructor of the resource. -pub fn X::dtor(self : X) -> Unit { - x.remove(self.0) +fn X::dtor(_self : X) -> Unit { + () } ///| -/// Destructor of the resource. -pub fn Z::dtor(self : Z) -> Unit { +fn Z::dtor(_self : Z) -> Unit { z_dropped.val += 1 - z.remove(self.0) } ///| -/// Destructor of the resource. -pub fn KebabCase::dtor(self : KebabCase) -> Unit { - k.remove(self.rep()) +fn KebabCase::dtor(_self : KebabCase) -> Unit { + () } ///| pub fn X::x(a : Int) -> X { - let rep = x_counter.val - x_counter.val += 1 - x[rep] = a - X::new(rep) + { value: a, } } ///| pub fn X::get_a(self : X) -> Int { - x[self.0] + self.value } ///| pub fn X::set_a(self : X, a : Int) -> Unit { - x[self.0] = a + self.value = a } ///| -pub fn X::add(self : X, a : Int) -> X { - x[self.rep()] += a +pub fn X::add(self : XHandle, a : Int) -> XHandle { + self.payload().value += a self } ///| pub fn Z::z(a : Int) -> Z { - let rep = z_counter.val - z_counter.val += 1 - z[rep] = a - Z::new(rep) + { value: a, } } ///| pub fn Z::get_a(self : Z) -> Int { - z[self.0] + self.value } ///| @@ -91,35 +78,29 @@ pub fn Z::num_dropped() -> UInt { } ///| -pub fn add(a : Z, b : Z) -> Z { - let rep = z_counter.val - z_counter.val += 1 - z[rep] = z[a.0] + z[b.0] - Z::new(rep) +pub fn add(a : Z, b : Z) -> ZHandle { + ZHandle::new({ value: a.value + b.value, }) } ///| -pub fn consume(x : X) -> Unit { +pub fn consume(x : XHandle) -> Unit { x.drop() } ///| pub fn KebabCase::kebab_case(a : UInt) -> KebabCase { - let rep = k_counter.val - k_counter.val += 1 - k[rep] = a - KebabCase::new(rep) + { value: a, } } ///| pub fn KebabCase::get_a(self : KebabCase) -> UInt { - k[self.rep()] + self.value } ///| -pub fn KebabCase::take_owned(self : KebabCase) -> UInt { +pub fn KebabCase::take_owned(self : KebabCaseHandle) -> UInt { defer self.drop() - k[self.rep()] + self.payload().value } ///| From 74875ffb20f786a9e19b723637b94f678baff624 Mon Sep 17 00:00:00 2001 From: zihang Date: Mon, 12 Oct 2026 15:31:37 +0800 Subject: [PATCH 2/5] test(moonbit): focus exported resource lifecycle coverage --- .../exported-resource-lifecycle/runner.rs | 34 ++++++ .../exported-resource-lifecycle/test.mbt | 76 ++++++++++++ .../exported-resource-lifecycle/test.wit | 24 ++++ .../moonbit/managed-resources/runner.rs | 42 ------- .../moonbit/managed-resources/test.mbt | 108 ------------------ .../moonbit/managed-resources/test.wit | 35 ------ .../resource_borrow_in_record/test.mbt | 29 +++++ 7 files changed, 163 insertions(+), 185 deletions(-) create mode 100644 tests/runtime/moonbit/exported-resource-lifecycle/runner.rs create mode 100644 tests/runtime/moonbit/exported-resource-lifecycle/test.mbt create mode 100644 tests/runtime/moonbit/exported-resource-lifecycle/test.wit delete mode 100644 tests/runtime/moonbit/managed-resources/runner.rs delete mode 100644 tests/runtime/moonbit/managed-resources/test.mbt delete mode 100644 tests/runtime/moonbit/managed-resources/test.wit create mode 100644 tests/runtime/resource_borrow_in_record/test.mbt diff --git a/tests/runtime/moonbit/exported-resource-lifecycle/runner.rs b/tests/runtime/moonbit/exported-resource-lifecycle/runner.rs new file mode 100644 index 000000000..52ba0b6be --- /dev/null +++ b/tests/runtime/moonbit/exported-resource-lifecycle/runner.rs @@ -0,0 +1,34 @@ +include!(env!("BINDINGS")); + +use test::exported_resource_lifecycle::api::*; + +struct Component; +export!(Component); + +impl Guest for Component { + fn run() { + // Fallible constructors register only successful application objects. + assert_eq!(Tracked::new(0, false).unwrap_err(), "zero"); + assert!(take_dropped().is_empty()); + drop(Tracked::new(1, false).unwrap()); + assert_eq!(take_dropped(), [1]); + + // Transferring ownership invalidates every alias to the outgoing wrapper. + let value = retain_alias(Tracked::new(2, false).unwrap()); + drop_saved_alias(); + assert!(take_dropped().is_empty()); + drop(value); + assert_eq!(take_dropped(), [2]); + + // Dropping a locally owned wrapper also invalidates its aliases. + drop_twice(Tracked::new(3, false).unwrap()); + assert_eq!(take_dropped(), [3]); + + // Destruction may reuse its released slot. The child must remain usable + // after the outer destructor finishes, with its own object still stored. + drop(Tracked::new(4, true).unwrap()); + assert_eq!(take_dropped(), [4]); + drop(take_reentrant_child()); + assert_eq!(take_dropped(), [5]); + } +} diff --git a/tests/runtime/moonbit/exported-resource-lifecycle/test.mbt b/tests/runtime/moonbit/exported-resource-lifecycle/test.mbt new file mode 100644 index 000000000..47b6ccd80 --- /dev/null +++ b/tests/runtime/moonbit/exported-resource-lifecycle/test.mbt @@ -0,0 +1,76 @@ +//@ [lang] +//@ path = 'gen/interface/test/exported-resource-lifecycle/api/impl.mbt' + +///| +struct Tracked { + id : UInt + reenter_on_drop : Bool +} + +///| +let dropped : Ref[Array[UInt]] = Ref([]) + +///| +let saved_alias : Ref[TrackedHandle?] = Ref(None) + +///| +let reentrant_child : Ref[TrackedHandle?] = Ref(None) + +///| +pub fn Tracked::tracked( + id : UInt, + reenter_on_drop : Bool, +) -> Result[Tracked, String] { + if id == 0 { + Err("zero") + } else { + Ok({ id, reenter_on_drop, }) + } +} + +///| +fn Tracked::dtor(self : Tracked) -> Unit { + dropped.val.push(self.id) + if self.reenter_on_drop { + // Keep the child alive after the outer destructor returns, so later cleanup + // detects a reused storage slot being overwritten by the outer destruction. + reentrant_child.val = Some( + TrackedHandle::tracked(self.id + 1, false).unwrap(), + ) + } +} + +///| +pub fn retain_alias(value : TrackedHandle) -> TrackedHandle { + saved_alias.val = Some(value) + value +} + +///| +pub fn drop_twice(value : TrackedHandle) -> Unit { + let other = value + value.drop() + other.drop() +} + +///| +pub fn drop_saved_alias() -> Unit { + if saved_alias.val is Some(value) { + value.drop() + saved_alias.val = None + } +} + +///| +pub fn take_reentrant_child() -> TrackedHandle { + let child = reentrant_child.val.unwrap() + reentrant_child.val = None + child +} + +///| +pub fn take_dropped() -> FixedArray[UInt] { + let result = dropped.val + dropped.val = [] + FixedArray::from_array(result[:]) +} diff --git a/tests/runtime/moonbit/exported-resource-lifecycle/test.wit b/tests/runtime/moonbit/exported-resource-lifecycle/test.wit new file mode 100644 index 000000000..1b862cb29 --- /dev/null +++ b/tests/runtime/moonbit/exported-resource-lifecycle/test.wit @@ -0,0 +1,24 @@ +// Generated MoonBit ownership and storage behavior only. Ordinary resource calls +// and borrowed records are covered by their shared runtime fixtures. +package test:exported-resource-lifecycle; + +interface api { + resource tracked { + constructor(id: u32, reenter-on-drop: bool) -> result; + } + + retain-alias: func(value: tracked) -> tracked; + drop-twice: func(value: tracked); + drop-saved-alias: func(); + take-reentrant-child: func() -> tracked; + take-dropped: func() -> list; +} + +world test { + export api; +} + +world runner { + import api; + export run: func(); +} diff --git a/tests/runtime/moonbit/managed-resources/runner.rs b/tests/runtime/moonbit/managed-resources/runner.rs deleted file mode 100644 index 3a5c70643..000000000 --- a/tests/runtime/moonbit/managed-resources/runner.rs +++ /dev/null @@ -1,42 +0,0 @@ -include!(env!("BINDINGS")); - -use test::managed_resources::api::*; - -struct Component; -export!(Component); - -impl Guest for Component { - fn run() { - assert_eq!(CheckedCounter::new(0).unwrap_err(), "zero"); - assert_eq!(checked_drop_count(), 0); - let checked = CheckedCounter::new(42).unwrap(); - assert_eq!(checked.get(), 42); - drop(checked); - assert_eq!(checked_drop_count(), 1); - - let first = Counter::new(10); - let second = Counter::new(20); - assert_eq!(first.get(), 10); - first.add(5); - assert_eq!(read(&first), 15); - assert_eq!(read_record(&Borrowed { value: &second }), 20); - let first = bounce(first); - drop_saved_alias(); - assert_eq!(first.get(), 25); - assert_eq!(drop_count(), 0); - consume(first); - assert_eq!(drop_count(), 1); - drop(second); - assert_eq!(drop_count(), 2); - - // Repeated allocation must not confuse reused representations and handles. - for i in 0..32 { - let counter = Counter::new(i); - assert_eq!(counter.get(), i); - drop(counter); - assert_eq!(drop_count(), 3 + i); - } - drop(Counter::new(999)); - assert_eq!(drop_count(), 36); - } -} diff --git a/tests/runtime/moonbit/managed-resources/test.mbt b/tests/runtime/moonbit/managed-resources/test.mbt deleted file mode 100644 index 14d881344..000000000 --- a/tests/runtime/moonbit/managed-resources/test.mbt +++ /dev/null @@ -1,108 +0,0 @@ -//@ [lang] -//@ path = 'gen/interface/test/managed-resources/api/impl.mbt' - -///| -struct Counter { - mut value : UInt -} - -///| -let drops : Ref[UInt] = Ref(0) - -///| -let saved_alias : Ref[CounterHandle?] = Ref(None) - -///| -pub fn Counter::counter(value : UInt) -> Counter { - { value, } -} - -///| -pub fn Counter::get(self : Counter) -> UInt { - self.value -} - -///| -pub fn Counter::add(self : Counter, delta : UInt) -> Unit { - self.value += delta -} - -///| -fn Counter::dtor(self : Counter) -> Unit { - drops.val += 1 - if self.value == 999 { - // Destruction can reenter storage allocation and reuse the released slot. - CounterHandle::counter(1).drop() - } -} - -///| -pub fn read(value : Counter) -> UInt { - value.value -} - -///| -pub fn read_record(value : Borrowed) -> UInt { - value.value.value -} - -///| -pub fn bounce(value : CounterHandle) -> CounterHandle { - value.add(10) - saved_alias.val = Some(value) - value -} - -///| -pub fn consume(value : CounterHandle) -> Unit { - let other = value - value.drop() - other.drop() -} - -///| -pub fn drop_saved_alias() -> Unit { - if saved_alias.val is Some(value) { - value.drop() - saved_alias.val = None - } -} - -///| -pub fn drop_count() -> UInt { - drops.val -} - -///| -struct CheckedCounter { - value : UInt -} - -///| -let checked_drops : Ref[UInt] = Ref(0) - -///| -pub fn CheckedCounter::checked_counter( - value : UInt, -) -> Result[CheckedCounter, String] { - if value == 0 { - Err("zero") - } else { - Ok({ value, }) - } -} - -///| -pub fn CheckedCounter::get(self : CheckedCounter) -> UInt { - self.value -} - -///| -fn CheckedCounter::dtor(_self : CheckedCounter) -> Unit { - checked_drops.val += 1 -} - -///| -pub fn checked_drop_count() -> UInt { - checked_drops.val -} diff --git a/tests/runtime/moonbit/managed-resources/test.wit b/tests/runtime/moonbit/managed-resources/test.wit deleted file mode 100644 index 5c8e28970..000000000 --- a/tests/runtime/moonbit/managed-resources/test.wit +++ /dev/null @@ -1,35 +0,0 @@ -package test:managed-resources; - -interface api { - resource counter { - constructor(value: u32); - get: func() -> u32; - add: func(delta: u32); - } - - resource checked-counter { - constructor(value: u32) -> result; - get: func() -> u32; - } - checked-drop-count: func() -> u32; - - record borrowed { - value: borrow, - } - - read: func(value: borrow) -> u32; - read-record: func(value: borrowed) -> u32; - bounce: func(value: counter) -> counter; - consume: func(value: counter); - drop-saved-alias: func(); - drop-count: func() -> u32; -} - -world test { - export api; -} - -world runner { - import api; - export run: func(); -} diff --git a/tests/runtime/resource_borrow_in_record/test.mbt b/tests/runtime/resource_borrow_in_record/test.mbt new file mode 100644 index 000000000..f82dd0115 --- /dev/null +++ b/tests/runtime/resource_borrow_in_record/test.mbt @@ -0,0 +1,29 @@ +//@ [lang] +//@ path = 'gen/interface/test/resource-borrow-in-record/to-test/impl.mbt' + +///| +struct Thing { + contents : String +} + +///| +pub fn Thing::thing(s : String) -> Thing { + { contents: s + " new", } +} + +///| +pub fn Thing::get(self : Thing) -> String { + self.contents + " get" +} + +///| +fn Thing::dtor(_self : Thing) -> Unit { + () +} + +///| +pub fn test_(values : Array[Foo]) -> Array[ThingHandle] { + values.map(fn(value) { + ThingHandle::new({ contents: value.thing.contents + " test", }) + }) +} From c8b0318b4358c3a1056b82adc2bcba6943293e6b Mon Sep 17 00:00:00 2001 From: zihang Date: Tue, 20 Oct 2026 13:44:29 +0800 Subject: [PATCH 3/5] test(moonbit): fix resource fixtures across the CI matrix --- crates/test/src/cpp.rs | 2 ++ crates/test/src/d.rs | 2 ++ crates/test/src/rust.rs | 4 ++- .../exported-resource-lifecycle/runner.mbt | 30 ++++++++++++++++ .../exported-resource-lifecycle/runner.rs | 34 ------------------- 5 files changed, 37 insertions(+), 35 deletions(-) create mode 100644 tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt delete mode 100644 tests/runtime/moonbit/exported-resource-lifecycle/runner.rs diff --git a/crates/test/src/cpp.rs b/crates/test/src/cpp.rs index 51ca2e827..b507a3fd9 100644 --- a/crates/test/src/cpp.rs +++ b/crates/test/src/cpp.rs @@ -52,6 +52,8 @@ impl LanguageMethods for Cpp { } return match name { "issue1514-6.wit" | "named-fixed-length-list.wit" => true, + // The WIT static `new` collides with the generated `New` factory. + "resource-payload-name-collision.wit" => true, _ => false, } || config.async_; } diff --git a/crates/test/src/d.rs b/crates/test/src/d.rs index 98c791df4..37da56001 100644 --- a/crates/test/src/d.rs +++ b/crates/test/src/d.rs @@ -37,6 +37,8 @@ impl LanguageMethods for D { || name == "map-in-world.wit" || name == "map-named-wit-map.wit" || name == "issue1642.wit" + // The WIT `dtor` method collides with the generated destructor wrapper. + || name == "resource-payload-name-collision.wit" } fn default_bindgen_args_for_codegen(&self) -> &[&str] { diff --git a/crates/test/src/rust.rs b/crates/test/src/rust.rs index fe2efed81..4e93bab84 100644 --- a/crates/test/src/rust.rs +++ b/crates/test/src/rust.rs @@ -98,7 +98,9 @@ impl LanguageMethods for Rust { return true; } - false + // The WIT static `new` collides with the generated resource constructor. + name == "resource-payload-name-collision.wit" + || name.starts_with("resource-payload-name-collision.wit-") } fn codegen_test_variants(&self) -> &[(&str, &[&str])] { diff --git a/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt b/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt new file mode 100644 index 000000000..529477e01 --- /dev/null +++ b/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt @@ -0,0 +1,30 @@ +//@ [lang] +//@ path = 'gen/world/runner/stub.mbt' +//@ pkg_config = """{ "warn-list": "-44", "supported-targets": "+wasm", "import": [{ "path": "test/exported-resource-lifecycle/interface/test/exported-resource-lifecycle/api", "alias": "api" }] }""" + +///| +pub fn run() -> Unit { + // Fallible constructors register only successful application objects. + guard @api.Tracked::tracked(0, false) is Err("zero") else { panic() } + guard @api.take_dropped() is [] else { panic() } + @api.Tracked::tracked(1, false).unwrap().drop() + guard @api.take_dropped() is [1] else { panic() } + + // Transferring ownership invalidates every alias to the outgoing wrapper. + let value = @api.retain_alias(@api.Tracked::tracked(2, false).unwrap()) + @api.drop_saved_alias() + guard @api.take_dropped() is [] else { panic() } + value.drop() + guard @api.take_dropped() is [2] else { panic() } + + // Dropping a locally owned wrapper also invalidates its aliases. + @api.drop_twice(@api.Tracked::tracked(3, false).unwrap()) + guard @api.take_dropped() is [3] else { panic() } + + // Destruction may reuse its released slot. The child must remain usable + // after the outer destructor finishes, with its own object still stored. + @api.Tracked::tracked(4, true).unwrap().drop() + guard @api.take_dropped() is [4] else { panic() } + @api.take_reentrant_child().drop() + guard @api.take_dropped() is [5] else { panic() } +} diff --git a/tests/runtime/moonbit/exported-resource-lifecycle/runner.rs b/tests/runtime/moonbit/exported-resource-lifecycle/runner.rs deleted file mode 100644 index 52ba0b6be..000000000 --- a/tests/runtime/moonbit/exported-resource-lifecycle/runner.rs +++ /dev/null @@ -1,34 +0,0 @@ -include!(env!("BINDINGS")); - -use test::exported_resource_lifecycle::api::*; - -struct Component; -export!(Component); - -impl Guest for Component { - fn run() { - // Fallible constructors register only successful application objects. - assert_eq!(Tracked::new(0, false).unwrap_err(), "zero"); - assert!(take_dropped().is_empty()); - drop(Tracked::new(1, false).unwrap()); - assert_eq!(take_dropped(), [1]); - - // Transferring ownership invalidates every alias to the outgoing wrapper. - let value = retain_alias(Tracked::new(2, false).unwrap()); - drop_saved_alias(); - assert!(take_dropped().is_empty()); - drop(value); - assert_eq!(take_dropped(), [2]); - - // Dropping a locally owned wrapper also invalidates its aliases. - drop_twice(Tracked::new(3, false).unwrap()); - assert_eq!(take_dropped(), [3]); - - // Destruction may reuse its released slot. The child must remain usable - // after the outer destructor finishes, with its own object still stored. - drop(Tracked::new(4, true).unwrap()); - assert_eq!(take_dropped(), [4]); - drop(take_reentrant_child()); - assert_eq!(take_dropped(), [5]); - } -} From 284f8d895feb1b412ba0fbdf5df331e211035f62 Mon Sep 17 00:00:00 2001 From: zihang Date: Tue, 20 Oct 2026 16:00:39 +0800 Subject: [PATCH 4/5] test(moonbit): update lifecycle runner syntax and configuration --- .../exported-resource-lifecycle/runner.mbt | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt b/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt index 529477e01..ee6ec38e3 100644 --- a/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt +++ b/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt @@ -1,30 +1,38 @@ //@ [lang] //@ path = 'gen/world/runner/stub.mbt' -//@ pkg_config = """{ "warn-list": "-44", "supported-targets": "+wasm", "import": [{ "path": "test/exported-resource-lifecycle/interface/test/exported-resource-lifecycle/api", "alias": "api" }] }""" +//@ pkg_config = ''' +//@ import { +//@ "test/exported-resource-lifecycle/interface/test/exported-resource-lifecycle/api" @api, +//@ } +//@ +//@ warnings = "-44" +//@ +//@ supported_targets = "wasm" +//@ ''' ///| pub fn run() -> Unit { // Fallible constructors register only successful application objects. - guard @api.Tracked::tracked(0, false) is Err("zero") else { panic() } - guard @api.take_dropped() is [] else { panic() } + guard! @api.Tracked::tracked(0, false) is Err("zero") + guard! @api.take_dropped() is [] @api.Tracked::tracked(1, false).unwrap().drop() - guard @api.take_dropped() is [1] else { panic() } + guard! @api.take_dropped() is [1] // Transferring ownership invalidates every alias to the outgoing wrapper. let value = @api.retain_alias(@api.Tracked::tracked(2, false).unwrap()) @api.drop_saved_alias() - guard @api.take_dropped() is [] else { panic() } + guard! @api.take_dropped() is [] value.drop() - guard @api.take_dropped() is [2] else { panic() } + guard! @api.take_dropped() is [2] // Dropping a locally owned wrapper also invalidates its aliases. @api.drop_twice(@api.Tracked::tracked(3, false).unwrap()) - guard @api.take_dropped() is [3] else { panic() } + guard! @api.take_dropped() is [3] // Destruction may reuse its released slot. The child must remain usable // after the outer destructor finishes, with its own object still stored. @api.Tracked::tracked(4, true).unwrap().drop() - guard @api.take_dropped() is [4] else { panic() } + guard! @api.take_dropped() is [4] @api.take_reentrant_child().drop() - guard @api.take_dropped() is [5] else { panic() } + guard! @api.take_dropped() is [5] } From fd6fa13db870ba4e225057c0cd01198b4a15e96d Mon Sep 17 00:00:00 2001 From: zihang Date: Wed, 21 Oct 2026 10:36:07 +0800 Subject: [PATCH 5/5] refactor(moonbit): refine resource storage and lifecycle coverage --- crates/moonbit/docs/resources.md | 125 ++++++++++++++- crates/moonbit/src/lib.rs | 44 +++++- crates/moonbit/src/pkg.rs | 1 + crates/moonbit/src/resources.rs | 29 ++-- .../resource-payload-name-collision.wit | 3 +- crates/test/src/cpp.rs | 2 - crates/test/src/d.rs | 2 - crates/test/src/rust.rs | 4 +- .../exported-resource-lifecycle/runner.mbt | 49 +++++- .../exported-resource-lifecycle/test.mbt | 106 +++++++++++++ .../exported-resource-lifecycle/test.wit | 12 +- .../moonbit/resource-payloads/leaf.mbt | 148 ++++++++++++++---- .../runtime/moonbit/resource-payloads/leaf.rs | 27 ++++ .../moonbit/resource-payloads/runner.mbt | 1 + .../moonbit/resource-payloads/test.wit | 2 + 15 files changed, 500 insertions(+), 55 deletions(-) rename {tests => crates/moonbit/tests}/codegen/resource-payload-name-collision.wit (88%) diff --git a/crates/moonbit/docs/resources.md b/crates/moonbit/docs/resources.md index f486b844d..2b0b96e95 100644 --- a/crates/moonbit/docs/resources.md +++ b/crates/moonbit/docs/resources.md @@ -59,6 +59,12 @@ MoonBit only creates the application object. To create a locally owned component resource, use `CounterHandle::counter(start)` or wrap an existing object with `CounterHandle::new(counter)`. +Each `new(object)` creates an independent component resource; it does not +deduplicate or reference-count the object. Registering the same object twice +invokes its destructor once per registration. Copying one handle instead shares +the same ownership state. Objects that own exclusive external resources should +be registered only once unless their implementation explicitly supports sharing. + ## Methods and ownership - WIT constructors, methods, and static functions are implemented on `Counter`. @@ -81,12 +87,124 @@ resource, use `CounterHandle::counter(start)` or wrap an existing object with - Async forwarding methods accept the same background task group as their implementation methods. +Local forwarding calls the object implementation directly. It checks ownership +at entry but does not establish a CM borrow or keep the resource alive across +suspension. The caller must prevent every alias from dropping or transferring +the resource throughout the borrowed use, including an async method call. + `Counter` is an ordinary MoonBit value. The bindings do not enforce a borrow lifetime on references to that value. Keeping an object reference does not extend the component resource's lifetime or keep application resources open after its destructor runs. Imported resources retain their existing ownership behavior and constructor/method names. +## Async ownership and cleanup scopes + +An async export's `background_group` lets explicitly spawned work outlive the +method's computation. The implementation +returns its result, the generated adapter calls CM `task.return`, and work in +the group can continue afterward. The MoonBit scope finishes when that work +finishes. Forwarding the group through a local handle method does not join it. +Work that must finish before producing the result belongs in the method itself +or in a separate local `with_task_group`. + +There are three cleanup scopes: + +- Use ordinary `defer` for values used only by the current computation. +- Use `background_group.add_defer` for an owned handle that must remain live + until the group's children finish. Register it before the first operation + that can fail or suspend. +- Give a returned future or stream producer ownership of the values it captures. + Use `defer` inside the producer once it starts, and `on_unstarted_drop` to + release captured handles if it is discarded before execution. + +Shield asynchronous cleanup from cancellation with `protect_from_cancel`. +Group defer blocks must be `nocancel`; synchronous handle `drop()` already is. + +For example, a response can transfer its owned body to the export's group: + +```moonbit +pub async fn Response::collect( + self : Response, + background_group : @async-core.TaskGroup[Unit], +) -> (String, String?) { + guard! self.body is Some(body) + self.body = None + background_group.add_defer(() => body.drop()) + body.collect(background_group) +} +``` + +The response no longer owns the body after clearing its field. The registered +cleanup retains the owning handle until all group children terminate, including +on error or cancellation. Neither an explicit `body.drop()` nor an ordinary +`defer body.drop()` here would cover work that outlives the method's return. +Do not wait for the background group inside the method: a worker may need the +caller to consume a returned stream or future before it can finish. + +The body's collection code has a shorter scope. It takes its stream and trailer +future out of the object's fields before suspending, then uses deferred cleanup +to close both on success, error, or cancellation. The complete handwritten +example is in +[`resource-payloads/leaf.mbt`](../../../tests/runtime/moonbit/resource-payloads/leaf.mbt). +That body's implementation currently finishes its own work before returning; +the response demonstrates retaining ownership across the larger group scope. + +Returned endpoint producers have a separate lifetime: the generated bridge +coroutines belong to the CM task and do not join the export's background group. +A group defer can therefore run before a returned producer finishes. Keep its +captured handle in the producer instead: + +```moonbit +pub async fn deferred_get( + counter : CounterHandle, + _background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Future[UInt] { + @async-core.Future::from( + async fn() { + defer counter.drop() + counter.get() + }, + on_unstarted_drop=() => counter.drop(), + ) +} +``` + +The unstarted callback runs only if the producer never starts; otherwise the +producer's `defer` handles completion, error, and cancellation. That callback +is synchronous, so it suits handles with synchronous `drop()`. Capturing an +endpoint whose cleanup must suspend requires a separate explicit ownership +and async-close design; it cannot simply be closed in that callback. Cleanup +of a resource *produced as the value* is also separate from cleanup of captures: +use `Future::ready_with_cleanup` or a stream's `cleanup` callback when needed. + +An async WIT method still borrows its receiver; calling it does not transfer +ownership or automatically drop it. Keeping a borrowed payload in a closure +does not keep its resource alive. Background work must own the resources it +needs, or have an explicit owner that retains them through completion. The +[`exported-resource-lifecycle` test](../../../tests/runtime/moonbit/exported-resource-lifecycle/test.mbt) +demonstrates this through a local async handle method. Its checker copies an +ordinary value from its borrowed receiver, then starts a worker with an owned +resource parameter. The receiver can be destroyed after the method returns; +the group's cleanup keeps the worker's resource alive. The caller verifies it +is still live, then opens a gate so the worker can use it and the group can +destroy it. No borrowed WIT argument is retained after return. +It also covers a returned producer that remains live after the group finishes, +and local discard before producer execution. Local discard matters because +lowering an endpoint for return may already start its producer. + +The body/response example deliberately asserts that collection has taken their +fields before destruction. This is a **test-only consumption requirement**, +not a general policy for abandoned responses. A production API must define +abandonment: for example, provide an async `close` operation that takes and +closes remaining endpoints under cancellation protection, then permit the +synchronous resource destructor to release the now-empty object. Both `collect` +and `close` must leave it safe to drop on success, error, or cancellation. +If callers may drop without closing, the destructor needs a synchronous +abandonment mechanism supported by the endpoint API. The fixture does not +provide that mechanism, and `background_group` is not a general finalizer for +arbitrary future drops. + ## Visibility and generated names Use a plain `struct Counter` (or another abstract type definition) to keep the @@ -115,8 +233,11 @@ The generated destructor removes the object from storage before calling release files, endpoints, or other resources it owns. Memory-only objects can use an empty destructor. -Storage slots are reused through array indexing and a free-slot stack. Cleanup -may create or destroy other resources, including reusing the released slot. +Storage uses one array: an occupied slot holds its object, while a vacant slot +links to the next free slot. A free-head reference tracks reusable slots without +a separate array or hashing. Lookup and release take constant time; allocation +takes amortized constant time. Cleanup may create or destroy other resources, +including reusing the released slot. Owned handles still require explicit transfer or `drop()`; MoonBit garbage collection does not release a component resource automatically. diff --git a/crates/moonbit/src/lib.rs b/crates/moonbit/src/lib.rs index 5fbf7c8b2..599c1cd93 100644 --- a/crates/moonbit/src/lib.rs +++ b/crates/moonbit/src/lib.rs @@ -421,7 +421,7 @@ impl WorldGenerator for MoonBit { .insert(id, name.clone()); // Preserve the WIT name for the implementation type. Allocate a separate - // owning wrapper name without colliding with other WIT types. + // owning wrapper and private storage names without colliding with WIT types. let mut type_names = Ns::default(); for (name, _) in &resolve.interfaces[id].types { let _ = type_names.insert(&name.to_moonbit_type_ident()); @@ -432,6 +432,14 @@ impl WorldGenerator for MoonBit { self.pkg_resolver.resource_handle_names.insert(*ty, handle); } } + // Allocate private names last so storage changes cannot rename public handles. + for ty in resolve.interfaces[id].types.values() { + if matches!(resolve.types[*ty].kind, TypeDefKind::Resource) { + let handle = &self.pkg_resolver.resource_handle_names[ty]; + let slot = type_names.tmp(&format!("Wasm{handle}Slot")); + self.pkg_resolver.resource_slot_names.insert(*ty, slot); + } + } let mut r#gen = self.interface(resolve, &name, Direction::Export, Some(key)); r#gen.types(id); @@ -3223,6 +3231,40 @@ mod tests { std::str::from_utf8(contents).unwrap() } + #[test] + fn exported_resource_helpers_preserve_wit_names() { + // These names collide with factories/destructors in other generators, + // so keep this regression specific to MoonBit rather than shared WIT. + let files = generate( + include_str!("../tests/codegen/resource-payload-name-collision.wit"), + "test", + ); + + let source = file(&files, "gen/interface/test/resource-payloads/api/top.mbt"); + for declaration in [ + "declare pub fn Counter::new(value : UInt) -> CounterHandle0", + "declare pub fn Counter::dtor(self : Counter) -> Unit", + "pub fn CounterHandle0::new0(payload : Counter) -> CounterHandle0", + "declare fn Counter::dtor0(_self : Counter) -> Unit", + "priv enum WasmCounterHandle0Slot1", + ] { + assert!(source.contains(declaration), "{source}"); + } + // Constructors and destruction must call the renamed lifecycle helpers; + // forwarding the WIT functions must keep their original meanings. + assert!( + source.contains("CounterHandle0::new0(Counter::counter(value))"), + "{source}" + ); + assert!(source.contains("Counter::new(value)"), "{source}"); + assert!( + source.contains("Counter::dtor(self.payload0())"), + "{source}" + ); + let ffi = file(&files, "gen/interface/test/resource-payloads/api/ffi.mbt"); + assert!(ffi.contains("Counter::dtor0(payload)"), "{ffi}"); + } + #[test] fn sync_world_does_not_emit_async_runtime_or_wrappers() { let files = generate( diff --git a/crates/moonbit/src/pkg.rs b/crates/moonbit/src/pkg.rs index bfdaafd75..cafa8dcaa 100644 --- a/crates/moonbit/src/pkg.rs +++ b/crates/moonbit/src/pkg.rs @@ -31,6 +31,7 @@ pub(crate) struct PkgResolver { pub import_interface_names: HashMap, pub export_interface_names: HashMap, pub resource_handle_names: HashMap, + pub resource_slot_names: HashMap, } impl PkgResolver { diff --git a/crates/moonbit/src/resources.rs b/crates/moonbit/src/resources.rs index 820da9791..d42c75d29 100644 --- a/crates/moonbit/src/resources.rs +++ b/crates/moonbit/src/resources.rs @@ -99,6 +99,7 @@ impl InterfaceGenerator<'_> { .unwrap() .to_moonbit_type_ident(); let resolver = &self.world_gen.pkg_resolver; + let slot = resolver.resource_slot_names[&id].clone(); let new = resolver.resource_helper_name(id, "new"); let payload = resolver.resource_helper_name(id, "payload"); let drop = resolver.resource_helper_name(id, "drop"); @@ -176,22 +177,31 @@ impl InterfaceGenerator<'_> { declare fn {data}::{dtor}(_self : {data}) -> Unit ///| - let wasm{name}Payloads : Array[{data}?] = [] + priv enum {slot} {{ + Occupied({data}) + Free(Int?) + }} + + ///| + // Vacant slots form a free list inside the payload array. + let wasm{name}Payloads : Array[{slot}] = [] ///| - let wasm{name}FreeReps : Array[Int] = [] + let wasm{name}FreeRep : Ref[Int?] = Ref(None) ///| /// Creates an owned resource and retains its payload until destruction. pub fn {name}::{new}(payload : {data}) -> {name} {{ - let rep = match wasm{name}FreeReps.pop() {{ + let rep = match wasm{name}FreeRep.val {{ Some(rep) => {{ - wasm{name}Payloads[rep] = Some(payload) + guard! wasm{name}Payloads[rep] is Free(next) + wasm{name}FreeRep.val = next + wasm{name}Payloads[rep] = Occupied(payload) rep }} None => {{ let rep = wasm{name}Payloads.length() - wasm{name}Payloads.push(Some(payload)) + wasm{name}Payloads.push(Occupied(payload)) rep }} }} @@ -236,7 +246,8 @@ impl InterfaceGenerator<'_> { ///| #doc(hidden) pub fn {name}::{borrow}(rep : Int) -> {data} {{ - wasm{name}Payloads[rep].unwrap() + guard! wasm{name}Payloads[rep] is Occupied(payload) + payload }} ///| @@ -260,10 +271,10 @@ impl InterfaceGenerator<'_> { ///| #doc(hidden) pub fn {func_name}(rep : Int) -> Unit {{ - let payload = wasm{name}Payloads[rep].unwrap() + let payload = {name}::{borrow}(rep) // Free the slot before user cleanup, which may create another resource. - wasm{name}Payloads[rep] = None - wasm{name}FreeReps.push(rep) + wasm{name}Payloads[rep] = Free(wasm{name}FreeRep.val) + wasm{name}FreeRep.val = Some(rep) {data}::{dtor}(payload) }} "#, diff --git a/tests/codegen/resource-payload-name-collision.wit b/crates/moonbit/tests/codegen/resource-payload-name-collision.wit similarity index 88% rename from tests/codegen/resource-payload-name-collision.wit rename to crates/moonbit/tests/codegen/resource-payload-name-collision.wit index 0965c8785..7acd9cc46 100644 --- a/tests/codegen/resource-payload-name-collision.wit +++ b/crates/moonbit/tests/codegen/resource-payload-name-collision.wit @@ -15,13 +15,14 @@ interface api { record counter-handle { value: u32, } + type wasm-counter-handle0-slot = u32; // Implementation types are user-defined, so aggregates must not require derives. variant borrowed { one(borrow), many(list>), } - read: func(value: borrowed, data: counter-handle); + read: func(value: borrowed, data: counter-handle, slot: wasm-counter-handle0-slot); // Exercise the MoonBit option that represents error resources as suberrors. resource example-error { diff --git a/crates/test/src/cpp.rs b/crates/test/src/cpp.rs index b507a3fd9..51ca2e827 100644 --- a/crates/test/src/cpp.rs +++ b/crates/test/src/cpp.rs @@ -52,8 +52,6 @@ impl LanguageMethods for Cpp { } return match name { "issue1514-6.wit" | "named-fixed-length-list.wit" => true, - // The WIT static `new` collides with the generated `New` factory. - "resource-payload-name-collision.wit" => true, _ => false, } || config.async_; } diff --git a/crates/test/src/d.rs b/crates/test/src/d.rs index 37da56001..98c791df4 100644 --- a/crates/test/src/d.rs +++ b/crates/test/src/d.rs @@ -37,8 +37,6 @@ impl LanguageMethods for D { || name == "map-in-world.wit" || name == "map-named-wit-map.wit" || name == "issue1642.wit" - // The WIT `dtor` method collides with the generated destructor wrapper. - || name == "resource-payload-name-collision.wit" } fn default_bindgen_args_for_codegen(&self) -> &[&str] { diff --git a/crates/test/src/rust.rs b/crates/test/src/rust.rs index 4e93bab84..fe2efed81 100644 --- a/crates/test/src/rust.rs +++ b/crates/test/src/rust.rs @@ -98,9 +98,7 @@ impl LanguageMethods for Rust { return true; } - // The WIT static `new` collides with the generated resource constructor. - name == "resource-payload-name-collision.wit" - || name.starts_with("resource-payload-name-collision.wit-") + false } fn codegen_test_variants(&self) -> &[(&str, &[&str])] { diff --git a/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt b/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt index ee6ec38e3..431de62b8 100644 --- a/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt +++ b/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt @@ -1,7 +1,9 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' //@ [lang] //@ path = 'gen/world/runner/stub.mbt' //@ pkg_config = ''' //@ import { +//@ "test/exported-resource-lifecycle/async-core" @async-core, //@ "test/exported-resource-lifecycle/interface/test/exported-resource-lifecycle/api" @api, //@ } //@ @@ -11,7 +13,7 @@ //@ ''' ///| -pub fn run() -> Unit { +pub async fn run(_background_group : @async-core.TaskGroup[Unit]) -> Unit { // Fallible constructors register only successful application objects. guard! @api.Tracked::tracked(0, false) is Err("zero") guard! @api.take_dropped() is [] @@ -35,4 +37,49 @@ pub fn run() -> Unit { guard! @api.take_dropped() is [4] @api.take_reentrant_child().drop() guard! @api.take_dropped() is [5] + + // A local async handle method returns while the gate is closed, but its owned + // argument stays alive until work in the supplied group finishes. Neither + // early drop nor a join inside the forwarder can satisfy this ordering. + let (gate, release) = @async-core.Future::new() + let completion = @api.use_in_background( + @api.Tracked::tracked(6, false).unwrap(), + gate, + ) + guard! @api.take_dropped() is [] + guard! release.complete(()) + completion.get() + completion.drop() + guard! @api.take_dropped() is [6] + + // Returned endpoint producers outlive the export's background group. The + // producer owns its handle until it computes the value, even with no children. + let result = @api.use_in_producer(@api.Tracked::tracked(7, false).unwrap()) + guard! @api.take_dropped() is [] + @api.release_producer() + guard! result.get() == 7 + result.drop() + guard! @api.take_dropped() is [7] + + // A lazy producer discarded before execution must release the same ownership. + @api.discard_unstarted_producer(@api.Tracked::tracked(8, false).unwrap()) + guard! @api.take_dropped() is [8] + + // Reuse multiple vacant slots without disturbing their live neighbours. + let first = @api.Tracked::tracked(9, false).unwrap() + let second = @api.Tracked::tracked(10, false).unwrap() + let third = @api.Tracked::tracked(11, false).unwrap() + let fourth = @api.Tracked::tracked(12, false).unwrap() + second.drop() + fourth.drop() + guard! @api.take_dropped() is [10, 12] + let fifth = @api.Tracked::tracked(13, false).unwrap() + let sixth = @api.Tracked::tracked(14, false).unwrap() + guard! first.id() == 9 && third.id() == 11 + guard! fifth.id() == 13 && sixth.id() == 14 + sixth.drop() + first.drop() + fifth.drop() + third.drop() + guard! @api.take_dropped() is [14, 9, 13, 11] } diff --git a/tests/runtime/moonbit/exported-resource-lifecycle/test.mbt b/tests/runtime/moonbit/exported-resource-lifecycle/test.mbt index 47b6ccd80..6a4931154 100644 --- a/tests/runtime/moonbit/exported-resource-lifecycle/test.mbt +++ b/tests/runtime/moonbit/exported-resource-lifecycle/test.mbt @@ -7,6 +7,11 @@ struct Tracked { reenter_on_drop : Bool } +///| +struct BackgroundCheck { + expected_id : UInt +} + ///| let dropped : Ref[Array[UInt]] = Ref([]) @@ -16,6 +21,9 @@ let saved_alias : Ref[TrackedHandle?] = Ref(None) ///| let reentrant_child : Ref[TrackedHandle?] = Ref(None) +///| +let producer_gate : Ref[@async-core.Promise[Unit]?] = Ref(None) + ///| pub fn Tracked::tracked( id : UInt, @@ -28,6 +36,11 @@ pub fn Tracked::tracked( } } +///| +pub fn Tracked::id(self : Tracked) -> UInt { + self.id +} + ///| fn Tracked::dtor(self : Tracked) -> Unit { dropped.val.push(self.id) @@ -74,3 +87,96 @@ pub fn take_dropped() -> FixedArray[UInt] { dropped.val = [] FixedArray::from_array(result[:]) } + +///| +pub async fn use_in_background( + value : TrackedHandle, + gate : @async-core.Future[Unit], + background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Future[Unit] { + let check = BackgroundCheckHandle::background_check(value.id()) + // The local forwarding method must return without joining its supplied group. + // Its receiver can then be destroyed: the worker owns a different resource. + defer check.drop() + check.run(value, gate, background_group) +} + +///| +pub fn BackgroundCheck::background_check( + expected_id : UInt, +) -> BackgroundCheck { + { expected_id, } +} + +///| +fn BackgroundCheck::dtor(_self : BackgroundCheck) -> Unit { + () +} + +///| +pub async fn BackgroundCheck::run( + self : BackgroundCheck, + value : TrackedHandle, + gate : @async-core.Future[Unit], + background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Future[Unit] { + // Snapshot ordinary data from the borrowed receiver before returning. The + // worker keeps an owned parameter; the group releases it after its children. + let expected_id = self.expected_id + let (completion, completed) = @async-core.Future::new() + background_group.add_defer(() => { + value.drop() + // The caller may discard the notification without affecting cleanup. + ignore(completed.complete(())) + }) + background_group.spawn_bg(async fn() { + defer @async-core.protect_from_cancel(() => gate.drop()) + gate.get() + guard! value.id() == expected_id + }) + // Do not join here: the caller can open the gate only after task.return. + completion +} + +///| +pub async fn use_in_producer( + value : TrackedHandle, + _background_group : @async-core.TaskGroup[Unit], +) -> @async-core.Future[UInt] { + // A local unit-valued gate controls the test without capturing another CM + // endpoint. Only the owned resource needs cleanup if the producer never runs. + let (gate, release) = @async-core.Future::new() + producer_gate.val = Some(release) + @async-core.Future::from( + async fn() { + defer value.drop() + defer @async-core.protect_from_cancel(() => gate.drop()) + gate.get() + value.id() + }, + on_unstarted_drop=() => value.drop(), + ) +} + +///| +pub fn release_producer() -> Unit { + let release = producer_gate.val.unwrap() + producer_gate.val = None + guard! release.complete(()) +} + +///| +pub async fn discard_unstarted_producer( + value : TrackedHandle, + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + // Drop locally: lowering a returned endpoint may already start its producer. + let future = @async-core.Future::from( + async fn() -> UInt { + defer value.drop() + abort("discarded producer must not run") + }, + on_unstarted_drop=() => value.drop(), + ) + future.drop() +} diff --git a/tests/runtime/moonbit/exported-resource-lifecycle/test.wit b/tests/runtime/moonbit/exported-resource-lifecycle/test.wit index 1b862cb29..81f93e959 100644 --- a/tests/runtime/moonbit/exported-resource-lifecycle/test.wit +++ b/tests/runtime/moonbit/exported-resource-lifecycle/test.wit @@ -5,6 +5,12 @@ package test:exported-resource-lifecycle; interface api { resource tracked { constructor(id: u32, reenter-on-drop: bool) -> result; + id: func() -> u32; + } + + resource background-check { + constructor(expected-id: u32); + run: async func(value: tracked, gate: future) -> future; } retain-alias: func(value: tracked) -> tracked; @@ -12,6 +18,10 @@ interface api { drop-saved-alias: func(); take-reentrant-child: func() -> tracked; take-dropped: func() -> list; + use-in-background: async func(value: tracked, gate: future) -> future; + use-in-producer: async func(value: tracked) -> future; + release-producer: func(); + discard-unstarted-producer: async func(value: tracked); } world test { @@ -20,5 +30,5 @@ world test { world runner { import api; - export run: func(); + export run: async func(); } diff --git a/tests/runtime/moonbit/resource-payloads/leaf.mbt b/tests/runtime/moonbit/resource-payloads/leaf.mbt index c37ca16be..041b4b43f 100644 --- a/tests/runtime/moonbit/resource-payloads/leaf.mbt +++ b/tests/runtime/moonbit/resource-payloads/leaf.mbt @@ -17,6 +17,11 @@ struct Body { mut trailers : @async-core.Future[FieldsHandle]? } +// This fixture requires collection before resource destruction. Its destructor +// assertions catch missed ownership transfers; they are not an abandonment +// policy for a production body. An API with async endpoint cleanup also needs +// an explicit async close operation before its synchronous resource drop. + ///| struct Response { mut body : BodyHandle? @@ -96,42 +101,44 @@ pub fn Body::body( } ///| -async fn collect_body_state(state : Body) -> (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 +pub async fn Body::collect( + self : Body, + _background_group : @async-core.TaskGroup[Unit], +) -> (String, String?) { + guard! self.contents is Some(contents) + self.contents = None + let trailers = self.trailers + self.trailers = None + // Collection takes ownership of both endpoints before its first suspension. + // They belong to this computation, so errors and cancellation must close them. + defer @async-core.protect_from_cancel(() => { + if trailers is Some(future) { + future.drop() } + }) + let bytes = { + defer @async-core.protect_from_cancel(() => contents.drop()) + let bytes : Array[Byte] = [] + for ;; { + match contents.read(16) { + Some(chunk) => + for byte in chunk { + bytes.push(byte) + } + None => break + } + } + Bytes::from_array(bytes[:]) } - contents.drop() - - let trailers = match state.trailers { + let trailers = match trailers { Some(future) => { - state.trailers = None let fields = future.get() - let value = fields.get() - fields.drop() - future.drop() - Some(value) + defer fields.drop() + Some(fields.get()) } 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(self) + (@utf8.decode_lossy(bytes[:]), trailers) } ///| @@ -153,11 +160,13 @@ pub async fn Response::collect( self : Response, background_group : @async-core.TaskGroup[Unit], ) -> (String, String?) { - guard self.body is Some(body) else { panic() } + guard! self.body is Some(body) self.body = None - let result = body.collect(background_group) - body.drop() - result + // The response hands ownership to the export's group. A forwarded method + // may return before work in that group finishes; returning is not a join. + // Register cleanup before calling it, including on error or cancellation. + background_group.add_defer(() => body.drop()) + body.collect(background_group) } ///| @@ -180,3 +189,76 @@ pub fn body_live_count() -> UInt { pub fn response_live_count() -> UInt { response_live.val } + +///| +suberror CollectionFailed { + CollectionFailed +} + +///| +pub async fn check_collection_cleanup( + _background_group : @async-core.TaskGroup[Unit], +) -> Unit { + let bodies = body_live.val + let responses = response_live.val + let fields = fields_live.val + + // A local producer failure propagates through both forwarding methods. A + // local group gives this check an explicit point after all cleanup finishes. + try + @async-core.with_task_group(group => { + let (contents, sink) = @async-core.Stream::new() + sink.close() + let trailers = @async-core.Future::from(async fn() { + raise CollectionFailed + }) + let response = ResponseHandle::response( + BodyHandle::body(contents, Some(trailers)), + ) + defer response.drop() + ignore(response.collect(group)) + }) + catch { + CollectionFailed => () + error => raise error + } noraise { + _ => abort("collection should propagate producer failure") + } + guard! body_live.val == bodies && response_live.val == responses + + // Cancel while collection is blocked in the contents stream, before it can + // read the trailer. Both the endpoint and its unread resource must be freed. + let (contents, sink) = @async-core.Stream::new() + @async-core.with_task_group(group => { + let trailers = @async-core.Future::ready_with_cleanup( + FieldsHandle::fields("unread trailer"), + fields => fields.drop(), + ) + let response = ResponseHandle::response( + BodyHandle::body(contents, Some(trailers)), + ) + defer response.drop() + let (started, start) = @async-core.Future::new() + defer @async-core.protect_from_cancel(() => started.drop()) + let reader = group.spawn( + async fn() { + guard! start.complete(()) + ignore(response.collect(group)) + }, + allow_failure=true, + ) + started.get() + reader.cancel() + try reader.wait() catch { + @async-core.TaskCancelled::TaskCancelled => () + error => raise error + } noraise { + _ => abort("collection should be cancelled") + } + }) + guard! body_live.val == bodies && response_live.val == responses + guard! fields_live.val == fields + let unread : FixedArray[Byte] = [0] + guard! !sink.write_all(unread[:]) + sink.close() +} diff --git a/tests/runtime/moonbit/resource-payloads/leaf.rs b/tests/runtime/moonbit/resource-payloads/leaf.rs index 33e3d3d9c..d7a784c3f 100644 --- a/tests/runtime/moonbit/resource-payloads/leaf.rs +++ b/tests/runtime/moonbit/resource-payloads/leaf.rs @@ -4,7 +4,9 @@ use crate::exports::my::test::leaf_interface::{ Body, Fields, Guest, GuestBody, GuestFields, GuestLeafThing, GuestResponse, }; use std::cell::RefCell; +use std::future::Future; use std::sync::atomic::{AtomicU32, Ordering}; +use std::task::Poll; use wit_bindgen::{FutureReader, StreamReader}; struct Component; @@ -59,6 +61,31 @@ impl Guest for Component { fn response_live_count() -> u32 { RESPONSE_LIVE_COUNT.load(Ordering::SeqCst) } + + async fn check_collection_cleanup() { + let bodies = Self::body_live_count(); + let responses = Self::response_live_count(); + let fields = Self::fields_live_count(); + let (mut writer, contents) = wit_stream::new::(); + let (trailer_writer, trailers) = + wit_future::new::(|| Fields::new(MyFields::new("unread trailer".into()))); + let response = MyResponse::new(Body::new(MyBody::new(contents, Some(trailers)))); + let mut collection = Box::pin(response.collect()); + // Dropping a pending Rust future is cancellation. Poll once to move + // ownership into the collection, then check RAII releases every layer. + std::future::poll_fn(|cx| { + assert!(collection.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + drop(collection); + drop(response); + drop(trailer_writer); + assert_eq!(Self::body_live_count(), bodies); + assert_eq!(Self::response_live_count(), responses); + assert_eq!(Self::fields_live_count(), fields); + assert_eq!(writer.write_one(0).await, Some(0)); + } } impl GuestLeafThing for MyLeafThing { diff --git a/tests/runtime/moonbit/resource-payloads/runner.mbt b/tests/runtime/moonbit/resource-payloads/runner.mbt index 65bc2eaf4..2e1c0850f 100644 --- a/tests/runtime/moonbit/resource-payloads/runner.mbt +++ b/tests/runtime/moonbit/resource-payloads/runner.mbt @@ -98,6 +98,7 @@ fn nested_leaf_future( ///| pub async fn run(background_group : @async-core.TaskGroup[Unit]) -> Unit { + @leaf-interface.check_collection_cleanup() { let before = @leaf-interface.leaf_drop_count() let output = @test-interface.relay_nested_leaf( diff --git a/tests/runtime/moonbit/resource-payloads/test.wit b/tests/runtime/moonbit/resource-payloads/test.wit index ea6ec9e2a..81999e586 100644 --- a/tests/runtime/moonbit/resource-payloads/test.wit +++ b/tests/runtime/moonbit/resource-payloads/test.wit @@ -28,6 +28,8 @@ interface leaf-interface { fields-live-count: func() -> u32; body-live-count: func() -> u32; response-live-count: func() -> u32; + // Exercise language-local unwinding and cancellation before crossing the ABI. + check-collection-cleanup: async func(); } interface test-interface {