diff --git a/crates/herkos-core/Cargo.toml b/crates/herkos-core/Cargo.toml index ea1d2f2..2a71d3e 100644 --- a/crates/herkos-core/Cargo.toml +++ b/crates/herkos-core/Cargo.toml @@ -11,9 +11,6 @@ keywords = ["webassembly", "wasm", "transpiler", "rust", "isolation"] categories = ["wasm", "development-tools::build-utils"] readme = "../../README.md" -[lib] -crate-type = ["rlib", "cdylib"] - [dependencies] wasmparser = { workspace = true } anyhow = { workspace = true } diff --git a/crates/herkos-core/src/codegen/function.rs b/crates/herkos-core/src/codegen/function.rs index 409a2ba..8bd22c9 100644 --- a/crates/herkos-core/src/codegen/function.rs +++ b/crates/herkos-core/src/codegen/function.rs @@ -172,12 +172,24 @@ pub fn generate_function_with_info( output.push_str(" loop {\n"); output.push_str(" match __current_block {\n"); + // Compute whether this function has a host parameter in scope + // (same conditions as in generate_signature_with_info) + let has_call_indirect_with_imports = + has_call_indirect(ir_func) && !info.func_imports.is_empty(); + let caller_has_host = has_import_calls(ir_func) + || has_global_import_access(ir_func, info.imported_globals.len()) + || has_call_indirect_with_imports; + for (idx, block) in ir_func.blocks.iter().enumerate() { output.push_str(&format!(" Block::B{} => {{\n", idx)); for instr in &block.instructions { - let code = - crate::codegen::instruction::generate_instruction_with_info(backend, instr, info)?; + let code = crate::codegen::instruction::generate_instruction_with_info( + backend, + instr, + info, + caller_has_host, + )?; output.push_str(&code); output.push('\n'); } @@ -212,9 +224,12 @@ fn generate_signature_with_info( ) -> String { let visibility = if is_public { "pub " } else { "" }; - // Check if function needs host parameter (imports or global imports) - let needs_host = - has_import_calls(ir_func) || has_global_import_access(ir_func, info.imported_globals.len()); + // Check if function needs host parameter (imports, global imports, or call_indirect with imports) + let has_call_indirect_with_imports = + has_call_indirect(ir_func) && !info.func_imports.is_empty(); + let needs_host = has_import_calls(ir_func) + || has_global_import_access(ir_func, info.imported_globals.len()) + || has_call_indirect_with_imports; let trait_bounds_opt = if needs_host { crate::codegen::traits::build_trait_bounds(info) } else { @@ -300,6 +315,16 @@ fn has_import_calls(ir_func: &IrFunction) -> bool { }) } +/// Check if an IR function has any call_indirect instructions. +fn has_call_indirect(ir_func: &IrFunction) -> bool { + ir_func.blocks.iter().any(|block| { + block + .instructions + .iter() + .any(|instr| matches!(instr, IrInstr::CallIndirect { .. })) + }) +} + /// Check if an IR function accesses any imported globals. fn has_global_import_access(ir_func: &IrFunction, num_imported_globals: usize) -> bool { if num_imported_globals == 0 { diff --git a/crates/herkos-core/src/codegen/instruction.rs b/crates/herkos-core/src/codegen/instruction.rs index 8add863..528f541 100644 --- a/crates/herkos-core/src/codegen/instruction.rs +++ b/crates/herkos-core/src/codegen/instruction.rs @@ -9,10 +9,14 @@ use anyhow::Result; use std::collections::HashMap; /// Generate code for a single instruction with module info. +/// +/// `caller_has_host` indicates whether the calling function has a `host` parameter in scope. +/// This is used by `call_indirect` to determine whether to pass `host` to dispatched functions. pub fn generate_instruction_with_info( backend: &B, instr: &IrInstr, info: &ModuleInfo, + caller_has_host: bool, ) -> Result { let code = match instr { IrInstr::Const { dest, value } => backend.emit_const(*dest, value), @@ -70,7 +74,14 @@ pub fn generate_instruction_with_info( type_idx, table_idx, args, - } => generate_call_indirect(*dest, type_idx.clone(), *table_idx, args, info), + } => generate_call_indirect( + *dest, + type_idx.clone(), + *table_idx, + args, + info, + caller_has_host, + ), IrInstr::Assign { dest, src } => backend.emit_assign(*dest, *src), @@ -184,12 +195,18 @@ pub fn generate_terminator_with_mapping( /// 1. Looks up the table entry by index /// 2. Checks the type signature matches /// 3. Dispatches to the matching function via a match on func_index +/// +/// `caller_has_host` indicates whether the calling function has a `host` parameter. +/// Each dispatch arm will only pass `host` to its target if both: +/// - The target function needs_host, AND +/// - The caller has_host in scope fn generate_call_indirect( dest: Option, type_idx: TypeIdx, table_idx: VarId, args: &[VarId], info: &ModuleInfo, + caller_has_host: bool, ) -> String { let has_globals = info.has_mutable_globals(); let has_memory = info.has_memory; @@ -217,19 +234,6 @@ fn generate_call_indirect( " if __entry.type_index != {canon_idx} {{ return Err(WasmTrap::IndirectCallTypeMismatch); }}\n" )); - // Build the common args string for dispatch calls - let base_args: Vec = args.iter().map(|a| a.to_string()).collect(); - let call_args = crate::codegen::utils::build_inner_call_args( - &base_args, - has_globals, - "globals", - has_memory, - "memory", - has_table, - "table", - ); - let args_str = call_args.join(", "); - // Build dispatch match — only dispatch to functions with matching // canonical type (structural equivalence) let dest_prefix = match dest { @@ -243,9 +247,24 @@ fn generate_call_indirect( for (func_idx, ir_func) in info.ir_functions.iter().enumerate() { if ir_func.type_idx.as_usize() == canon_idx { + // Per-arm args generation: only pass host if both target needs it AND caller has it + let mut arm_base: Vec = args.iter().map(|a| a.to_string()).collect(); + if ir_func.needs_host && caller_has_host { + arm_base.push("host".to_string()); + } + let arm_call_args = crate::codegen::utils::build_inner_call_args( + &arm_base, + has_globals, + "globals", + has_memory, + "memory", + has_table, + "table", + ); + let arm_args_str = arm_call_args.join(", "); code.push_str(&format!( " {} => func_{}({})?,\n", - func_idx, func_idx, args_str + func_idx, func_idx, arm_args_str )); } } diff --git a/crates/herkos-core/src/ir/builder/assembly.rs b/crates/herkos-core/src/ir/builder/assembly.rs index 485ec51..ba31b7c 100644 --- a/crates/herkos-core/src/ir/builder/assembly.rs +++ b/crates/herkos-core/src/ir/builder/assembly.rs @@ -151,10 +151,15 @@ fn enrich_ir_functions( imported_globals: &[ImportedGlobalDef], ) -> Result<()> { let num_imported_globals = imported_globals.len(); + let has_func_imports = parsed + .imports + .iter() + .any(|i| matches!(i.kind, ImportKind::Function(_))); for (func_idx, func) in parsed.functions.iter().enumerate() { if let Some(ir_func) = ir_functions.get_mut(func_idx) { ir_func.type_idx = TypeIdx::new(canonical_type[func.type_idx as usize]); - ir_func.needs_host = function_calls_imports(ir_func, num_imported_globals); + ir_func.needs_host = + function_calls_imports(ir_func, num_imported_globals, has_func_imports); } else { return Err(anyhow::anyhow!( "IR function missing for parsed function index {}", @@ -166,7 +171,17 @@ fn enrich_ir_functions( } /// Determines if a function calls imports or accesses imported globals. -fn function_calls_imports(ir_func: &IrFunction, num_imported_globals: usize) -> bool { +/// +/// Returns true if the function: +/// - Has a direct CallImport instruction, OR +/// - Accesses an imported global, OR +/// - Uses CallIndirect when the module has any function imports +/// (because call_indirect may dispatch to functions that need the host parameter) +fn function_calls_imports( + ir_func: &IrFunction, + num_imported_globals: usize, + has_func_imports: bool, +) -> bool { ir_func.blocks.iter().any(|block| { block.instructions.iter().any(|instr| { matches!(instr, IrInstr::CallImport { .. }) @@ -177,6 +192,7 @@ fn function_calls_imports(ir_func: &IrFunction, num_imported_globals: usize) -> | IrInstr::GlobalSet { index, .. } if index.as_usize() < num_imported_globals )) + || (has_func_imports && matches!(instr, IrInstr::CallIndirect { .. })) }) }) } diff --git a/crates/herkos-tests/data/wat/indirect_call_import.wat b/crates/herkos-tests/data/wat/indirect_call_import.wat new file mode 100644 index 0000000..e574ef9 --- /dev/null +++ b/crates/herkos-tests/data/wat/indirect_call_import.wat @@ -0,0 +1,36 @@ +;; Regression test for issue #19: indirect calls missing host parameter for WASI functions +;; +;; This module has: +;; - A function import: env.log +;; - A writer function that calls the import directly (needs_host = true) +;; - A dispatcher function that uses call_indirect to reach writer (but has no direct import call) +;; +;; The bug: dispatcher gets no host parameter even though it dispatches to writer which needs it. + +(module + (type $log_fn (func (param i32))) + (type $dispatch_fn (func (param i32 i32))) + + (import "env" "log" (func $log (type $log_fn))) + + (table 1 funcref) + (elem (i32.const 0) $writer) + + ;; writer calls the import directly — needs host parameter + (func $writer (type $log_fn) + local.get 0 + call $log + ) + + ;; dispatcher uses call_indirect to reach writer + ;; dispatcher has no direct import call itself, but before the fix + ;; it would not get a host parameter, causing codegen to fail + (func $dispatcher (type $dispatch_fn) + local.get 0 + local.get 1 + call_indirect (type $log_fn) + ) + + (export "writer" (func $writer)) + (export "dispatcher" (func $dispatcher)) +) diff --git a/crates/herkos-tests/tests/indirect_call_import.rs b/crates/herkos-tests/tests/indirect_call_import.rs new file mode 100644 index 0000000..73ed717 --- /dev/null +++ b/crates/herkos-tests/tests/indirect_call_import.rs @@ -0,0 +1,71 @@ +//! Regression test for issue #19: indirect calls missing host parameter +//! +//! Tests that call_indirect correctly dispatches to functions that call imports, +//! even when the dispatcher function itself has no direct import calls. + +use herkos_runtime::WasmResult; +use herkos_tests::indirect_call_import; + +// Mock host implementation +struct MockHost { + last_logged: Option, +} + +impl MockHost { + fn new() -> Self { + MockHost { last_logged: None } + } +} + +// Implement the generated EnvImports trait +impl indirect_call_import::EnvImports for MockHost { + fn log(&mut self, value: i32) -> WasmResult<()> { + self.last_logged = Some(value); + Ok(()) + } +} + +#[test] +fn test_call_indirect_with_import() { + let mut host = MockHost::new(); + let mut module = indirect_call_import::new().unwrap(); + + // Test direct call to writer (should call import directly) + module.writer(42, &mut host).unwrap(); + assert_eq!( + host.last_logged, + Some(42), + "writer should call log import with 42" + ); + + // Reset for next test + host.last_logged = None; + + // Test call_indirect via dispatcher + // dispatcher(99, 0) → call_indirect(0) → writer(99) → log(99) + module.dispatcher(99, 0, &mut host).unwrap(); + assert_eq!( + host.last_logged, + Some(99), + "dispatcher should call_indirect to writer which logs 99" + ); +} + +#[test] +fn test_call_indirect_multiple_dispatches() { + let mut host = MockHost::new(); + let mut module = indirect_call_import::new().unwrap(); + + // Verify multiple dispatches work correctly + for i in 1..=5 { + host.last_logged = None; + module.dispatcher(i * 10, 0, &mut host).unwrap(); + assert_eq!( + host.last_logged, + Some(i * 10), + "dispatcher call {}: log should be called with {}", + i, + i * 10 + ); + } +}