diff --git a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs index 8a1f32f291..24dab481c6 100644 --- a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs +++ b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs @@ -131,6 +131,32 @@ pub(super) fn detect_optional_feature_usage( ctx.needs_wasm_runtime = true; } + // Robust fallback for WebAssembly detection. The static lowering in + // `module_static.rs` sets `hir_module.uses_webassembly` for direct + // `WebAssembly.Module`/`instantiate`/etc. call sites, but a minified + // bundle can reach the `WebAssembly` global via dynamic property access + // (`const WA = WebAssembly; WA.Module(bytes)`, `globalThis.WebAssembly`, + // `globalThis["WebAssembly"]`) that lowers to an ordinary `PropertyGet` + // or `Ident` without hitting any set-site. The codegen still emits + // `js_webassembly_*` FFI calls for those paths, so without this fallback + // the `wasm-host` feature stays off and the link dies with + // `_js_webassembly_module_new` undefined. Mirror the fetch/crypto + // fallbacks above: scan the final HIR for the `WebAssembly` token. + // Over-matching only over-links the wasm host (a size cost); the rule + // is zero false negatives. + if !ctx.needs_wasm_runtime { + let hir_debug: String = format!( + "{:?}{:?}{:?}", + &hir_module.init, &hir_module.functions, &hir_module.classes + ); + if hir_debug.contains("property: \"WebAssembly\"") + || hir_debug.contains("class_name: \"WebAssembly\"") + || hir_debug.contains("\"WebAssembly\"") + { + ctx.needs_wasm_runtime = true; + } + } + // Detect crypto.* builtin usage (randomBytes/randomUUID/sha256/md5 used // without `import crypto`). The runtime symbols live behind the // perry-stdlib `crypto` Cargo feature, so we need to flip that on for diff --git a/crates/perry/src/commands/compile/optimized_libs/no_auto.rs b/crates/perry/src/commands/compile/optimized_libs/no_auto.rs index fc5f669233..60201a56b7 100644 --- a/crates/perry/src/commands/compile/optimized_libs/no_auto.rs +++ b/crates/perry/src/commands/compile/optimized_libs/no_auto.rs @@ -5,8 +5,12 @@ use std::process::Command; use crate::OutputFormat; +use super::super::library_search::{ + android_cross_env, find_harmonyos_sdk, harmonyos_cross_env, +}; use super::super::{ - find_perry_workspace_root, is_android_target, rust_target_triple, CompilationContext, + find_perry_workspace_root, is_android_target, is_windows_target, rust_target_triple, + CompilationContext, }; /// Resolve well-known wrapper archives without rebuilding runtime/stdlib. @@ -18,6 +22,17 @@ use super::super::{ /// archives, but when the Perry workspace source is available, build a missing /// wrapper once in the caller's cargo target dir so fresh dev checkouts still /// link no-auto parity cases correctly. +/// +/// When the program references `WebAssembly.*` (or `--enable-wasm-runtime` was +/// passed, which folds into `ctx.needs_wasm_runtime`), the prebuilt +/// `libperry_runtime.a` is insufficient: `wasm-host` is deliberately kept out +/// of perry-runtime's `default` feature set so non-wasm programs don't pay for +/// wasmi. The no-auto path can't enable a cargo feature on an already-built +/// archive, so it does a targeted rebuild of just `perry-runtime-static` with +/// default features + `perry-runtime/wasm-host` into a dedicated target dir. +/// This is the same on-demand build pattern `build_missing_prebuilt_ext_lib` +/// uses for CPU-only ext wrappers. `perry-wasm-host` has no tokio dep, so +/// there is no #507 shared-tokio concern with the prebuilt stdlib. pub(crate) fn resolve_no_auto_optimized_libs( ctx: &CompilationContext, target: Option<&str>, @@ -32,13 +47,144 @@ pub(crate) fn resolve_no_auto_optimized_libs( } else { Vec::new() }; + // Issue #76 — the prebuilt runtime is built WITHOUT `wasm-host` (kept + // out of `default` to avoid wasmi bloat on non-wasm programs). When the + // program uses `WebAssembly.*`, rebuild just the runtime with the + // feature on so `js_webassembly_*` symbols are defined. The prebuilt + // stdlib is unaffected (wasm-host only adds a module to perry-runtime). + let runtime = if ctx.needs_wasm_runtime { + build_wasm_host_runtime(target, format, verbose) + } else { + None + }; OptimizedLibs { + runtime, prefer_well_known_before_stdlib: !well_known_libs.is_empty(), well_known_libs, ..OptimizedLibs::empty() } } +/// Build `perry-runtime-static` with default features + `perry-runtime/wasm-host` +/// into a dedicated target dir so the prebuilt `libperry_runtime.a` is not +/// clobbered. Returns the path to the rebuilt archive, or `None` when there's +/// no workspace source or the build fails (the caller falls back to the +/// prebuilt runtime, which will fail to link with `_js_webassembly_*` +/// undefined — the error message points the user at the cause). +fn build_wasm_host_runtime( + target: Option<&str>, + format: OutputFormat, + verbose: u8, +) -> Option { + let workspace_root = find_perry_workspace_root()?; + let crate_dir = workspace_root.join("crates").join("perry-runtime-static"); + if !crate_dir.is_dir() { + if matches!(format, OutputFormat::Text) && verbose > 0 { + eprintln!( + " wasm-host (no-auto): skipping runtime rebuild — crate source not found at {}", + crate_dir.display() + ); + } + return None; + } + + if matches!(format, OutputFormat::Text) { + println!( + " wasm-host (no-auto): rebuilding perry-runtime-static with wasm-host feature" + ); + } + + // Use a dedicated target dir so the prebuilt libperry_runtime.a in + // target/release is not overwritten. Cargo's incremental cache makes + // repeat builds a no-op. + let wasm_host_target_dir = workspace_root.join("target").join("perry-wasm-host-runtime"); + + let mut cargo_cmd = Command::new("cargo"); + cargo_cmd + .current_dir(&workspace_root) + .env("CARGO_TARGET_DIR", &wasm_host_target_dir) + .arg("build") + .arg("--release") + .arg("-p") + .arg("perry-runtime-static") + .arg("--features") + .arg("perry-runtime/wasm-host"); + if let Some(triple) = rust_target_triple(target) { + cargo_cmd.arg("--target").arg(triple); + } + // Cross-compile envs — mirror `build_missing_prebuilt_ext_lib` so a + // `--target harmonyos` / Android rebuild of the runtime (which has C + // deps via libmimalloc-sys) can succeed. + if matches!(target, Some("harmonyos") | Some("harmonyos-simulator")) { + match find_harmonyos_sdk() { + Some(sdk) => { + for (k, v) in harmonyos_cross_env(&sdk, target) { + cargo_cmd.env(k, v); + } + } + None => { + if matches!(format, OutputFormat::Text) && verbose > 0 { + eprintln!( + " wasm-host (no-auto): skipping runtime rebuild — OHOS SDK not found (set OHOS_SDK_HOME)" + ); + } + return None; + } + } + } + if is_android_target(target) { + if let Some(ndk) = std::env::var_os("ANDROID_NDK_HOME") { + for (k, v) in + android_cross_env(std::path::Path::new(&ndk), target) + { + cargo_cmd.env(k, v); + } + } + } + + match cargo_cmd.status() { + Ok(status) if status.success() => {} + Ok(status) => { + if matches!(format, OutputFormat::Text) { + eprintln!( + " wasm-host (no-auto): cargo build for perry-runtime-static failed ({status})" + ); + } + return None; + } + Err(err) => { + if matches!(format, OutputFormat::Text) { + eprintln!( + " wasm-host (no-auto): failed to spawn cargo ({err})" + ); + } + return None; + } + } + + let lib_name = if is_windows_target(target) { + "perry_runtime.lib" + } else { + "libperry_runtime.a" + }; + let mut release_dir = wasm_host_target_dir; + if let Some(triple) = rust_target_triple(target) { + release_dir = release_dir.join(triple); + } + let built = release_dir.join("release").join(lib_name); + if built.exists() { + return Some(built); + } + + if matches!(format, OutputFormat::Text) && verbose > 0 { + eprintln!( + " wasm-host (no-auto): cargo finished but {lib_name} was not produced at {}", + built.display() + ); + } + None +} + /// #2532 / #3954 — resolve the `perry-ext-*` staticlibs a program needs /// while runtime/stdlib auto-specialization is disabled. /// diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index b24c810174..5599a21e34 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -1182,3 +1182,75 @@ fn ext_zlib_covers_every_stdlib_symbol_the_flip_strips() { them; a list that outlives its entries stops being a ratchet." ); } + +/// Regression: a program that references `WebAssembly.*` must get the +/// `perry-runtime/wasm-host` cross feature so the runtime archive carries +/// `js_webassembly_*` symbol definitions. Without it the link fails with +/// `_js_webassembly_module_new` undefined (issue #76). +#[test] +fn wasm_usage_enables_wasm_host_cross_feature() { + let workspace_root = find_perry_workspace_root().expect("workspace root"); + let mut ctx = CompilationContext::new(workspace_root); + ctx.needs_wasm_runtime = true; + + let features = compute_required_features( + &ctx.native_module_imports, + ctx.uses_fetch, + ctx.uses_crypto_builtins, + ); + let cross = auto_optimized_cross_features(&ctx, &features, &[]); + assert!( + cross.iter().any(|f| f == "perry-runtime/wasm-host"), + "needs_wasm_runtime=true must add perry-runtime/wasm-host to the \ + cross-feature set so the runtime archive defines js_webassembly_* \ + symbols; got: {cross:?}" + ); +} + +/// Regression: a program that does NOT reference `WebAssembly.*` must NOT +/// get the `perry-runtime/wasm-host` cross feature — non-wasm programs +/// don't pay for wasmi (the feature is deliberately kept out of `default`). +#[test] +fn non_wasm_usage_does_not_enable_wasm_host_cross_feature() { + let workspace_root = find_perry_workspace_root().expect("workspace root"); + let ctx = CompilationContext::new(workspace_root); + + let features = compute_required_features( + &ctx.native_module_imports, + ctx.uses_fetch, + ctx.uses_crypto_builtins, + ); + let cross = auto_optimized_cross_features(&ctx, &features, &[]); + assert!( + !cross.iter().any(|f| f == "perry-runtime/wasm-host"), + "needs_wasm_runtime=false must NOT add perry-runtime/wasm-host — \ + non-wasm programs must not link the wasmi host; got: {cross:?}" + ); +} + +/// Regression: the auto-optimize cache key must differ between a wasm-using +/// program and a non-wasm program so cargo doesn't serve a cached non-wasm +/// runtime archive (missing `js_webassembly_*`) to a wasm program, or vice +/// versa (an archive carrying unresolved `perry_wasm_host_*` refs). +#[test] +fn wasm_usage_changes_auto_optimize_cache_key() { + let workspace_root = find_perry_workspace_root().expect("workspace root"); + let ctx_no_wasm = CompilationContext::new(workspace_root.clone()); + let mut ctx_wasm = CompilationContext::new(workspace_root); + ctx_wasm.needs_wasm_runtime = true; + + let features = compute_required_features( + &ctx_no_wasm.native_module_imports, + ctx_no_wasm.uses_fetch, + ctx_no_wasm.uses_crypto_builtins, + ); + let feature_arg = features_to_cargo_arg(&features); + let key_no_wasm = + auto_optimized_cache_key(&feature_arg, true, false, None, &ctx_no_wasm); + let key_wasm = + auto_optimized_cache_key(&feature_arg, true, false, None, &ctx_wasm); + assert_ne!( + key_no_wasm, key_wasm, + "wasm usage must change the cache key so the target dirs don't collide" + ); +} diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 341a2b90eb..d3f1158b74 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -5285,7 +5285,19 @@ pub fn run_with_parse_cache( // both the symbol-stub scan below and the final link resolve it. Cargo's // freshness check makes this a no-op when it is already current; programs // that do not use wasm skip the check entirely. - let use_wasm_host = ctx.needs_wasm_runtime || args.enable_wasm_runtime; + // + // `--enable-wasm-runtime` is an explicit override: fold it into + // `ctx.needs_wasm_runtime` so EVERY downstream path (the `wasm-host` + // cargo feature in `auto_optimized_cross_features`, the no-auto runtime + // rebuild, the library link, and the symbol-stub scan) treats it the + // same as auto-detected `WebAssembly.*` usage. Without this fold the + // flag only linked `libperry_wasm_host.a` but never enabled the + // `perry-runtime/wasm-host` cargo feature, so `js_webassembly_*` + // symbols stayed undefined in the runtime archive. + if args.enable_wasm_runtime { + ctx.needs_wasm_runtime = true; + } + let use_wasm_host = ctx.needs_wasm_runtime; let wasm_host_lib_resolved = if use_wasm_host { // Prefer a Cargo freshness check when workspace source is available. // Merely finding an archive is insufficient after the host ABI grows: