From 8bdc51ddcc8b8bd55bc03f027ff31fe955e46c7f 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 a8971b08b91d155cdbddeff035a2c569c47fe2b7 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 c26d078aa71bc4c44fd09887586ca82f276d4870 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 d40dde67b68ca62eeccc0fcee0f78e5e4d5910cc 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 167528426da8cacb85cee38161edfd34ea31ef24 Mon Sep 17 00:00:00 2001 From: zihang Date: Wed, 21 Oct 2026 10:36:07 +0800 Subject: [PATCH 5/5] test(moonbit): demonstrate async resource cleanup scopes --- crates/moonbit/docs/resources.md | 104 ++++++++++++ .../exported-resource-lifecycle/runner.mbt | 31 +++- .../exported-resource-lifecycle/test.mbt | 75 +++++++++ .../exported-resource-lifecycle/test.wit | 7 +- .../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 + 8 files changed, 360 insertions(+), 35 deletions(-) diff --git a/crates/moonbit/docs/resources.md b/crates/moonbit/docs/resources.md index f486b844d..8529e5e0e 100644 --- a/crates/moonbit/docs/resources.md +++ b/crates/moonbit/docs/resources.md @@ -87,6 +87,110 @@ 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 with an owned handle: its caller receives the result, verifies +the resource is still live, then opens a gate so the worker can use it and the +group can destroy it. It does not retain a borrowed WIT argument 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 diff --git a/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt b/tests/runtime/moonbit/exported-resource-lifecycle/runner.mbt index ee6ec38e3..e549a65f6 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,31 @@ pub fn run() -> Unit { guard! @api.take_dropped() is [4] @api.take_reentrant_child().drop() guard! @api.take_dropped() is [5] + + // The result returns while the gate is closed, but the owned resource stays + // alive until background computation finishes. Neither early drop nor a + // join before returning 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] } diff --git a/tests/runtime/moonbit/exported-resource-lifecycle/test.mbt b/tests/runtime/moonbit/exported-resource-lifecycle/test.mbt index 47b6ccd80..282ab6c20 100644 --- a/tests/runtime/moonbit/exported-resource-lifecycle/test.mbt +++ b/tests/runtime/moonbit/exported-resource-lifecycle/test.mbt @@ -16,6 +16,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 +31,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 +82,70 @@ 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] { + // Keep the owning handle, rather than a borrowed payload, for deferred work. + // The group releases it after its children finish, including cancellation. + 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(())) + }) + let id = value.id() + background_group.spawn_bg(async fn() { + defer @async-core.protect_from_cancel(() => gate.drop()) + gate.get() + guard! value.id() == 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..271b547e9 100644 --- a/tests/runtime/moonbit/exported-resource-lifecycle/test.wit +++ b/tests/runtime/moonbit/exported-resource-lifecycle/test.wit @@ -5,6 +5,7 @@ package test:exported-resource-lifecycle; interface api { resource tracked { constructor(id: u32, reenter-on-drop: bool) -> result; + id: func() -> u32; } retain-alias: func(value: tracked) -> tracked; @@ -12,6 +13,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 +25,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 {