diff --git a/Cargo.lock b/Cargo.lock index 3efe762..d277289 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ - "gimli", + "gimli 0.31.1", ] [[package]] @@ -111,6 +111,12 @@ dependencies = [ "syn", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.21.7" @@ -268,7 +274,7 @@ dependencies = [ "cranelift-control", "cranelift-entity", "cranelift-isle", - "gimli", + "gimli 0.31.1", "hashbrown 0.14.5", "log", "regalloc2", @@ -469,6 +475,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -542,17 +554,34 @@ dependencies = [ "wasip2", ] +[[package]] +name = "gimli" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22030e2c5a68ec659fde1e949a745124b48e6fa8b045b7ed5bd1fe4ccc5c4e5d" +dependencies = [ + "fallible-iterator 0.2.0", + "indexmap 1.9.3", + "stable_deref_trait", +] + [[package]] name = "gimli" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" dependencies = [ - "fallible-iterator", - "indexmap", + "fallible-iterator 0.3.0", + "indexmap 2.13.0", "stable_deref_trait", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -591,6 +620,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -757,7 +796,7 @@ checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "crc32fast", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "memchr", ] @@ -786,6 +825,8 @@ dependencies = [ "sha2", "thiserror 1.0.69", "tokio", + "toml", + "walrus", "wasm-encoder 0.219.2", "wasmparser 0.219.2", "wasmtime", @@ -1219,7 +1260,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.13.0", "serde", "serde_spanned", "toml_datetime", @@ -1279,6 +1320,34 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walrus" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6481311b98508f4bc2d0abbfa5d42172e7a54b4b24d8f15e28b0dc650be0c59f" +dependencies = [ + "anyhow", + "gimli 0.26.2", + "id-arena", + "leb128", + "log", + "walrus-macro", + "wasm-encoder 0.214.0", + "wasmparser 0.214.0", +] + +[[package]] +name = "walrus-macro" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ad39ff894c43c9649fa724cdde9a6fc50b855d517ef071a93e5df82fe51d3" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1339,6 +1408,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.214.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff694f02a8d7a50b6922b197ae03883fbf18cdb2ae9fbee7b6148456f5f44041" +dependencies = [ + "leb128", +] + [[package]] name = "wasm-encoder" version = "0.219.2" @@ -1359,6 +1437,20 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wasmparser" +version = "0.214.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5309c1090e3e84dad0d382f42064e9933fdaedb87e468cc239f0eabea73ddcb6" +dependencies = [ + "ahash", + "bitflags", + "hashbrown 0.14.5", + "indexmap 2.13.0", + "semver", + "serde", +] + [[package]] name = "wasmparser" version = "0.219.2" @@ -1368,7 +1460,7 @@ dependencies = [ "ahash", "bitflags", "hashbrown 0.14.5", - "indexmap", + "indexmap 2.13.0", "semver", "serde", ] @@ -1380,7 +1472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -1410,9 +1502,9 @@ dependencies = [ "cfg-if", "encoding_rs", "fxprof-processed-profile", - "gimli", + "gimli 0.31.1", "hashbrown 0.14.5", - "indexmap", + "indexmap 2.13.0", "ittapi", "libc", "libm", @@ -1515,7 +1607,7 @@ dependencies = [ "cranelift-entity", "cranelift-frontend", "cranelift-native", - "gimli", + "gimli 0.31.1", "itertools", "log", "object 0.36.7", @@ -1537,8 +1629,8 @@ dependencies = [ "cpp_demangle", "cranelift-bitset", "cranelift-entity", - "gimli", - "indexmap", + "gimli 0.31.1", + "indexmap 2.13.0", "log", "object 0.36.7", "postcard", @@ -1617,7 +1709,7 @@ checksum = "89d6b5297bea14d8387c3974b2b011de628cc9b188f135cec752b74fd368964b" dependencies = [ "anyhow", "cranelift-codegen", - "gimli", + "gimli 0.31.1", "object 0.36.7", "target-lexicon", "wasmparser 0.219.2", @@ -1634,7 +1726,7 @@ checksum = "bf3963c9c29df91564d8bd181eb00d0dbaeafa1b2a01e15952bb7391166b704e" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "wit-parser", ] @@ -1699,7 +1791,7 @@ checksum = "8b42b678c8651ec4900d7600037d235429fc985c31cbc33515885ec0d2a9e158" dependencies = [ "anyhow", "cranelift-codegen", - "gimli", + "gimli 0.31.1", "regalloc2", "smallvec", "target-lexicon", @@ -1819,7 +1911,7 @@ checksum = "ca004bb251010fe956f4a5b9d4bf86b4e415064160dd6669569939e8cbf2504f" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3eb9d2a..e38e934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,8 @@ anyhow = "1.0" # Static composition - parse and emit WASM modules wasmparser = "0.219" wasm-encoder = "0.219" +walrus = "0.23" # pack compose: internalize memory/globals into one self-contained module +toml = "0.8" # pack compose: manifest parsing # Hashing for interface compatibility sha2 = "0.10" diff --git a/assets/adder_fixedbase.wasm b/assets/adder_fixedbase.wasm new file mode 100755 index 0000000..d641e7b Binary files /dev/null and b/assets/adder_fixedbase.wasm differ diff --git a/assets/doubler_fixedbase.wasm b/assets/doubler_fixedbase.wasm new file mode 100755 index 0000000..8ae27a2 Binary files /dev/null and b/assets/doubler_fixedbase.wasm differ diff --git a/docs/pic-composition.md b/docs/pic-composition.md index b5ad6cd..32ec3ad 100644 --- a/docs/pic-composition.md +++ b/docs/pic-composition.md @@ -190,18 +190,47 @@ substrate, and the smallest useful validation of §3. --- -## 6. Level 3 (future): static merge → one `.wasm` +## 6. Level 3 (shipped): static merge → one `.wasm` — `pack compose` + +Implemented as the **`pack compose`** command. It merges the packages (allocator ++ A + B + …) into a **single self-contained `.wasm` with zero imports** — +the original "package it up and pass it around" goal — that runs on any stock +runtime with no harness (`wasm-tools validate` clean). + +**Pipeline** (`src/compose/static_compose.rs`): +1. **`wasm-merge`** (binaryen) fuses the modules and internalizes the + cross-package imports: `(import "math" "double")` → a direct call to the + provider's `double`; `(import "pack:alloc" …)` → a call to the merged-in + allocator. Each package is named by the import-module-name its consumers use. +2. A **`walrus`** pass does what `wasm-merge` can't: unify the several + `env.memory` imports into one internal memory, bake the allocator's + `__memory_base` / `__heap_base` / `__heap_end` imports into constants, and + export the memory. + +**Build recipe** — packages are compiled at disjoint **fixed bases** (non-PIC, so +data lands at absolute addresses needing no relocation): -The PIC side modules (allocator + A + B) are relocatable objects. A wasm linker -step (`wasm-ld -r`, or a purpose-built merge) can combine them into a single -relocatable module with one fixed memory layout, finalized into **one passable -`.wasm`** — the original "package it up and pass it around" goal. This is a -larger, separate effort; §3 (runtime PIC composition) is its prerequisite and its -input. +``` +RUSTFLAGS="-Clink-arg=--import-memory -Clink-arg=--initial-memory=8388608 \ + -Clink-arg=--stack-first -Clink-arg=-zstack-size=262144 \ + -Clink-arg=--global-base= -Clink-arg=--no-entry" +``` + +**CLI** — `packr compose [-o out.wasm]`, where the manifest lists +`[[package]]` entries (providers before consumers) with `name` = the consumer's +import module-name, plus an optional `[layout]`. Requires `wasm-merge` (binaryen) +on `PATH`. + +> **Packaging, not performance.** The merge internalizes the *call*, but the packr +> ABI marshalling (encode/decode/alloc per cross-call) is fossilized into the +> compiled packages, so it remains. `pack compose` gives one portable artifact; it +> does not make the boundary cheaper. A by-reference shared-memory ABI would be the +> separate perf lever. Keep **both**: runtime PIC composition (level 2) stays valuable for loading / -swapping packages at runtime; the static merge (level 3) is for shipping a frozen -bundle. They share the same PIC side-module format. +swapping packages at runtime; the static merge (`pack compose`) is for shipping a +frozen, self-contained bundle. They share the same PIC/fixed-base side-module +substrate. --- diff --git a/flake.nix b/flake.nix index 967fd7f..f7ea22a 100644 --- a/flake.nix +++ b/flake.nix @@ -49,6 +49,7 @@ cargo-expand wasm-pack wasmtime + binaryen # wasm-merge, used by `pack compose` gh ]; diff --git a/src/compose/merger.rs b/src/compose/merger.rs deleted file mode 100644 index e318038..0000000 --- a/src/compose/merger.rs +++ /dev/null @@ -1,1387 +0,0 @@ -//! Module merging logic. -//! -//! Handles merging multiple WASM modules into one, including: -//! - Type deduplication -//! - Index remapping for functions, types, globals, etc. -//! - Resolving imports to internal calls -//! - Memory and data segment merging - -use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; - -use wasm_encoder::{ - CodeSection, ConstExpr as EncConstExpr, DataSection, ElementSection, Elements, - ExportKind as EncExportKind, ExportSection, Function, FunctionSection, GlobalSection, - GlobalType, HeapType, ImportSection, Instruction, MemorySection, MemoryType, Module, RefType, - TableSection, TableType, TypeSection, ValType, -}; - -use super::error::ComposeError; -use super::parser::{ - BlockType, ConstExpr, DataSegment, DataSegmentKind, Element, ElementItems, ElementKind, - ExportKind, FunctionCode, Global, ImportKind, MemArg, ParsedModule, RefTypeKind, - StoredOperator, -}; - -/// Wiring specification: how to resolve an import. -#[derive(Debug, Clone)] -pub struct Wiring { - /// The module that has the import. - pub consumer: String, - /// The import module name (as declared in the WASM import). - pub import_module: String, - /// The import function name. - pub import_fn: String, - /// The module providing the implementation. - pub provider: String, - /// The export name in the provider module. - pub provider_export: String, -} - -/// Export specification: what to export from the merged module. -#[derive(Debug, Clone)] -pub struct ExportSpec { - /// Name of the export in the merged module. - pub name: String, - /// Source module name. - pub source_module: String, - /// Export name in the source module. - pub source_export: String, -} - -/// The merger combines multiple parsed modules into a single WASM module. -pub struct Merger { - modules: Vec, - wirings: Vec, - exports: Vec, -} - -/// Index remapping for a single module when merged. -#[derive(Debug, Default)] -struct IndexRemap { - /// Old type index -> new type index. - types: HashMap, - /// Old function index -> new function index. - functions: HashMap, - /// Old table index -> new table index. - tables: HashMap, - /// Old memory index -> new memory index. - memories: HashMap, - /// Old global index -> new global index. - globals: HashMap, -} - -impl Merger { - /// Create a new merger. - pub fn new(modules: Vec, wirings: Vec, exports: Vec) -> Self { - Self { - modules, - wirings, - exports, - } - } - - /// Merge the modules and produce a combined WASM binary. - pub fn merge(self) -> Result, ComposeError> { - if self.modules.is_empty() { - return Err(ComposeError::NoModules); - } - - // Step 1: Build dependency graph and topologically sort modules - let sorted_modules = self.topological_sort()?; - - // Step 2: Build combined structures with remapping - let mut merged = MergedModule::new(); - - // Maps to track where things end up - let mut module_remaps: HashMap = HashMap::new(); - let mut resolved_imports: HashMap<(String, String, String), u32> = HashMap::new(); - - // Step 3: Collect all types and deduplicate - for module in &sorted_modules { - let mut remap = IndexRemap::default(); - for (old_idx, func_type) in module.types.iter().enumerate() { - let new_idx = merged.add_type(func_type); - remap.types.insert(old_idx as u32, new_idx); - } - module_remaps.insert(module.name.clone(), remap); - } - - // Step 4: Process imports and figure out which become internal calls - // First pass: collect all wiring info - let mut import_resolutions: HashMap<(String, String, String), (String, String)> = - HashMap::new(); - for wiring in &self.wirings { - import_resolutions.insert( - ( - wiring.consumer.clone(), - wiring.import_module.clone(), - wiring.import_fn.clone(), - ), - (wiring.provider.clone(), wiring.provider_export.clone()), - ); - } - - // Step 5: Process imports from ALL modules first - // This is critical: WASM requires all imports before all defined functions, - // so we must collect all imports first to know the total import count - // before assigning indices to defined functions. - for module in &sorted_modules { - let remap = module_remaps.get_mut(&module.name).unwrap(); - - // Process imports - let mut import_func_idx = 0u32; - let mut import_table_idx = 0u32; - let mut import_memory_idx = 0u32; - let mut import_global_idx = 0u32; - - for import in &module.imports { - match &import.kind { - ImportKind::Function(type_idx) => { - let old_func_idx = import_func_idx; - import_func_idx += 1; - - let key = ( - module.name.clone(), - import.module.clone(), - import.name.clone(), - ); - - if import_resolutions.contains_key(&key) { - // This import is resolved internally - we'll fix the index later - // after we've processed the provider - let placeholder = u32::MAX; // Placeholder - remap.functions.insert(old_func_idx, placeholder); - resolved_imports.insert(key, old_func_idx); - } else { - // Keep as external import - let new_type_idx = remap.types[type_idx]; - let new_func_idx = merged.add_import_function( - &import.module, - &import.name, - new_type_idx, - ); - remap.functions.insert(old_func_idx, new_func_idx); - } - } - ImportKind::Table(table_ty) => { - let old_idx = import_table_idx; - import_table_idx += 1; - let new_idx = - merged.add_import_table(&import.module, &import.name, table_ty); - remap.tables.insert(old_idx, new_idx); - } - ImportKind::Memory(mem_ty) => { - let old_idx = import_memory_idx; - import_memory_idx += 1; - let new_idx = - merged.add_import_memory(&import.module, &import.name, mem_ty); - remap.memories.insert(old_idx, new_idx); - } - ImportKind::Global(global_ty) => { - let old_idx = import_global_idx; - import_global_idx += 1; - let new_idx = - merged.add_import_global(&import.module, &import.name, global_ty); - remap.globals.insert(old_idx, new_idx); - } - } - } - } - - // Step 5b: Process defined entities (tables, memories, globals, functions) - // Now that all imports are processed, we know the correct starting indices. - for module in &sorted_modules { - let remap = module_remaps.get_mut(&module.name).unwrap(); - - // Add defined tables - for (i, table_ty) in module.tables.iter().enumerate() { - let old_idx = module.num_imported_tables + i as u32; - let new_idx = merged.add_table(table_ty); - remap.tables.insert(old_idx, new_idx); - } - - // Add defined memories - SHARE memory across all modules - // When modules are wired together, they need to share memory for - // cross-module function calls to work (strings/data passed by pointer). - // We map all module-defined memories to a single shared memory (index 0). - for (i, mem_ty) in module.memories.iter().enumerate() { - let old_idx = module.num_imported_memories + i as u32; - // Add memory only if this is the first one we've seen - let new_idx = if merged.memories.is_empty() { - merged.add_memory(mem_ty) - } else { - // Expand the shared memory if this module needs more - merged.expand_memory(mem_ty); - 0 // All modules share memory 0 - }; - remap.memories.insert(old_idx, new_idx); - } - - // Add defined globals - for (i, global) in module.globals.iter().enumerate() { - let old_idx = module.num_imported_globals + i as u32; - let (new_idx, _unified) = merged.add_global(global, remap); - remap.globals.insert(old_idx, new_idx); - } - - // Add defined functions (just declare types for now, code comes later) - for (i, &type_idx) in module.functions.iter().enumerate() { - let old_func_idx = module.num_imported_functions + i as u32; - let new_type_idx = remap.types[&type_idx]; - let new_func_idx = merged.add_function(new_type_idx); - remap.functions.insert(old_func_idx, new_func_idx); - } - } - - // Step 6: Resolve internal wirings now that we know all function indices - for wiring in &self.wirings { - let provider_module = sorted_modules - .iter() - .find(|m| m.name == wiring.provider) - .ok_or_else(|| ComposeError::ModuleNotFound(wiring.provider.clone()))?; - - let export = provider_module - .get_export(&wiring.provider_export) - .ok_or_else(|| ComposeError::FunctionNotFound { - module: wiring.provider.clone(), - function: wiring.provider_export.clone(), - })?; - - if export.kind != ExportKind::Function { - return Err(ComposeError::FunctionNotFound { - module: wiring.provider.clone(), - function: wiring.provider_export.clone(), - }); - } - - let provider_remap = &module_remaps[&wiring.provider]; - let new_func_idx = provider_remap.functions[&export.index]; - - // Update the consumer's remap for this import - let consumer_remap = module_remaps.get_mut(&wiring.consumer).unwrap(); - let key = ( - wiring.consumer.clone(), - wiring.import_module.clone(), - wiring.import_fn.clone(), - ); - if let Some(&old_import_idx) = resolved_imports.get(&key) { - consumer_remap - .functions - .insert(old_import_idx, new_func_idx); - } - } - - // Step 7: Add function bodies with remapped indices - for module in &sorted_modules { - let remap = &module_remaps[&module.name]; - for code in &module.code { - let func = remap_function_body(code, remap); - merged.add_code(func); - } - } - - // Step 8: Add data segments with relocation to avoid overlap - // First module's data stays at original offsets, subsequent modules are relocated - let mut cumulative_data_offset = 0u32; - for module in &sorted_modules { - let remap = &module_remaps[&module.name]; - for data in &module.data { - merged.add_data(data, remap, cumulative_data_offset); - } - // After adding this module's data, update the offset for the next module - // Align to 8-byte boundary for safety - cumulative_data_offset = (merged.highest_data_addr + 7) & !7; - } - - // Step 9: Add element segments - for module in &sorted_modules { - let remap = &module_remaps[&module.name]; - for elem in &module.elements { - merged.add_element(elem, remap); - } - } - - // Step 10: Add exports - for export_spec in &self.exports { - let source_module = sorted_modules - .iter() - .find(|m| m.name == export_spec.source_module) - .ok_or_else(|| ComposeError::ModuleNotFound(export_spec.source_module.clone()))?; - - let export = source_module - .get_export(&export_spec.source_export) - .ok_or_else(|| ComposeError::FunctionNotFound { - module: export_spec.source_module.clone(), - function: export_spec.source_export.clone(), - })?; - - let remap = &module_remaps[&export_spec.source_module]; - let new_idx = match export.kind { - ExportKind::Function => remap.functions[&export.index], - ExportKind::Table => remap.tables[&export.index], - ExportKind::Memory => remap.memories[&export.index], - ExportKind::Global => remap.globals[&export.index], - }; - - merged.add_export(&export_spec.name, export.kind, new_idx); - } - - // Step 11: Encode the merged module - merged.encode() - } - - /// Topologically sort modules so providers come before consumers. - fn topological_sort(&self) -> Result, ComposeError> { - let module_map: HashMap<&str, &ParsedModule> = - self.modules.iter().map(|m| (m.name.as_str(), m)).collect(); - - // Build dependency graph: consumer -> providers it depends on - let mut deps: HashMap<&str, HashSet<&str>> = HashMap::new(); - for module in &self.modules { - deps.insert(&module.name, HashSet::new()); - } - - for wiring in &self.wirings { - if let Some(dep_set) = deps.get_mut(wiring.consumer.as_str()) { - if module_map.contains_key(wiring.provider.as_str()) { - dep_set.insert(wiring.provider.as_str()); - } - } - } - - // Kahn's algorithm for topological sort - let mut in_degree: HashMap<&str, usize> = HashMap::new(); - for module in &self.modules { - in_degree.insert(&module.name, 0); - } - for providers in deps.values() { - for provider in providers { - *in_degree.get_mut(provider).unwrap() += 1; - } - } - - let mut queue: Vec<&str> = in_degree - .iter() - .filter(|(_, °)| deg == 0) - .map(|(&name, _)| name) - .collect(); - - let mut result = Vec::new(); - while let Some(name) = queue.pop() { - result.push(module_map[name]); - if let Some(providers) = deps.get(name) { - for provider in providers { - let deg = in_degree.get_mut(provider).unwrap(); - *deg -= 1; - if *deg == 0 { - queue.push(provider); - } - } - } - } - - if result.len() != self.modules.len() { - return Err(ComposeError::CircularDependency { - cycle: self.modules.iter().map(|m| m.name.clone()).collect(), - }); - } - - // Reverse so providers come first - result.reverse(); - Ok(result) - } -} - -/// The merged module being built. -struct MergedModule { - types: Vec, - type_dedup: HashMap, - - imports: Vec, - num_imported_functions: u32, - num_imported_tables: u32, - num_imported_memories: u32, - num_imported_globals: u32, - - functions: Vec, // type indices - tables: Vec, - memories: Vec, - globals: Vec<(wasmparser::GlobalType, ConstExpr)>, - exports: Vec<(String, ExportKind, u32)>, - code: Vec, - data: Vec<(DataSegmentKind, Vec)>, - elements: Vec, - - /// Highest data segment end address. - /// Used to relocate subsequent modules' data to avoid overlap. - highest_data_addr: u32, - - /// Index of the unified heap pointer global (mut i32, initial 0xC000). - /// When modules share memory, they must share this global so allocations - /// from different modules don't overlap. - unified_heap_ptr: Option, - - /// Index of the unified encoding stack pointer global (mut i32, initial 0xB000). - /// This is used for CGRF tuple encoding and must be shared when modules - /// share memory. - unified_enc_tuple_sp: Option, -} - -struct MergedImport { - module: String, - name: String, - kind: ImportKind, -} - -struct MergedElement { - kind: ElementKind, - items: Vec, // Remapped function indices -} - -/// Key for type deduplication. -#[derive(Hash, Eq, PartialEq)] -struct TypeKey { - params: Vec, - results: Vec, -} - -impl TypeKey { - fn from_func_type(ty: &wasmparser::FuncType) -> Self { - Self { - params: ty.params().iter().map(val_type_byte).collect(), - results: ty.results().iter().map(val_type_byte).collect(), - } - } -} - -fn val_type_byte(ty: &wasmparser::ValType) -> u8 { - match ty { - wasmparser::ValType::I32 => 0x7F, - wasmparser::ValType::I64 => 0x7E, - wasmparser::ValType::F32 => 0x7D, - wasmparser::ValType::F64 => 0x7C, - wasmparser::ValType::V128 => 0x7B, - wasmparser::ValType::Ref(r) => { - if r.is_func_ref() { - 0x70 - } else { - 0x6F - } - } - } -} - -impl MergedModule { - fn new() -> Self { - Self { - types: Vec::new(), - type_dedup: HashMap::new(), - imports: Vec::new(), - num_imported_functions: 0, - num_imported_tables: 0, - num_imported_memories: 0, - num_imported_globals: 0, - functions: Vec::new(), - tables: Vec::new(), - memories: Vec::new(), - globals: Vec::new(), - exports: Vec::new(), - code: Vec::new(), - data: Vec::new(), - elements: Vec::new(), - highest_data_addr: 0, - unified_heap_ptr: None, - unified_enc_tuple_sp: None, - } - } - - fn add_type(&mut self, func_type: &wasmparser::FuncType) -> u32 { - let key = TypeKey::from_func_type(func_type); - if let Some(&idx) = self.type_dedup.get(&key) { - return idx; - } - let idx = self.types.len() as u32; - self.types.push(func_type.clone()); - self.type_dedup.insert(key, idx); - idx - } - - fn next_function_index(&self) -> u32 { - self.num_imported_functions + self.functions.len() as u32 - } - - fn next_table_index(&self) -> u32 { - self.num_imported_tables + self.tables.len() as u32 - } - - fn next_memory_index(&self) -> u32 { - self.num_imported_memories + self.memories.len() as u32 - } - - fn next_global_index(&self) -> u32 { - self.num_imported_globals + self.globals.len() as u32 - } - - fn add_import_function(&mut self, module: &str, name: &str, type_idx: u32) -> u32 { - let idx = self.num_imported_functions; - self.num_imported_functions += 1; - self.imports.push(MergedImport { - module: module.to_string(), - name: name.to_string(), - kind: ImportKind::Function(type_idx), - }); - idx - } - - fn add_import_table( - &mut self, - module: &str, - name: &str, - table_ty: &wasmparser::TableType, - ) -> u32 { - let idx = self.num_imported_tables; - self.num_imported_tables += 1; - self.imports.push(MergedImport { - module: module.to_string(), - name: name.to_string(), - kind: ImportKind::Table(*table_ty), - }); - idx - } - - fn add_import_memory( - &mut self, - module: &str, - name: &str, - mem_ty: &wasmparser::MemoryType, - ) -> u32 { - let idx = self.num_imported_memories; - self.num_imported_memories += 1; - self.imports.push(MergedImport { - module: module.to_string(), - name: name.to_string(), - kind: ImportKind::Memory(*mem_ty), - }); - idx - } - - fn add_import_global( - &mut self, - module: &str, - name: &str, - global_ty: &wasmparser::GlobalType, - ) -> u32 { - let idx = self.num_imported_globals; - self.num_imported_globals += 1; - self.imports.push(MergedImport { - module: module.to_string(), - name: name.to_string(), - kind: ImportKind::Global(*global_ty), - }); - idx - } - - fn add_table(&mut self, table_ty: &wasmparser::TableType) -> u32 { - let idx = self.next_table_index(); - self.tables.push(*table_ty); - idx - } - - fn add_memory(&mut self, mem_ty: &wasmparser::MemoryType) -> u32 { - let idx = self.next_memory_index(); - self.memories.push(*mem_ty); - idx - } - - /// Expand the shared memory (index 0) to accommodate a larger requirement. - /// Takes the maximum of the current and new memory limits. - fn expand_memory(&mut self, mem_ty: &wasmparser::MemoryType) { - if self.memories.is_empty() { - return; - } - let shared = &mut self.memories[0]; - // Take the maximum initial and maximum sizes - shared.initial = shared.initial.max(mem_ty.initial); - shared.maximum = match (shared.maximum, mem_ty.maximum) { - (Some(a), Some(b)) => Some(a.max(b)), - (Some(a), None) | (None, Some(a)) => Some(a), - (None, None) => None, - }; - } - - /// Add a global, unifying heap-related globals when modules share memory. - /// - /// Wisp-compiled modules have two critical globals that must be shared - /// when modules share memory: - /// - Heap pointer ($__heap_ptr): mut i32, initial value 0xC000 (49152) - /// - Encoding stack pointer ($enc_tuple_sp): mut i32, initial value 0xB000 (45056) - /// - /// Without unification, each module would allocate from the same starting - /// address, causing memory corruption. - fn add_global(&mut self, global: &Global, _remap: &IndexRemap) -> (u32, bool) { - // Check if this is a heap pointer global (mut i32, value 49152) - if self.is_heap_ptr_global(global) { - if let Some(unified_idx) = self.unified_heap_ptr { - // Return the existing unified heap pointer - return (unified_idx, true); - } else { - // First heap pointer - add it and track - let idx = self.next_global_index(); - self.globals.push((global.ty, global.init_expr.clone())); - self.unified_heap_ptr = Some(idx); - return (idx, false); - } - } - - // Check if this is an enc_tuple_sp global (mut i32, value 45056) - if self.is_enc_tuple_sp_global(global) { - if let Some(unified_idx) = self.unified_enc_tuple_sp { - // Return the existing unified enc_tuple_sp - return (unified_idx, true); - } else { - // First enc_tuple_sp - add it and track - let idx = self.next_global_index(); - self.globals.push((global.ty, global.init_expr.clone())); - self.unified_enc_tuple_sp = Some(idx); - return (idx, false); - } - } - - // Regular global - add as new - let idx = self.next_global_index(); - self.globals.push((global.ty, global.init_expr.clone())); - (idx, false) - } - - /// Check if a global is the heap pointer (mut i32, value 0xC000). - fn is_heap_ptr_global(&self, global: &Global) -> bool { - if !global.ty.mutable { - return false; - } - if global.ty.content_type != wasmparser::ValType::I32 { - return false; - } - matches!(global.init_expr, ConstExpr::I32Const(49152)) - } - - /// Check if a global is the encoding tuple stack pointer (mut i32, value 0xB000). - fn is_enc_tuple_sp_global(&self, global: &Global) -> bool { - if !global.ty.mutable { - return false; - } - if global.ty.content_type != wasmparser::ValType::I32 { - return false; - } - matches!(global.init_expr, ConstExpr::I32Const(45056)) - } - - fn add_function(&mut self, type_idx: u32) -> u32 { - let idx = self.next_function_index(); - self.functions.push(type_idx); - idx - } - - fn add_code(&mut self, func: Function) { - self.code.push(func); - } - - /// Add a data segment with relocation support. - /// The `data_offset` parameter specifies how much to offset this segment - /// to avoid overlap with other modules' data. - fn add_data(&mut self, data: &DataSegment, remap: &IndexRemap, data_offset: u32) { - let kind = match &data.kind { - DataSegmentKind::Active { - memory_index, - offset_expr, - } => { - let new_mem_idx = remap - .memories - .get(memory_index) - .copied() - .unwrap_or(*memory_index); - // Apply data offset if this is a constant offset - let new_offset = match offset_expr { - ConstExpr::I32Const(orig_offset) => { - let relocated = (*orig_offset as u32).saturating_add(data_offset); - // Track highest data address - let data_end = relocated + data.data.len() as u32; - if data_end > self.highest_data_addr { - self.highest_data_addr = data_end; - } - ConstExpr::I32Const(relocated as i32) - } - _ => { - // For non-constant offsets, we can't easily relocate - // Just track the data size for highest_data_addr - self.highest_data_addr = self.highest_data_addr.max(data.data.len() as u32); - offset_expr.clone() - } - }; - DataSegmentKind::Active { - memory_index: new_mem_idx, - offset_expr: new_offset, - } - } - DataSegmentKind::Passive => DataSegmentKind::Passive, - }; - self.data.push((kind, data.data.clone())); - } - - fn add_element(&mut self, elem: &Element, remap: &IndexRemap) { - let items = match &elem.items { - ElementItems::Functions(funcs) => funcs - .iter() - .map(|&idx| remap.functions.get(&idx).copied().unwrap_or(idx)) - .collect(), - ElementItems::Expressions(_) => { - // For expression-based elements, we'd need more complex handling - // For now, skip these - return; - } - }; - - let kind = match &elem.kind { - ElementKind::Active { - table_index, - offset_expr, - } => { - let new_table_idx = remap - .tables - .get(table_index) - .copied() - .unwrap_or(*table_index); - ElementKind::Active { - table_index: new_table_idx, - offset_expr: offset_expr.clone(), - } - } - ElementKind::Passive => ElementKind::Passive, - ElementKind::Declared => ElementKind::Declared, - }; - - self.elements.push(MergedElement { kind, items }); - } - - fn add_export(&mut self, name: &str, kind: ExportKind, index: u32) { - self.exports.push((name.to_string(), kind, index)); - } - - fn encode(self) -> Result, ComposeError> { - let mut module = Module::new(); - - // Type section - if !self.types.is_empty() { - let mut types = TypeSection::new(); - for func_type in &self.types { - let params: Vec = - func_type.params().iter().map(convert_val_type).collect(); - let results: Vec = - func_type.results().iter().map(convert_val_type).collect(); - types.ty().function(params, results); - } - module.section(&types); - } - - // Import section - if !self.imports.is_empty() { - let mut imports = ImportSection::new(); - for import in &self.imports { - match &import.kind { - ImportKind::Function(type_idx) => { - imports.import( - &import.module, - &import.name, - wasm_encoder::EntityType::Function(*type_idx), - ); - } - ImportKind::Table(table_ty) => { - let enc_ty = convert_table_type(table_ty); - imports.import(&import.module, &import.name, enc_ty); - } - ImportKind::Memory(mem_ty) => { - let enc_ty = convert_memory_type(mem_ty); - imports.import(&import.module, &import.name, enc_ty); - } - ImportKind::Global(global_ty) => { - let enc_ty = convert_global_type(global_ty); - imports.import(&import.module, &import.name, enc_ty); - } - } - } - module.section(&imports); - } - - // Function section - if !self.functions.is_empty() { - let mut functions = FunctionSection::new(); - for &type_idx in &self.functions { - functions.function(type_idx); - } - module.section(&functions); - } - - // Table section - if !self.tables.is_empty() { - let mut tables = TableSection::new(); - for table_ty in &self.tables { - let enc_ty = convert_table_type(table_ty); - tables.table(enc_ty); - } - module.section(&tables); - } - - // Memory section - if !self.memories.is_empty() { - let mut memories = MemorySection::new(); - for mem_ty in &self.memories { - let enc_ty = convert_memory_type(mem_ty); - memories.memory(enc_ty); - } - module.section(&memories); - } - - // Global section - if !self.globals.is_empty() { - let mut globals = GlobalSection::new(); - for (global_ty, init_expr) in &self.globals { - let enc_ty = convert_global_type(global_ty); - let enc_expr = convert_const_expr(init_expr); - globals.global(enc_ty, &enc_expr); - } - module.section(&globals); - } - - // Export section - if !self.exports.is_empty() { - let mut exports = ExportSection::new(); - for (name, kind, index) in &self.exports { - let enc_kind = match kind { - ExportKind::Function => EncExportKind::Func, - ExportKind::Table => EncExportKind::Table, - ExportKind::Memory => EncExportKind::Memory, - ExportKind::Global => EncExportKind::Global, - }; - exports.export(name, enc_kind, *index); - } - module.section(&exports); - } - - // Element section - if !self.elements.is_empty() { - let mut elements = ElementSection::new(); - for elem in &self.elements { - match &elem.kind { - ElementKind::Active { - table_index, - offset_expr, - } => { - let offset = convert_const_expr(offset_expr); - elements.active( - Some(*table_index), - &offset, - Elements::Functions(Cow::Borrowed(&elem.items)), - ); - } - ElementKind::Passive => { - elements.passive(Elements::Functions(Cow::Borrowed(&elem.items))); - } - ElementKind::Declared => { - elements.declared(Elements::Functions(Cow::Borrowed(&elem.items))); - } - } - } - module.section(&elements); - } - - // Code section - if !self.code.is_empty() { - let mut code = CodeSection::new(); - for func in &self.code { - code.function(func); - } - module.section(&code); - } - - // Data section - if !self.data.is_empty() { - let mut data = DataSection::new(); - for (kind, bytes) in &self.data { - match kind { - DataSegmentKind::Active { - memory_index, - offset_expr, - } => { - let offset = convert_const_expr(offset_expr); - data.active(*memory_index, &offset, bytes.iter().copied()); - } - DataSegmentKind::Passive => { - data.passive(bytes.iter().copied()); - } - } - } - module.section(&data); - } - - Ok(module.finish()) - } -} - -/// Remap indices in a function body. -fn remap_function_body(code: &FunctionCode, remap: &IndexRemap) -> Function { - let mut func = Function::new( - code.locals - .iter() - .map(|(count, ty)| (*count, convert_val_type(ty))), - ); - - for op in &code.operators { - let inst = convert_stored_operator(op, remap); - func.instruction(&inst); - } - - func -} - -/// Convert a stored operator to a wasm-encoder instruction with index remapping. -fn convert_stored_operator(op: &StoredOperator, remap: &IndexRemap) -> Instruction<'static> { - match op { - // Control flow - StoredOperator::Unreachable => Instruction::Unreachable, - StoredOperator::Nop => Instruction::Nop, - StoredOperator::Block(bt) => Instruction::Block(convert_block_type(bt, remap)), - StoredOperator::Loop(bt) => Instruction::Loop(convert_block_type(bt, remap)), - StoredOperator::If(bt) => Instruction::If(convert_block_type(bt, remap)), - StoredOperator::Else => Instruction::Else, - StoredOperator::End => Instruction::End, - StoredOperator::Br(depth) => Instruction::Br(*depth), - StoredOperator::BrIf(depth) => Instruction::BrIf(*depth), - StoredOperator::BrTable { targets, default } => { - Instruction::BrTable(Cow::Owned(targets.clone()), *default) - } - StoredOperator::Return => Instruction::Return, - StoredOperator::Call(idx) => { - let new_idx = remap.functions.get(idx).copied().unwrap_or(*idx); - Instruction::Call(new_idx) - } - StoredOperator::CallIndirect { - type_index, - table_index, - } => { - let new_type = remap.types.get(type_index).copied().unwrap_or(*type_index); - let new_table = remap - .tables - .get(table_index) - .copied() - .unwrap_or(*table_index); - Instruction::CallIndirect { - type_index: new_type, - table_index: new_table, - } - } - StoredOperator::ReturnCall(idx) => { - let new_idx = remap.functions.get(idx).copied().unwrap_or(*idx); - Instruction::ReturnCall(new_idx) - } - StoredOperator::ReturnCallIndirect { - type_index, - table_index, - } => { - let new_type = remap.types.get(type_index).copied().unwrap_or(*type_index); - let new_table = remap - .tables - .get(table_index) - .copied() - .unwrap_or(*table_index); - Instruction::ReturnCallIndirect { - type_index: new_type, - table_index: new_table, - } - } - - // Parametric - StoredOperator::Drop => Instruction::Drop, - StoredOperator::Select => Instruction::Select, - StoredOperator::TypedSelect(ty) => Instruction::TypedSelect(convert_val_type(ty)), - - // Variable access - StoredOperator::LocalGet(idx) => Instruction::LocalGet(*idx), - StoredOperator::LocalSet(idx) => Instruction::LocalSet(*idx), - StoredOperator::LocalTee(idx) => Instruction::LocalTee(*idx), - StoredOperator::GlobalGet(idx) => { - let new_idx = remap.globals.get(idx).copied().unwrap_or(*idx); - Instruction::GlobalGet(new_idx) - } - StoredOperator::GlobalSet(idx) => { - let new_idx = remap.globals.get(idx).copied().unwrap_or(*idx); - Instruction::GlobalSet(new_idx) - } - - // Table operations - StoredOperator::TableGet(idx) => { - let new_idx = remap.tables.get(idx).copied().unwrap_or(*idx); - Instruction::TableGet(new_idx) - } - StoredOperator::TableSet(idx) => { - let new_idx = remap.tables.get(idx).copied().unwrap_or(*idx); - Instruction::TableSet(new_idx) - } - StoredOperator::TableGrow(idx) => { - let new_idx = remap.tables.get(idx).copied().unwrap_or(*idx); - Instruction::TableGrow(new_idx) - } - StoredOperator::TableSize(idx) => { - let new_idx = remap.tables.get(idx).copied().unwrap_or(*idx); - Instruction::TableSize(new_idx) - } - StoredOperator::TableFill(idx) => { - let new_idx = remap.tables.get(idx).copied().unwrap_or(*idx); - Instruction::TableFill(new_idx) - } - StoredOperator::TableCopy { dst, src } => { - let new_dst = remap.tables.get(dst).copied().unwrap_or(*dst); - let new_src = remap.tables.get(src).copied().unwrap_or(*src); - Instruction::TableCopy { - src_table: new_src, - dst_table: new_dst, - } - } - StoredOperator::TableInit { elem, table } => { - let new_table = remap.tables.get(table).copied().unwrap_or(*table); - Instruction::TableInit { - elem_index: *elem, - table: new_table, - } - } - StoredOperator::ElemDrop(idx) => Instruction::ElemDrop(*idx), - - // Memory load operations - StoredOperator::I32Load(m) => Instruction::I32Load(convert_memarg(m, remap)), - StoredOperator::I64Load(m) => Instruction::I64Load(convert_memarg(m, remap)), - StoredOperator::F32Load(m) => Instruction::F32Load(convert_memarg(m, remap)), - StoredOperator::F64Load(m) => Instruction::F64Load(convert_memarg(m, remap)), - StoredOperator::I32Load8S(m) => Instruction::I32Load8S(convert_memarg(m, remap)), - StoredOperator::I32Load8U(m) => Instruction::I32Load8U(convert_memarg(m, remap)), - StoredOperator::I32Load16S(m) => Instruction::I32Load16S(convert_memarg(m, remap)), - StoredOperator::I32Load16U(m) => Instruction::I32Load16U(convert_memarg(m, remap)), - StoredOperator::I64Load8S(m) => Instruction::I64Load8S(convert_memarg(m, remap)), - StoredOperator::I64Load8U(m) => Instruction::I64Load8U(convert_memarg(m, remap)), - StoredOperator::I64Load16S(m) => Instruction::I64Load16S(convert_memarg(m, remap)), - StoredOperator::I64Load16U(m) => Instruction::I64Load16U(convert_memarg(m, remap)), - StoredOperator::I64Load32S(m) => Instruction::I64Load32S(convert_memarg(m, remap)), - StoredOperator::I64Load32U(m) => Instruction::I64Load32U(convert_memarg(m, remap)), - - // Memory store operations - StoredOperator::I32Store(m) => Instruction::I32Store(convert_memarg(m, remap)), - StoredOperator::I64Store(m) => Instruction::I64Store(convert_memarg(m, remap)), - StoredOperator::F32Store(m) => Instruction::F32Store(convert_memarg(m, remap)), - StoredOperator::F64Store(m) => Instruction::F64Store(convert_memarg(m, remap)), - StoredOperator::I32Store8(m) => Instruction::I32Store8(convert_memarg(m, remap)), - StoredOperator::I32Store16(m) => Instruction::I32Store16(convert_memarg(m, remap)), - StoredOperator::I64Store8(m) => Instruction::I64Store8(convert_memarg(m, remap)), - StoredOperator::I64Store16(m) => Instruction::I64Store16(convert_memarg(m, remap)), - StoredOperator::I64Store32(m) => Instruction::I64Store32(convert_memarg(m, remap)), - - // Memory operations - StoredOperator::MemorySize(idx) => { - let new_idx = remap.memories.get(idx).copied().unwrap_or(*idx); - Instruction::MemorySize(new_idx) - } - StoredOperator::MemoryGrow(idx) => { - let new_idx = remap.memories.get(idx).copied().unwrap_or(*idx); - Instruction::MemoryGrow(new_idx) - } - StoredOperator::MemoryInit { data, mem } => { - let new_mem = remap.memories.get(mem).copied().unwrap_or(*mem); - Instruction::MemoryInit { - data_index: *data, - mem: new_mem, - } - } - StoredOperator::DataDrop(idx) => Instruction::DataDrop(*idx), - StoredOperator::MemoryCopy { dst, src } => { - let new_dst = remap.memories.get(dst).copied().unwrap_or(*dst); - let new_src = remap.memories.get(src).copied().unwrap_or(*src); - Instruction::MemoryCopy { - src_mem: new_src, - dst_mem: new_dst, - } - } - StoredOperator::MemoryFill(idx) => { - let new_idx = remap.memories.get(idx).copied().unwrap_or(*idx); - Instruction::MemoryFill(new_idx) - } - - // Constants - StoredOperator::I32Const(v) => Instruction::I32Const(*v), - StoredOperator::I64Const(v) => Instruction::I64Const(*v), - StoredOperator::F32Const(v) => Instruction::F32Const(*v), - StoredOperator::F64Const(v) => Instruction::F64Const(*v), - - // Comparison operators - StoredOperator::I32Eqz => Instruction::I32Eqz, - StoredOperator::I32Eq => Instruction::I32Eq, - StoredOperator::I32Ne => Instruction::I32Ne, - StoredOperator::I32LtS => Instruction::I32LtS, - StoredOperator::I32LtU => Instruction::I32LtU, - StoredOperator::I32GtS => Instruction::I32GtS, - StoredOperator::I32GtU => Instruction::I32GtU, - StoredOperator::I32LeS => Instruction::I32LeS, - StoredOperator::I32LeU => Instruction::I32LeU, - StoredOperator::I32GeS => Instruction::I32GeS, - StoredOperator::I32GeU => Instruction::I32GeU, - - StoredOperator::I64Eqz => Instruction::I64Eqz, - StoredOperator::I64Eq => Instruction::I64Eq, - StoredOperator::I64Ne => Instruction::I64Ne, - StoredOperator::I64LtS => Instruction::I64LtS, - StoredOperator::I64LtU => Instruction::I64LtU, - StoredOperator::I64GtS => Instruction::I64GtS, - StoredOperator::I64GtU => Instruction::I64GtU, - StoredOperator::I64LeS => Instruction::I64LeS, - StoredOperator::I64LeU => Instruction::I64LeU, - StoredOperator::I64GeS => Instruction::I64GeS, - StoredOperator::I64GeU => Instruction::I64GeU, - - StoredOperator::F32Eq => Instruction::F32Eq, - StoredOperator::F32Ne => Instruction::F32Ne, - StoredOperator::F32Lt => Instruction::F32Lt, - StoredOperator::F32Gt => Instruction::F32Gt, - StoredOperator::F32Le => Instruction::F32Le, - StoredOperator::F32Ge => Instruction::F32Ge, - - StoredOperator::F64Eq => Instruction::F64Eq, - StoredOperator::F64Ne => Instruction::F64Ne, - StoredOperator::F64Lt => Instruction::F64Lt, - StoredOperator::F64Gt => Instruction::F64Gt, - StoredOperator::F64Le => Instruction::F64Le, - StoredOperator::F64Ge => Instruction::F64Ge, - - // Numeric operators - i32 - StoredOperator::I32Clz => Instruction::I32Clz, - StoredOperator::I32Ctz => Instruction::I32Ctz, - StoredOperator::I32Popcnt => Instruction::I32Popcnt, - StoredOperator::I32Add => Instruction::I32Add, - StoredOperator::I32Sub => Instruction::I32Sub, - StoredOperator::I32Mul => Instruction::I32Mul, - StoredOperator::I32DivS => Instruction::I32DivS, - StoredOperator::I32DivU => Instruction::I32DivU, - StoredOperator::I32RemS => Instruction::I32RemS, - StoredOperator::I32RemU => Instruction::I32RemU, - StoredOperator::I32And => Instruction::I32And, - StoredOperator::I32Or => Instruction::I32Or, - StoredOperator::I32Xor => Instruction::I32Xor, - StoredOperator::I32Shl => Instruction::I32Shl, - StoredOperator::I32ShrS => Instruction::I32ShrS, - StoredOperator::I32ShrU => Instruction::I32ShrU, - StoredOperator::I32Rotl => Instruction::I32Rotl, - StoredOperator::I32Rotr => Instruction::I32Rotr, - - // Numeric operators - i64 - StoredOperator::I64Clz => Instruction::I64Clz, - StoredOperator::I64Ctz => Instruction::I64Ctz, - StoredOperator::I64Popcnt => Instruction::I64Popcnt, - StoredOperator::I64Add => Instruction::I64Add, - StoredOperator::I64Sub => Instruction::I64Sub, - StoredOperator::I64Mul => Instruction::I64Mul, - StoredOperator::I64DivS => Instruction::I64DivS, - StoredOperator::I64DivU => Instruction::I64DivU, - StoredOperator::I64RemS => Instruction::I64RemS, - StoredOperator::I64RemU => Instruction::I64RemU, - StoredOperator::I64And => Instruction::I64And, - StoredOperator::I64Or => Instruction::I64Or, - StoredOperator::I64Xor => Instruction::I64Xor, - StoredOperator::I64Shl => Instruction::I64Shl, - StoredOperator::I64ShrS => Instruction::I64ShrS, - StoredOperator::I64ShrU => Instruction::I64ShrU, - StoredOperator::I64Rotl => Instruction::I64Rotl, - StoredOperator::I64Rotr => Instruction::I64Rotr, - - // Numeric operators - f32 - StoredOperator::F32Abs => Instruction::F32Abs, - StoredOperator::F32Neg => Instruction::F32Neg, - StoredOperator::F32Ceil => Instruction::F32Ceil, - StoredOperator::F32Floor => Instruction::F32Floor, - StoredOperator::F32Trunc => Instruction::F32Trunc, - StoredOperator::F32Nearest => Instruction::F32Nearest, - StoredOperator::F32Sqrt => Instruction::F32Sqrt, - StoredOperator::F32Add => Instruction::F32Add, - StoredOperator::F32Sub => Instruction::F32Sub, - StoredOperator::F32Mul => Instruction::F32Mul, - StoredOperator::F32Div => Instruction::F32Div, - StoredOperator::F32Min => Instruction::F32Min, - StoredOperator::F32Max => Instruction::F32Max, - StoredOperator::F32Copysign => Instruction::F32Copysign, - - // Numeric operators - f64 - StoredOperator::F64Abs => Instruction::F64Abs, - StoredOperator::F64Neg => Instruction::F64Neg, - StoredOperator::F64Ceil => Instruction::F64Ceil, - StoredOperator::F64Floor => Instruction::F64Floor, - StoredOperator::F64Trunc => Instruction::F64Trunc, - StoredOperator::F64Nearest => Instruction::F64Nearest, - StoredOperator::F64Sqrt => Instruction::F64Sqrt, - StoredOperator::F64Add => Instruction::F64Add, - StoredOperator::F64Sub => Instruction::F64Sub, - StoredOperator::F64Mul => Instruction::F64Mul, - StoredOperator::F64Div => Instruction::F64Div, - StoredOperator::F64Min => Instruction::F64Min, - StoredOperator::F64Max => Instruction::F64Max, - StoredOperator::F64Copysign => Instruction::F64Copysign, - - // Conversions - StoredOperator::I32WrapI64 => Instruction::I32WrapI64, - StoredOperator::I32TruncF32S => Instruction::I32TruncF32S, - StoredOperator::I32TruncF32U => Instruction::I32TruncF32U, - StoredOperator::I32TruncF64S => Instruction::I32TruncF64S, - StoredOperator::I32TruncF64U => Instruction::I32TruncF64U, - StoredOperator::I64ExtendI32S => Instruction::I64ExtendI32S, - StoredOperator::I64ExtendI32U => Instruction::I64ExtendI32U, - StoredOperator::I64TruncF32S => Instruction::I64TruncF32S, - StoredOperator::I64TruncF32U => Instruction::I64TruncF32U, - StoredOperator::I64TruncF64S => Instruction::I64TruncF64S, - StoredOperator::I64TruncF64U => Instruction::I64TruncF64U, - StoredOperator::F32ConvertI32S => Instruction::F32ConvertI32S, - StoredOperator::F32ConvertI32U => Instruction::F32ConvertI32U, - StoredOperator::F32ConvertI64S => Instruction::F32ConvertI64S, - StoredOperator::F32ConvertI64U => Instruction::F32ConvertI64U, - StoredOperator::F32DemoteF64 => Instruction::F32DemoteF64, - StoredOperator::F64ConvertI32S => Instruction::F64ConvertI32S, - StoredOperator::F64ConvertI32U => Instruction::F64ConvertI32U, - StoredOperator::F64ConvertI64S => Instruction::F64ConvertI64S, - StoredOperator::F64ConvertI64U => Instruction::F64ConvertI64U, - StoredOperator::F64PromoteF32 => Instruction::F64PromoteF32, - StoredOperator::I32ReinterpretF32 => Instruction::I32ReinterpretF32, - StoredOperator::I64ReinterpretF64 => Instruction::I64ReinterpretF64, - StoredOperator::F32ReinterpretI32 => Instruction::F32ReinterpretI32, - StoredOperator::F64ReinterpretI64 => Instruction::F64ReinterpretI64, - - // Sign extension - StoredOperator::I32Extend8S => Instruction::I32Extend8S, - StoredOperator::I32Extend16S => Instruction::I32Extend16S, - StoredOperator::I64Extend8S => Instruction::I64Extend8S, - StoredOperator::I64Extend16S => Instruction::I64Extend16S, - StoredOperator::I64Extend32S => Instruction::I64Extend32S, - - // Saturating truncation - StoredOperator::I32TruncSatF32S => Instruction::I32TruncSatF32S, - StoredOperator::I32TruncSatF32U => Instruction::I32TruncSatF32U, - StoredOperator::I32TruncSatF64S => Instruction::I32TruncSatF64S, - StoredOperator::I32TruncSatF64U => Instruction::I32TruncSatF64U, - StoredOperator::I64TruncSatF32S => Instruction::I64TruncSatF32S, - StoredOperator::I64TruncSatF32U => Instruction::I64TruncSatF32U, - StoredOperator::I64TruncSatF64S => Instruction::I64TruncSatF64S, - StoredOperator::I64TruncSatF64U => Instruction::I64TruncSatF64U, - - // Reference types - StoredOperator::RefNull(kind) => match kind { - RefTypeKind::Func => Instruction::RefNull(HeapType::Abstract { - shared: false, - ty: wasm_encoder::AbstractHeapType::Func, - }), - RefTypeKind::Extern => Instruction::RefNull(HeapType::Abstract { - shared: false, - ty: wasm_encoder::AbstractHeapType::Extern, - }), - }, - StoredOperator::RefIsNull => Instruction::RefIsNull, - StoredOperator::RefFunc(idx) => { - let new_idx = remap.functions.get(idx).copied().unwrap_or(*idx); - Instruction::RefFunc(new_idx) - } - } -} - -fn convert_val_type(ty: &wasmparser::ValType) -> ValType { - match ty { - wasmparser::ValType::I32 => ValType::I32, - wasmparser::ValType::I64 => ValType::I64, - wasmparser::ValType::F32 => ValType::F32, - wasmparser::ValType::F64 => ValType::F64, - wasmparser::ValType::V128 => ValType::V128, - wasmparser::ValType::Ref(r) => { - if r.is_func_ref() { - ValType::Ref(RefType::FUNCREF) - } else { - ValType::Ref(RefType::EXTERNREF) - } - } - } -} - -fn convert_block_type(bt: &BlockType, remap: &IndexRemap) -> wasm_encoder::BlockType { - match bt { - BlockType::Empty => wasm_encoder::BlockType::Empty, - BlockType::Type(ty) => wasm_encoder::BlockType::Result(convert_val_type(ty)), - BlockType::FuncType(idx) => { - let new_idx = remap.types.get(idx).copied().unwrap_or(*idx); - wasm_encoder::BlockType::FunctionType(new_idx) - } - } -} - -fn convert_memarg(m: &MemArg, remap: &IndexRemap) -> wasm_encoder::MemArg { - let new_mem = remap.memories.get(&m.memory).copied().unwrap_or(m.memory); - wasm_encoder::MemArg { - offset: m.offset, - align: m.align, - memory_index: new_mem, - } -} - -fn convert_table_type(ty: &wasmparser::TableType) -> TableType { - let ref_type = if ty.element_type.is_func_ref() { - RefType::FUNCREF - } else { - RefType::EXTERNREF - }; - TableType { - element_type: ref_type, - minimum: ty.initial, - maximum: ty.maximum, - table64: ty.table64, - shared: ty.shared, - } -} - -fn convert_memory_type(ty: &wasmparser::MemoryType) -> MemoryType { - MemoryType { - minimum: ty.initial, - maximum: ty.maximum, - memory64: ty.memory64, - shared: ty.shared, - page_size_log2: ty.page_size_log2, - } -} - -fn convert_global_type(ty: &wasmparser::GlobalType) -> GlobalType { - GlobalType { - val_type: convert_val_type(&ty.content_type), - mutable: ty.mutable, - shared: ty.shared, - } -} - -fn convert_const_expr(expr: &ConstExpr) -> EncConstExpr { - match expr { - ConstExpr::I32Const(v) => EncConstExpr::i32_const(*v), - ConstExpr::I64Const(v) => EncConstExpr::i64_const(*v), - ConstExpr::F32Const(v) => EncConstExpr::f32_const(*v), - ConstExpr::F64Const(v) => EncConstExpr::f64_const(*v), - ConstExpr::GlobalGet(idx) => EncConstExpr::global_get(*idx), - ConstExpr::RefNull(kind) => { - let heap_type = match kind { - RefTypeKind::Func => HeapType::Abstract { - shared: false, - ty: wasm_encoder::AbstractHeapType::Func, - }, - RefTypeKind::Extern => HeapType::Abstract { - shared: false, - ty: wasm_encoder::AbstractHeapType::Extern, - }, - }; - EncConstExpr::ref_null(heap_type) - } - ConstExpr::RefFunc(idx) => EncConstExpr::ref_func(*idx), - } -} diff --git a/src/compose/mod.rs b/src/compose/mod.rs index 36011d0..ed847a8 100644 --- a/src/compose/mod.rs +++ b/src/compose/mod.rs @@ -1,378 +1,31 @@ -//! Static Composition for WASM Packages +//! Static composition of WASM packages into a single self-contained binary. //! -//! This module provides static composition of WASM packages - merging multiple -//! WASM modules into a single binary where imports from one module are resolved -//! to exports from another. +//! Produces one merged `.wasm` where each package's cross-package imports are +//! resolved to direct internal calls — no runtime cross-module dispatch, and +//! (unlike a runtime [`crate::runtime::PicCompositionBuilder`]) no host harness: +//! the output has zero imports and runs on any stock runtime. //! -//! Unlike runtime composition (see `crate::runtime::CompositionBuilder`), static -//! composition produces a single merged WASM file that can be loaded and executed -//! without the overhead of cross-module calls. +//! See [`compose`] for the pipeline (binaryen `wasm-merge` + a `walrus` +//! internalize pass). [`ParsedModule`] is a lightweight reusable WASM parser +//! (also used by the `inspect` CLI command). //! //! # Example //! //! ```ignore -//! use packr::compose::StaticComposer; +//! use packr::compose::{compose, ComposeSpec, PackageSpec}; //! -//! // Load package bytes -//! let doubler_wasm = std::fs::read("doubler.wasm")?; -//! let adder_wasm = std::fs::read("adder.wasm")?; -//! -//! // Build static composition -//! let composed = StaticComposer::new() -//! .add_module("doubler", doubler_wasm)? -//! .add_module("adder", adder_wasm)? -//! .wire("adder", "math", "double", "doubler", "transform") -//! .export("process", "adder", "process") -//! .export("memory", "adder", "memory") -//! .compose()?; -//! -//! // `composed` is a single WASM binary with all functions merged +//! let composed = compose(&ComposeSpec::new(vec![ +//! PackageSpec::new("pack:alloc", std::fs::read("pack_alloc.wasm")?), +//! PackageSpec::new("math", std::fs::read("doubler.wasm")?), // adder imports "math" +//! PackageSpec::new("adder", std::fs::read("adder.wasm")?), +//! ]))?; //! std::fs::write("composed.wasm", composed)?; //! ``` -//! -//! # How It Works -//! -//! 1. **Parse** each module to extract types, imports, exports, functions, etc. -//! 2. **Topologically sort** modules so providers are processed before consumers -//! 3. **Deduplicate types** across modules -//! 4. **Merge functions**: resolved imports become internal `call` instructions -//! 5. **Remap indices** in all function bodies -//! 6. **Emit** combined module using wasm-encoder mod error; -mod merger; mod parser; +mod static_compose; pub use error::ComposeError; pub use parser::ParsedModule; - -use merger::{ExportSpec, Merger, Wiring}; -use std::collections::HashMap; - -/// Builder for creating statically composed WASM modules. -/// -/// `StaticComposer` merges multiple WASM modules into a single binary, -/// resolving inter-module imports to direct function calls. -pub struct StaticComposer { - modules: Vec, - wirings: Vec, - exports: Vec, -} - -impl StaticComposer { - /// Create a new static composer. - pub fn new() -> Self { - Self { - modules: Vec::new(), - wirings: Vec::new(), - exports: Vec::new(), - } - } - - /// Add a WASM module to the composition. - /// - /// # Arguments - /// - /// * `name` - A unique name for this module, used to reference it in wiring - /// * `wasm` - The raw WASM bytes of the module - /// - /// # Errors - /// - /// Returns `ComposeError::ParseError` if the WASM module is invalid. - pub fn add_module(mut self, name: &str, wasm: Vec) -> Result { - let parsed = ParsedModule::parse(name, &wasm)?; - self.modules.push(parsed); - Ok(self) - } - - /// Wire an import from one module to an export from another. - /// - /// When the `consumer` module imports `import_module::import_fn`, it will - /// be resolved to call the `provider_export` from the `provider` module - /// instead of being kept as an external import. - /// - /// # Arguments - /// - /// * `consumer` - Name of the module that has the import - /// * `import_module` - The module name in the import declaration - /// * `import_fn` - The function name in the import declaration - /// * `provider` - Name of the module that provides the implementation - /// * `provider_export` - The export name in the provider module - /// - /// # Example - /// - /// If `adder.wasm` has: - /// ```wat - /// (import "math" "double" (func ...)) - /// ``` - /// - /// And `doubler.wasm` exports a function called "transform", you'd wire: - /// ```ignore - /// composer.wire("adder", "math", "double", "doubler", "transform") - /// ``` - pub fn wire( - mut self, - consumer: &str, - import_module: &str, - import_fn: &str, - provider: &str, - provider_export: &str, - ) -> Self { - self.wirings.push(Wiring { - consumer: consumer.to_string(), - import_module: import_module.to_string(), - import_fn: import_fn.to_string(), - provider: provider.to_string(), - provider_export: provider_export.to_string(), - }); - self - } - - /// Automatically wire imports to exports based on name matching. - /// - /// For each unresolved import, if there's an export with the same name - /// in another module, wire them together. - /// - /// # Errors - /// - /// Returns `ComposeError::TypeMismatch` if matching imports/exports have - /// incompatible function signatures. - pub fn auto_wire(mut self) -> Result { - // Build a map of all exports across all modules - let mut export_map: HashMap> = HashMap::new(); - for module in &self.modules { - for export in &module.exports { - export_map - .entry(export.name.clone()) - .or_default() - .push((&module.name, export)); - } - } - - // For each module's imports, try to find a matching export - for module in &self.modules { - for import in &module.imports { - // Skip if already wired - let already_wired = self.wirings.iter().any(|w| { - w.consumer == module.name - && w.import_module == import.module - && w.import_fn == import.name - }); - if already_wired { - continue; - } - - // Look for matching export by function name - if let Some(exports) = export_map.get(&import.name) { - // Find an export from a different module - for (provider_name, export) in exports { - if *provider_name != module.name { - // Check that it's a function export matching a function import - if matches!(import.kind, parser::ImportKind::Function(_)) - && export.kind == parser::ExportKind::Function - { - self.wirings.push(Wiring { - consumer: module.name.clone(), - import_module: import.module.clone(), - import_fn: import.name.clone(), - provider: provider_name.to_string(), - provider_export: export.name.clone(), - }); - break; - } - } - } - } - } - } - - Ok(self) - } - - /// Add an export to the composed module. - /// - /// This specifies which functions/memories/etc. from the source modules - /// should be exported from the composed module. - /// - /// # Arguments - /// - /// * `name` - The name of the export in the composed module - /// * `source_module` - The module that has the export - /// * `source_fn` - The name of the export in the source module - pub fn export(mut self, name: &str, source_module: &str, source_fn: &str) -> Self { - self.exports.push(ExportSpec { - name: name.to_string(), - source_module: source_module.to_string(), - source_export: source_fn.to_string(), - }); - self - } - - /// Compose the modules into a single WASM binary. - /// - /// # Errors - /// - /// Returns an error if: - /// - No modules were added - /// - A referenced module is not found - /// - A wired import/export is not found - /// - There's a circular dependency - /// - The merged module fails to encode - pub fn compose(self) -> Result, ComposeError> { - if self.modules.is_empty() { - return Err(ComposeError::NoModules); - } - - let merger = Merger::new(self.modules, self.wirings, self.exports); - merger.merge() - } -} - -impl Default for StaticComposer { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_static_composer_api() { - // Just test that the API compiles - we'll add real tests later - let _composer = StaticComposer::new(); - } - - #[test] - fn test_parse_simple_module() { - // A minimal valid WASM module - let wasm = wat::parse_str( - r#" - (module - (func $add (param i32 i32) (result i32) - local.get 0 - local.get 1 - i32.add - ) - (export "add" (func $add)) - ) - "#, - ) - .unwrap(); - - let parsed = ParsedModule::parse("test", &wasm).unwrap(); - assert_eq!(parsed.name, "test"); - assert_eq!(parsed.functions.len(), 1); - assert_eq!(parsed.exports.len(), 1); - assert_eq!(parsed.exports[0].name, "add"); - } - - #[test] - fn test_compose_simple_modules() { - // Module A: exports a doubler function - let module_a = wat::parse_str( - r#" - (module - (memory (export "memory") 1) - (func $double (param i32) (result i32) - local.get 0 - i32.const 2 - i32.mul - ) - (export "double" (func $double)) - ) - "#, - ) - .unwrap(); - - // Module B: imports double, exports add_doubled - let module_b = wat::parse_str( - r#" - (module - (import "math" "double" (func $double (param i32) (result i32))) - (func $add_doubled (param i32 i32) (result i32) - local.get 0 - call $double - local.get 1 - call $double - i32.add - ) - (export "add_doubled" (func $add_doubled)) - ) - "#, - ) - .unwrap(); - - let composed = StaticComposer::new() - .add_module("doubler", module_a) - .unwrap() - .add_module("adder", module_b) - .unwrap() - .wire("adder", "math", "double", "doubler", "double") - .export("add_doubled", "adder", "add_doubled") - .export("memory", "doubler", "memory") - .compose() - .unwrap(); - - // Verify the composed module is valid - assert!(!composed.is_empty()); - - // Parse the composed module to verify structure - let parsed = ParsedModule::parse("composed", &composed).unwrap(); - - // Should have the exported function - assert!(parsed.exports.iter().any(|e| e.name == "add_doubled")); - assert!(parsed.exports.iter().any(|e| e.name == "memory")); - - // Should have no imports (the math::double import was resolved) - assert_eq!(parsed.num_imported_functions, 0); - } - - #[test] - fn test_auto_wire() { - // Module A: exports "transform" - let module_a = wat::parse_str( - r#" - (module - (func $transform (param i32) (result i32) - local.get 0 - i32.const 10 - i32.add - ) - (export "transform" (func $transform)) - ) - "#, - ) - .unwrap(); - - // Module B: imports "transform" from any module - let module_b = wat::parse_str( - r#" - (module - (import "util" "transform" (func $transform (param i32) (result i32))) - (func $process (param i32) (result i32) - local.get 0 - call $transform - ) - (export "process" (func $process)) - ) - "#, - ) - .unwrap(); - - let composed = StaticComposer::new() - .add_module("a", module_a) - .unwrap() - .add_module("b", module_b) - .unwrap() - .auto_wire() - .unwrap() - .export("process", "b", "process") - .compose() - .unwrap(); - - let parsed = ParsedModule::parse("composed", &composed).unwrap(); - assert_eq!(parsed.num_imported_functions, 0); - } -} +pub use static_compose::{compose, ComposeSpec, Layout, PackageSpec}; diff --git a/src/compose/static_compose.rs b/src/compose/static_compose.rs new file mode 100644 index 0000000..97671bc --- /dev/null +++ b/src/compose/static_compose.rs @@ -0,0 +1,227 @@ +//! Static composition of PIC packages into one self-contained `.wasm`. +//! +//! Pipeline: +//! 1. **`wasm-merge`** (binaryen) fuses the package modules and internalizes the +//! cross-package imports — `(import "math" "double")` becomes a direct call to +//! the provider's `double`, `(import "pack:alloc" ...)` becomes a call to the +//! merged-in allocator. Each package is named by the import-module-name its +//! consumers use (`"math"`, `"pack:alloc"`, ...). +//! 2. A **`walrus`** pass does the one thing `wasm-merge` can't: unify the several +//! `env.memory` imports into a single internal memory, turn the allocator's +//! `__memory_base` / `__heap_base` / `__heap_end` imports into baked-in +//! constants, and export the memory. +//! +//! The result has ZERO imports and validates on any stock runtime. +//! +//! Requires `wasm-merge` (binaryen) on `PATH` at compose time. + +use std::path::PathBuf; +use std::process::Command; + +use walrus::ir::VisitorMut; +use walrus::{ConstExpr, DataKind, GlobalKind, ImportKind, MemoryId, Module}; + +/// One package in a static composition. +pub struct PackageSpec { + /// The `wasm-merge` module name — the import module-name that *consumers* use + /// for this package's exports. E.g. `"math"` if a consumer does + /// `(import "math" "double")`, or `"pack:alloc"` for the allocator. + /// + /// Providers must be listed before the packages that consume them. + pub name: String, + /// Compiled package bytes, built with the fixed-base composition recipe + /// (`--global-base= --stack-first -zstack-size=... --import-memory + /// --no-entry`), with disjoint bases per package. + pub wasm: Vec, +} + +impl PackageSpec { + pub fn new(name: impl Into, wasm: Vec) -> Self { + Self { + name: name.into(), + wasm, + } + } +} + +/// Byte-offset layout of the single shared linear memory the composed module owns. +/// Package data segments sit at their link-time fixed bases; the allocator's +/// mstate + heap live above them. +#[derive(Clone, Copy, Debug)] +pub struct Layout { + /// Internal memory size, in 64KiB pages. + pub memory_pages: u32, + /// Allocator `__memory_base` — where its `.data` (mstate) is placed. Must be + /// above every package's data region. + pub alloc_base: u32, + /// dlmalloc heap start (above `alloc_base`). + pub heap_base: u32, + /// dlmalloc heap end. + pub heap_end: u32, +} + +impl Default for Layout { + fn default() -> Self { + // Matches the two-package (doubler @0x50000, adder @0xD0000) recipe with + // room to spare; callers with more/larger packages should widen this. + Self { + memory_pages: 128, // 8 MiB + alloc_base: 0xE_0000, + heap_base: 0xF_0000, + heap_end: 0x80_0000, + } + } +} + +/// A static-composition request: the packages (in provider-before-consumer order) +/// and the memory layout. +pub struct ComposeSpec { + pub packages: Vec, + pub layout: Layout, +} + +impl ComposeSpec { + pub fn new(packages: Vec) -> Self { + Self { + packages, + layout: Layout::default(), + } + } +} + +/// Compose `spec` into a single self-contained `.wasm` (zero imports). +pub fn compose(spec: &ComposeSpec) -> anyhow::Result> { + anyhow::ensure!(!spec.packages.is_empty(), "no packages to compose"); + let merged = wasm_merge(&spec.packages)?; + internalize(&merged, &spec.layout) +} + +/// Shell out to binaryen's `wasm-merge` to fuse modules + wire cross-imports. +fn wasm_merge(packages: &[PackageSpec]) -> anyhow::Result> { + let dir = std::env::temp_dir().join(format!("pack-compose-{}", std::process::id())); + std::fs::create_dir_all(&dir)?; + + let mut cmd = Command::new("wasm-merge"); + let mut inputs: Vec = Vec::new(); + for (i, p) in packages.iter().enumerate() { + let path = dir.join(format!("in{i}.wasm")); + std::fs::write(&path, &p.wasm)?; + cmd.arg(&path).arg(&p.name); + inputs.push(path); + } + let out = dir.join("merged.wasm"); + cmd.arg("-o") + .arg(&out) + .arg("--enable-multimemory") + .arg("--rename-export-conflicts"); + + let status = cmd.status().map_err(|e| { + anyhow::anyhow!("failed to run `wasm-merge` (is binaryen installed and on PATH?): {e}") + })?; + anyhow::ensure!(status.success(), "`wasm-merge` exited with failure"); + + let bytes = std::fs::read(&out)?; + let _ = std::fs::remove_dir_all(&dir); + Ok(bytes) +} + +/// Remaps every memory reference in a function body to a single canonical memory. +struct MemRemap { + to: MemoryId, +} +impl VisitorMut for MemRemap { + fn visit_memory_id_mut(&mut self, id: &mut MemoryId) { + *id = self.to; + } +} + +/// The `walrus` pass: unify memories, internalize the allocator's base/heap +/// globals into constants, export the memory. Yields a zero-import module. +fn internalize(merged: &[u8], layout: &Layout) -> anyhow::Result> { + let mut m = Module::from_buffer(merged)?; + + // 1. Turn the allocator's imported globals into baked-in constants. + let mut global_fixups = Vec::new(); + for imp in m.imports.iter() { + if let ImportKind::Global(g) = imp.kind { + let val: i32 = match imp.name.as_str() { + "__memory_base" => layout.alloc_base as i32, + "__table_base" => 0, + "__heap_base" => layout.heap_base as i32, + "__heap_end" => layout.heap_end as i32, + other => anyhow::bail!( + "unexpected global import `{}::{other}` — packages must use the \ + fixed-base composition recipe", + imp.module + ), + }; + global_fixups.push((imp.id(), g, val)); + } + } + for (imp_id, g, val) in &global_fixups { + m.globals.get_mut(*g).kind = + GlobalKind::Local(ConstExpr::Value(walrus::ir::Value::I32(*val))); + m.imports.delete(*imp_id); + } + + // 2. Unify the several `env.memory` imports into one internal memory. + let mut mem_imports = Vec::new(); + for imp in m.imports.iter() { + if let ImportKind::Memory(mem) = imp.kind { + mem_imports.push((imp.id(), mem)); + } + } + anyhow::ensure!( + !mem_imports.is_empty(), + "merged module has no memory import" + ); + let canonical = mem_imports[0].1; + + let mut v = MemRemap { to: canonical }; + let fids: Vec<_> = m.funcs.iter_local().map(|(id, _)| id).collect(); + for fid in fids { + let f = m.funcs.get_mut(fid).kind.unwrap_local_mut(); + let entry = f.entry_block(); + walrus::ir::dfs_pre_order_mut(&mut v, f, entry); + } + + let data_ids: Vec<_> = m.data.iter().map(|d| d.id()).collect(); + for did in data_ids { + let d = m.data.get_mut(did); + if let DataKind::Active { memory, offset } = &mut d.kind { + *memory = canonical; + // A local `global.get` is not a valid data-segment offset; the + // allocator segment used the (now-internalized) __memory_base global. + if let ConstExpr::Global(_) = offset { + *offset = ConstExpr::Value(walrus::ir::Value::I32(layout.alloc_base as i32)); + } + } + } + + { + let mem = m.memories.get_mut(canonical); + mem.import = None; + mem.initial = layout.memory_pages as u64; + mem.maximum = Some(layout.memory_pages as u64); + } + for (imp_id, mem_id) in mem_imports { + m.imports.delete(imp_id); + if mem_id != canonical { + m.memories.delete(mem_id); + } + } + + // 3. Export the memory so a host can marshal args/results through it. + if !m.exports.iter().any(|e| e.name == "memory") { + m.exports.add("memory", canonical); + } + + // 4. It must now stand alone. + let remaining = m.imports.iter().count(); + anyhow::ensure!( + remaining == 0, + "expected zero imports after internalize, got {remaining}" + ); + + Ok(m.emit_wasm()) +} diff --git a/src/lib.rs b/src/lib.rs index 80f54c7..6019243 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -87,4 +87,4 @@ pub use transform::{InterfaceTransform, RpcTransform, TransformRegistry}; pub use types::{Arena, Case, Field, Function, Param, Type, TypePath}; pub use codegen::generate_rust; -pub use compose::{ComposeError, ParsedModule, StaticComposer}; +pub use compose::{compose, ComposeError, ComposeSpec, Layout, PackageSpec, ParsedModule}; diff --git a/src/main.rs b/src/main.rs index 92ea356..53ee688 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,16 @@ enum Commands { #[arg(long)] json: bool, }, + + /// Statically compose packages into one self-contained `.wasm` (zero imports) + Compose { + /// Path to the compose manifest (TOML) + manifest: PathBuf, + + /// Output path (overrides `output` in the manifest) + #[arg(long, short = 'o')] + output: Option, + }, } fn main() -> anyhow::Result<()> { @@ -44,7 +54,115 @@ fn main() -> anyhow::Result<()> { hashes, json, } => inspect_command(&wasm_file, hashes, json), + Commands::Compose { manifest, output } => compose_command(&manifest, output), + } +} + +/// A `pack compose` manifest. +/// +/// ```toml +/// output = "composed.wasm" +/// +/// [[package]] # providers before consumers +/// name = "pack:alloc" # the wasm-merge module name = the import module-name +/// wasm = "pack_alloc.wasm" +/// +/// [[package]] +/// name = "math" # adder does (import "math" "double") +/// wasm = "doubler.wasm" +/// +/// [[package]] +/// name = "adder" +/// wasm = "adder.wasm" +/// +/// [layout] # optional; hex ok +/// memory_pages = 128 +/// alloc_base = 0xE0000 +/// heap_base = 0xF0000 +/// heap_end = 0x800000 +/// ``` +#[derive(serde::Deserialize)] +struct Manifest { + output: Option, + #[serde(default)] + package: Vec, + layout: Option, +} + +#[derive(serde::Deserialize)] +struct ManifestPackage { + name: String, + wasm: String, +} + +#[derive(serde::Deserialize)] +struct ManifestLayout { + memory_pages: Option, + alloc_base: Option, + heap_base: Option, + heap_end: Option, +} + +fn compose_command( + manifest_path: &PathBuf, + output_override: Option, +) -> anyhow::Result<()> { + use packr::{compose, ComposeSpec, Layout, PackageSpec}; + + let text = std::fs::read_to_string(manifest_path) + .map_err(|e| anyhow::anyhow!("failed to read manifest {}: {e}", manifest_path.display()))?; + let manifest: Manifest = toml::from_str(&text).map_err(|e| { + anyhow::anyhow!("failed to parse manifest {}: {e}", manifest_path.display()) + })?; + anyhow::ensure!( + !manifest.package.is_empty(), + "manifest lists no [[package]] entries" + ); + + // Paths in the manifest are relative to the manifest's directory. + let base_dir = manifest_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")); + + let mut packages = Vec::new(); + for p in &manifest.package { + let wpath = base_dir.join(&p.wasm); + let bytes = std::fs::read(&wpath) + .map_err(|e| anyhow::anyhow!("failed to read package {}: {e}", wpath.display()))?; + packages.push(PackageSpec::new(p.name.clone(), bytes)); } + + let mut layout = Layout::default(); + if let Some(l) = &manifest.layout { + if let Some(v) = l.memory_pages { + layout.memory_pages = v; + } + if let Some(v) = l.alloc_base { + layout.alloc_base = v; + } + if let Some(v) = l.heap_base { + layout.heap_base = v; + } + if let Some(v) = l.heap_end { + layout.heap_end = v; + } + } + + let composed = compose(&ComposeSpec { packages, layout })?; + + let out = output_override + .or_else(|| manifest.output.as_ref().map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from("composed.wasm")); + std::fs::write(&out, &composed) + .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", out.display()))?; + + println!( + "composed {} package(s) -> {} ({} bytes, zero imports)", + manifest.package.len(), + out.display(), + composed.len() + ); + Ok(()) } fn inspect_command(wasm_file: &PathBuf, show_hashes: bool, json: bool) -> anyhow::Result<()> { diff --git a/src/runtime/composition.rs b/src/runtime/composition.rs deleted file mode 100644 index 4113e4b..0000000 --- a/src/runtime/composition.rs +++ /dev/null @@ -1,960 +0,0 @@ -//! Package Composition -//! -//! Enables composing multiple packages together, wiring one package's -//! exports to another package's imports. -//! -//! # Example -//! -//! ```ignore -//! use packr::runtime::CompositionBuilder; -//! use packr::abi::Value; -//! -//! // Load package bytes -//! let doubler_wasm = std::fs::read("doubler.wasm")?; -//! let adder_wasm = std::fs::read("adder.wasm")?; -//! -//! // Build composition -//! let mut composition = CompositionBuilder::new() -//! .add_package("doubler", doubler_wasm) -//! .add_package("adder", adder_wasm) -//! .wire("adder", "math", "double", "doubler", "transform") -//! .build()?; -//! -//! // Call the adder, which internally calls doubler -//! let result = composition.call("adder", "process", &Value::S64(5))?; -//! assert_eq!(result, Value::S64(11)); // (5 * 2) + 1 -//! ``` - -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use crate::abi::{decode, encode, Value}; -use crate::runtime::{RuntimeError, INPUT_BUFFER_OFFSET, RESULT_LEN_OFFSET, RESULT_PTR_OFFSET}; -use wasmtime::{Caller, Engine, Linker, Module, Store}; - -/// A host function that can be wired into compositions. -/// -/// Host functions follow the guest-allocates ABI, taking encoded input bytes -/// and returning encoded output bytes. -pub type HostFn = Arc Result, String> + Send + Sync>; - -/// Builder for creating composed packages with cross-package imports. -pub struct CompositionBuilder { - engine: Engine, - packages: Vec, - host_functions: Vec, -} - -struct PackageDefinition { - name: String, - wasm_bytes: Vec, - imports: Vec, -} - -struct HostFunctionDef { - module: String, - function: String, - handler: HostFn, -} - -#[derive(Clone)] -struct ImportWiring { - import_module: String, - import_function: String, - source_package: String, - source_function: String, -} - -impl CompositionBuilder { - /// Create a new composition builder. - pub fn new() -> Self { - // Enable multi-memory for composed modules that may come from pack-compose - let mut config = wasmtime::Config::new(); - config.wasm_multi_memory(true); - let engine = Engine::new(&config).expect("failed to create engine"); - Self { - engine, - packages: Vec::new(), - host_functions: Vec::new(), - } - } - - /// Add a package to the composition. - pub fn add_package(mut self, name: impl Into, wasm_bytes: Vec) -> Self { - self.packages.push(PackageDefinition { - name: name.into(), - wasm_bytes, - imports: Vec::new(), - }); - self - } - - /// Add a host function that can satisfy imports. - /// - /// The handler receives encoded input bytes and returns encoded output bytes. - /// Use `pack::abi::decode` and `pack::abi::encode` to work with Values. - /// - /// # Example - /// - /// ```ignore - /// use packr::abi::{decode, encode, Value}; - /// - /// builder.add_host_function("my:interface", "my-func", |input_bytes| { - /// let input: Value = decode(input_bytes)?; - /// // Process input... - /// let output = Value::S64(42); - /// encode(&output).map_err(|e| e.to_string()) - /// }) - /// ``` - pub fn add_host_function( - mut self, - module: impl Into, - function: impl Into, - handler: F, - ) -> Self - where - F: Fn(&[u8]) -> Result, String> + Send + Sync + 'static, - { - self.host_functions.push(HostFunctionDef { - module: module.into(), - function: function.into(), - handler: Arc::new(handler), - }); - self - } - - /// Add a typed host function that works with Values directly. - /// - /// This is a convenience wrapper around `add_host_function` that handles - /// encoding/decoding automatically. - /// - /// # Example - /// - /// ```ignore - /// builder.add_host_function_typed("my:interface", "double", |input: Value| { - /// match input { - /// Value::S64(n) => Ok(Value::S64(n * 2)), - /// _ => Err("expected s64".to_string()), - /// } - /// }) - /// ``` - pub fn add_host_function_typed( - self, - module: impl Into, - function: impl Into, - handler: F, - ) -> Self - where - F: Fn(Value) -> Result + Send + Sync + 'static, - { - let module = module.into(); - let function = function.into(); - self.add_host_function(module, function, move |input_bytes| { - let input = decode(input_bytes).map_err(|e| e.to_string())?; - let output = handler(input)?; - encode(&output).map_err(|e| e.to_string()) - }) - } - - /// Wire an import from one package to an export from another. - /// - /// When `target_package` calls `import_module::import_function`, - /// it will actually call `source_package`'s `source_function`. - pub fn wire( - mut self, - target_package: impl Into, - import_module: impl Into, - import_function: impl Into, - source_package: impl Into, - source_function: impl Into, - ) -> Self { - let target = target_package.into(); - let wiring = ImportWiring { - import_module: import_module.into(), - import_function: import_function.into(), - source_package: source_package.into(), - source_function: source_function.into(), - }; - - for pkg in &mut self.packages { - if pkg.name == target { - pkg.imports.push(wiring); - return self; - } - } - - self - } - - /// Build the composition. - pub fn build(self) -> Result { - // Compile all modules first - let mut compiled: HashMap = HashMap::new(); - for pkg in &self.packages { - if !pkg.wasm_bytes.is_empty() { - let module = Module::new(&self.engine, &pkg.wasm_bytes) - .map_err(|e| RuntimeError::WasmError(e.to_string()))?; - compiled.insert(pkg.name.clone(), module); - } - } - - // Shared registry for cross-package calls - let registry: Arc> = Arc::new(Mutex::new(PackageRegistry { - packages: HashMap::new(), - })); - - // Topological sort: instantiate packages without imports first - // Note: if there are host functions defined, ALL packages need them, - // so treat them all as consumers in that case - let has_host_functions = !self.host_functions.is_empty(); - - let providers: Vec<_> = self - .packages - .iter() - .filter(|p| p.imports.is_empty() && !has_host_functions) - .collect(); - - let consumers: Vec<_> = self - .packages - .iter() - .filter(|p| !p.imports.is_empty() || has_host_functions) - .collect(); - - // Instantiate providers first - for pkg in providers { - let module = compiled.get(&pkg.name).ok_or_else(|| { - RuntimeError::ModuleNotFound(format!("Package '{}' not found", pkg.name)) - })?; - - let mut linker = Linker::<()>::new(&self.engine); - super::register_default_alloc(&mut linker)?; - let mut store = Store::new(&self.engine, ()); - - let instance = linker - .instantiate(&mut store, module) - .map_err(|e| RuntimeError::WasmError(e.to_string()))?; - - registry.lock().unwrap().packages.insert( - pkg.name.clone(), - PackageEntry { - store: Arc::new(Mutex::new(UntypedStore::Unit(store))), - instance, - }, - ); - } - - // Now instantiate consumers with wired imports - for pkg in consumers { - let module = compiled.get(&pkg.name).ok_or_else(|| { - RuntimeError::ModuleNotFound(format!("Package '{}' not found", pkg.name)) - })?; - - let mut linker = Linker::::new(&self.engine); - super::register_default_alloc(&mut linker)?; - - // Wire host functions first - for host_fn in &self.host_functions { - let handler = Arc::clone(&host_fn.handler); - - linker - .func_wrap( - &host_fn.module, - &host_fn.function, - move |mut caller: Caller<'_, ComposedState>, - in_ptr: i32, - in_len: i32, - out_ptr_ptr: i32, - out_len_ptr: i32| - -> i32 { - host_function_call( - &mut caller, - &handler, - in_ptr, - in_len, - out_ptr_ptr, - out_len_ptr, - ) - }, - ) - .map_err(|e| RuntimeError::WasmError(e.to_string()))?; - } - - // Wire each cross-package import - for wiring in &pkg.imports { - let source_pkg = wiring.source_package.clone(); - let source_fn = wiring.source_function.clone(); - let reg = Arc::clone(®istry); - - linker - .func_wrap( - &wiring.import_module, - &wiring.import_function, - move |mut caller: Caller<'_, ComposedState>, - in_ptr: i32, - in_len: i32, - out_ptr: i32, - out_cap: i32| - -> i32 { - cross_package_call( - &mut caller, - ®, - &source_pkg, - &source_fn, - in_ptr, - in_len, - out_ptr, - out_cap, - ) - }, - ) - .map_err(|e| RuntimeError::WasmError(e.to_string()))?; - } - - let state = ComposedState { - _registry: Arc::clone(®istry), - }; - let mut store = Store::new(&self.engine, state); - - let instance = linker - .instantiate(&mut store, module) - .map_err(|e| RuntimeError::WasmError(e.to_string()))?; - - // Add to registry (consumers can also be called) - registry.lock().unwrap().packages.insert( - pkg.name.clone(), - PackageEntry { - store: Arc::new(Mutex::new(UntypedStore::Composed(store))), - instance, - }, - ); - } - - Ok(BuiltComposition { - _engine: self.engine, - registry, - }) - } -} - -/// Handle a cross-package call using the guest-allocates ABI. -/// -/// # ABI -/// -/// The source function has signature: -/// ```text -/// fn(in_ptr: i32, in_len: i32, out_ptr_ptr: i32, out_len_ptr: i32) -> i32 -/// ``` -/// -/// Returns 0 on success (output ptr/len written to slots), -1 on error. -#[allow(clippy::too_many_arguments)] -fn cross_package_call( - caller: &mut Caller<'_, ComposedState>, - registry: &Arc>, - source_pkg: &str, - source_fn: &str, - in_ptr: i32, - in_len: i32, - out_ptr_ptr: i32, - out_len_ptr: i32, -) -> i32 { - // Read input from caller's memory - let memory = match caller.get_export("memory") { - Some(wasmtime::Extern::Memory(m)) => m, - _ => return -1, - }; - - let mut input_bytes = vec![0u8; in_len as usize]; - if memory - .read(&caller, in_ptr as usize, &mut input_bytes) - .is_err() - { - return -1; - } - - // Look up the source package and get an Arc to its store - // Important: release the registry lock before calling into WASM - let (store_arc, instance) = { - let reg = registry.lock().unwrap(); - let source = match reg.packages.get(source_pkg) { - Some(p) => p, - None => return -1, - }; - (Arc::clone(&source.store), source.instance) - }; - // Registry lock released here - - // Call the source package - try dynamic allocation, fall back to fixed buffer - let result = { - let mut store_guard = store_arc.lock().unwrap(); - - // Get memory - let src_memory = match store_guard.get_memory(&instance) { - Some(m) => m, - None => return -1, - }; - - // Try to allocate input buffer dynamically, fall back to fixed buffer - let (in_ptr, dynamic_input) = match store_guard.get_alloc_func(&instance) { - Some(alloc_func) => { - match store_guard.call_alloc(&alloc_func, input_bytes.len() as i32) { - Ok(ptr) if ptr != 0 => (ptr, true), - _ => (INPUT_BUFFER_OFFSET as i32, false), - } - } - None => (INPUT_BUFFER_OFFSET as i32, false), - }; - - // Write input to buffer - if store_guard - .write_memory(&src_memory, in_ptr as usize, &input_bytes) - .is_err() - { - return -1; - } - - // Call the source function - let func = match store_guard.get_typed_func(&instance, source_fn) { - Some(f) => f, - None => return -1, - }; - - let status = match store_guard.call_func( - &func, - in_ptr, - input_bytes.len() as i32, - RESULT_PTR_OFFSET as i32, - RESULT_LEN_OFFSET as i32, - ) { - Ok(s) => s, - Err(_) => return -1, - }; - - // Free the input buffer if dynamically allocated - if dynamic_input { - if let Some(free_func) = store_guard.get_free_func(&instance) { - let _ = store_guard.call_free(&free_func, in_ptr, input_bytes.len() as i32); - } - } - - if status != 0 { - // Error occurred - read error message from slots and propagate - // For now, just return -1 - return -1; - } - - // Read output pointer and length from the slots - let mut ptr_bytes = [0u8; 4]; - let mut len_bytes = [0u8; 4]; - if store_guard - .read_memory(&src_memory, RESULT_PTR_OFFSET, &mut ptr_bytes) - .is_err() - { - return -1; - } - if store_guard - .read_memory(&src_memory, RESULT_LEN_OFFSET, &mut len_bytes) - .is_err() - { - return -1; - } - - let out_ptr = i32::from_le_bytes(ptr_bytes) as usize; - let out_len = i32::from_le_bytes(len_bytes) as usize; - - // Read output from the guest-allocated buffer - let mut output_bytes = vec![0u8; out_len]; - if store_guard - .read_memory(&src_memory, out_ptr, &mut output_bytes) - .is_err() - { - return -1; - } - - // Free the guest's output buffer - if let Some(free_func) = store_guard.get_free_func(&instance) { - let _ = store_guard.call_free(&free_func, out_ptr as i32, out_len as i32); - } - - output_bytes - }; - - // The caller also uses guest-allocates ABI, so we need to allocate in caller's memory - // For cross-package calls, the caller is also a guest, so we write to caller's result slots - // Actually, the caller provided out_ptr_ptr and out_len_ptr, so we need to allocate - // in the caller's memory and write the ptr/len there. - - // For simplicity in cross-package calls, we'll allocate in caller's heap - // This requires the caller to have __pack_alloc exported, which may not exist. - // - // Alternative: Use a fixed buffer region in caller for cross-package results. - // Let's use the same RESULT region as a data buffer for now (after the ptr/len slots). - const CROSS_CALL_BUFFER_OFFSET: usize = RESULT_LEN_OFFSET + 4; - - if memory - .write(&mut *caller, CROSS_CALL_BUFFER_OFFSET, &result) - .is_err() - { - return -1; - } - - // Write the buffer location to caller's result slots - let result_ptr = CROSS_CALL_BUFFER_OFFSET as i32; - let result_len = result.len() as i32; - - if memory - .write( - &mut *caller, - out_ptr_ptr as usize, - &result_ptr.to_le_bytes(), - ) - .is_err() - { - return -1; - } - if memory - .write( - &mut *caller, - out_len_ptr as usize, - &result_len.to_le_bytes(), - ) - .is_err() - { - return -1; - } - - 0 // Success -} - -/// Handle a host function call using the guest-allocates ABI. -/// -/// Similar to cross_package_call but invokes a host-provided function -/// instead of another WASM package. -fn host_function_call( - caller: &mut Caller<'_, ComposedState>, - handler: &HostFn, - in_ptr: i32, - in_len: i32, - out_ptr_ptr: i32, - out_len_ptr: i32, -) -> i32 { - // Read input from caller's memory - let memory = match caller.get_export("memory") { - Some(wasmtime::Extern::Memory(m)) => m, - _ => return -1, - }; - - let mut input_bytes = vec![0u8; in_len as usize]; - if memory - .read(&caller, in_ptr as usize, &mut input_bytes) - .is_err() - { - return -1; - } - - // Call the host function - let result = match handler(&input_bytes) { - Ok(bytes) => bytes, - Err(_) => return -1, - }; - - // Write result to caller's memory using a fixed buffer region - // (same approach as cross_package_call) - const HOST_CALL_BUFFER_OFFSET: usize = RESULT_LEN_OFFSET + 4; - - if memory - .write(&mut *caller, HOST_CALL_BUFFER_OFFSET, &result) - .is_err() - { - return -1; - } - - // Write the buffer location to caller's result slots - let result_ptr = HOST_CALL_BUFFER_OFFSET as i32; - let result_len = result.len() as i32; - - if memory - .write( - &mut *caller, - out_ptr_ptr as usize, - &result_ptr.to_le_bytes(), - ) - .is_err() - { - return -1; - } - if memory - .write( - &mut *caller, - out_len_ptr as usize, - &result_len.to_le_bytes(), - ) - .is_err() - { - return -1; - } - - 0 // Success -} - -impl Default for CompositionBuilder { - fn default() -> Self { - Self::new() - } -} - -struct ComposedState { - _registry: Arc>, -} - -struct PackageRegistry { - packages: HashMap, -} - -struct PackageEntry { - store: Arc>, - instance: wasmtime::Instance, -} - -/// Wrapper to handle stores with different state types -enum UntypedStore { - Unit(Store<()>), - Composed(Store), -} - -impl UntypedStore { - fn get_memory(&mut self, instance: &wasmtime::Instance) -> Option { - match self { - UntypedStore::Unit(store) => instance.get_memory(&mut *store, "memory"), - UntypedStore::Composed(store) => instance.get_memory(&mut *store, "memory"), - } - } - - fn write_memory( - &mut self, - memory: &wasmtime::Memory, - offset: usize, - data: &[u8], - ) -> Result<(), ()> { - match self { - UntypedStore::Unit(store) => memory.write(&mut *store, offset, data).map_err(|_| ()), - UntypedStore::Composed(store) => { - memory.write(&mut *store, offset, data).map_err(|_| ()) - } - } - } - - fn read_memory( - &mut self, - memory: &wasmtime::Memory, - offset: usize, - data: &mut [u8], - ) -> Result<(), ()> { - match self { - UntypedStore::Unit(store) => memory.read(&*store, offset, data).map_err(|_| ()), - UntypedStore::Composed(store) => memory.read(&*store, offset, data).map_err(|_| ()), - } - } - - fn get_typed_func( - &mut self, - instance: &wasmtime::Instance, - name: &str, - ) -> Option> { - match self { - UntypedStore::Unit(store) => instance.get_typed_func(&mut *store, name).ok(), - UntypedStore::Composed(store) => instance.get_typed_func(&mut *store, name).ok(), - } - } - - fn get_free_func( - &mut self, - instance: &wasmtime::Instance, - ) -> Option> { - match self { - UntypedStore::Unit(store) => instance.get_typed_func(&mut *store, "__pack_free").ok(), - UntypedStore::Composed(store) => { - instance.get_typed_func(&mut *store, "__pack_free").ok() - } - } - } - - fn get_alloc_func( - &mut self, - instance: &wasmtime::Instance, - ) -> Option> { - match self { - UntypedStore::Unit(store) => instance.get_typed_func(&mut *store, "__pack_alloc").ok(), - UntypedStore::Composed(store) => { - instance.get_typed_func(&mut *store, "__pack_alloc").ok() - } - } - } - - fn call_alloc(&mut self, func: &wasmtime::TypedFunc, size: i32) -> Result { - match self { - UntypedStore::Unit(store) => func.call(&mut *store, size).map_err(|_| ()), - UntypedStore::Composed(store) => func.call(&mut *store, size).map_err(|_| ()), - } - } - - fn call_func( - &mut self, - func: &wasmtime::TypedFunc<(i32, i32, i32, i32), i32>, - a: i32, - b: i32, - c: i32, - d: i32, - ) -> Result { - match self { - UntypedStore::Unit(store) => func.call(&mut *store, (a, b, c, d)).map_err(|e| { - eprintln!("[PACK DEBUG] call_func error: {:?}", e); - }), - UntypedStore::Composed(store) => func.call(&mut *store, (a, b, c, d)).map_err(|e| { - eprintln!("[PACK DEBUG] call_func error: {:?}", e); - }), - } - } - - fn call_free( - &mut self, - func: &wasmtime::TypedFunc<(i32, i32), ()>, - ptr: i32, - len: i32, - ) -> Result<(), ()> { - match self { - UntypedStore::Unit(store) => func.call(&mut *store, (ptr, len)).map_err(|_| ()), - UntypedStore::Composed(store) => func.call(&mut *store, (ptr, len)).map_err(|_| ()), - } - } - - fn get_types_func( - &mut self, - instance: &wasmtime::Instance, - ) -> Option> { - match self { - UntypedStore::Unit(store) => instance.get_typed_func(&mut *store, "__pack_types").ok(), - UntypedStore::Composed(store) => { - instance.get_typed_func(&mut *store, "__pack_types").ok() - } - } - } - - fn call_types_func( - &mut self, - func: &wasmtime::TypedFunc<(i32, i32), i32>, - a: i32, - b: i32, - ) -> Result { - match self { - UntypedStore::Unit(store) => func.call(&mut *store, (a, b)).map_err(|_| ()), - UntypedStore::Composed(store) => func.call(&mut *store, (a, b)).map_err(|_| ()), - } - } -} - -/// A built composition ready for execution. -pub struct BuiltComposition { - _engine: Engine, - registry: Arc>, -} - -impl BuiltComposition { - /// Call a function on a package in the composition. - pub fn call( - &mut self, - package: &str, - function: &str, - input: &Value, - ) -> Result { - // Look up package and clone Arc references before releasing lock - // This is important to avoid deadlock when WASM calls back into registry - let (store_arc, instance) = { - let reg = self.registry.lock().unwrap(); - let pkg = reg.packages.get(package).ok_or_else(|| { - RuntimeError::ModuleNotFound(format!("Package '{}' not found", package)) - })?; - (Arc::clone(&pkg.store), pkg.instance) - }; - // Registry lock released here - - let mut store = store_arc.lock().unwrap(); - - // Encode input - let input_bytes = encode(input).map_err(|e| RuntimeError::AbiError(e.to_string()))?; - - // Get memory - let memory = store - .get_memory(&instance) - .ok_or_else(|| RuntimeError::MemoryError("No memory export".into()))?; - - // Try to allocate input buffer dynamically, fall back to fixed buffer - let (in_ptr, dynamic_input) = match store.get_alloc_func(&instance) { - Some(alloc_func) => match store.call_alloc(&alloc_func, input_bytes.len() as i32) { - Ok(ptr) if ptr != 0 => (ptr, true), - _ => (INPUT_BUFFER_OFFSET as i32, false), - }, - None => (INPUT_BUFFER_OFFSET as i32, false), - }; - - // Write input to buffer - store - .write_memory(&memory, in_ptr as usize, &input_bytes) - .map_err(|_| RuntimeError::MemoryError("Failed to write input".into()))?; - - // Get and call the function - let func = store - .get_typed_func(&instance, function) - .ok_or_else(|| RuntimeError::FunctionNotFound(function.to_string()))?; - - let status = store - .call_func( - &func, - in_ptr, - input_bytes.len() as i32, - RESULT_PTR_OFFSET as i32, - RESULT_LEN_OFFSET as i32, - ) - .map_err(|e| RuntimeError::WasmError(format!("Function call failed: {:?}", e)))?; - - // Free the input buffer if dynamically allocated - if dynamic_input { - if let Some(free_func) = store.get_free_func(&instance) { - let _ = store.call_free(&free_func, in_ptr, input_bytes.len() as i32); - } - } - - if status != 0 { - // Error - read error message from result slots - let mut ptr_bytes = [0u8; 4]; - let mut len_bytes = [0u8; 4]; - let _ = store.read_memory(&memory, RESULT_PTR_OFFSET, &mut ptr_bytes); - let _ = store.read_memory(&memory, RESULT_LEN_OFFSET, &mut len_bytes); - let err_ptr = i32::from_le_bytes(ptr_bytes) as usize; - let err_len = i32::from_le_bytes(len_bytes) as usize; - - let mut err_bytes = vec![0u8; err_len]; - if store.read_memory(&memory, err_ptr, &mut err_bytes).is_ok() { - if let Ok(err_msg) = String::from_utf8(err_bytes) { - // Free the error buffer - if let Some(free_func) = store.get_free_func(&instance) { - let _ = store.call_free(&free_func, err_ptr as i32, err_len as i32); - } - return Err(RuntimeError::WasmError(err_msg)); - } - } - return Err(RuntimeError::WasmError("Function returned error".into())); - } - - // Read output pointer and length from result slots - let mut ptr_bytes = [0u8; 4]; - let mut len_bytes = [0u8; 4]; - store - .read_memory(&memory, RESULT_PTR_OFFSET, &mut ptr_bytes) - .map_err(|_| RuntimeError::MemoryError("Failed to read result ptr".into()))?; - store - .read_memory(&memory, RESULT_LEN_OFFSET, &mut len_bytes) - .map_err(|_| RuntimeError::MemoryError("Failed to read result len".into()))?; - - let out_ptr = i32::from_le_bytes(ptr_bytes) as usize; - let out_len = i32::from_le_bytes(len_bytes) as usize; - - // Read output from guest-allocated buffer - let mut output_bytes = vec![0u8; out_len]; - store - .read_memory(&memory, out_ptr, &mut output_bytes) - .map_err(|_| RuntimeError::MemoryError("Failed to read output".into()))?; - - // Free the guest's buffer - if let Some(free_func) = store.get_free_func(&instance) { - let _ = store.call_free(&free_func, out_ptr as i32, out_len as i32); - } - - decode(&output_bytes).map_err(|e| RuntimeError::AbiError(e.to_string())) - } - - /// List all packages in the composition. - pub fn packages(&self) -> Vec { - self.registry - .lock() - .unwrap() - .packages - .keys() - .cloned() - .collect() - } - - /// Read embedded type metadata from a package in the composition. - /// - /// Returns `Err(MetadataError::NotFound)` if the package doesn't exist - /// or doesn't export `__pack_types`. - pub fn types( - &mut self, - package: &str, - ) -> Result { - let (store_arc, instance) = { - let reg = self.registry.lock().unwrap(); - let pkg = reg - .packages - .get(package) - .ok_or(crate::metadata::MetadataError::NotFound)?; - (Arc::clone(&pkg.store), pkg.instance) - }; - - let mut store = store_arc.lock().unwrap(); - - let types_func = store - .get_types_func(&instance) - .ok_or(crate::metadata::MetadataError::NotFound)?; - - let status = store - .call_types_func( - &types_func, - RESULT_PTR_OFFSET as i32, - RESULT_LEN_OFFSET as i32, - ) - .map_err(|_| crate::metadata::MetadataError::CallFailed("call failed".into()))?; - - if status != 0 { - return Err(crate::metadata::MetadataError::CallFailed( - "non-zero status from __pack_types".into(), - )); - } - - let memory = store - .get_memory(&instance) - .ok_or_else(|| crate::metadata::MetadataError::CallFailed("no memory".into()))?; - let mut ptr_bytes = [0u8; 4]; - let mut len_bytes = [0u8; 4]; - store - .read_memory(&memory, RESULT_PTR_OFFSET, &mut ptr_bytes) - .map_err(|_| crate::metadata::MetadataError::CallFailed("read ptr failed".into()))?; - store - .read_memory(&memory, RESULT_LEN_OFFSET, &mut len_bytes) - .map_err(|_| crate::metadata::MetadataError::CallFailed("read len failed".into()))?; - - let out_ptr = i32::from_le_bytes(ptr_bytes) as usize; - let out_len = i32::from_le_bytes(len_bytes) as usize; - - let mut metadata_bytes = vec![0u8; out_len]; - store - .read_memory(&memory, out_ptr, &mut metadata_bytes) - .map_err(|_| { - crate::metadata::MetadataError::CallFailed("read metadata failed".into()) - })?; - - crate::metadata::decode_metadata(&metadata_bytes) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_composition_builder_api() { - let _builder = CompositionBuilder::new() - .add_package("doubler", vec![]) - .add_package("adder", vec![]) - .wire("adder", "math", "double", "doubler", "transform"); - } -} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 92209f5..2981dc9 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -2,13 +2,11 @@ //! //! Handles package instantiation, linking, and execution. -mod composition; mod host; pub mod interceptor; mod interface_check; mod pic_composition; -pub use composition::{BuiltComposition, CompositionBuilder, HostFn}; pub use host::{ AsyncCtx, Ctx, DefaultHostProvider, ErrorHandler, HostFunctionError, HostFunctionErrorKind, HostFunctionProvider, HostLinkerBuilder, InterfaceBuilder, LinkerError, INPUT_BUFFER_OFFSET, diff --git a/tests/compose.rs b/tests/compose.rs new file mode 100644 index 0000000..c391ee1 --- /dev/null +++ b/tests/compose.rs @@ -0,0 +1,102 @@ +//! Static composition (`pack compose`) end-to-end test. +//! +//! Composes the allocator + doubler + adder fixtures into ONE self-contained +//! `.wasm` and runs `adder.process` — which calls `doubler.double` internally — +//! on a bare wasmtime with NO imports provided. Proves the merge + memory +//! unification + allocator internalization produce a working, standalone module. +//! +//! Requires `wasm-merge` (binaryen) on PATH; skips cleanly if absent (CI runs it +//! under `nix develop`, which provides binaryen). +//! +//! Fixtures (`assets/*_fixedbase.wasm`) are the packages built with the fixed-base +//! recipe: `RUSTFLAGS="-Clink-arg=--import-memory -Clink-arg=--initial-memory=8388608 +//! -Clink-arg=--stack-first -Clink-arg=-zstack-size=262144 +//! -Clink-arg=--global-base=<327680|851968> -Clink-arg=--no-entry"`. + +use std::path::Path; + +use packr::abi::{decode, encode, Value}; +use packr::{compose, ComposeSpec, PackageSpec}; +use wasmtime::{Engine, Instance, Linker, Module, Store}; + +fn asset(name: &str) -> Vec { + let p = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("assets") + .join(name); + std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) +} + +fn wasm_merge_available() -> bool { + std::process::Command::new("wasm-merge") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[test] +fn compose_adder_calls_doubler_standalone() { + if !wasm_merge_available() { + eprintln!("SKIP: wasm-merge (binaryen) not on PATH"); + return; + } + + let composed = compose(&ComposeSpec::new(vec![ + PackageSpec::new("pack:alloc", asset("pack_alloc_module.wasm")), + PackageSpec::new("math", asset("doubler_fixedbase.wasm")), // adder imports "math" + PackageSpec::new("adder", asset("adder_fixedbase.wasm")), + ])) + .expect("compose failed"); + + // Load on a BARE runtime — zero imports, no harness. + let engine = Engine::default(); + let module = Module::new(&engine, &composed).expect("composed module invalid"); + let mut store = Store::new(&engine, ()); + let inst = Linker::new(&engine) + .instantiate(&mut store, &module) + .expect("instantiate failed (module should be self-contained)"); + if let Ok(c) = inst.get_typed_func::<(), ()>(&mut store, "__wasm_call_ctors") { + c.call(&mut store, ()).unwrap(); + } + + for (input, expected) in [(5, 11), (0, 1), (100, 201), (-3, -5)] { + assert_eq!( + call_process(&mut store, &inst, input), + Value::S64(expected), + "process({input})" + ); + } +} + +/// Marshal `n` through the composed module's `process` via the packr ABI. +fn call_process(store: &mut Store<()>, inst: &Instance, n: i64) -> Value { + let mem = inst + .exports(&mut *store) + .filter_map(|e| e.into_memory()) + .next() + .expect("composed module must export a memory"); + + let bytes = encode(&Value::S64(n)).unwrap(); + let pa = inst + .get_typed_func::(&mut *store, "__pack_alloc") + .unwrap(); + let in_ptr = pa.call(&mut *store, bytes.len() as i32).unwrap(); + mem.write(&mut *store, in_ptr as usize, &bytes).unwrap(); + let slots = pa.call(&mut *store, 8).unwrap(); + + let f = inst + .get_typed_func::<(i32, i32, i32, i32), i32>(&mut *store, "process") + .unwrap(); + let status = f + .call(&mut *store, (in_ptr, bytes.len() as i32, slots, slots + 4)) + .unwrap(); + assert_eq!(status, 0, "guest returned error status"); + + let mut sb = [0u8; 8]; + mem.read(&*store, slots as usize, &mut sb).unwrap(); + let out_ptr = i32::from_le_bytes(sb[0..4].try_into().unwrap()) as usize; + let out_len = i32::from_le_bytes(sb[4..8].try_into().unwrap()) as usize; + let mut out = vec![0u8; out_len]; + mem.read(&*store, out_ptr, &mut out).unwrap(); + decode(&out).unwrap() +} diff --git a/tests/composition.rs b/tests/composition.rs deleted file mode 100644 index c02dbb5..0000000 --- a/tests/composition.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! Package composition tests -//! -//! These tests verify that packages can be composed together, -//! with one package's imports satisfied by another package's exports. - -use packr::abi::Value; -use packr::runtime::CompositionBuilder; -use std::path::Path; - -/// Load the doubler package (exports `double`) -fn load_doubler_package() -> Vec { - let wasm_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("packages/doubler/target/wasm32-unknown-unknown/release/doubler_package.wasm"); - std::fs::read(&wasm_path).unwrap_or_else(|e| { - panic!( - "Failed to read doubler package at {}: {}. Run: cd packages/doubler && cargo build --target wasm32-unknown-unknown --release", - wasm_path.display(), - e - ) - }) -} - -/// Load the adder package (imports `double` from "math", exports `process`) -fn load_adder_package() -> Vec { - let wasm_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("packages/adder/target/wasm32-unknown-unknown/release/adder_package.wasm"); - std::fs::read(&wasm_path).unwrap_or_else(|e| { - panic!( - "Failed to read adder package at {}: {}. Run: cd packages/adder && cargo build --target wasm32-unknown-unknown --release", - wasm_path.display(), - e - ) - }) -} - -#[test] -fn composition_doubler_alone() { - // Test that doubler works standalone - let doubler_wasm = load_doubler_package(); - - let mut composition = CompositionBuilder::new() - .add_package("doubler", doubler_wasm) - .build() - .expect("failed to build composition"); - - // Call double(5) => 10 - let result = composition - .call("doubler", "double", &Value::S64(5)) - .expect("failed to call double"); - - assert_eq!(result, Value::S64(10)); -} - -#[test] -fn composition_adder_with_doubler() { - // The main test: compose adder with doubler - let doubler_wasm = load_doubler_package(); - let adder_wasm = load_adder_package(); - - let mut composition = CompositionBuilder::new() - .add_package("doubler", doubler_wasm) - .add_package("adder", adder_wasm) - // Wire adder's import of "math::double" to doubler's "double" export - .wire("adder", "math", "double", "doubler", "double") - .build() - .expect("failed to build composition"); - - // Call process(5): - // - adder imports double(5) from "math" module - // - doubler's double(5) returns 10 - // - adder adds 1: 10 + 1 = 11 - let result = composition - .call("adder", "process", &Value::S64(5)) - .expect("failed to call process"); - - assert_eq!(result, Value::S64(11)); -} - -#[test] -fn composition_multiple_calls() { - let doubler_wasm = load_doubler_package(); - let adder_wasm = load_adder_package(); - - let mut composition = CompositionBuilder::new() - .add_package("doubler", doubler_wasm) - .add_package("adder", adder_wasm) - .wire("adder", "math", "double", "doubler", "double") - .build() - .expect("failed to build composition"); - - // Test with various inputs - let test_cases = vec![ - (0, 1), // 0 * 2 + 1 = 1 - (1, 3), // 1 * 2 + 1 = 3 - (5, 11), // 5 * 2 + 1 = 11 - (10, 21), // 10 * 2 + 1 = 21 - (100, 201), // 100 * 2 + 1 = 201 - ]; - - for (input, expected) in test_cases { - let result = composition - .call("adder", "process", &Value::S64(input)) - .unwrap_or_else(|_| panic!("failed to call process with {}", input)); - assert_eq!( - result, - Value::S64(expected), - "process({}) should be {}", - input, - expected - ); - } -} - -#[test] -fn composition_list_packages() { - let doubler_wasm = load_doubler_package(); - let adder_wasm = load_adder_package(); - - let composition = CompositionBuilder::new() - .add_package("doubler", doubler_wasm) - .add_package("adder", adder_wasm) - .wire("adder", "math", "double", "doubler", "double") - .build() - .expect("failed to build composition"); - - let packages = composition.packages(); - assert_eq!(packages.len(), 2); - assert!(packages.contains(&"doubler".to_string())); - assert!(packages.contains(&"adder".to_string())); -} - -#[test] -fn composition_call_both_packages() { - let doubler_wasm = load_doubler_package(); - let adder_wasm = load_adder_package(); - - let mut composition = CompositionBuilder::new() - .add_package("doubler", doubler_wasm) - .add_package("adder", adder_wasm) - .wire("adder", "math", "double", "doubler", "double") - .build() - .expect("failed to build composition"); - - // Can call either package - let double_result = composition - .call("doubler", "double", &Value::S64(7)) - .expect("failed to call doubler"); - assert_eq!(double_result, Value::S64(14)); - - let process_result = composition - .call("adder", "process", &Value::S64(7)) - .expect("failed to call adder"); - assert_eq!(process_result, Value::S64(15)); // 7 * 2 + 1 -} - -#[test] -fn composition_negative_numbers() { - let doubler_wasm = load_doubler_package(); - let adder_wasm = load_adder_package(); - - let mut composition = CompositionBuilder::new() - .add_package("doubler", doubler_wasm) - .add_package("adder", adder_wasm) - .wire("adder", "math", "double", "doubler", "double") - .build() - .expect("failed to build composition"); - - // Test with negative numbers - let result = composition - .call("adder", "process", &Value::S64(-5)) - .expect("failed to call process with negative"); - - // -5 * 2 + 1 = -9 - assert_eq!(result, Value::S64(-9)); -} diff --git a/tests/type_metadata.rs b/tests/type_metadata.rs index 389d281..83d7e66 100644 --- a/tests/type_metadata.rs +++ b/tests/type_metadata.rs @@ -2,7 +2,7 @@ use packr::abi::{encode, Value, ValueType}; use packr::metadata::{decode_metadata, MetadataError, TypeDesc}; -use packr::runtime::{CompositionBuilder, Runtime}; +use packr::runtime::Runtime; fn load_wasm(name: &str) -> Vec { let manifest_dir = env!("CARGO_MANIFEST_DIR"); @@ -73,38 +73,6 @@ fn test_doubler_metadata() { assert_eq!(double_fn.results[0], TypeDesc::Value); } -#[test] -fn test_adder_metadata_with_imports() { - let doubler_wasm = load_wasm("doubler"); - let adder_wasm = load_wasm("adder"); - - let mut composition = CompositionBuilder::new() - .add_package("doubler", doubler_wasm) - .add_package("adder", adder_wasm) - .wire("adder", "math", "double", "doubler", "double") - .build() - .expect("build composition"); - - let metadata = composition.types("adder").expect("adder metadata"); - - // Adder imports from "math" - assert_eq!(metadata.imports().len(), 1); - let import_fn = &metadata.imports()[0]; - assert_eq!(import_fn.interface, "math"); - assert_eq!(import_fn.name, "double"); - assert_eq!(import_fn.params.len(), 1); - assert_eq!(import_fn.params[0].name, "n"); - assert_eq!(import_fn.params[0].ty, TypeDesc::S64); - assert_eq!(import_fn.results.len(), 1); - assert_eq!(import_fn.results[0], TypeDesc::S64); - - // Adder exports "process" - assert_eq!(metadata.exports().len(), 1); - let export_fn = &metadata.exports()[0]; - assert_eq!(export_fn.name, "process"); - assert_eq!(export_fn.params[0].ty, TypeDesc::Value); -} - #[test] fn test_no_metadata() { // A minimal WAT module without __pack_types