makepad/libs/stitch/tests/host_calls.rs
ymote 71c9ddcbb9
stitch: a host call no longer leaves its handler's frame on the native stack (#1294)
call_host and the host branch of call_indirect got the trampoline's
Result<StackGuard, Error> back through a stack slot in the handler's own
frame. That slot's address escapes into the call, so LLVM could not turn
the handler's final call to the next instruction into a sibling call, and
every host call left a frame behind, roughly 150-200 bytes each (Apple
M5 Max). A module that calls a host function in a loop overflowed an
8 MiB thread after 40,000-60,000 calls and aborted the process; calls
between wasm functions were not affected.

The host call now happens in an out-of-line helper, so the slot lives in
the helper's frame. A million host calls run on a 1 MiB stack, and a host
call is faster: about 22 ns before, 10 ns after, in a release build.

tests/host_calls.rs loops a million direct and indirect host calls on a
1 MiB thread. Without the fix both abort with a stack overflow, in debug
and release builds.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:00:33 +02:00

75 lines
2.7 KiB
Rust

//! A host function called in a loop must not grow the native stack.
//!
//! Every threaded instruction ends in a sibling call to the next one. A
//! handler that keeps something alive in its frame across that call leaves
//! the frame behind, so a module that calls a host function many times in one
//! invocation runs out of native stack.
use makepad_stitch::{Engine, Func, Linker, Module, Store, Val};
const CALLS: i32 = 1_000_000;
const WAT: &str = r#"
(module
(type $t (func (param i32) (result i32)))
(import "env" "tick" (func $tick (type $t)))
(table 1 funcref)
(elem (i32.const 0) $tick)
(func (export "direct") (param $n i32) (result i32)
(local $i i32) (local $acc i32)
(block $done
(loop $next
(br_if $done (i32.ge_u (local.get $i) (local.get $n)))
(local.set $acc (call $tick (local.get $acc)))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $next)))
(local.get $acc))
(func (export "indirect") (param $n i32) (result i32)
(local $i i32) (local $acc i32)
(block $done
(loop $next
(br_if $done (i32.ge_u (local.get $i) (local.get $n)))
(local.set $acc (call_indirect (type $t) (local.get $acc) (i32.const 0)))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $next)))
(local.get $acc)))
"#;
/// Calls `export(CALLS)` on a thread with a 1 MiB stack: a leak of even a
/// few hundred bytes per host call overflows it long before the loop ends.
fn run(export: &'static str) -> i32 {
std::thread::Builder::new()
.stack_size(1 << 20)
.spawn(move || {
let buf = wast::parser::ParseBuffer::new(WAT).unwrap();
let mut wat: wast::Wat = wast::parser::parse(&buf).unwrap();
let bytes = wat.encode().unwrap();
let engine = Engine::new();
let mut store = Store::new(engine);
let module = Module::new(store.engine(), &bytes).unwrap();
let mut linker = Linker::new();
let tick = Func::wrap(&mut store, |x: i32| -> i32 { x + 1 });
linker.define("env", "tick", tick);
let instance = linker.instantiate(&mut store, &module).unwrap();
let mut results = [Val::I32(0)];
instance
.exported_func(export)
.unwrap()
.call(&mut store, &[Val::I32(CALLS)], &mut results)
.unwrap();
results[0].to_i32().unwrap()
})
.unwrap()
.join()
.unwrap()
}
#[test]
fn host_calls_in_a_loop_keep_the_native_stack_flat() {
assert_eq!(run("direct"), CALLS);
}
#[test]
fn indirect_host_calls_in_a_loop_keep_the_native_stack_flat() {
assert_eq!(run("indirect"), CALLS);
}