Skip to content

Commit 9bcb02a

Browse files
shogochiailwshangclaude
authored
feat(instrument): Add --stub-wasi flag to replace WASI imports with stubs (#104)
* feat(instrument): Add --stub-wasi flag to replace WASI imports with stubs Problem: When using `ic-wasm instrument` on a canister WASM compiled with Emscripten, the resulting instrumented WASM contains WASI imports (fd_close, fd_write, fd_seek, etc.) that IC rejects at install time: Error: Wasm module has an invalid import section. Module imports function 'fd_close' from 'wasi_snapshot_preview1' that is not exported by the runtime. Solution: Add `--stub-wasi` flag that replaces WASI imports with local stub functions returning 0 (success) or trapping for proc_exit. This allows Emscripten- generated WASMs to be instrumented and deployed to IC. Usage: ic-wasm canister.wasm -o out.wasm instrument --stub-wasi * docs(instrument): Reorganize documentation to clarify execution tracing vs WASI stubbing - Restructure Instrument section intro to list both capabilities - Add subsection headers: "Execution tracing" and "Stubbing WASI imports" - Move WASI stubbing docs to its own subsection after execution tracing - Update CHANGELOG with --stub-wasi flag addition Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Linwei Shang <linwei.shang@dfinity.org> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent d4361d5 commit 9bcb02a

File tree

7 files changed

+326
-2
lines changed

7 files changed

+326
-2
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [unreleased]
88

9+
* Add `--stub-wasi` flag to `instrument` subcommand to replace WASI imports with stub functions.
10+
911
## [0.9.9] - 2025-11-18
1012

1113
* Add support for comments in optional hidden endpoint file for `check-endpoints` command.

README.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,13 @@ Usage: `ic-wasm <input.wasm> check-endpoints [--candid <file>] [--hidden <file>]
126126

127127
### Instrument (experimental)
128128

129-
Instrument canister method to emit execution trace to stable memory.
129+
Provides instrumentation capabilities for canister WebAssembly modules:
130+
- **Execution tracing**: Instrument canister methods to emit execution trace to stable memory for performance profiling
131+
- **WASI compatibility**: Replace WASI imports with stub functions to enable modules compiled with Emscripten or wasi-sdk to run on the Internet Computer
130132

131-
Usage: `ic-wasm <input.wasm> -o <output.wasm> instrument --trace-only func1 --trace-only func2 --start-page 16 --page-limit 30`
133+
Usage: `ic-wasm <input.wasm> -o <output.wasm> instrument [--trace-only func1] [--start-page 16] [--page-limit 30] [--stub-wasi]`
134+
135+
#### Execution tracing
132136

133137
Instrumented canister has the following additional endpoints:
134138

@@ -203,6 +207,30 @@ fn post_upgrade() {
203207
* We cannot measure query calls.
204208
* No concurrent calls.
205209

210+
#### Stubbing WASI imports
211+
212+
The `--stub-wasi` flag replaces WASI imports with local stub functions, enabling WASM modules compiled with Emscripten or wasi-sdk to run on the Internet Computer. Without this flag, such modules would fail at install time with errors like:
213+
214+
```
215+
Error: Wasm module has an invalid import section.
216+
Module imports function 'fd_close' from 'wasi_snapshot_preview1' that is not exported by the runtime.
217+
```
218+
219+
The stub functions behave as follows:
220+
221+
| WASI Function | Stub Behavior |
222+
|---------------|---------------|
223+
| `fd_close` | Returns 0 (success) |
224+
| `fd_write` | Writes 0 to nwritten, returns 0 |
225+
| `fd_read` | Writes 0 to nread, returns 0 |
226+
| `fd_seek` | Writes 0 to newoffset, returns 0 |
227+
| `environ_sizes_get` | Writes 0 to both params, returns 0 |
228+
| `environ_get` | Returns 0 |
229+
| `proc_exit` | Traps (unreachable) |
230+
| Others | Returns 0 |
231+
232+
**Note**: This is a workaround for edge cases. The recommended approach is to build without WASI imports (e.g., using `wasm32-unknown-unknown` target with ic-cdk for Rust). Stub functions return success, which may hide real failures if your code depends on WASI functionality.
233+
206234
## Library
207235

208236
To use `ic-wasm` as a library, add this to your `Cargo.toml`:

src/bin/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ enum SubCommand {
101101
/// The number of pages of the preallocated stable memory
102102
#[clap(short, long, requires("start_page"))]
103103
page_limit: Option<i32>,
104+
/// Replace WASI imports with stub functions that return 0 (success)
105+
#[clap(long)]
106+
stub_wasi: bool,
104107
},
105108
/// Check canister endpoints against provided Candid interface
106109
#[cfg(feature = "check-endpoints")]
@@ -164,12 +167,14 @@ fn main() -> anyhow::Result<()> {
164167
trace_only,
165168
start_page,
166169
page_limit,
170+
stub_wasi,
167171
} => {
168172
use ic_wasm::instrumentation::{instrument, Config};
169173
let config = Config {
170174
trace_only_funcs: trace_only.clone().unwrap_or(vec![]),
171175
start_address: start_page.map(|page| i64::from(page) * 65536),
172176
page_limit: *page_limit,
177+
stub_wasi: *stub_wasi,
173178
};
174179
instrument(&mut m, config).map_err(|e| anyhow::anyhow!("{e}"))?;
175180
}

src/instrumentation.rs

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub struct Config {
3838
pub trace_only_funcs: Vec<String>,
3939
pub start_address: Option<i64>,
4040
pub page_limit: Option<i32>,
41+
pub stub_wasi: bool,
4142
}
4243
impl Config {
4344
pub fn is_preallocated(&self) -> bool {
@@ -61,6 +62,9 @@ impl Config {
6162
/// When trace_only_funcs is not empty, counting and tracing is only enabled for those listed functions per update call.
6263
/// TODO: doesn't handle recursive entry functions. Need to create a wrapper for the recursive entry function.
6364
pub fn instrument(m: &mut Module, config: Config) -> Result<(), String> {
65+
if config.stub_wasi {
66+
stub_wasi_imports(m);
67+
}
6468
let mut trace_only_ids = HashSet::new();
6569
for name in config.trace_only_funcs.iter() {
6670
let id = match m.funcs.by_name(name) {
@@ -960,3 +964,223 @@ fn make_toggle_func(m: &mut Module, name: &str, var: GlobalId) {
960964
let id = builder.finish(vec![], &mut m.funcs);
961965
m.exports.add(&format!("canister_update {name}"), id);
962966
}
967+
968+
/// Replace WASI imports with stub functions that return 0 (success) or trap for proc_exit
969+
fn stub_wasi_imports(m: &mut Module) {
970+
use walrus::FunctionBuilder;
971+
972+
// Find all WASI imports
973+
let wasi_imports: Vec<_> = m
974+
.imports
975+
.iter()
976+
.filter(|i| i.module == "wasi_snapshot_preview1")
977+
.filter_map(|i| {
978+
if let ImportKind::Function(func_id) = i.kind {
979+
Some((i.id(), i.name.clone(), func_id))
980+
} else {
981+
None
982+
}
983+
})
984+
.collect();
985+
986+
let memory = m.memories.iter().next().map(|mem| mem.id());
987+
988+
for (import_id, name, old_func_id) in wasi_imports {
989+
// Get the function type
990+
let func = m.funcs.get(old_func_id);
991+
let ty_id = func.ty();
992+
let ty = m.types.get(ty_id);
993+
let params: Vec<_> = ty.params().to_vec();
994+
let results: Vec<_> = ty.results().to_vec();
995+
996+
// Create stub function
997+
let mut builder = FunctionBuilder::new(&mut m.types, &params, &results);
998+
builder.name(format!("__wasi_{name}_stub"));
999+
1000+
// Create locals for parameters
1001+
let param_locals: Vec<_> = params.iter().map(|t| m.locals.add(*t)).collect();
1002+
1003+
match name.as_str() {
1004+
"fd_write" => {
1005+
// fd_write(fd: i32, iovs: i32, iovs_len: i32, nwritten: i32) -> i32
1006+
// Write 0 to nwritten and return 0
1007+
if let Some(mem) = memory {
1008+
if param_locals.len() >= 4 {
1009+
builder
1010+
.func_body()
1011+
.local_get(param_locals[3]) // nwritten ptr
1012+
.i32_const(0)
1013+
.store(
1014+
mem,
1015+
StoreKind::I32 { atomic: false },
1016+
MemArg {
1017+
offset: 0,
1018+
align: 4,
1019+
},
1020+
)
1021+
.i32_const(0);
1022+
} else {
1023+
builder.func_body().i32_const(0);
1024+
}
1025+
} else {
1026+
builder.func_body().i32_const(0);
1027+
}
1028+
}
1029+
"fd_read" => {
1030+
// fd_read(fd: i32, iovs: i32, iovs_len: i32, nread: i32) -> i32
1031+
// Write 0 to nread and return 0
1032+
if let Some(mem) = memory {
1033+
if param_locals.len() >= 4 {
1034+
builder
1035+
.func_body()
1036+
.local_get(param_locals[3]) // nread ptr
1037+
.i32_const(0)
1038+
.store(
1039+
mem,
1040+
StoreKind::I32 { atomic: false },
1041+
MemArg {
1042+
offset: 0,
1043+
align: 4,
1044+
},
1045+
)
1046+
.i32_const(0);
1047+
} else {
1048+
builder.func_body().i32_const(0);
1049+
}
1050+
} else {
1051+
builder.func_body().i32_const(0);
1052+
}
1053+
}
1054+
"fd_seek" => {
1055+
// fd_seek(fd: i32, offset: i64, whence: i32, newoffset: i32) -> i32
1056+
// Write 0 to newoffset and return 0
1057+
if let Some(mem) = memory {
1058+
if param_locals.len() >= 4 {
1059+
builder
1060+
.func_body()
1061+
.local_get(param_locals[3]) // newoffset ptr
1062+
.i64_const(0)
1063+
.store(
1064+
mem,
1065+
StoreKind::I64 { atomic: false },
1066+
MemArg {
1067+
offset: 0,
1068+
align: 8,
1069+
},
1070+
)
1071+
.i32_const(0);
1072+
} else {
1073+
builder.func_body().i32_const(0);
1074+
}
1075+
} else {
1076+
builder.func_body().i32_const(0);
1077+
}
1078+
}
1079+
"fd_close" => {
1080+
// fd_close(fd: i32) -> i32
1081+
// Just return 0 (success)
1082+
builder.func_body().i32_const(0);
1083+
}
1084+
"environ_sizes_get" => {
1085+
// environ_sizes_get(count: i32, buf_size: i32) -> i32
1086+
// Write 0 to both pointers and return 0
1087+
if let Some(mem) = memory {
1088+
if param_locals.len() >= 2 {
1089+
builder
1090+
.func_body()
1091+
.local_get(param_locals[0]) // count ptr
1092+
.i32_const(0)
1093+
.store(
1094+
mem,
1095+
StoreKind::I32 { atomic: false },
1096+
MemArg {
1097+
offset: 0,
1098+
align: 4,
1099+
},
1100+
)
1101+
.local_get(param_locals[1]) // buf_size ptr
1102+
.i32_const(0)
1103+
.store(
1104+
mem,
1105+
StoreKind::I32 { atomic: false },
1106+
MemArg {
1107+
offset: 0,
1108+
align: 4,
1109+
},
1110+
)
1111+
.i32_const(0);
1112+
} else {
1113+
builder.func_body().i32_const(0);
1114+
}
1115+
} else {
1116+
builder.func_body().i32_const(0);
1117+
}
1118+
}
1119+
"environ_get" => {
1120+
// environ_get(environ: i32, environ_buf: i32) -> i32
1121+
// Just return 0 (no environment variables)
1122+
builder.func_body().i32_const(0);
1123+
}
1124+
"proc_exit" => {
1125+
// proc_exit(code: i32) -> !
1126+
// Trap unconditionally
1127+
builder.func_body().unreachable();
1128+
}
1129+
_ => {
1130+
// Default: just return 0 for i32 result, or appropriate zero values
1131+
for result in &results {
1132+
match result {
1133+
ValType::I32 => {
1134+
builder.func_body().i32_const(0);
1135+
}
1136+
ValType::I64 => {
1137+
builder.func_body().i64_const(0);
1138+
}
1139+
ValType::F32 => {
1140+
builder.func_body().f32_const(0.0);
1141+
}
1142+
ValType::F64 => {
1143+
builder.func_body().f64_const(0.0);
1144+
}
1145+
_ => {}
1146+
}
1147+
}
1148+
}
1149+
}
1150+
1151+
let stub_func_id = builder.finish(param_locals, &mut m.funcs);
1152+
1153+
// Replace all calls to old_func_id with stub_func_id
1154+
for (_, func) in m.funcs.iter_local_mut() {
1155+
replace_calls_in_func(func, old_func_id, stub_func_id);
1156+
}
1157+
1158+
// Remove the import
1159+
m.imports.delete(import_id);
1160+
}
1161+
}
1162+
1163+
fn replace_calls_in_func(func: &mut LocalFunction, old_id: FunctionId, new_id: FunctionId) {
1164+
let mut stack = vec![func.entry_block()];
1165+
while let Some(seq_id) = stack.pop() {
1166+
let mut builder = func.builder_mut().instr_seq(seq_id);
1167+
for (instr, _) in builder.instrs_mut().iter_mut() {
1168+
match instr {
1169+
Instr::Call(Call { func }) if *func == old_id => {
1170+
*func = new_id;
1171+
}
1172+
Instr::Block(Block { seq }) | Instr::Loop(Loop { seq }) => {
1173+
stack.push(*seq);
1174+
}
1175+
Instr::IfElse(IfElse {
1176+
consequent,
1177+
alternative,
1178+
}) => {
1179+
stack.push(*consequent);
1180+
stack.push(*alternative);
1181+
}
1182+
_ => {}
1183+
}
1184+
}
1185+
}
1186+
}

tests/tests.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,3 +491,47 @@ fn create_tempfile(content: &str) -> NamedTempFile {
491491
write!(temp_file, "{content}").expect("Failed to write temp file content");
492492
temp_file
493493
}
494+
495+
#[test]
496+
fn stub_wasi() {
497+
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests");
498+
let out_path = path.join("out-wasi.wasm");
499+
500+
// First verify input has WASI imports
501+
let input_module = walrus::Module::from_file(path.join("wasi-test.wasm")).unwrap();
502+
let input_wasi_imports: Vec<_> = input_module
503+
.imports
504+
.iter()
505+
.filter(|i| i.module == "wasi_snapshot_preview1")
506+
.collect();
507+
assert!(
508+
!input_wasi_imports.is_empty(),
509+
"Input should have WASI imports"
510+
);
511+
512+
// Test that --stub-wasi removes WASI imports
513+
wasm_input("wasi-test.wasm", false)
514+
.arg("-o")
515+
.arg(&out_path)
516+
.arg("instrument")
517+
.arg("--stub-wasi")
518+
.assert()
519+
.success();
520+
521+
// Verify the output WASM has no WASI imports
522+
let module = walrus::Module::from_file(&out_path).unwrap();
523+
let wasi_imports: Vec<_> = module
524+
.imports
525+
.iter()
526+
.filter(|i| i.module == "wasi_snapshot_preview1")
527+
.collect();
528+
529+
assert!(
530+
wasi_imports.is_empty(),
531+
"WASI imports should be removed, but found: {:?}",
532+
wasi_imports.iter().map(|i| &i.name).collect::<Vec<_>>()
533+
);
534+
535+
// Clean up
536+
fs::remove_file(&out_path).ok();
537+
}

tests/wasi-test.wasm

236 Bytes
Binary file not shown.

tests/wasi-test.wat

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
(module
2+
;; WASI imports
3+
(import "wasi_snapshot_preview1" "fd_close" (func $fd_close (param i32) (result i32)))
4+
(import "wasi_snapshot_preview1" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
5+
(import "wasi_snapshot_preview1" "fd_seek" (func $fd_seek (param i32 i64 i32 i32) (result i32)))
6+
7+
;; IC imports
8+
(import "ic0" "msg_reply" (func $msg_reply))
9+
(import "ic0" "msg_reply_data_append" (func $msg_reply_data_append (param i32 i32)))
10+
11+
(memory 1)
12+
13+
(func $test (export "canister_query test")
14+
;; Call WASI functions (they will fail at runtime without stubs)
15+
i32.const 1
16+
call $fd_close
17+
drop
18+
19+
call $msg_reply
20+
)
21+
)

0 commit comments

Comments
 (0)