diff --git a/Cargo.lock b/Cargo.lock index 8aa7c0ec1..3343c246b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2766,7 +2766,7 @@ dependencies = [ [[package]] name = "typst-dev-assets" version = "0.12.0" -source = "git+https://github.com/typst/typst-dev-assets?rev=b07d156#b07d1560143d6883887358d30edb25cb12fcf5b9" +source = "git+https://github.com/typst/typst-dev-assets?rev=7f8999d#7f8999d19907cd6e1148b295efbc844921c0761c" [[package]] name = "typst-docs" diff --git a/Cargo.toml b/Cargo.toml index 1be7816a7..6b592cd39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ typst-syntax = { path = "crates/typst-syntax", version = "0.12.0" } typst-timing = { path = "crates/typst-timing", version = "0.12.0" } typst-utils = { path = "crates/typst-utils", version = "0.12.0" } typst-assets = { git = "https://github.com/typst/typst-assets", rev = "8cccef9" } -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "b07d156" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "7f8999d" } arrayvec = "0.7.4" az = "1.2" base64 = "0.22" diff --git a/crates/typst-eval/src/call.rs b/crates/typst-eval/src/call.rs index f59235c78..2a2223e15 100644 --- a/crates/typst-eval/src/call.rs +++ b/crates/typst-eval/src/call.rs @@ -6,8 +6,8 @@ use typst_library::diag::{ }; use typst_library::engine::{Engine, Sink, Traced}; use typst_library::foundations::{ - Arg, Args, Bytes, Capturer, Closure, Content, Context, Func, IntoValue, - NativeElement, Scope, Scopes, SymbolElem, Value, + Arg, Args, Capturer, Closure, Content, Context, Func, NativeElement, Scope, Scopes, + SymbolElem, Value, }; use typst_library::introspection::Introspector; use typst_library::math::LrElem; @@ -315,13 +315,7 @@ fn eval_field_call( (target, args) }; - if let Value::Plugin(plugin) = &target { - // Call plugins by converting args to bytes. - let bytes = args.all::()?; - args.finish()?; - let value = plugin.call(&field, bytes).at(span)?.into_value(); - Ok(FieldCall::Resolved(value)) - } else if let Some(callee) = target.ty().scope().get(&field) { + if let Some(callee) = target.ty().scope().get(&field) { args.insert(0, target_expr.span(), target); Ok(FieldCall::Normal(callee.clone(), args)) } else if let Value::Content(content) = &target { diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 0f8abddb7..24b76537a 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -452,16 +452,6 @@ fn field_access_completions( } } } - Value::Plugin(plugin) => { - for name in plugin.iter() { - ctx.completions.push(Completion { - kind: CompletionKind::Func, - label: name.clone(), - apply: None, - detail: None, - }) - } - } _ => {} } } diff --git a/crates/typst-library/src/foundations/func.rs b/crates/typst-library/src/foundations/func.rs index cb3eba161..a05deb1f3 100644 --- a/crates/typst-library/src/foundations/func.rs +++ b/crates/typst-library/src/foundations/func.rs @@ -9,11 +9,11 @@ use ecow::{eco_format, EcoString}; use typst_syntax::{ast, Span, SyntaxNode}; use typst_utils::{singleton, LazyHash, Static}; -use crate::diag::{bail, SourceResult, StrResult}; +use crate::diag::{bail, At, SourceResult, StrResult}; use crate::engine::Engine; use crate::foundations::{ - cast, repr, scope, ty, Args, CastInfo, Content, Context, Element, IntoArgs, Scope, - Selector, Type, Value, + cast, repr, scope, ty, Args, Bytes, CastInfo, Content, Context, Element, IntoArgs, + PluginFunc, Scope, Selector, Type, Value, }; /// A mapping from argument values to a return value. @@ -151,6 +151,8 @@ enum Repr { Element(Element), /// A user-defined closure. Closure(Arc>), + /// A plugin WebAssembly function. + Plugin(Arc), /// A nested function with pre-applied arguments. With(Arc<(Func, Args)>), } @@ -164,6 +166,7 @@ impl Func { Repr::Native(native) => Some(native.name), Repr::Element(elem) => Some(elem.name()), Repr::Closure(closure) => closure.name(), + Repr::Plugin(func) => Some(func.name()), Repr::With(with) => with.0.name(), } } @@ -176,6 +179,7 @@ impl Func { Repr::Native(native) => Some(native.title), Repr::Element(elem) => Some(elem.title()), Repr::Closure(_) => None, + Repr::Plugin(_) => None, Repr::With(with) => with.0.title(), } } @@ -186,6 +190,7 @@ impl Func { Repr::Native(native) => Some(native.docs), Repr::Element(elem) => Some(elem.docs()), Repr::Closure(_) => None, + Repr::Plugin(_) => None, Repr::With(with) => with.0.docs(), } } @@ -204,6 +209,7 @@ impl Func { Repr::Native(native) => Some(&native.0.params), Repr::Element(elem) => Some(elem.params()), Repr::Closure(_) => None, + Repr::Plugin(_) => None, Repr::With(with) => with.0.params(), } } @@ -221,6 +227,7 @@ impl Func { Some(singleton!(CastInfo, CastInfo::Type(Type::of::()))) } Repr::Closure(_) => None, + Repr::Plugin(_) => None, Repr::With(with) => with.0.returns(), } } @@ -231,6 +238,7 @@ impl Func { Repr::Native(native) => native.keywords, Repr::Element(elem) => elem.keywords(), Repr::Closure(_) => &[], + Repr::Plugin(_) => &[], Repr::With(with) => with.0.keywords(), } } @@ -241,6 +249,7 @@ impl Func { Repr::Native(native) => Some(&native.0.scope), Repr::Element(elem) => Some(elem.scope()), Repr::Closure(_) => None, + Repr::Plugin(_) => None, Repr::With(with) => with.0.scope(), } } @@ -266,6 +275,14 @@ impl Func { } } + /// Extract the plugin function, if it is one. + pub fn to_plugin(&self) -> Option<&PluginFunc> { + match &self.repr { + Repr::Plugin(func) => Some(func), + _ => None, + } + } + /// Call the function with the given context and arguments. pub fn call( &self, @@ -307,6 +324,12 @@ impl Func { context, args, ), + Repr::Plugin(func) => { + let inputs = args.all::()?; + let output = func.call(inputs).at(args.span)?; + args.finish()?; + Ok(Value::Bytes(output)) + } Repr::With(with) => { args.items = with.1.items.iter().cloned().chain(args.items).collect(); with.0.call(engine, context, args) @@ -425,12 +448,30 @@ impl From for Func { } } +impl From<&'static NativeFuncData> for Func { + fn from(data: &'static NativeFuncData) -> Self { + Repr::Native(Static(data)).into() + } +} + impl From for Func { fn from(func: Element) -> Self { Repr::Element(func).into() } } +impl From for Func { + fn from(closure: Closure) -> Self { + Repr::Closure(Arc::new(LazyHash::new(closure))).into() + } +} + +impl From for Func { + fn from(func: PluginFunc) -> Self { + Repr::Plugin(Arc::new(func)).into() + } +} + /// A Typst function that is defined by a native Rust type that shadows a /// native Rust function. pub trait NativeFunc { @@ -466,12 +507,6 @@ pub struct NativeFuncData { pub returns: LazyLock, } -impl From<&'static NativeFuncData> for Func { - fn from(data: &'static NativeFuncData) -> Self { - Repr::Native(Static(data)).into() - } -} - cast! { &'static NativeFuncData, self => Func::from(self).into_value(), @@ -525,12 +560,6 @@ impl Closure { } } -impl From for Func { - fn from(closure: Closure) -> Self { - Repr::Closure(Arc::new(LazyHash::new(closure))).into() - } -} - cast! { Closure, self => Value::Func(self.into()), diff --git a/crates/typst-library/src/foundations/mod.rs b/crates/typst-library/src/foundations/mod.rs index 2921481bc..a790da4f4 100644 --- a/crates/typst-library/src/foundations/mod.rs +++ b/crates/typst-library/src/foundations/mod.rs @@ -25,7 +25,8 @@ mod int; mod label; mod module; mod none; -mod plugin; +#[path = "plugin.rs"] +mod plugin_; mod scope; mod selector; mod str; @@ -56,7 +57,7 @@ pub use self::int::*; pub use self::label::*; pub use self::module::*; pub use self::none::*; -pub use self::plugin::*; +pub use self::plugin_::*; pub use self::repr::Repr; pub use self::scope::*; pub use self::selector::*; @@ -114,11 +115,11 @@ pub(super) fn define(global: &mut Scope, inputs: Dict, features: &Features) { global.define_type::(); global.define_type::(); global.define_type::(); - global.define_type::(); global.define_func::(); global.define_func::(); global.define_func::(); global.define_func::(); + global.define_func::(); if features.is_enabled(Feature::Html) { global.define_func::(); } diff --git a/crates/typst-library/src/foundations/module.rs b/crates/typst-library/src/foundations/module.rs index 2001aca16..3ee59c106 100644 --- a/crates/typst-library/src/foundations/module.rs +++ b/crates/typst-library/src/foundations/module.rs @@ -7,14 +7,20 @@ use typst_syntax::FileId; use crate::diag::StrResult; use crate::foundations::{repr, ty, Content, Scope, Value}; -/// An evaluated module, either built-in or resulting from a file. +/// An module of definitions. /// -/// You can access definitions from the module using -/// [field access notation]($scripting/#fields) and interact with it using the -/// [import and include syntaxes]($scripting/#modules). Alternatively, it is -/// possible to convert a module to a dictionary, and therefore access its -/// contents dynamically, using the -/// [dictionary constructor]($dictionary/#constructor). +/// A module +/// - be built-in +/// - stem from a [file import]($scripting/#modules) +/// - stem from a [package import]($scripting/#packages) (and thus indirectly +/// its entrypoint file) +/// - result from a call to the [plugin]($plugin) function +/// +/// You can access definitions from the module using [field access +/// notation]($scripting/#fields) and interact with it using the [import and +/// include syntaxes]($scripting/#modules). Alternatively, it is possible to +/// convert a module to a dictionary, and therefore access its contents +/// dynamically, using the [dictionary constructor]($dictionary/#constructor). /// /// # Example /// ```example diff --git a/crates/typst-library/src/foundations/ops.rs b/crates/typst-library/src/foundations/ops.rs index 7dbdde8ff..6c2408446 100644 --- a/crates/typst-library/src/foundations/ops.rs +++ b/crates/typst-library/src/foundations/ops.rs @@ -447,7 +447,6 @@ pub fn equal(lhs: &Value, rhs: &Value) -> bool { (Args(a), Args(b)) => a == b, (Type(a), Type(b)) => a == b, (Module(a), Module(b)) => a == b, - (Plugin(a), Plugin(b)) => a == b, (Datetime(a), Datetime(b)) => a == b, (Duration(a), Duration(b)) => a == b, (Dyn(a), Dyn(b)) => a == b, diff --git a/crates/typst-library/src/foundations/plugin.rs b/crates/typst-library/src/foundations/plugin.rs index d41261edc..cbc0f52de 100644 --- a/crates/typst-library/src/foundations/plugin.rs +++ b/crates/typst-library/src/foundations/plugin.rs @@ -4,43 +4,27 @@ use std::sync::{Arc, Mutex}; use ecow::{eco_format, EcoString}; use typst_syntax::Spanned; -use wasmi::{AsContext, AsContextMut}; +use wasmi::Memory; use crate::diag::{bail, At, SourceResult, StrResult}; use crate::engine::Engine; -use crate::foundations::{func, repr, scope, ty, Bytes}; +use crate::foundations::{cast, func, scope, Bytes, Func, Module, Scope, Value}; use crate::loading::{DataSource, Load}; -/// A WebAssembly plugin. +/// Loads a WebAssembly module. /// -/// Typst is capable of interfacing with plugins compiled to WebAssembly. Plugin -/// functions may accept multiple [byte buffers]($bytes) as arguments and return -/// a single byte buffer. They should typically be wrapped in idiomatic Typst -/// functions that perform the necessary conversions between native Typst types -/// and bytes. +/// The resulting [module] will contain one Typst [function] for each function +/// export of the loaded WebAssembly module. /// -/// Plugins run in isolation from your system, which means that printing, -/// reading files, or anything like that will not be supported for security -/// reasons. To run as a plugin, a program needs to be compiled to a 32-bit -/// shared WebAssembly library. Many compilers will use the -/// [WASI ABI](https://wasi.dev/) by default or as their only option (e.g. -/// emscripten), which allows printing, reading files, etc. This ABI will not -/// directly work with Typst. You will either need to compile to a different -/// target or [stub all functions](https://github.com/astrale-sharp/wasm-minimal-protocol/tree/master/crates/wasi-stub). +/// Typst WebAssembly plugins need to follow a specific +/// [protocol]($plugin/#protocol). To run as a plugin, a program needs to be +/// compiled to a 32-bit shared WebAssembly library. Plugin functions may accept +/// multiple [byte buffers]($bytes) as arguments and return a single byte +/// buffer. They should typically be wrapped in idiomatic Typst functions that +/// perform the necessary conversions between native Typst types and bytes. /// -/// # Plugins and Packages -/// Plugins are distributed as packages. A package can make use of a plugin -/// simply by including a WebAssembly file and loading it. Because the -/// byte-based plugin interface is quite low-level, plugins are typically -/// exposed through wrapper functions, that also live in the same package. -/// -/// # Purity -/// Plugin functions must be pure: Given the same arguments, they must always -/// return the same value. The reason for this is that Typst functions must be -/// pure (which is quite fundamental to the language design) and, since Typst -/// function can call plugin functions, this requirement is inherited. In -/// particular, if a plugin function is called twice with the same arguments, -/// Typst might cache the results and call your function only once. +/// For security reasons, plugins run in isolation from your system. This means +/// that printing, reading files, or similar things are not supported. /// /// # Example /// ```example @@ -55,6 +39,50 @@ use crate::loading::{DataSource, Load}; /// #concat("hello", "world") /// ``` /// +/// Since the plugin function returns a module, it can be used with import +/// syntax: +/// ```typ +/// #import plugin("hello.wasm"): concatenate +/// ``` +/// +/// # Purity +/// Plugin functions **must be pure:** A plugin function call most not have any +/// observable side effects on future plugin calls and given the same arguments, +/// it must always return the same value. +/// +/// The reason for this is that Typst functions must be pure (which is quite +/// fundamental to the language design) and, since Typst function can call +/// plugin functions, this requirement is inherited. In particular, if a plugin +/// function is called twice with the same arguments, Typst might cache the +/// results and call your function only once. Moreover, Typst may run multiple +/// instances of your plugin in multiple threads, with no state shared between +/// them. +/// +/// Typst does not enforce plugin function purity (for efficiency reasons), but +/// calling an impure function will lead to unpredictable and irreproducible +/// results and must be avoided. +/// +/// That said, mutable operations _can be_ useful for plugins that require +/// costly runtime initialization. Due to the purity requirement, such +/// initialization cannot be performed through a normal function call. Instead, +/// Typst exposes a [plugin transition API]($plugin.transition), which executes +/// a function call and then creates a derived module with new functions which +/// will observe the side effects produced by the transition call. The original +/// plugin remains unaffected. +/// +/// # Plugins and Packages +/// Any Typst code can make use of a plugin simply by including a WebAssembly +/// file and loading it. However, because the byte-based plugin interface is +/// quite low-level, plugins are typically exposed through a package containing +/// the plugin and idiomatic wrapper functions. +/// +/// # WASI +/// Many compilers will use the [WASI ABI](https://wasi.dev/) by default or as +/// their only option (e.g. emscripten), which allows printing, reading files, +/// etc. This ABI will not directly work with Typst. You will either need to +/// compile to a different target or [stub all +/// functions](https://github.com/astrale-sharp/wasm-minimal-protocol/tree/master/crates/wasi-stub). +/// /// # Protocol /// To be used as a plugin, a WebAssembly module must conform to the following /// protocol: @@ -67,8 +95,8 @@ use crate::loading::{DataSource, Load}; /// lengths, so `usize/size_t` may be preferable), and return one 32-bit /// integer. /// -/// - The function should first allocate a buffer `buf` of length -/// `a_1 + a_2 + ... + a_n`, and then call +/// - The function should first allocate a buffer `buf` of length `a_1 + a_2 + +/// ... + a_n`, and then call /// `wasm_minimal_protocol_write_args_to_buffer(buf.ptr)`. /// /// - The `a_1` first bytes of the buffer now constitute the first argument, the @@ -85,19 +113,21 @@ use crate::loading::{DataSource, Load}; /// then interpreted as an UTF-8 encoded error message. /// /// ## Imports -/// Plugin modules need to import two functions that are provided by the runtime. -/// (Types and functions are described using WAT syntax.) +/// Plugin modules need to import two functions that are provided by the +/// runtime. (Types and functions are described using WAT syntax.) /// -/// - `(import "typst_env" "wasm_minimal_protocol_write_args_to_buffer" (func (param i32)))` +/// - `(import "typst_env" "wasm_minimal_protocol_write_args_to_buffer" (func +/// (param i32)))` /// /// Writes the arguments for the current function into a plugin-allocated -/// buffer. When a plugin function is called, it -/// [receives the lengths](#exports) of its input buffers as arguments. It -/// should then allocate a buffer whose capacity is at least the sum of these -/// lengths. It should then call this function with a `ptr` to the buffer to -/// fill it with the arguments, one after another. +/// buffer. When a plugin function is called, it [receives the +/// lengths](#exports) of its input buffers as arguments. It should then +/// allocate a buffer whose capacity is at least the sum of these lengths. It +/// should then call this function with a `ptr` to the buffer to fill it with +/// the arguments, one after another. /// -/// - `(import "typst_env" "wasm_minimal_protocol_send_result_to_host" (func (param i32 i32)))` +/// - `(import "typst_env" "wasm_minimal_protocol_send_result_to_host" (func +/// (param i32 i32)))` /// /// Sends the output of the current function to the host (Typst). The first /// parameter shall be a pointer to a buffer (`ptr`), while the second is the @@ -106,72 +136,147 @@ use crate::loading::{DataSource, Load}; /// interpreted as an error message, it should be encoded as UTF-8. /// /// # Resources -/// For more resources, check out the -/// [wasm-minimal-protocol repository](https://github.com/astrale-sharp/wasm-minimal-protocol). -/// It contains: +/// For more resources, check out the [wasm-minimal-protocol +/// repository](https://github.com/astrale-sharp/wasm-minimal-protocol). It +/// contains: /// /// - A list of example plugin implementations and a test runner for these /// examples /// - Wrappers to help you write your plugin in Rust (Zig wrapper in /// development) /// - A stubber for WASI -#[ty(scope, cast)] -#[derive(Clone)] -pub struct Plugin(Arc); - -/// The internal representation of a plugin. -struct Repr { - /// The raw WebAssembly bytes. - bytes: Bytes, - /// The function defined by the WebAssembly module. - functions: Vec<(EcoString, wasmi::Func)>, - /// Owns all data associated with the WebAssembly module. - store: Mutex, -} - -/// Owns all data associated with the WebAssembly module. -type Store = wasmi::Store; - -/// If there was an error reading/writing memory, keep the offset + length to -/// display an error message. -struct MemoryError { - offset: u32, - length: u32, - write: bool, -} -/// The persistent store data used for communication between store and host. -#[derive(Default)] -struct StoreData { - args: Vec, - output: Vec, - memory_error: Option, +#[func(scope)] +pub fn plugin( + engine: &mut Engine, + /// A path to a WebAssembly file or raw WebAssembly bytes. + /// + /// For more details about paths, see the [Paths section]($syntax/#paths). + source: Spanned, +) -> SourceResult { + let data = source.load(engine.world)?; + Plugin::module(data).at(source.span) } #[scope] -impl Plugin { - /// Creates a new plugin from a WebAssembly file. - #[func(constructor)] - pub fn construct( - engine: &mut Engine, - /// A path to a WebAssembly file or raw WebAssembly bytes. - /// - /// For more details about paths, see the [Paths section]($syntax/#paths). - source: Spanned, - ) -> SourceResult { - let data = source.load(engine.world)?; - Plugin::new(data).at(source.span) +impl plugin { + /// Calls a plugin function that has side effects and returns a new module + /// with plugin functions that are guaranteed to have observed the results + /// of the mutable call. + /// + /// Note that calling an impure function through a normal function call + /// (without use of the transition API) is forbidden and leads to + /// unpredictable behaviour. Read the [section on purity]($plugin/#purity) + /// for more details. + /// + /// In the example below, we load the plugin `hello-mut.wasm` which exports + /// two functions: The `get()` function retrieves a global array as a + /// string. The `add(value)` function adds a value to the global array. + /// + /// We call `add` via the transition API. The call `mutated.get()` on the + /// derived module will observe the addition. Meanwhile the original module + /// remains untouched as demonstrated by the `base.get()` call. + /// + /// _Note:_ Due to limitations in the internal WebAssembly implementation, + /// the transition API can only guarantee to reflect changes in the plugin's + /// memory, not in WebAssembly globals. If your plugin relies on changes to + /// globals being visible after transition, you might want to avoid use of + /// the transition API for now. We hope to lift this limitation in the + /// future. + /// + /// ```typ + /// #let base = plugin("hello-mut.wasm") + /// #assert.eq(base.get(), "[]") + /// + /// #let mutated = plugin.transition(base.add, "hello") + /// #assert.eq(base.get(), "[]") + /// #assert.eq(mutated.get(), "[hello]") + /// ``` + #[func] + pub fn transition( + /// The plugin function to call. + func: PluginFunc, + /// The byte buffers to call the function with. + #[variadic] + arguments: Vec, + ) -> StrResult { + func.transition(arguments) } } +/// A function loaded from a WebAssembly plugin. +#[derive(Debug, Clone, PartialEq, Hash)] +pub struct PluginFunc { + /// The underlying plugin, shared by this and the other functions. + plugin: Arc, + /// The name of the plugin function. + name: EcoString, +} + +impl PluginFunc { + /// The name of the plugin function. + pub fn name(&self) -> &str { + &self.name + } + + /// Call the WebAssembly function with the given arguments. + #[comemo::memoize] + #[typst_macros::time(name = "call plugin")] + pub fn call(&self, args: Vec) -> StrResult { + self.plugin.call(&self.name, args) + } + + /// Transition a plugin and turn the result into a module. + #[comemo::memoize] + #[typst_macros::time(name = "transition plugin")] + pub fn transition(&self, args: Vec) -> StrResult { + self.plugin.transition(&self.name, args).map(Plugin::into_module) + } +} + +cast! { + PluginFunc, + self => Value::Func(self.into()), + v: Func => v.to_plugin().ok_or("expected plugin function")?.clone(), +} + +/// A plugin with potentially multiple instances for multi-threaded +/// execution. +struct Plugin { + /// Shared by all variants of the plugin. + base: Arc, + /// A pool of plugin instances. + /// + /// When multiple plugin calls run concurrently due to multi-threading, we + /// create new instances whenever we run out of ones. + pool: Mutex>, + /// A snapshot that new instances should be restored to. + snapshot: Option, + /// A combined hash that incorporates all function names and arguments used + /// in transitions of this plugin, such that this plugin has a deterministic + /// hash and equality check that can differentiate it from "siblings" (same + /// base, different transitions). + fingerprint: u128, +} + impl Plugin { - /// Create a new plugin from raw WebAssembly bytes. + /// Create a plugin and turn it into a module. #[comemo::memoize] #[typst_macros::time(name = "load plugin")] - pub fn new(bytes: Bytes) -> StrResult { + fn module(bytes: Bytes) -> StrResult { + Self::new(bytes).map(Self::into_module) + } + + /// Create a new plugin from raw WebAssembly bytes. + fn new(bytes: Bytes) -> StrResult { let engine = wasmi::Engine::default(); let module = wasmi::Module::new(&engine, bytes.as_slice()) .map_err(|err| format!("failed to load WebAssembly module ({err})"))?; + // Ensure that the plugin exports its memory. + if !matches!(module.get_export("memory"), Some(wasmi::ExternType::Memory(_))) { + bail!("plugin does not export its memory"); + } + let mut linker = wasmi::Linker::new(&engine); linker .func_wrap( @@ -188,58 +293,174 @@ impl Plugin { ) .unwrap(); - let mut store = Store::new(&engine, StoreData::default()); - let instance = linker - .instantiate(&mut store, &module) + let base = Arc::new(PluginBase { bytes, linker, module }); + let instance = PluginInstance::new(&base, None)?; + + Ok(Self { + base, + snapshot: None, + fingerprint: 0, + pool: Mutex::new(vec![instance]), + }) + } + + /// Execute a function with access to an instsance. + fn call(&self, func: &str, args: Vec) -> StrResult { + // Acquire an instance from the pool (potentially creating a new one). + let mut instance = self.acquire()?; + + // Execute the call on an instance from the pool. If the call fails, we + // return early and _don't_ return the instance to the pool as it might + // be irrecoverably damaged. + let output = instance.call(func, args)?; + + // Return the instance to the pool. + self.pool.lock().unwrap().push(instance); + + Ok(output) + } + + /// Call a mutable plugin function, producing a new mutable whose functions + /// are guaranteed to be able to observe the mutation. + fn transition(&self, func: &str, args: Vec) -> StrResult { + // Derive a new transition hash from the old one and the function and arguments. + let fingerprint = typst_utils::hash128(&(self.fingerprint, func, &args)); + + // Execute the mutable call on an instance. + let mut instance = self.acquire()?; + + // Call the function. If the call fails, we return early and _don't_ + // return the instance to the pool as it might be irrecoverably damaged. + instance.call(func, args)?; + + // Snapshot the instance after the mutable call. + let snapshot = instance.snapshot(); + + // Create a new plugin and move (this is important!) the used instance + // into it, so that the old plugin won't observe the mutation. Also + // save the snapshot so that instances that are initialized for the + // transitioned plugin's pool observe the mutation. + Ok(Self { + base: self.base.clone(), + snapshot: Some(snapshot), + fingerprint, + pool: Mutex::new(vec![instance]), + }) + } + + /// Acquire an instance from the pool (or create a new one). + fn acquire(&self) -> StrResult { + // Don't use match to ensure that the lock is released before we create + // a new instance. + if let Some(instance) = self.pool.lock().unwrap().pop() { + return Ok(instance); + } + + PluginInstance::new(&self.base, self.snapshot.as_ref()) + } + + /// Turn a plugin into a Typst module containing plugin functions. + fn into_module(self) -> Module { + let shared = Arc::new(self); + + // Build a scope from the collected functions. + let mut scope = Scope::new(); + for export in shared.base.module.exports() { + if matches!(export.ty(), wasmi::ExternType::Func(_)) { + let name = EcoString::from(export.name()); + let func = PluginFunc { plugin: shared.clone(), name: name.clone() }; + scope.define(name, Func::from(func)); + } + } + + Module::anonymous(scope) + } +} + +impl Debug for Plugin { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + f.pad("Plugin(..)") + } +} + +impl PartialEq for Plugin { + fn eq(&self, other: &Self) -> bool { + self.base.bytes == other.base.bytes && self.fingerprint == other.fingerprint + } +} + +impl Hash for Plugin { + fn hash(&self, state: &mut H) { + self.base.bytes.hash(state); + self.fingerprint.hash(state); + } +} + +/// Shared by all pooled & transitioned variants of the plugin. +struct PluginBase { + /// The raw WebAssembly bytes. + bytes: Bytes, + /// The compiled WebAssembly module. + module: wasmi::Module, + /// A linker used to create a `Store` for execution. + linker: wasmi::Linker, +} + +/// An single plugin instance for single-threaded execution. +struct PluginInstance { + /// The underlying wasmi instance. + instance: wasmi::Instance, + /// The execution store of this concrete plugin instance. + store: wasmi::Store, +} + +/// A snapshot of a plugin instance. +struct Snapshot { + /// The number of pages in the main memory. + mem_pages: u32, + /// The data in the main memory. + mem_data: Vec, +} + +impl PluginInstance { + /// Create a new execution instance of a plugin, potentially restoring + /// a snapshot. + #[typst_macros::time(name = "create plugin instance")] + fn new(base: &PluginBase, snapshot: Option<&Snapshot>) -> StrResult { + let mut store = wasmi::Store::new(base.linker.engine(), CallData::default()); + let instance = base + .linker + .instantiate(&mut store, &base.module) .and_then(|pre_instance| pre_instance.start(&mut store)) .map_err(|e| eco_format!("{e}"))?; - // Ensure that the plugin exports its memory. - if !matches!( - instance.get_export(&store, "memory"), - Some(wasmi::Extern::Memory(_)) - ) { - bail!("plugin does not export its memory"); + let mut instance = PluginInstance { instance, store }; + if let Some(snapshot) = snapshot { + instance.restore(snapshot); } - - // Collect exported functions. - let functions = instance - .exports(&store) - .filter_map(|export| { - let name = export.name().into(); - export.into_func().map(|func| (name, func)) - }) - .collect(); - - Ok(Plugin(Arc::new(Repr { bytes, functions, store: Mutex::new(store) }))) + Ok(instance) } - /// Call the plugin function with the given `name`. - #[comemo::memoize] - #[typst_macros::time(name = "call plugin")] - pub fn call(&self, name: &str, args: Vec) -> StrResult { - // Find the function with the given name. - let func = self - .0 - .functions - .iter() - .find(|(v, _)| v == name) - .map(|&(_, func)| func) - .ok_or_else(|| { - eco_format!("plugin does not contain a function called {name}") - })?; + /// Call a plugin function with byte arguments. + fn call(&mut self, func: &str, args: Vec) -> StrResult { + let handle = self + .instance + .get_export(&self.store, func) + .unwrap() + .into_func() + .unwrap(); + let ty = handle.ty(&self.store); - let mut store = self.0.store.lock().unwrap(); - let ty = func.ty(store.as_context()); - - // Check function signature. + // Check function signature. Do this lazily only when a function is called + // because there might be exported functions like `_initialize` that don't + // match the schema. if ty.params().iter().any(|&v| v != wasmi::core::ValType::I32) { bail!( - "plugin function `{name}` has a parameter that is not a 32-bit integer" + "plugin function `{func}` has a parameter that is not a 32-bit integer" ); } if ty.results() != [wasmi::core::ValType::I32] { - bail!("plugin function `{name}` does not return exactly one 32-bit integer"); + bail!("plugin function `{func}` does not return exactly one 32-bit integer"); } // Check inputs. @@ -260,23 +481,26 @@ impl Plugin { .collect::>(); // Store the input data. - store.data_mut().args = args; + self.store.data_mut().args = args; // Call the function. let mut code = wasmi::Val::I32(-1); - func.call(store.as_context_mut(), &lengths, std::slice::from_mut(&mut code)) + handle + .call(&mut self.store, &lengths, std::slice::from_mut(&mut code)) .map_err(|err| eco_format!("plugin panicked: {err}"))?; + if let Some(MemoryError { offset, length, write }) = - store.data_mut().memory_error.take() + self.store.data_mut().memory_error.take() { return Err(eco_format!( - "plugin tried to {kind} out of bounds: pointer {offset:#x} is out of bounds for {kind} of length {length}", + "plugin tried to {kind} out of bounds: \ + pointer {offset:#x} is out of bounds for {kind} of length {length}", kind = if write { "write" } else { "read" } )); } // Extract the returned data. - let output = std::mem::take(&mut store.data_mut().output); + let output = std::mem::take(&mut self.store.data_mut().output); // Parse the functions return value. match code { @@ -293,39 +517,63 @@ impl Plugin { Ok(Bytes::new(output)) } - /// An iterator over all the function names defined by the plugin. - pub fn iter(&self) -> impl Iterator { - self.0.functions.as_slice().iter().map(|(func_name, _)| func_name) + /// Creates a snapshot of this instance from which another one can be + /// initialized. + #[typst_macros::time(name = "save snapshot")] + fn snapshot(&self) -> Snapshot { + let memory = self.memory(); + let mem_pages = memory.size(&self.store); + let mem_data = memory.data(&self.store).to_vec(); + Snapshot { mem_pages, mem_data } + } + + /// Restores the instance to a snapshot. + #[typst_macros::time(name = "restore snapshot")] + fn restore(&mut self, snapshot: &Snapshot) { + let memory = self.memory(); + let current_size = memory.size(&self.store); + if current_size < snapshot.mem_pages { + memory + .grow(&mut self.store, snapshot.mem_pages - current_size) + .unwrap(); + } + + memory.data_mut(&mut self.store)[..snapshot.mem_data.len()] + .copy_from_slice(&snapshot.mem_data); + } + + /// Retrieves a handle to the plugin's main memory. + fn memory(&self) -> Memory { + self.instance + .get_export(&self.store, "memory") + .unwrap() + .into_memory() + .unwrap() } } -impl Debug for Plugin { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - f.pad("Plugin(..)") - } +/// The persistent store data used for communication between store and host. +#[derive(Default)] +struct CallData { + /// Arguments for a current call. + args: Vec, + /// The results of the current call. + output: Vec, + /// A memory error that occured during execution of the current call. + memory_error: Option, } -impl repr::Repr for Plugin { - fn repr(&self) -> EcoString { - "plugin(..)".into() - } -} - -impl PartialEq for Plugin { - fn eq(&self, other: &Self) -> bool { - self.0.bytes == other.0.bytes - } -} - -impl Hash for Plugin { - fn hash(&self, state: &mut H) { - self.0.bytes.hash(state); - } +/// If there was an error reading/writing memory, keep the offset + length to +/// display an error message. +struct MemoryError { + offset: u32, + length: u32, + write: bool, } /// Write the arguments to the plugin function into the plugin's memory. fn wasm_minimal_protocol_write_args_to_buffer( - mut caller: wasmi::Caller, + mut caller: wasmi::Caller, ptr: u32, ) { let memory = caller.get_export("memory").unwrap().into_memory().unwrap(); @@ -346,7 +594,7 @@ fn wasm_minimal_protocol_write_args_to_buffer( /// Extracts the output of the plugin function from the plugin's memory. fn wasm_minimal_protocol_send_result_to_host( - mut caller: wasmi::Caller, + mut caller: wasmi::Caller, ptr: u32, len: u32, ) { diff --git a/crates/typst-library/src/foundations/scope.rs b/crates/typst-library/src/foundations/scope.rs index 99c9a37e6..b7b4a6d9d 100644 --- a/crates/typst-library/src/foundations/scope.rs +++ b/crates/typst-library/src/foundations/scope.rs @@ -167,6 +167,14 @@ impl Scope { Default::default() } + /// Create a new scope with the given capacity. + pub fn with_capacity(capacity: usize) -> Self { + Self { + map: IndexMap::with_capacity(capacity), + ..Default::default() + } + } + /// Create a new scope with duplication prevention. pub fn deduplicating() -> Self { Self { deduplicate: true, ..Default::default() } diff --git a/crates/typst-library/src/foundations/value.rs b/crates/typst-library/src/foundations/value.rs index d99027728..4fa380b46 100644 --- a/crates/typst-library/src/foundations/value.rs +++ b/crates/typst-library/src/foundations/value.rs @@ -15,8 +15,8 @@ use crate::diag::{HintedStrResult, HintedString, StrResult}; use crate::foundations::{ fields, ops, repr, Args, Array, AutoValue, Bytes, CastInfo, Content, Datetime, Decimal, Dict, Duration, Fold, FromValue, Func, IntoValue, Label, Module, - NativeElement, NativeType, NoneValue, Plugin, Reflect, Repr, Resolve, Scope, Str, - Styles, Symbol, SymbolElem, Type, Version, + NativeElement, NativeType, NoneValue, Reflect, Repr, Resolve, Scope, Str, Styles, + Symbol, SymbolElem, Type, Version, }; use crate::layout::{Abs, Angle, Em, Fr, Length, Ratio, Rel}; use crate::text::{RawContent, RawElem, TextElem}; @@ -84,8 +84,6 @@ pub enum Value { Type(Type), /// A module. Module(Module), - /// A WebAssembly plugin. - Plugin(Plugin), /// A dynamic value. Dyn(Dynamic), } @@ -147,7 +145,6 @@ impl Value { Self::Args(_) => Type::of::(), Self::Type(_) => Type::of::(), Self::Module(_) => Type::of::(), - Self::Plugin(_) => Type::of::(), Self::Dyn(v) => v.ty(), } } @@ -251,7 +248,6 @@ impl Debug for Value { Self::Args(v) => Debug::fmt(v, f), Self::Type(v) => Debug::fmt(v, f), Self::Module(v) => Debug::fmt(v, f), - Self::Plugin(v) => Debug::fmt(v, f), Self::Dyn(v) => Debug::fmt(v, f), } } @@ -289,7 +285,6 @@ impl Repr for Value { Self::Args(v) => v.repr(), Self::Type(v) => v.repr(), Self::Module(v) => v.repr(), - Self::Plugin(v) => v.repr(), Self::Dyn(v) => v.repr(), } } @@ -340,7 +335,6 @@ impl Hash for Value { Self::Args(v) => v.hash(state), Self::Type(v) => v.hash(state), Self::Module(v) => v.hash(state), - Self::Plugin(v) => v.hash(state), Self::Dyn(v) => v.hash(state), } } @@ -661,7 +655,6 @@ primitive! { primitive! { Args: "arguments", Args } primitive! { Type: "type", Type } primitive! { Module: "module", Module } -primitive! { Plugin: "plugin", Plugin } impl Reflect for Arc { fn input() -> CastInfo { diff --git a/tests/suite/foundations/plugin.typ b/tests/suite/foundations/plugin.typ index 0842980ec..9feacc030 100644 --- a/tests/suite/foundations/plugin.typ +++ b/tests/suite/foundations/plugin.typ @@ -9,6 +9,37 @@ bytes("value3-value1-value2"), ) +--- plugin-func --- +#let p = plugin("/assets/plugins/hello.wasm") +#test(type(p.hello), function) +#test(("a", "b").map(bytes).map(p.double_it), ("a.a", "b.b").map(bytes)) + +--- plugin-import --- +#import plugin("/assets/plugins/hello.wasm"): hello, double_it + +#test(hello(), bytes("Hello from wasm!!!")) +#test(double_it(bytes("hey!")), bytes("hey!.hey!")) + +--- plugin-transition --- +#let empty = plugin("/assets/plugins/hello-mut.wasm") +#test(str(empty.get()), "[]") + +#let hello = plugin.transition(empty.add, bytes("hello")) +#test(str(empty.get()), "[]") +#test(str(hello.get()), "[hello]") + +#let world = plugin.transition(empty.add, bytes("world")) +#let hello_you = plugin.transition(hello.add, bytes("you")) + +#test(str(empty.get()), "[]") +#test(str(hello.get()), "[hello]") +#test(str(world.get()), "[world]") +#test(str(hello_you.get()), "[hello, you]") + +#let hello2 = plugin.transition(empty.add, bytes("hello")) +#test(hello == world, false) +#test(hello == hello2, true) + --- plugin-wrong-number-of-arguments --- #let p = plugin("/assets/plugins/hello.wasm") diff --git a/tools/test-helper/package.json b/tools/test-helper/package.json index d34213fb0..08a60fa31 100644 --- a/tools/test-helper/package.json +++ b/tools/test-helper/package.json @@ -1,104 +1,107 @@ { - "name": "typst-test-helper", - "publisher": "typst", - "displayName": "Typst Test Helper", - "description": "Helps to run, compare and update Typst tests.", - "version": "0.0.1", - "categories": [ - "Other" - ], - "activationEvents": [ - "workspaceContains:tests/suite/playground.typ" - ], - "main": "./dist/extension.js", - "contributes": { - "commands": [ - { - "command": "typst-test-helper.refreshFromPreview", - "title": "Refresh preview", - "category": "Typst Test Helper", - "icon": "$(refresh)" - }, - { - "command": "typst-test-helper.runFromPreview", - "title": "Run test", - "category": "Typst Test Helper", - "icon": "$(debug-start)", - "enablement": "typst-test-helper.runButtonEnabled" - }, - { - "command": "typst-test-helper.saveFromPreview", - "title": "Run and save reference output", - "category": "Typst Test Helper", - "icon": "$(save)", - "enablement": "typst-test-helper.runButtonEnabled" - }, - { - "command": "typst-test-helper.copyImageFilePathFromPreviewContext", - "title": "Copy image file path", - "category": "Typst Test Helper" - }, - { - "command": "typst-test-helper.increaseResolution", - "title": "Render at higher resolution", - "category": "Typst Test Helper", - "icon": "$(zoom-in)", - "enablement": "typst-test-helper.runButtonEnabled" - }, - { - "command": "typst-test-helper.decreaseResolution", - "title": "Render at lower resolution", - "category": "Typst Test Helper", - "icon": "$(zoom-out)", - "enablement": "typst-test-helper.runButtonEnabled" - } - ], - "menus": { - "editor/title": [ - { - "when": "activeWebviewPanelId == typst-test-helper.preview", - "command": "typst-test-helper.refreshFromPreview", - "group": "navigation@1" - }, - { - "when": "activeWebviewPanelId == typst-test-helper.preview", - "command": "typst-test-helper.runFromPreview", - "group": "navigation@2" - }, - { - "when": "activeWebviewPanelId == typst-test-helper.preview", - "command": "typst-test-helper.saveFromPreview", - "group": "navigation@3" - }, - { - "when": "activeWebviewPanelId == typst-test-helper.preview", - "command": "typst-test-helper.increaseResolution", - "group": "navigation@4" - }, - { - "when": "activeWebviewPanelId == typst-test-helper.preview", - "command": "typst-test-helper.decreaseResolution", - "group": "navigation@4" - } - ], - "webview/context": [ - { - "command": "typst-test-helper.copyImageFilePathFromPreviewContext", - "when": "webviewId == typst-test-helper.preview && (webviewSection == png || webviewSection == ref)" - } - ] - } - }, - "scripts": { - "build": "tsc -p ./", - "watch": "tsc -watch -p ./" - }, - "devDependencies": { - "@types/node": "18.x", - "@types/vscode": "^1.88.0", - "typescript": "^5.3.3" - }, - "engines": { - "vscode": "^1.88.0" - } -} + "name": "typst-test-helper", + "publisher": "typst", + "displayName": "Typst Test Helper", + "description": "Helps to run, compare and update Typst tests.", + "version": "0.0.1", + "categories": [ + "Other" + ], + "activationEvents": [ + "workspaceContains:tests/suite/playground.typ" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { + "command": "typst-test-helper.refreshFromPreview", + "title": "Refresh preview", + "category": "Typst Test Helper", + "icon": "$(refresh)" + }, + { + "command": "typst-test-helper.runFromPreview", + "title": "Run test", + "category": "Typst Test Helper", + "icon": "$(debug-start)", + "enablement": "typst-test-helper.runButtonEnabled" + }, + { + "command": "typst-test-helper.saveFromPreview", + "title": "Run and save reference output", + "category": "Typst Test Helper", + "icon": "$(save)", + "enablement": "typst-test-helper.runButtonEnabled" + }, + { + "command": "typst-test-helper.copyImageFilePathFromPreviewContext", + "title": "Copy image file path", + "category": "Typst Test Helper" + }, + { + "command": "typst-test-helper.increaseResolution", + "title": "Render at higher resolution", + "category": "Typst Test Helper", + "icon": "$(zoom-in)", + "enablement": "typst-test-helper.runButtonEnabled" + }, + { + "command": "typst-test-helper.decreaseResolution", + "title": "Render at lower resolution", + "category": "Typst Test Helper", + "icon": "$(zoom-out)", + "enablement": "typst-test-helper.runButtonEnabled" + } + ], + "menus": { + "editor/title": [ + { + "when": "activeWebviewPanelId == typst-test-helper.preview", + "command": "typst-test-helper.refreshFromPreview", + "group": "navigation@1" + }, + { + "when": "activeWebviewPanelId == typst-test-helper.preview", + "command": "typst-test-helper.runFromPreview", + "group": "navigation@2" + }, + { + "when": "activeWebviewPanelId == typst-test-helper.preview", + "command": "typst-test-helper.saveFromPreview", + "group": "navigation@3" + }, + { + "when": "activeWebviewPanelId == typst-test-helper.preview", + "command": "typst-test-helper.increaseResolution", + "group": "navigation@4" + }, + { + "when": "activeWebviewPanelId == typst-test-helper.preview", + "command": "typst-test-helper.decreaseResolution", + "group": "navigation@4" + } + ], + "webview/context": [ + { + "command": "typst-test-helper.copyImageFilePathFromPreviewContext", + "when": "webviewId == typst-test-helper.preview && (webviewSection == png || webviewSection == ref)" + } + ] + } + }, + "scripts": { + "build": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/node": "18.x", + "@types/vscode": "^1.88.0", + "typescript": "^5.3.3" + }, + "engines": { + "vscode": "^1.88.0" + }, + "__metadata": { + "size": 35098973 + } +} \ No newline at end of file