Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions crates/herkos-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
35 changes: 30 additions & 5 deletions crates/herkos-core/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,24 @@ pub fn generate_function_with_info<B: Backend>(
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');
}
Expand Down Expand Up @@ -212,9 +224,12 @@ fn generate_signature_with_info<B: Backend>(
) -> 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 {
Expand Down Expand Up @@ -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 {
Expand Down
49 changes: 34 additions & 15 deletions crates/herkos-core/src/codegen/instruction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<B: Backend>(
backend: &B,
instr: &IrInstr,
info: &ModuleInfo,
caller_has_host: bool,
) -> Result<String> {
let code = match instr {
IrInstr::Const { dest, value } => backend.emit_const(*dest, value),
Expand Down Expand Up @@ -70,7 +74,14 @@ pub fn generate_instruction_with_info<B: Backend>(
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),

Expand Down Expand Up @@ -184,12 +195,18 @@ pub fn generate_terminator_with_mapping<B: Backend>(
/// 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<VarId>,
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;
Expand Down Expand Up @@ -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<String> = 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 {
Expand All @@ -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<String> = 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
));
}
}
Expand Down
20 changes: 18 additions & 2 deletions crates/herkos-core/src/ir/builder/assembly.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}",
Expand All @@ -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 { .. })
Expand All @@ -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 { .. }))
})
})
}
Expand Down
36 changes: 36 additions & 0 deletions crates/herkos-tests/data/wat/indirect_call_import.wat
Original file line number Diff line number Diff line change
@@ -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))
)
71 changes: 71 additions & 0 deletions crates/herkos-tests/tests/indirect_call_import.rs
Original file line number Diff line number Diff line change
@@ -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<i32>,
}

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
);
}
}
Loading