diff --git a/packages/forge/README.md b/packages/forge/README.md index 39bc2163f..d765d389a 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -94,8 +94,9 @@ have no `.` or `..` parts, and no two files may share one. Things to know: -- **Each group runs in an instance of its own.** `generateGroup` sees - nothing that `generate` or another group kept in module state. +- **Groups run in parallel across a pool of instances.** Instances are + reused across groups of the same forge, so module state may carry over + between calls. - **There is no Node.** Plugins have no `fs`, `process`, network or clock. Libraries in plain JavaScript bundle in and work. - **QuickJS runs the plugin.** `forge-plugin build` compiles it in, for a diff --git a/packages/forge/plugin/forge.wit b/packages/forge/plugin/forge.wit index af93aa10e..8df8edc18 100644 --- a/packages/forge/plugin/forge.wit +++ b/packages/forge/plugin/forge.wit @@ -219,6 +219,7 @@ world plugin { /// Generates files for the forge. Called exactly once. export generate: func() -> result, string>; /// Generates files for a group. Called exactly once for each group, in - /// parallel with other groups, each in an instance of its own. + /// parallel with other groups. Instances are reused across groups of + /// the same forge. export generate-group: func(group: group-id) -> result, string>; } diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index 9b374eab9..634e319ea 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -18,9 +18,9 @@ * ``` * * The forge stays in forge's process. Each part is fetched the first time - * a plugin reads it, and kept until the call ends, so reading a part again - * is free, and the same part is always the same object: compare groups, - * methods and models with `===`. + * a plugin reads it, and kept in the instance across group calls, so + * reading a part again is free, and the same part is always the same + * object: compare groups, methods and models with `===`. * * @module */ @@ -44,8 +44,8 @@ export interface Plugin { generate?(forge: Forge): Iterable; /** * Generates files for a group. Called exactly once for each group, in - * parallel with other groups, each in an instance of its own. Nothing a - * call keeps in module state is seen by the calls for other groups. + * parallel with other groups. Instances are reused across groups of the + * same forge, so module state may carry over between calls. */ generateGroup?(group: Group): Iterable; } @@ -382,12 +382,13 @@ export interface ModelType { * Throw to fail: forge stops and prints the error's message. */ export function definePlugin(plugin: Plugin): Exports { + const forge = lazy(() => new ForgeImpl()); const exports = { generate() { - return call(() => files(plugin.generate?.(new ForgeImpl()) ?? [])); + return call(() => files(plugin.generate?.(forge()) ?? [])); }, generateGroup(id: host.GroupId) { - return call(() => files(plugin.generateGroup?.(new ForgeImpl().group(id)) ?? [])); + return call(() => files(plugin.generateGroup?.(forge().group(id)) ?? [])); }, } satisfies Pick; return exports; @@ -497,12 +498,12 @@ function lazy(f: () => T): () => T { }; } -/** Returns what `cache` has under `id`, making it with `make` if it has nothing. */ -function cached(cache: Map, id: number, make: () => T): T { - let value = cache.get(id); +/** Returns what `cache` has at index `id`, making it with `make` if it has nothing. */ +function cached(cache: (T | undefined)[], id: number, make: () => T): T { + let value = cache[id]; if (value === undefined) { value = make(); - cache.set(id, value); + cache[id] = value; } return value; } @@ -554,21 +555,24 @@ function upperFirst(word: string): string { } class ForgeImpl implements Forge { - readonly version: string; - readonly description: string; - readonly baseUrl: string; - private readonly groupCache = new Map(); - private readonly methodCache = new Map(); - private readonly modelCache = new Map(); - private readonly typeCache = new Map(); + private readonly info = lazy(() => host.info()); + private readonly groupCache: (GroupImpl | undefined)[] = []; + private readonly methodCache: (MethodImpl | undefined)[] = []; + private readonly modelCache: (ModelImpl | undefined)[] = []; + private readonly typeCache: (Type | undefined)[] = []; private readonly allGroups = lazy(() => ids(host.groups()).map((id) => this.group(id))); private readonly topModels = lazy(() => ids(host.models()).map((id) => this.model(id))); - constructor() { - const info = host.info(); - this.version = info.version; - this.description = info.description; - this.baseUrl = info.baseUrl; + get version(): string { + return this.info().version; + } + + get description(): string { + return this.info().description; + } + + get baseUrl(): string { + return this.info().baseUrl; } get groups(): readonly Group[] { diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 97220a570..311937ed3 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -280,7 +280,8 @@ world plugin { /// Generates files for the forge. Called exactly once. export generate: func() -> result, string>; /// Generates files for a group. Called exactly once for each group, in - /// parallel with other groups, each in an instance of its own. + /// parallel with other groups. Instances are reused across groups of + /// the same forge. export generate-group: func(group: group-id) -> result, string>; } "# @@ -291,14 +292,42 @@ use bindings::forge::plugin::forge as wit; /// A plugin compiled to a WebAssembly component of the `plugin` world in /// [`WIT`]. /// -/// It compiles the component once, then keeps a pool of instances, so -/// groups run in parallel without compiling or instantiating it again. A -/// call takes an instance from the pool, or makes one if none is free, and -/// returns it unless the plugin trapped. +/// It compiles the component once, then keeps a pool of instances for the +/// current [`Forge`], so groups run in parallel without compiling or +/// instantiating it again, and each instance only ever reads one [`Forge`]. +/// A call takes an instance from the pool, or makes one if none is free, +/// and returns it unless the plugin trapped. pub struct WasmPlugin { engine: Engine, pre: bindings::PluginPre, - pool: Mutex>, + pool: Mutex, +} + +#[derive(Default)] +struct Pool { + forge: ForgeKey, + instances: Vec, +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +struct ForgeKey { + forge: usize, + groups: (usize, usize), + methods: (usize, usize), + models: (usize, usize), + types: (usize, usize), +} + +impl ForgeKey { + fn of(forge: &Forge) -> Self { + ForgeKey { + forge: ptr::from_ref(forge).addr(), + groups: (forge.groups.as_ptr().addr(), forge.groups.len()), + methods: (forge.methods.as_ptr().addr(), forge.methods.len()), + models: (forge.models.as_ptr().addr(), forge.models.len()), + types: (forge.types.as_ptr().addr(), forge.types.len()), + } + } } /// A file a [`WasmPlugin`] generated, with its content already rendered. @@ -331,7 +360,7 @@ impl WasmPlugin { Ok(WasmPlugin { engine, pre, - pool: Mutex::new(Vec::new()), + pool: Mutex::new(Pool::default()), }) } @@ -341,7 +370,15 @@ impl WasmPlugin { forge: &Forge, f: impl FnOnce(&bindings::Plugin, &mut Store) -> wasmtime::Result, String>>, ) -> Result, Err> { - let pooled = self.pool.lock().unwrap_or_else(|err| err.into_inner()).pop(); + let forge_key = ForgeKey::of(forge); + let pooled = { + let mut pool = self.pool.lock().unwrap_or_else(|err| err.into_inner()); + if pool.forge != forge_key { + pool.forge = forge_key; + pool.instances.clear(); + } + pool.instances.pop() + }; let mut instance = match pooled { Some(instance) => instance, None => self.instantiate()?, @@ -353,7 +390,11 @@ impl WasmPlugin { // A trap can leave the instance broken, so it is dropped instead. let files = result.map_err(error)?; - self.pool.lock().unwrap_or_else(|err| err.into_inner()).push(instance); + let mut pool = self.pool.lock().unwrap_or_else(|err| err.into_inner()); + if pool.forge == forge_key { + pool.instances.push(instance); + } + drop(pool); let files = files?.into_iter().map(|file| WasmFile { path: file.path, diff --git a/tests/wasm/ts/Cargo.lock b/tests/wasm/ts/Cargo.lock index 4b7ba2030..83c2630de 100644 --- a/tests/wasm/ts/Cargo.lock +++ b/tests/wasm/ts/Cargo.lock @@ -14,15 +14,6 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "elsa" -version = "1.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -39,7 +30,6 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" name = "forge-ts-wasm" version = "0.1.0" dependencies = [ - "elsa", "serde_json", "wit-bindgen", ] @@ -189,12 +179,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "syn" version = "2.0.119" diff --git a/tests/wasm/ts/Cargo.toml b/tests/wasm/ts/Cargo.toml index a81338482..3fcbcfd46 100644 --- a/tests/wasm/ts/Cargo.toml +++ b/tests/wasm/ts/Cargo.toml @@ -21,7 +21,6 @@ crate-type = ["cdylib"] dump = [] [dependencies] -elsa = "1" # Reads the JSON text the host sends for a `Json`, keeping objects in order. serde_json = { version = "1", features = ["preserve_order"] } wit-bindgen = "0.57" diff --git a/tests/wasm/ts/src/lib.rs b/tests/wasm/ts/src/lib.rs index b289aa574..58c96c884 100644 --- a/tests/wasm/ts/src/lib.rs +++ b/tests/wasm/ts/src/lib.rs @@ -7,16 +7,14 @@ //! host's `forge` interface (see `forge::wasm::WIT`). //! //! The forge stays in the host. A part is fetched the first time the -//! plugin reads it in a call, and kept until the call ends. +//! plugin reads it, and kept in the instance across group calls. #![allow(dead_code)] extern crate self as forge; +use std::sync::{LazyLock, OnceLock}; use std::{fmt, iter}; - -use elsa::FrozenMap; - mod json; mod name; @@ -84,21 +82,19 @@ impl wit::Guest for Component { fn generate() -> Result, String> { Ok(vec![wit::File { path: "dump.txt".to_owned(), - content: dump::dump(&Forge::fetch()).into_bytes(), + content: dump::dump(&FORGE).into_bytes(), }]) } #[cfg(not(feature = "dump"))] fn generate() -> Result, String> { - let forge = Forge::fetch(); - render(bin::ts::Generator.generate(&forge).map_err(|err| err.to_string())?) + render(bin::ts::Generator.generate(&FORGE).map_err(|err| err.to_string())?) } fn generate_group(group: u32) -> Result, String> { - let forge = Forge::fetch(); render( bin::ts::Generator - .generate_group(forge.group(group)) + .generate_group(FORGE.group(group)) .map_err(|err| err.to_string())?, ) } @@ -128,37 +124,47 @@ pub struct Forge { pub description: String, pub base_url: String, - groups: Vec, - top_models: Vec, + groups: OnceLock>, + top_models: OnceLock>, + + group_data: Vec>, + method_data: Vec>, + model_data: Vec>, + type_data: Vec>, +} + +static FORGE: LazyLock = LazyLock::new(Forge::fetch); - group_data: FrozenMap>, - method_data: FrozenMap>, - model_data: FrozenMap>, - type_data: FrozenMap>, +fn slots(n: u32) -> Vec> { + (0..n).map(|_| OnceLock::new()).collect() } impl Forge { fn fetch() -> Forge { let info = host::info(); + let counts = host::counts(); Forge { version: info.version, description: info.description, base_url: info.base_url, - groups: host::groups(), - top_models: host::models(), - group_data: FrozenMap::new(), - method_data: FrozenMap::new(), - model_data: FrozenMap::new(), - type_data: FrozenMap::new(), + groups: OnceLock::new(), + top_models: OnceLock::new(), + group_data: slots(counts.groups), + method_data: slots(counts.methods), + model_data: slots(counts.models), + type_data: slots(counts.types), } } pub fn groups(&self) -> impl Iterator> { - self.groups.iter().map(|&id| self.group(id)) + self.groups.get_or_init(host::groups).iter().map(|&id| self.group(id)) } pub fn models(&self) -> impl ExactSizeIterator> { - self.top_models.iter().map(|&id| self.model(id)) + self.top_models + .get_or_init(host::models) + .iter() + .map(|&id| self.model(id)) } fn group(&self, id: u32) -> Group<'_> { @@ -183,31 +189,23 @@ impl Forge { } fn group_data(&self, id: u32) -> &GroupData { - if let Some(data) = self.group_data.get(&id) { - return data; - } - let g = host::get_group(id); - self.group_data.insert( - id, - Box::new(GroupData { + self.group_data[id as usize].get_or_init(|| { + let g = host::get_group(id); + GroupData { name: Name::from_words(g.name), description: g.description, path: g.path, children: g.children, methods: g.methods, models: g.models, - }), - ) + } + }) } fn method_data(&self, id: u32) -> &MethodData { - if let Some(data) = self.method_data.get(&id) { - return data; - } - let m = host::get_method(id); - self.method_data.insert( - id, - Box::new(MethodData { + self.method_data[id as usize].get_or_init(|| { + let m = host::get_method(id); + MethodData { name: Name::from_words(m.name), group: m.group, http_method: match m.http_method { @@ -251,33 +249,26 @@ impl Forge { .into_iter() .map(|(name, value)| (name, Json::parse(&value))) .collect(), - }), - ) + } + }) } fn model_data(&self, id: u32) -> &ModelData { - if let Some(data) = self.model_data.get(&id) { - return data; - } - let m = host::get_model(id); - self.model_data.insert( - id, - Box::new(ModelData { + self.model_data[id as usize].get_or_init(|| { + let m = host::get_model(id); + ModelData { schema_name: m.schema_name, name: Name::from_words(m.name), group: m.group, description: m.description, deprecated: m.deprecated, ty: m.ty, - }), - ) + } + }) } fn type_data(&self, id: u32) -> &TypeData { - if let Some(data) = self.type_data.get(&id) { - return data; - } - let data = match host::get_type(id) { + self.type_data[id as usize].get_or_init(|| match host::get_type(id) { host::TypeInfo::Scalar(scalar) => TypeData::Scalar(match scalar { host::Scalar::Unknown => Scalar::Unknown, host::Scalar::Null => Scalar::Null, @@ -347,8 +338,7 @@ impl Forge { }), host::TypeInfo::Intersection(members) => TypeData::Intersection(members), host::TypeInfo::Model(id) => TypeData::Model(id), - }; - self.type_data.insert(id, Box::new(data)) + }) } } diff --git a/tests/wasm/ts/wit/forge.wit b/tests/wasm/ts/wit/forge.wit index af93aa10e..8df8edc18 100644 --- a/tests/wasm/ts/wit/forge.wit +++ b/tests/wasm/ts/wit/forge.wit @@ -219,6 +219,7 @@ world plugin { /// Generates files for the forge. Called exactly once. export generate: func() -> result, string>; /// Generates files for a group. Called exactly once for each group, in - /// parallel with other groups, each in an instance of its own. + /// parallel with other groups. Instances are reused across groups of + /// the same forge. export generate-group: func(group: group-id) -> result, string>; }