From 3b53dda7d5395aa9891589789cec47ed82b460fc Mon Sep 17 00:00:00 2001 From: teleios Date: Thu, 2 Jul 2026 17:46:50 +0200 Subject: [PATCH] Add Component::Instance#get_export_index/#get_resource and WIT resource support Instance previously only exposed #get_func, making it impossible to fully drive a component that exports a WIT `resource` (e.g. a constructor plus instance methods) from Ruby: there was no way to look up the resource's exported type, and Val::Resource/Type::Own/Type::Borrow conversions were hard `not_implemented!` stubs. - Instance#get_export_index / #get_resource wrap wasmtime's own methods of the same name; the shared lookup helper now accepts a previously-resolved ExportIndex (bare or nested in an Array) so lookups can be cached and chained, matching wasmtime's own doc example, and internally prefers get_export_index over get_export since the ComponentItem it discards is never used. - New Wasmtime::Component::Resource wraps ResourceAny + its owning Store, requiring an explicit #resource_drop (wasmtime requires every ResourceAny, even borrows, to be explicitly dropped; there's no safe GC finalizer hook for re-entering a Store during collection). - New Wasmtime::Component::ExportIndex and ResourceType wrap the matching wasmtime types; both are inert Copy data with no Store dependency, so unlike Resource they need no GC mark. - convert.rs now lifts/lowers resource values for the Func::invoke path (calling exported functions), and guards against passing a Resource obtained from one Store into a call on a different Store, which would otherwise silently index into the wrong store's resource table. - Host-defined import functions (Linker#root.func_new) still don't support resources in either direction; that path never had a Ruby-visible Store handle to thread through, and stays out of scope here. Co-Authored-By: Claude Sonnet 5 --- ext/src/ruby_api/component.rs | 6 + ext/src/ruby_api/component/convert.rs | 66 +++++++--- ext/src/ruby_api/component/export_index.rs | 48 +++++++ ext/src/ruby_api/component/func.rs | 5 +- ext/src/ruby_api/component/instance.rs | 115 ++++++++++++---- ext/src/ruby_api/component/resource.rs | 146 +++++++++++++++++++++ ext/src/ruby_api/store.rs | 10 ++ spec/unit/component/convert_spec.rb | 53 +++++++- spec/unit/component/instance_spec.rb | 66 ++++++++++ 9 files changed, 470 insertions(+), 45 deletions(-) create mode 100644 ext/src/ruby_api/component/export_index.rs create mode 100644 ext/src/ruby_api/component/resource.rs diff --git a/ext/src/ruby_api/component.rs b/ext/src/ruby_api/component.rs index 1ceb68ff..0a78267a 100644 --- a/ext/src/ruby_api/component.rs +++ b/ext/src/ruby_api/component.rs @@ -1,7 +1,9 @@ mod convert; +mod export_index; mod func; mod instance; mod linker; +mod resource; mod wasi_command; use super::root; @@ -17,9 +19,11 @@ use magnus::{ use rb_sys::tracking_allocator::ManuallyTracked; use wasmtime::component::Component as ComponentImpl; +pub use export_index::ExportIndex; pub use func::Func; pub use instance::Instance; pub use linker::Linker; +pub use resource::{Resource, ResourceType}; pub use wasi_command::WasiCommand; pub fn component_namespace(ruby: &Ruby) -> RModule { @@ -169,6 +173,8 @@ pub fn init(ruby: &Ruby) -> Result<(), Error> { linker::init(ruby, &namespace)?; instance::init(ruby, &namespace)?; func::init(ruby, &namespace)?; + export_index::init(ruby, &namespace)?; + resource::init(ruby, &namespace)?; convert::init(ruby)?; wasi_command::init(ruby, &namespace)?; diff --git a/ext/src/ruby_api/component/convert.rs b/ext/src/ruby_api/component/convert.rs index 0f4f9f7f..4a3c1aab 100644 --- a/ext/src/ruby_api/component/convert.rs +++ b/ext/src/ruby_api/component/convert.rs @@ -1,11 +1,12 @@ -use crate::ruby_api::component::component_namespace; +use crate::ruby_api::component::{component_namespace, Resource}; use crate::ruby_api::errors::ExceptionMessage; use crate::ruby_api::store::StoreContextValue; use crate::{define_rb_intern, err, error, not_implemented}; use magnus::rb_sys::AsRawValue; use magnus::value::{IntoId, Lazy, ReprValue}; use magnus::{ - prelude::*, try_convert, value, Error, IntoValue, RArray, RClass, RHash, RString, Ruby, Value, + prelude::*, try_convert, value, Error, IntoValue, RArray, RClass, RHash, RString, Ruby, + TryConvert, Value, }; use wasmtime::component::{Type, Val}; @@ -25,7 +26,7 @@ define_rb_intern!( pub(crate) fn component_val_to_rb( ruby: &Ruby, val: Val, - _store: Option<&StoreContextValue>, + store: Option<&StoreContextValue>, ) -> Result { match val { Val::Bool(bool) => Ok(bool.into_value_with(ruby)), @@ -44,14 +45,14 @@ pub(crate) fn component_val_to_rb( Val::List(vec) => { let array = ruby.ary_new_capa(vec.len()); for val in vec { - array.push(component_val_to_rb(ruby, val, _store)?)?; + array.push(component_val_to_rb(ruby, val, store)?)?; } Ok(array.into_value_with(ruby)) } Val::Record(fields) => { let hash = ruby.hash_new(); for (name, val) in fields { - let rb_value = component_val_to_rb(ruby, val, _store) + let rb_value = component_val_to_rb(ruby, val, store) .map_err(|e| e.append(format!(" (struct field \"{name}\")")))?; hash.aset(name.as_str(), rb_value)? } @@ -61,13 +62,13 @@ pub(crate) fn component_val_to_rb( Val::Tuple(vec) => { let array = ruby.ary_new_capa(vec.len()); for val in vec { - array.push(component_val_to_rb(ruby, val, _store)?)?; + array.push(component_val_to_rb(ruby, val, store)?)?; } Ok(array.into_value_with(ruby)) } Val::Variant(kind, val) => { let payload = match val { - Some(val) => component_val_to_rb(ruby, *val, _store)?, + Some(val) => component_val_to_rb(ruby, *val, store)?, None => ruby.qnil().into_value_with(ruby), }; @@ -78,7 +79,7 @@ pub(crate) fn component_val_to_rb( } Val::Enum(kind) => Ok(kind.as_str().into_value_with(ruby)), Val::Option(val) => match val { - Some(val) => Ok(component_val_to_rb(ruby, *val, _store)?), + Some(val) => Ok(component_val_to_rb(ruby, *val, store)?), None => Ok(ruby.qnil().as_value()), }, Val::Result(val) => { @@ -87,13 +88,21 @@ pub(crate) fn component_val_to_rb( Err(val) => (ERROR.into_id_with(ruby), val), }; let ruby_argument = match val { - Some(val) => component_val_to_rb(ruby, *val, _store)?, + Some(val) => component_val_to_rb(ruby, *val, store)?, None => ruby.qnil().as_value(), }; result_class(ruby).funcall(ruby_method, (ruby_argument,)) } Val::Flags(vec) => Ok(vec.into_value_with(ruby)), - Val::Resource(_resource_any) => not_implemented!(ruby, "Resource not implemented"), + Val::Resource(resource_any) => match store.and_then(StoreContextValue::as_store) { + Some(store) => Ok(ruby + .obj_wrap(Resource::from_inner(store, resource_any)) + .as_value()), + None => not_implemented!( + ruby, + "Resource not implemented for host-defined component functions" + ), + }, Val::Future(_) => not_implemented!(ruby, "Future not implemented"), Val::ErrorContext(_) => not_implemented!(ruby, "ErrorContext not implemented"), Val::Stream(_) => not_implemented!(ruby, "Stream not implemented"), @@ -103,7 +112,7 @@ pub(crate) fn component_val_to_rb( pub(crate) fn rb_to_component_val( value: Value, - _store: Option<&StoreContextValue>, + store: Option<&StoreContextValue>, ty: &Type, ) -> Result { let ruby = Ruby::get_with(value); @@ -142,7 +151,7 @@ pub(crate) fn rb_to_component_val( // SAFETY: we don't mutate the RArray and we don't call into // user code so user code can't mutate it either. for (i, value) in unsafe { rarray.as_slice() }.iter().enumerate() { - let component_val = rb_to_component_val(*value, _store, &ty) + let component_val = rb_to_component_val(*value, store, &ty) .map_err(|e| e.append(format!(" (list item at index {i})")))?; vals.push(component_val); @@ -158,7 +167,7 @@ pub(crate) fn rb_to_component_val( .get(field.name) .ok_or_else(|| error!("struct field missing: {}", field.name)) .and_then(|v| { - rb_to_component_val(v, _store, &field.ty) + rb_to_component_val(v, store, &field.ty) .map_err(|e| e.append(format!(" (struct field \"{}\")", field.name))) })?; @@ -184,7 +193,7 @@ pub(crate) fn rb_to_component_val( let mut vals: Vec = Vec::with_capacity(rarray.len()); for (i, (ty, value)) in types.zip(unsafe { rarray.as_slice() }.iter()).enumerate() { - let component_val = rb_to_component_val(*value, _store, &ty) + let component_val = rb_to_component_val(*value, store, &ty) .map_err(|error| error.append(format!(" (tuple value at index {i})")))?; vals.push(component_val); @@ -213,7 +222,7 @@ pub(crate) fn rb_to_component_val( let payload_rb: Value = value.funcall(VALUE.into_id_with(&ruby), ())?; let payload_val = match (&case.ty, payload_rb.is_nil()) { - (Some(ty), _) => rb_to_component_val(payload_rb, _store, ty) + (Some(ty), _) => rb_to_component_val(payload_rb, store, ty) .map(|val| Some(Box::new(val))) .map_err(|e| e.append(format!(" (variant value for \"{}\")", &name))), @@ -240,7 +249,7 @@ pub(crate) fn rb_to_component_val( } else { Ok(Val::Option(Some(Box::new(rb_to_component_val( value, - _store, + store, &option_type.ty(), )?)))) } @@ -252,7 +261,7 @@ pub(crate) fn rb_to_component_val( if is_ok { let ok_value = value.funcall::<_, (), Value>(OK.into_id_with(&ruby), ())?; match result_type.ok() { - Some(ty) => rb_to_component_val(ok_value, _store, &ty) + Some(ty) => rb_to_component_val(ok_value, store, &ty) .map(|val| Val::Result(Result::Ok(Some(Box::new(val))))), None => { if ok_value.is_nil() { @@ -268,7 +277,7 @@ pub(crate) fn rb_to_component_val( } else { let err_value = value.funcall::<_, (), Value>(ERROR.into_id_with(&ruby), ())?; match result_type.err() { - Some(ty) => rb_to_component_val(err_value, _store, &ty) + Some(ty) => rb_to_component_val(err_value, store, &ty) .map(|val| Val::Result(Result::Err(Some(Box::new(val))))), None => { if err_value.is_nil() { @@ -284,8 +293,25 @@ pub(crate) fn rb_to_component_val( } } Type::Flags(_) => Vec::::try_convert(value).map(Val::Flags), - Type::Own(_resource_type) => not_implemented!(ruby, "Resource not implemented"), - Type::Borrow(_resource_type) => not_implemented!(ruby, "Resource not implemented"), + Type::Own(_) | Type::Borrow(_) => { + let resource = <&Resource>::try_convert(value)?; + + match store.and_then(StoreContextValue::as_store) { + Some(current_store) + if resource.store().as_value().as_raw() + == current_store.as_value().as_raw() => + { + Ok(Val::Resource(resource.get()?)) + } + Some(_) => { + err!("Resource belongs to a different Store than the one being called into") + } + None => not_implemented!( + ruby, + "Resource not implemented for host-defined component functions" + ), + } + } Type::Future(_) => not_implemented!(ruby, "Future not implemented"), Type::Stream(_) => not_implemented!(ruby, "Stream not implemented"), Type::ErrorContext => not_implemented!(ruby, "ErrorContext not implemented"), diff --git a/ext/src/ruby_api/component/export_index.rs b/ext/src/ruby_api/component/export_index.rs new file mode 100644 index 00000000..a4ec7ec4 --- /dev/null +++ b/ext/src/ruby_api/component/export_index.rs @@ -0,0 +1,48 @@ +use magnus::{method, prelude::*, Error, Module, RModule, Ruby}; +use wasmtime::component::ComponentExportIndex; + +/// @yard +/// @rename Wasmtime::Component::ExportIndex +/// Represents a resolved handle to a named export within a {Component}. +/// Can be passed back as the +handle+ argument to {Instance#get_func}, +/// {Instance#get_resource}, or {Instance#get_export_index} (either directly, +/// or as an element of an +Array+ handle) to avoid re-resolving the same +/// nested export by name repeatedly. +/// @see https://docs.rs/wasmtime/latest/wasmtime/component/struct.ComponentExportIndex.html Wasmtime's Rust doc +#[magnus::wrap( + class = "Wasmtime::Component::ExportIndex", + size, + free_immediately, + frozen_shareable +)] +pub struct ExportIndex { + inner: ComponentExportIndex, +} + +unsafe impl Send for ExportIndex {} + +impl ExportIndex { + pub fn from_inner(inner: ComponentExportIndex) -> Self { + Self { inner } + } + + pub fn inner(&self) -> ComponentExportIndex { + self.inner + } + + /// @yard + /// @def ==(other) + /// @param other [Object] + /// @return [Boolean] + fn eq(&self, other: &ExportIndex) -> bool { + self.inner == other.inner + } +} + +pub fn init(ruby: &Ruby, namespace: &RModule) -> Result<(), Error> { + let class = namespace.define_class("ExportIndex", ruby.class_object())?; + class.define_method("==", method!(ExportIndex::eq, 1))?; + class.define_method("eql?", method!(ExportIndex::eq, 1))?; + + Ok(()) +} diff --git a/ext/src/ruby_api/component/func.rs b/ext/src/ruby_api/component/func.rs index 9e115b68..60244664 100644 --- a/ext/src/ruby_api/component/func.rs +++ b/ext/src/ruby_api/component/func.rs @@ -53,7 +53,10 @@ use wasmtime::component::{Func as FuncImpl, Type, Val}; /// - invalid {Variant#name}, /// - unparametrized variant and not nil {Variant#value}. /// resource (own or borrow):: -/// Not yet supported. +/// {Wasmtime::Component::Resource} instance. MUST be explicitly destroyed +/// with {Wasmtime::Component::Resource#resource_drop} once no longer +/// needed — see {Wasmtime::Component::Resource}'s class documentation for +/// details. #[derive(TypedData)] #[magnus(class = "Wasmtime::Component::Func", size, mark, free_immediately)] pub struct Func { diff --git a/ext/src/ruby_api/component/instance.rs b/ext/src/ruby_api/component/instance.rs index 32106012..ebfc7e76 100644 --- a/ext/src/ruby_api/component/instance.rs +++ b/ext/src/ruby_api/component/instance.rs @@ -1,4 +1,7 @@ -use crate::ruby_api::{component::Func, Store}; +use crate::ruby_api::{ + component::{ExportIndex, Func, ResourceType}, + Store, +}; use std::{borrow::BorrowMut, cell::RefCell}; use crate::error; @@ -45,7 +48,7 @@ impl Instance { /// Retrieves a Wasm function from the component instance. /// /// @def get_func(handle) - /// @param handle [String, Array] The path of the function to retrieve + /// @param handle [String, Array, ExportIndex] The path of the function to retrieve /// @return [Func, nil] The function if it exists, nil otherwise /// /// @example Retrieve a top-level +add+ export: @@ -62,46 +65,112 @@ impl Instance { Ok(func) } + /// @yard + /// Retrieves a handle to a named export within the component instance, + /// without resolving it to a concrete kind (function, resource, etc). The + /// returned {ExportIndex} can be passed back as (or within) the +handle+ + /// argument to {#get_func}, {#get_resource}, or {#get_export_index} + /// itself, to avoid re-resolving the same nested export by name + /// repeatedly. + /// + /// @def get_export_index(handle) + /// @param handle [String, Array, ExportIndex] The path of the export to retrieve + /// @return [ExportIndex, nil] The export index if it exists, nil otherwise + /// + /// @example Retrieve the index of a nested +resource+ instance's export, then reuse it: + /// idx = instance.get_export_index("resource") + /// instance.get_func([idx, "[constructor]wrapped-string"]) + pub fn get_export_index( + rb_self: Obj, + handle: Value, + ) -> Result, Error> { + let index = rb_self.export_index(handle)?.map(ExportIndex::from_inner); + + Ok(index) + } + + /// @yard + /// Retrieves an exported WIT +resource+ type from the component instance. + /// + /// @def get_resource(handle) + /// @param handle [String, Array, ExportIndex] The path of the resource type to retrieve + /// @return [ResourceType, nil] The resource type if it exists, nil otherwise + /// + /// @example Retrieve the +wrapped-string+ resource type nested under a +resource+ export: + /// instance.get_resource(["resource", "wrapped-string"]) + pub fn get_resource(rb_self: Obj, handle: Value) -> Result, Error> { + let resource_type = rb_self + .export_index(handle)? + .and_then(|index| { + rb_self + .inner + .get_resource(rb_self.store.context_mut(), index) + }) + .map(ResourceType::from_inner); + + Ok(resource_type) + } + fn export_index(&self, handle: Value) -> Result, Error> { let ruby = Ruby::get_with(handle); let invalid_arg = || { Error::new( ruby.exception_type_error(), format!( - "invalid argument for component index, expected String | Array, got {}", + "invalid argument for component index, expected String | Array | ExportIndex, got {}", handle.inspect() ), ) }; - let index = if let Some(name) = RString::from_value(handle) { - self.inner - .get_export(self.store.context_mut(), None, unsafe { name.as_str()? }) - .map(|(_, index)| index) - } else if let Some(names) = RArray::from_value(handle) { - unsafe { names.as_slice() } + if let Some(name) = RString::from_value(handle) { + return Ok(self + .inner + .get_export_index(self.store.context_mut(), None, unsafe { name.as_str()? })); + } + + if let Some(elements) = RArray::from_value(handle) { + let index = unsafe { elements.as_slice() } .iter() - .try_fold::<_, _, Result<_, Error>>(None, |index, name| { - let name = RString::from_value(*name).ok_or_else(invalid_arg)?; - - Ok(self - .inner - .get_export(self.store.context_mut(), index.as_ref(), unsafe { - name.as_str()? - }) - .map(|(_, index)| index)) - })? - } else { - return Err(invalid_arg()); - }; + .try_fold::<_, _, Result<_, Error>>(None, |index, element| { + self.resolve_element(*element, index, invalid_arg) + })?; - Ok(index) + return Ok(index); + } + + if let Ok(export_index) = <&ExportIndex>::try_convert(handle) { + return Ok(Some(export_index.inner())); + } + + Err(invalid_arg()) + } + + fn resolve_element( + &self, + element: Value, + index: Option, + invalid_arg: impl Fn() -> Error, + ) -> Result, Error> { + if let Some(name) = RString::from_value(element) { + Ok(self + .inner + .get_export_index(self.store.context_mut(), index.as_ref(), unsafe { + name.as_str()? + })) + } else if let Ok(export_index) = <&ExportIndex>::try_convert(element) { + Ok(Some(export_index.inner())) + } else { + Err(invalid_arg()) + } } } pub fn init(ruby: &Ruby, namespace: &RModule) -> Result<(), Error> { let instance = namespace.define_class("Instance", ruby.class_object())?; instance.define_method("get_func", method!(Instance::get_func, 1))?; + instance.define_method("get_export_index", method!(Instance::get_export_index, 1))?; + instance.define_method("get_resource", method!(Instance::get_resource, 1))?; Ok(()) } diff --git a/ext/src/ruby_api/component/resource.rs b/ext/src/ruby_api/component/resource.rs new file mode 100644 index 00000000..7316effc --- /dev/null +++ b/ext/src/ruby_api/component/resource.rs @@ -0,0 +1,146 @@ +use std::cell::RefCell; + +use crate::error; +use crate::ruby_api::store::Store; +use magnus::{ + gc::Marker, method, prelude::*, typed_data::Obj, DataTypeFunctions, Error, Module, RModule, + Ruby, TypedData, +}; +use wasmtime::component::{ResourceAny as ResourceAnyImpl, ResourceType as ResourceTypeImpl}; + +/// @yard +/// @rename Wasmtime::Component::ResourceType +/// Represents the type of a WIT +resource+. Two {ResourceType}s are equal iff +/// they describe the same resource type in the same {Component} instantiation. +/// Returned by {Instance#get_resource} and {Resource#type}. There is no public +/// constructor. +/// @see https://docs.rs/wasmtime/latest/wasmtime/component/struct.ResourceType.html Wasmtime's Rust doc +#[magnus::wrap( + class = "Wasmtime::Component::ResourceType", + size, + free_immediately, + frozen_shareable +)] +pub struct ResourceType { + inner: ResourceTypeImpl, +} + +unsafe impl Send for ResourceType {} + +impl ResourceType { + pub fn from_inner(inner: ResourceTypeImpl) -> Self { + Self { inner } + } + + /// @yard + /// @def ==(other) + /// @param other [Object] + /// @return [Boolean] + fn eq(&self, other: &ResourceType) -> bool { + self.inner == other.inner + } +} + +/// @yard +/// @rename Wasmtime::Component::Resource +/// Represents a handle to a WIT +resource+ (either +own+ or +borrow+), +/// as produced by calling a Wasm component export that returns a resource, or +/// passed as an argument to one that accepts one. +/// +/// IMPORTANT: a {Resource} MUST be explicitly destroyed with {#resource_drop} +/// once it is no longer needed. This holds for +own+ *and* +borrow+ handles +/// alike: both hold state in the owning {Store} that must be released. There +/// is no automatic finalizer: Ruby's GC may run at times where it would be +/// unsafe to re-enter the {Store} (e.g. to invoke a guest-defined +/// destructor), so failing to call {#resource_drop} will leak store-side +/// resource-table state for the life of the {Store}. +/// +/// Passing an +own+ {Resource} into a function parameter transfers +/// ownership of the underlying resource to the callee. Using the same +/// {Resource} object afterwards (including calling {#resource_drop} on it) +/// may then raise, since the store-side handle it pointed to no longer +/// belongs to the host. +/// +/// A {Resource} is tied to the {Store} it was obtained from; passing one into +/// a function call on a different {Store} raises. +/// @see https://docs.rs/wasmtime/latest/wasmtime/component/struct.ResourceAny.html Wasmtime's Rust doc for +ResourceAny+ +#[derive(TypedData)] +#[magnus(class = "Wasmtime::Component::Resource", size, mark, free_immediately)] +pub struct Resource { + store: Obj, + inner: RefCell>, +} + +unsafe impl Send for Resource {} + +impl DataTypeFunctions for Resource { + fn mark(&self, marker: &Marker) { + marker.mark(self.store); + } +} + +impl Resource { + pub fn from_inner(store: Obj, inner: ResourceAnyImpl) -> Self { + Self { + store, + inner: RefCell::new(Some(inner)), + } + } + + /// Returns the live inner resource handle, without consuming it. + /// Errors if the resource has already been dropped. + pub(crate) fn get(&self) -> Result { + (*self.inner.borrow()).ok_or_else(|| error!("Resource has already been dropped")) + } + + /// The `Store` this resource was obtained from. + pub(crate) fn store(&self) -> Obj { + self.store + } + + /// @yard + /// @def owned? + /// @return [Boolean] whether this is an +own+ (+true+) or +borrow+ + /// (+false+) handle. + fn owned(&self) -> Result { + Ok(self.get()?.owned()) + } + + /// @yard + /// @def type + /// @return [ResourceType] + fn type_(&self) -> Result { + Ok(ResourceType::from_inner(self.get()?.ty())) + } + + /// @yard + /// Explicitly destroys this resource, releasing store-side state and + /// invoking the guest-defined destructor if applicable. MUST be called + /// exactly once for every {Resource}, including borrows. + /// @def resource_drop + /// @return [nil] + fn resource_drop(&self) -> Result<(), Error> { + let resource_any = self + .inner + .borrow_mut() + .take() + .ok_or_else(|| error!("Resource has already been dropped"))?; + + resource_any + .resource_drop(self.store.context_mut()) + .map_err(|e| error!("{}", e)) + } +} + +pub fn init(ruby: &Ruby, namespace: &RModule) -> Result<(), Error> { + let resource_type = namespace.define_class("ResourceType", ruby.class_object())?; + resource_type.define_method("==", method!(ResourceType::eq, 1))?; + resource_type.define_method("eql?", method!(ResourceType::eq, 1))?; + + let resource = namespace.define_class("Resource", ruby.class_object())?; + resource.define_method("owned?", method!(Resource::owned, 0))?; + resource.define_method("type", method!(Resource::type_, 0))?; + resource.define_method("resource_drop", method!(Resource::resource_drop, 0))?; + + Ok(()) +} diff --git a/ext/src/ruby_api/store.rs b/ext/src/ruby_api/store.rs index 822e22c2..c2cc4ca6 100644 --- a/ext/src/ruby_api/store.rs +++ b/ext/src/ruby_api/store.rs @@ -369,6 +369,16 @@ impl StoreContextValue<'_> { } } + /// Returns the `Obj` backing this context, if any. `None` for the + /// `Caller` variant, since a `Caller` is only valid on the stack for the + /// duration of a host call and must not be persisted. + pub fn as_store(&self) -> Option> { + match self { + Self::Store(store) => Some(Ruby::get().unwrap().get_inner(*store)), + Self::Caller(_) => None, + } + } + pub fn set_last_error(&self, error: Error) { let ruby = Ruby::get().unwrap(); match self { diff --git a/spec/unit/component/convert_spec.rb b/spec/unit/component/convert_spec.rb index 6b75c03a..1f88893d 100644 --- a/spec/unit/component/convert_spec.rb +++ b/spec/unit/component/convert_spec.rb @@ -58,7 +58,58 @@ def call_func(name, *args) end end - # TODO resource + describe "resources" do + let(:ctor) { instance.get_func(["resource", "[constructor]wrapped-string"]) } + let(:to_string) { instance.get_func(["resource", "[method]wrapped-string.to-string"]) } + let(:resource_owned) { instance.get_func(["resource", "resource-owned"]) } + + it "constructs a resource, calls a borrow method (repeatedly), and drops it" do + resource = ctor.call("hello") + + expect(resource).to be_instance_of(Resource) + expect(resource.owned?).to be true + expect(resource.type).to eq(instance.get_resource(["resource", "wrapped-string"])) + + expect(to_string.call(resource)).to eq("hello") + expect(to_string.call(resource)).to eq("hello") # borrow: reusable + + expect(resource.resource_drop).to be_nil + end + + it "raises on double drop" do + resource = ctor.call("hello") + resource.resource_drop + + expect { resource.resource_drop }.to raise_error(/already been dropped/) + end + + it "raises when using a dropped resource" do + resource = ctor.call("hello") + resource.resource_drop + + expect { to_string.call(resource) }.to raise_error(/already been dropped/) + end + + it "transfers ownership into a function taking own" do + resource = ctor.call("owned") + + expect { resource_owned.call(resource) }.not_to raise_error + end + + it "raises TypeError when passing a non-Resource where a resource is expected" do + expect { resource_owned.call("not a resource") } + .to raise_error(TypeError, /Resource/) + end + + it "raises when passing a Resource obtained from a different Store" do + other_instance = linker.instantiate(Store.new(GLOBAL_ENGINE), @types_component) + other_ctor = other_instance.get_func(["resource", "[constructor]wrapped-string"]) + resource = other_ctor.call("hello") + + expect { to_string.call(resource) } + .to raise_error(/different Store/) + end + end describe "failures" do [ diff --git a/spec/unit/component/instance_spec.rb b/spec/unit/component/instance_spec.rb index b403aef3..2c70864e 100644 --- a/spec/unit/component/instance_spec.rb +++ b/spec/unit/component/instance_spec.rb @@ -5,10 +5,12 @@ module Component RSpec.describe Instance do before(:all) do @adder_component = Component.from_file(GLOBAL_ENGINE, "spec/fixtures/component_adder.wat") + @types_component = Component.from_file(GLOBAL_ENGINE, "spec/fixtures/component_types.wasm") end let(:linker) { Linker.new(engine) } let(:adder_instance) { linker.instantiate(store, @adder_component) } + let(:types_instance) { linker.instantiate(store, @types_component) } describe "#get_func" do it "returns a root func" do @@ -31,6 +33,70 @@ module Component expect { adder_instance.get_func([nil]) } .to raise_error(TypeError, /invalid argument for component index/) end + + it "accepts a previously resolved ExportIndex" do + index = adder_instance.get_export_index(["adder", "add"]) + expect(adder_instance.get_func(index)).to be_instance_of(Wasmtime::Component::Func) + end + end + + describe "#get_export_index" do + it "returns an ExportIndex for a root export" do + expect(adder_instance.get_export_index("add")).to be_instance_of(Wasmtime::Component::ExportIndex) + end + + it "returns an ExportIndex for a nested export" do + expect(types_instance.get_export_index(["resource", "wrapped-string"])) + .to be_instance_of(Wasmtime::Component::ExportIndex) + end + + it "returns nil for a missing export" do + expect(adder_instance.get_export_index("no")).to be_nil + expect(adder_instance.get_export_index(["add", "no"])).to be_nil + end + + it "raises for invalid arg" do + expect { adder_instance.get_export_index(3) } + .to raise_error(TypeError, /invalid argument for component index/) + + expect { adder_instance.get_export_index([nil]) } + .to raise_error(TypeError, /invalid argument for component index/) + end + + it "allows chaining a resolved ExportIndex into further lookups" do + resource_index = types_instance.get_export_index("resource") + + expect(types_instance.get_export_index([resource_index, "wrapped-string"])) + .to eq(types_instance.get_export_index(["resource", "wrapped-string"])) + + expect(types_instance.get_func([resource_index, "[constructor]wrapped-string"])) + .to be_instance_of(Wasmtime::Component::Func) + end + end + + describe "#get_resource" do + it "returns a ResourceType for an exported resource" do + expect(types_instance.get_resource(["resource", "wrapped-string"])) + .to be_instance_of(Wasmtime::Component::ResourceType) + end + + it "returns nil for a non-resource or missing export" do + expect(types_instance.get_resource(["resource", "resource-owned"])).to be_nil + expect(types_instance.get_resource(["resource", "no"])).to be_nil + expect(types_instance.get_resource("no")).to be_nil + end + + it "raises for invalid arg" do + expect { types_instance.get_resource(3) } + .to raise_error(TypeError, /invalid argument for component index/) + end + + it "returns equal ResourceTypes for repeated lookups of the same resource" do + a = types_instance.get_resource(["resource", "wrapped-string"]) + b = types_instance.get_resource(["resource", "wrapped-string"]) + + expect(a).to eq(b) + end end end end