From c95500f54f67d19fef16fd25c053e2b60201397c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 16 Jun 2026 06:30:23 +0200 Subject: [PATCH 1/2] feat(compile): defer non-resolvable dynamic import() to a throw-on-reach runtime error by default (#5230) A dynamic import(spec) whose specifier is runtime-computed (non-resolvable) previously hard-failed the build ("path argument is not statically resolvable"). Apply the same defer/notice/strict policy as #5206's eval deferral: - Default (defer): compile the site to a rejected Promise carrying a descriptive Error ("dynamic import() of a runtime-computed path cannot run in an ahead-of-time compiled binary (:)"), so await import(spec) throws only if reached. The build succeeds and the app's core runs. - Notice: the site is recorded in #5206's shared sink and surfaces in the SAME end-of-compile notice (generalized header: "N ahead-of-time-unsupported site(s)"), with kind import(...) + file:line. - Strict: restores the historical hard compile error. Unifies with #5206 by generalizing DeferredEvalSite into a shared ahead-of-time-unsupported-site concept (new perry_hir::record_deferred_aot_site) that both eval/new Function and dynamic import push into; one notice prints all. Strict scheme: perry.strict=true covers both eval and dynamic import. Dedicated knobs scoped to imports: --strict-dynamic-import flag and perry.dynamicImport="defer"|"error" (overrides the broad perry.strict for import sites). PERRY_ALLOW_EVAL=1 is the shared escape hatch (forces defer for both). Resolvable forms (string literals, ternaries, template-with-const-locals, finite union-typed params, globs) compile + load unchanged. Tests: new issue_5230_deferred_dynamic_import.rs (defer+notice+run+throw, strict flag, perry.strict, perry.dynamicImport=error, defer-override). #5206 tests stay green (assertions updated for the generalized notice header). --- .../perry-codegen/src/expr/dyn_extern_i18n.rs | 26 +- crates/perry-hir/src/eval_classifier.rs | 35 ++- crates/perry-hir/src/ir/expr.rs | 14 + crates/perry-hir/src/lib.rs | 5 +- crates/perry-hir/src/lower/expr_call/mod.rs | 2 + crates/perry-hir/src/stable_hash/expr.rs | 2 +- crates/perry/src/commands/compile.rs | 28 +- .../src/commands/compile/collect_modules.rs | 85 +++++- .../perry/src/commands/compile/host_config.rs | 34 ++- crates/perry/src/commands/compile/types.rs | 22 ++ crates/perry/src/commands/dev.rs | 1 + crates/perry/src/commands/run/mod.rs | 1 + .../issue_5206_deferred_eval_runtime_error.rs | 8 +- .../issue_5230_deferred_dynamic_import.rs | 273 ++++++++++++++++++ docs/src/cli/flags.md | 1 + docs/src/language/limitations.md | 40 ++- 16 files changed, 531 insertions(+), 46 deletions(-) create mode 100644 crates/perry/tests/issue_5230_deferred_dynamic_import.rs diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index ca9dbd4af9..f4c567e441 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -113,7 +113,31 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(I64, &entry_ptr), (DOUBLE, &options_val)], )) } - Expr::DynamicImport { paths, arg } => { + Expr::DynamicImport { + paths, + arg, + deferred_error, + .. + } => { + // #5230: a non-resolvable (runtime-computed) specifier was + // *deferred* (the default, non-strict policy — analog of #5206's + // eval deferral). Evaluate the arg for its side effects, then + // reject the promise with a descriptive `Error` so + // `await import(spec)` throws only if this site is actually + // reached, instead of failing the whole build. + if let Some(msg) = deferred_error { + let _ = lower_expr(ctx, arg)?; + // Build the `Error(msg)` value the same way `new Error()` + // does (see `Expr::ErrorNew`): intern the message as a string + // literal handle, then `js_error_new_from_value`. + let msg_val = lower_expr(ctx, &Expr::String(msg.clone()))?; + let blk = ctx.block(); + let err_ptr = blk.call(I64, "js_error_new_from_value", &[(DOUBLE, &msg_val)]); + let err_box = nanbox_pointer_inline(blk, &err_ptr); + let p = blk.call(I64, "js_promise_rejected", &[(DOUBLE, &err_box)]); + return Ok(nanbox_pointer_inline(blk, &p)); + } + // Defensive: an empty `paths` list means the resolver pass // failed to populate this node, which `collect_modules` // should have raised as a compile error. Fall through to a diff --git a/crates/perry-hir/src/eval_classifier.rs b/crates/perry-hir/src/eval_classifier.rs index dbb87a5d39..617fbff0b0 100644 --- a/crates/perry-hir/src/eval_classifier.rs +++ b/crates/perry-hir/src/eval_classifier.rs @@ -323,11 +323,17 @@ thread_local! { /// module lowered more than once isn't counted twice. static EVAL_DEFERRED_SITES: Mutex> = Mutex::new(Vec::new()); -/// A bucket-3 site that was compiled to a deferred runtime error. Reported in -/// the end-of-compile notice (#5206). +/// A site that was compiled to a deferred throw-on-reach runtime error +/// instead of failing the build. Reported in the end-of-compile notice. +/// +/// #5206 introduced this for runtime-unknown `eval(...)` / `new Function(...)` +/// sites; #5230 generalized it to any ahead-of-time-unsupported surface +/// (currently also a non-resolvable dynamic `import(...)`). The `kind` string +/// distinguishes the surface (`eval(...)`, `new Function(...)`, `import(...)`) +/// so a single notice can list every degraded site of every kind together. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeferredEvalSite { - /// Display label of the call shape, e.g. `new Function(...)`. + /// Display label of the surface, e.g. `new Function(...)` or `import(...)`. pub kind: String, /// `file:line` of the site. pub location: String, @@ -346,12 +352,17 @@ pub fn eval_strict_mode() -> bool { EVAL_STRICT_MODE.with(|s| *s.borrow()) } -/// Record a deferred bucket-3 site for the end-of-compile notice. Idempotent -/// per `(kind, location)`. -fn record_deferred_site(classification: &EvalClassification) { +/// Record a deferred ahead-of-time-unsupported site for the end-of-compile +/// notice. Shared sink for every degraded surface (#5206 eval / `new Function`, +/// #5230 dynamic `import(...)`). Idempotent per `(kind, location)` so the same +/// site lowered more than once (rayon re-lower, two passes) isn't double-counted. +/// +/// `kind` is the display label of the surface (e.g. `import(...)`); `location` +/// is `file:line`. +pub fn record_deferred_aot_site(kind: impl Into, location: impl Into) { let site = DeferredEvalSite { - kind: classification.surface.label().to_string(), - location: classification.location(), + kind: kind.into(), + location: location.into(), }; if let Ok(mut v) = EVAL_DEFERRED_SITES.lock() { if !v.contains(&site) { @@ -360,6 +371,14 @@ fn record_deferred_site(classification: &EvalClassification) { } } +/// Record a deferred bucket-3 eval/Function site for the end-of-compile notice. +fn record_deferred_site(classification: &EvalClassification) { + record_deferred_aot_site( + classification.surface.label().to_string(), + classification.location(), + ); +} + /// Drain and return every deferred bucket-3 site recorded so far this /// compile. Called by the driver to render the end-of-compile notice. pub fn take_deferred_eval_sites() -> Vec { diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index 37c0da8b3e..ba85f4e136 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -2518,6 +2518,20 @@ pub enum Expr { DynamicImport { paths: Vec, arg: Box, + /// Byte offset (`span.lo.0`) of the `import(...)` call in its module's + /// source, captured at lowering time. Used by the driver to resolve a + /// `file:line` for the #5230 deferred-site notice (HIR `Expr` carries no + /// span otherwise). `0` when unknown. + byte_offset: u32, + /// #5230: when `Some(msg)`, the path argument was non-resolvable + /// (runtime-computed) and this site was *deferred* (the default, + /// non-strict policy — the analog of #5206's eval deferral). Codegen + /// lowers it to a rejected `Promise` carrying an `Error(msg)`, so + /// `await import(spec)` throws a descriptive error *only if reached* + /// rather than failing the whole build. `None` is the normal case + /// (`paths` resolved to a finite set). In strict mode such a site is a + /// compile error instead and never produces a node with this set. + deferred_error: Option, }, /// Compile-time-resolved `new Worker(filename, options?)` from /// `node:worker_threads`. The filename expression follows the same diff --git a/crates/perry-hir/src/lib.rs b/crates/perry-hir/src/lib.rs index 91dc6e07dc..217b2c1a75 100644 --- a/crates/perry-hir/src/lib.rs +++ b/crates/perry-hir/src/lib.rs @@ -40,8 +40,9 @@ pub use dynamic_import::{ pub use egress::{audit_module_egress, EgressRefusalReason, EgressViolation}; pub use enums::fix_imported_enums; pub use eval_classifier::{ - classify as classify_eval_surface, set_eval_strict_mode, take_deferred_eval_sites, - DeferredEvalSite, EvalBucket, EvalClassification, EvalDecision, EvalSurface, + classify as classify_eval_surface, record_deferred_aot_site, set_eval_strict_mode, + take_deferred_eval_sites, DeferredEvalSite, EvalBucket, EvalClassification, EvalDecision, + EvalSurface, }; pub use ir::*; pub use js_transform::{ diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index dccecdb9b7..97bb2d5cb9 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -649,6 +649,8 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result { tag(h, 12054); module.as_ref().hash(h); name.as_ref().hash(h); } Expr::WebAssemblyInstantiate(bytes) => { tag(h, 12028); bytes.as_ref().hash(h); } Expr::WebAssemblyCallExport { instance, name, args, } => { tag(h, 12029); instance.as_ref().hash(h); name.as_ref().hash(h); args.hash(h); } - Expr::DynamicImport { paths, arg } => { tag(h, 12030); for p in paths { p.hash(h); } arg.as_ref().hash(h); } + Expr::DynamicImport { paths, arg, byte_offset, deferred_error } => { tag(h, 12030); for p in paths { p.hash(h); } arg.as_ref().hash(h); byte_offset.hash(h); deferred_error.hash(h); } Expr::WorkerNew { paths, filename, options } => { tag(h, 12055); for p in paths { p.hash(h); } diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index a84c6cefa2..ac7e6d4b07 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -5761,12 +5761,14 @@ pub fn run_with_parse_cache( cleanup_intermediates(args.keep_intermediates, &obj_cleanup_paths); - // #5206: visible end-of-compile notice listing every runtime-eval site - // (`eval(...)` / `new Function()`) that was compiled to a - // deferred runtime error instead of blocking the build. Strict-eval mode - // (`--strict-eval` / `perry.eval = "error"`) never reaches here for such - // a site — it fails the build earlier. Text format only (JSON consumers - // get a clean machine-readable result on stdout). + // #5206 / #5230: visible end-of-compile notice listing every + // ahead-of-time-unsupported site that was compiled to a deferred runtime + // error instead of blocking the build — runtime-unknown `eval(...)` / + // `new Function()` and non-resolvable dynamic `import(...)`. + // Strict mode (`--strict-eval` / `--strict-dynamic-import` / `perry.eval = + // "error"` / `perry.dynamicImport = "error"` / `perry.strict`) never reaches + // here for a covered site — it fails the build earlier. Text format only + // (JSON consumers get a clean machine-readable result on stdout). print_deferred_eval_notice(format); let final_output_path = result_app_dir.unwrap_or(exe_path); @@ -5783,10 +5785,12 @@ pub fn run_with_parse_cache( }) } -/// #5206: print the end-of-compile notice for runtime-eval sites that were -/// compiled to deferred runtime errors. Drains the process-global sink (so -/// re-running a compile in the same process starts fresh) and prints a single -/// stand-out block. No-op when there are no such sites or for JSON output. +/// #5206 / #5230: print the end-of-compile notice for ahead-of-time-unsupported +/// sites (runtime-unknown `eval(...)` / `new Function(...)`, and non-resolvable +/// dynamic `import(...)`) that were compiled to deferred runtime errors. Drains +/// the shared process-global sink (so re-running a compile in the same process +/// starts fresh) and prints a single stand-out block. No-op when there are no +/// such sites or for JSON output. fn print_deferred_eval_notice(format: OutputFormat) { let sites = perry_hir::take_deferred_eval_sites(); if sites.is_empty() || !matches!(format, OutputFormat::Text) { @@ -5807,7 +5811,7 @@ fn print_deferred_eval_notice(format: OutputFormat) { }; eprintln!(); eprintln!( - "{y}{b}notice:{r}{y} {n} runtime-eval {plural} compiled to a deferred runtime error (throws only if reached):{r}" + "{y}{b}notice:{r}{y} {n} ahead-of-time-unsupported {plural} compiled to a deferred runtime error (throws only if reached):{r}" ); // Align the locations into a column for readability. let kind_width = sites.iter().map(|s| s.kind.len()).max().unwrap_or(0); @@ -5820,7 +5824,7 @@ fn print_deferred_eval_notice(format: OutputFormat) { ); } eprintln!( - " Pass {b}--strict-eval{r} (or set {b}perry.eval = \"error\"{r}) to make these a compile-time error instead." + " Pass {b}--strict-eval{r}/{b}--strict-dynamic-import{r} (or set {b}perry.strict = true{r}) to make these a compile-time error instead." ); eprintln!(); } diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index a442fbe132..f0119a25fb 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -818,9 +818,28 @@ fn collect_module_one( &module_const_locals, &dynamic_param_literals, ); - let mut dynamic_path_sets: Vec> = Vec::new(); + // #5230: re-install this module's source (cleared after the lower above) so + // `current_module_line_at` can resolve a `file:line` for a deferred dynamic + // import's notice/runtime-error message. Cleared again after the fill pass. + perry_hir::set_current_module_source(source.clone()); + // Per-site outcome, aligned 1:1 with the `for_each_dynamic_import` + // traversal order so the mutable fill pass below can apply them. + // `Resolved(set)` populates `paths`; `Deferred(msg)` (#5230) leaves + // `paths` empty and sets `deferred_error` so codegen lowers the site to a + // rejected promise that throws `msg` only if reached. + enum DynImportOutcome { + Resolved(Vec), + Deferred(String), + } + let mut dynamic_path_sets: Vec = Vec::new(); perry_hir::for_each_dynamic_import(&hir_module, &mut |expr| { - if let perry_hir::Expr::DynamicImport { paths, arg } = expr { + if let perry_hir::Expr::DynamicImport { + paths, + arg, + byte_offset, + .. + } = expr + { if !paths.is_empty() { // Already resolved (e.g. a second pass on the same module). return; @@ -851,7 +870,7 @@ fn collect_module_one( new_dyn_imports.push(p.clone()); } } - dynamic_path_sets.push(set); + dynamic_path_sets.push(DynImportOutcome::Resolved(set)); } perry_hir::Resolution::Unresolved(reason) => { // #1674 sub-part B: a non-resolvable template specifier with @@ -886,16 +905,43 @@ fn collect_module_one( new_dyn_imports.push(p.clone()); } } - dynamic_path_sets.push(matches); + dynamic_path_sets.push(DynImportOutcome::Resolved(matches)); return; } } - dyn_errors.push(format!( - "dynamic import() in module {} ({}): {}", - module_name, - canonical.display(), - reason - )); + // #5230: a genuinely runtime-computed specifier. This is the + // analog of #5206's runtime-unknown eval bucket. Strict mode + // (`--strict-dynamic-import` / `perry.dynamicImport = "error"` + // / `perry.strict`) restores the historical hard compile + // error. The default policy *defers* it: compile the site to + // a rejected promise that throws a descriptive Error only if + // reached, record it for the shared end-of-compile notice, + // and keep building so plugin-loader apps compile + run their + // core. `PERRY_ALLOW_EVAL=1` forces defer (shared AOT escape + // hatch). + if ctx.strict_dynamic_import + && !perry_hir::eval_classifier::eval_override_enabled() + { + dyn_errors.push(format!( + "dynamic import() in module {} ({}): {}", + module_name, + canonical.display(), + reason + )); + } else { + let line = perry_hir::current_module_line_at(*byte_offset) + .filter(|&l| l != 0); + let loc = match line { + Some(l) => format!("{}:{}", source_file_path, l), + None => source_file_path.clone(), + }; + let msg = format!( + "dynamic import() of a runtime-computed path cannot run in an \ + ahead-of-time compiled binary ({loc})" + ); + perry_hir::record_deferred_aot_site("import(...)", loc); + dynamic_path_sets.push(DynImportOutcome::Deferred(msg)); + } } } } @@ -965,14 +1011,22 @@ fn collect_module_one( drop(dynamic_local_literals); drop(module_const_locals); if !dyn_errors.is_empty() { + perry_hir::clear_current_module_source(); return Err(anyhow!("{}", dyn_errors.join("\n"))); } let mut dynamic_path_sets = dynamic_path_sets.into_iter(); perry_hir::for_each_dynamic_import_mut(&mut hir_module, &mut |expr| { - if let perry_hir::Expr::DynamicImport { paths, .. } = expr { - if paths.is_empty() { - if let Some(set) = dynamic_path_sets.next() { - *paths = set; + if let perry_hir::Expr::DynamicImport { + paths, + deferred_error, + .. + } = expr + { + if paths.is_empty() && deferred_error.is_none() { + match dynamic_path_sets.next() { + Some(DynImportOutcome::Resolved(set)) => *paths = set, + Some(DynImportOutcome::Deferred(msg)) => *deferred_error = Some(msg), + None => {} } } } @@ -987,6 +1041,9 @@ fn collect_module_one( } } }); + // #5230: done with the dynamic-import line resolution; don't leak this + // module's source onto unrelated work on this (possibly rayon-worker) thread. + perry_hir::clear_current_module_source(); for source in new_dyn_imports { // A dynamic edge to the same source as a static import is folded // into the existing static edge: that edge already gives us full diff --git a/crates/perry/src/commands/compile/host_config.rs b/crates/perry/src/commands/compile/host_config.rs index 134da22eb9..e5a444858c 100644 --- a/crates/perry/src/commands/compile/host_config.rs +++ b/crates/perry/src/commands/compile/host_config.rs @@ -356,6 +356,8 @@ pub(super) fn apply_pkg_and_toml_config( .and_then(|v| v.as_bool()) { ctx.strict_eval = strict; + // #5230: the broad `perry.strict` covers dynamic imports too. + ctx.strict_dynamic_import = strict; } if let Some(mode) = pkg .get("perry") @@ -368,6 +370,19 @@ pub(super) fn apply_pkg_and_toml_config( _ => {} } } + // #5230: dedicated `perry.dynamicImport = "defer" | "error"` + // overrides the broad `perry.strict` for dynamic-import sites. + if let Some(mode) = pkg + .get("perry") + .and_then(|p| p.get("dynamicImport")) + .and_then(|v| v.as_str()) + { + match mode { + "error" | "strict" => ctx.strict_dynamic_import = true, + "defer" => ctx.strict_dynamic_import = false, + _ => {} + } + } // #502: perry.allowedHosts — compile-time URL/host // egress allowlist. Patterns: exact host, "*.foo.com" // subdomain wildcard, "https://host/prefix*" URL @@ -523,6 +538,8 @@ pub(super) fn apply_pkg_and_toml_config( if let Some(perry_tbl) = table.get("perry").and_then(|v| v.as_table()) { if let Some(strict) = perry_tbl.get("strict").and_then(|v| v.as_bool()) { ctx.strict_eval = strict; + // #5230: broad `perry.strict` covers dynamic imports too. + ctx.strict_dynamic_import = strict; } if let Some(mode) = perry_tbl.get("eval").and_then(|v| v.as_str()) { match mode { @@ -531,17 +548,30 @@ pub(super) fn apply_pkg_and_toml_config( _ => {} } } + // #5230: dedicated `perry.dynamicImport` overrides for imports. + if let Some(mode) = perry_tbl.get("dynamicImport").and_then(|v| v.as_str()) { + match mode { + "error" | "strict" => ctx.strict_dynamic_import = true, + "defer" => ctx.strict_dynamic_import = false, + _ => {} + } + } } } } - // CLI flag opts in. + // CLI flags opt in. if args.strict_eval { ctx.strict_eval = true; } + if args.strict_dynamic_import { + ctx.strict_dynamic_import = true; + } // Back-compat: `PERRY_ALLOW_EVAL` forces non-strict for a one-off build, - // overriding any strict flag/config. + // overriding any strict flag/config — for both eval and dynamic import + // (shared AOT escape hatch, #5230). if perry_hir::eval_classifier::eval_override_enabled() { ctx.strict_eval = false; + ctx.strict_dynamic_import = false; } // Install into the HIR thread-local before any lowering begins (re-applied // per rayon worker in collect_modules.rs, mirroring the dynamic-stdlib diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 7512081755..8fa71770b1 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -292,6 +292,17 @@ pub struct CompileArgs { #[arg(long)] pub strict_eval: bool, + /// #5230 — strict dynamic-import mode. Fail the build at compile time if a + /// dynamic `import(...)` has a runtime-computed (non-resolvable) specifier + /// (the historical behavior). By default such a site is instead compiled to + /// a rejected `Promise` that throws a descriptive `Error` only if reached, + /// and is listed in the same end-of-build notice as deferred eval sites. + /// Also settable via `"perry": { "dynamicImport": "error" }`; the broad + /// `"perry": { "strict": true }` covers it too. `PERRY_ALLOW_EVAL=1` forces + /// this off. + #[arg(long)] + pub strict_dynamic_import: bool, + /// Minimum Windows version the compiled executable must run on. /// Accepted values: `7`, `8`, `10` (default `10`). Ignored on every /// non-Windows target. @@ -672,6 +683,16 @@ pub struct CompilationContext { /// package.json or perry.toml → CLI `--strict-eval`. `PERRY_ALLOW_EVAL=1` /// always forces this off (back-compat escape hatch). pub strict_eval: bool, + /// #5230: strict mode for non-resolvable (runtime-computed) dynamic + /// `import(...)` specifiers. When true, such a site is a hard compile error + /// (the historical behavior). When false (the default), it is deferred to a + /// rejected `Promise` that throws a descriptive `Error` only if reached, and + /// recorded in the shared end-of-compile notice. Defaults to `strict_eval` + /// so the broad `perry.strict = true` covers both; overridden by the + /// dedicated `perry.dynamicImport = "defer" | "error"` config and the + /// `--strict-dynamic-import` CLI flag. `PERRY_ALLOW_EVAL=1` forces it off + /// (shared AOT escape hatch). + pub strict_dynamic_import: bool, /// #503: package names whose modules may legitimately use dynamic /// stdlib dispatch (`perry.allowDynamicStdlibDispatch: [...]`). /// Consulted per-module during HIR lowering; ignored when @@ -873,6 +894,7 @@ impl CompilationContext { extra_stdlib_features: BTreeSet::new(), refuse_dynamic_stdlib_dispatch: true, strict_eval: false, + strict_dynamic_import: false, allow_dynamic_stdlib_packages: HashSet::new(), js_runtime_importers: Vec::new(), permissions: std::collections::BTreeMap::new(), diff --git a/crates/perry/src/commands/dev.rs b/crates/perry/src/commands/dev.rs index 9dc550ae51..642920443b 100644 --- a/crates/perry/src/commands/dev.rs +++ b/crates/perry/src/commands/dev.rs @@ -306,6 +306,7 @@ fn build_once( emit_sandbox: false, lockdown: false, strict_eval: false, + strict_dynamic_import: false, min_windows_version: "10".to_string(), windows_subsystem: "auto".to_string(), // Phase 2 v7: harmonyos signing flags. `perry dev` is the watch diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index bc3fd7ed26..02a37a5329 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -237,6 +237,7 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) -> emit_sandbox: false, lockdown: false, strict_eval: false, + strict_dynamic_import: false, min_windows_version: "10".to_string(), windows_subsystem: "auto".to_string(), // Phase 2 v7: harmonyos signing flags. `perry run` is for local diff --git a/crates/perry/tests/issue_5206_deferred_eval_runtime_error.rs b/crates/perry/tests/issue_5206_deferred_eval_runtime_error.rs index 8827eecb8b..ac4e0d6213 100644 --- a/crates/perry/tests/issue_5206_deferred_eval_runtime_error.rs +++ b/crates/perry/tests/issue_5206_deferred_eval_runtime_error.rs @@ -119,9 +119,11 @@ fn default_defer_compiles_prints_notice_and_runs() { "default compile must succeed; stderr:\n{stderr}" ); - // The visible end-of-compile notice: count + kind + file:line. + // The visible end-of-compile notice: count + kind + file:line. #5230 + // generalized the headline ("ahead-of-time-unsupported site(s)") so eval and + // dynamic-import deferrals share one notice. assert!( - stderr.contains("runtime-eval site"), + stderr.contains("ahead-of-time-unsupported site"), "expected the deferred-eval notice; stderr:\n{stderr}" ); assert!( @@ -234,7 +236,7 @@ fn allow_eval_env_overrides_strict_config() { "PERRY_ALLOW_EVAL=1 must override a strict config and build; stderr:\n{stderr}" ); assert!( - stderr.contains("runtime-eval site"), + stderr.contains("ahead-of-time-unsupported site"), "back-compat override must still print the deferred notice; stderr:\n{stderr}" ); } diff --git a/crates/perry/tests/issue_5230_deferred_dynamic_import.rs b/crates/perry/tests/issue_5230_deferred_dynamic_import.rs new file mode 100644 index 0000000000..052695b9f9 --- /dev/null +++ b/crates/perry/tests/issue_5230_deferred_dynamic_import.rs @@ -0,0 +1,273 @@ +//! Regression test for #5230: a non-resolvable (runtime-computed) dynamic +//! `import(...)` no longer blocks the build by default — it applies the same +//! defer/notice/strict policy as #5206's eval deferral. +//! +//! Default (non-strict) behavior: +//! 1. compilation SUCCEEDS even with a runtime-computed `import(spec)` in a +//! cold (never-taken) branch, +//! 2. a visible end-of-compile NOTICE lists the degraded site under the SAME +//! header as deferred eval sites (count + kind `import(...)` + `file:line`), +//! 3. the binary runs fine when the import path is never reached, including a +//! sibling RESOLVABLE literal `import("./real.js")` that still loads, and +//! 4. if the deferred import IS reached it throws a descriptive, catchable +//! `Error` (not a crash/segfault, not a silent no-op). +//! +//! Strict mode (`--strict-dynamic-import`, `perry.dynamicImport = "error"`, or +//! the broad `perry.strict = true`) restores the historical hard compile-time +//! refusal ("not statically resolvable"). + +use std::path::PathBuf; +use std::process::Command; +use std::sync::Once; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn target_debug_dir() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")) + .join("debug") +} + +/// Build `libperry_runtime.a` once so the compiled binaries can link (mirrors +/// the #5206 test; the CI `cargo-test` job doesn't pre-build the staticlib). +fn ensure_runtime_archive() { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let build = Command::new(cargo) + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime") + .output() + .expect("run cargo build -p perry-runtime"); + assert!( + build.status.success(), + "cargo build -p perry-runtime failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr) + ); + }); +} + +fn runtime_dir() -> PathBuf { + ensure_runtime_archive(); + target_debug_dir() +} + +/// A resolvable sibling module — the literal `import("./real.js")` must still +/// compile and load it (regression guard for the resolvable forms). +const REAL_MODULE: &str = r#"export const greeting = "hello from real module"; +"#; + +/// The cold-path fixture: +/// - `import("./real.js")` is a string literal → resolvable → still loads. +/// - `import(p + ".js")` is runtime-computed → non-resolvable → deferred. +/// The deferred site is only reached behind `--force-import`. +const COLD_FIXTURE: &str = r#" +async function loadReal() { + const m = await import("./real.js"); + console.log("REAL:" + m.greeting); +} + +async function loadPlugin(name: string) { + const p = name; + const m = await import(p + ".js"); + return m; +} + +await loadReal(); + +if (process.argv.indexOf("--force-import") !== -1) { + try { + await loadPlugin("./real"); + console.log("NO_THROW"); + } catch (e: any) { + console.log("CAUGHT:" + (e && e.message)); + } +} +"#; + +fn write_fixture(root: &std::path::Path) { + std::fs::write(root.join("real.js"), REAL_MODULE).expect("write real.js"); + std::fs::write(root.join("main.ts"), COLD_FIXTURE).expect("write main.ts"); +} + +fn compile(root: &std::path::Path, extra_args: &[&str]) -> std::process::Output { + let entry = root.join("main.ts"); + let output = root.join("main_bin"); + let mut cmd = Command::new(perry_bin()); + cmd.current_dir(root) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache"); + for a in extra_args { + cmd.arg(a); + } + cmd.env("PERRY_NO_AUTO_OPTIMIZE", "1"); + cmd.env("PERRY_RUNTIME_DIR", runtime_dir()); + cmd.output().expect("run perry compile") +} + +#[test] +fn default_defer_compiles_prints_notice_runs_and_throws_on_reach() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + + let out = compile(root, &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "default compile must succeed; stderr:\n{stderr}" + ); + + // The shared end-of-compile notice (same header as deferred eval) names the + // dynamic-import kind and a `file:line`. + assert!( + stderr.contains("ahead-of-time-unsupported site"), + "expected the shared deferred-site notice; stderr:\n{stderr}" + ); + assert!( + stderr.contains("import(...)"), + "notice must name the dynamic-import kind; stderr:\n{stderr}" + ); + assert!( + stderr.contains("main.ts:9"), + "notice must name the site location (file:line); stderr:\n{stderr}" + ); + + // The resolvable literal import loads; the deferred site is never reached. + let bin = root.join("main_bin"); + let run = Command::new(&bin).output().expect("run compiled binary"); + assert!(run.status.success(), "binary must run the resolvable path"); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!( + stdout.contains("REAL:hello from real module"), + "resolvable literal import must still load; got:\n{stdout}" + ); + + // Forcing the deferred path throws a descriptive, catchable Error. + let run2 = Command::new(&bin) + .arg("--force-import") + .output() + .expect("run compiled binary --force-import"); + let stdout2 = String::from_utf8_lossy(&run2.stdout); + assert!( + run2.status.success(), + "the binary must not crash when the deferred import is reached" + ); + assert!( + stdout2.contains("CAUGHT:"), + "the reached deferred import must throw a catchable Error; got:\n{stdout2}" + ); + assert!( + stdout2.contains("runtime-computed path cannot run in an ahead-of-time compiled binary"), + "the thrown Error must be descriptive; got:\n{stdout2}" + ); + assert!( + !stdout2.contains("NO_THROW"), + "the reached deferred import must NOT silently no-op; got:\n{stdout2}" + ); +} + +#[test] +fn strict_dynamic_import_flag_refuses_at_compile_time() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + + let out = compile(root, &["--strict-dynamic-import"]); + assert!( + !out.status.success(), + "--strict-dynamic-import must fail the build for a runtime-computed import" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("not statically resolvable"), + "strict mode must print the not-resolvable refusal; stderr:\n{stderr}" + ); +} + +#[test] +fn perry_strict_config_covers_dynamic_import() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + // The broad `perry.strict = true` must cover dynamic imports too. + std::fs::write( + root.join("package.json"), + r#"{ "name": "strict-dynimport-cfg", "perry": { "strict": true } }"#, + ) + .expect("write package.json"); + + let out = compile(root, &[]); + assert!( + !out.status.success(), + "perry.strict = true must fail the build for a runtime-computed import" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("not statically resolvable"), + "perry.strict must restore the refusal; stderr:\n{stderr}" + ); +} + +#[test] +fn perry_dynamic_import_error_config_refuses() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + std::fs::write( + root.join("package.json"), + r#"{ "name": "dynimport-error-cfg", "perry": { "dynamicImport": "error" } }"#, + ) + .expect("write package.json"); + + let out = compile(root, &[]); + assert!( + !out.status.success(), + "perry.dynamicImport = \"error\" must fail the build" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("not statically resolvable"), + "dynamicImport=error must restore the refusal; stderr:\n{stderr}" + ); +} + +#[test] +fn perry_dynamic_import_defer_overrides_broad_strict() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + // Dedicated knob (defer) wins over the broad strict for dynamic imports. + std::fs::write( + root.join("package.json"), + r#"{ "name": "dynimport-override", "perry": { "strict": true, "dynamicImport": "defer" } }"#, + ) + .expect("write package.json"); + + let out = compile(root, &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "perry.dynamicImport=\"defer\" must override perry.strict for imports; stderr:\n{stderr}" + ); + assert!( + stderr.contains("ahead-of-time-unsupported site"), + "override must still defer + notice; stderr:\n{stderr}" + ); +} diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index b5d410bfbf..2cd0e6ec13 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -87,6 +87,7 @@ Minification strips comments, collapses whitespace, and mangles local variable/p | `--enable-wasm-runtime` | Force-link the wasmi WebAssembly host runtime (auto-detected when `WebAssembly.*` is referenced; needed only when loading via dlopen / FFI without a static reference) | | `--type-check` | Enable type checking via tsgo IPC | | `--strict-eval` | Fail the build if any runtime-unknown `eval(...)` / `new Function()` site is reachable. By default such a site is compiled to a deferred runtime error (throws only if reached) and a compile-time notice is printed. Also settable via `perry.eval = "error"` / `perry.strict = true` (package.json or perry.toml). `PERRY_ALLOW_EVAL=1` forces it off. See [Limitations](../language/limitations.md#no-eval-or-dynamic-code). | +| `--strict-dynamic-import` | Fail the build if a dynamic `import(...)` has a runtime-computed (non-resolvable) specifier. By default such a site is compiled to a rejected `Promise` that throws a descriptive `Error` only if reached, and is listed in the same end-of-build notice as deferred eval sites. Also settable via `perry.dynamicImport = "error"` / `perry.strict = true` (package.json or perry.toml). `PERRY_ALLOW_EVAL=1` forces it off. Resolvable forms (string literals, ternaries of resolvable arms, template literals over const locals, finite union-typed params, glob) are unaffected. See [Limitations](../language/limitations.md#no-eval-or-dynamic-code). | ## Environment Variables diff --git a/docs/src/language/limitations.md b/docs/src/language/limitations.md index 9e3fc88b80..8e4ae2b619 100644 --- a/docs/src/language/limitations.md +++ b/docs/src/language/limitations.md @@ -43,15 +43,41 @@ evaluated; a `new Function(...)` returns a function that throws when called), and prints a single end-of-compile notice listing every degraded site: ```text -notice: 2 runtime-eval site(s) compiled to a deferred runtime error (throws only if reached): - - new Function(...) src/cli/cmd/debug/agent.handler.ts:41 +notice: 3 ahead-of-time-unsupported site(s) compiled to a deferred runtime error (throws only if reached): - eval(...) src/foo.ts:12 - Pass --strict-eval (or set perry.eval = "error") to make these a compile-time error instead. + - import(...) src/plugins/loader.ts:88 + - new Function(...) src/cli/cmd/debug/agent.handler.ts:41 + Pass --strict-eval/--strict-dynamic-import (or set perry.strict = true) to make these a compile-time error instead. ``` This lets a single such call in a cold path ship without aborting the whole build, while still failing loudly (and catchably) if that path runs. +### Dynamic `import()` with a runtime-computed specifier (#5230) + +A dynamic `import(spec)` whose `spec` is only known at runtime (a plugin loader +building a path from a variable) is subject to the **same defer/notice/strict +policy** as `eval`. By default it compiles to a rejected `Promise` carrying a +descriptive `Error` (so `await import(spec)` throws *only if reached*), is +listed in the shared notice above under the `import(...)` kind, and does **not** +abort the build. This lets an app with a plugin-loader path compile and run its +core, with only the plugin-load path throwing if exercised. + +Resolvable specifiers are unaffected and still compile + load: string literals +(`import("./mod.js")`), ternaries of resolvable arms, template literals over +`const` locals (`` import(`./${KIND}.js`) ``), finite string-literal-union +parameters, and directory globs. + +```ts +// Resolvable → compiled + loaded as today. +const real = await import("./real.js"); + +// Runtime-computed → deferred (default): throws only if this line runs. +async function loadPlugin(name: string) { + return await import(name + ".js"); +} +``` + ### Strict mode: refuse at compile time To make every runtime-unknown site a hard compile-time error instead, opt into @@ -67,6 +93,14 @@ package.json/perry.toml config → `--strict-eval` (opts in). The legacy `PERRY_ALLOW_EVAL=1` environment variable still works: it forces non-strict (defer) mode for a one-off build, overriding any strict flag/config. +The same strict controls apply to runtime-computed dynamic `import()` (#5230). +The broad `perry.strict = true` covers both eval and dynamic import. For a knob +scoped to dynamic imports only, use `--strict-dynamic-import` or +`"perry": { "dynamicImport": "error" }` (accepts `"defer"` (default) or +`"error"`); the dedicated knob overrides the broad `perry.strict` for import +sites. `PERRY_ALLOW_EVAL=1` is the shared AOT escape hatch — it forces defer for +both eval and dynamic import. + Test262 rows that only observe parsing or executing a code string remain intentional AOT exclusions, not runtime dynamic-code work. This includes the `language/white-space/comment-{multi,single}-{form-feed,horizontal-tab,nbsp,space,vertical-tab}.js` From 3a776569b1c7b0b8742cd1b6c374a311ca588641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 16 Jun 2026 07:08:30 +0200 Subject: [PATCH 2/2] feat(compile): defer `.wasm` ESM imports to throw-on-call stubs (#5235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `import ... from "./x.wasm"` previously hard-failed because perry read the `.wasm` as UTF-8 (`stream did not contain valid UTF-8`). Defer it, matching the #5206/#5230 deferred-AOT-site policy: - New `collect_modules/wasm_asset.rs`: read the `.wasm` BYTES, walk the WebAssembly export section (section id 7) via defensive unsigned LEB128 decoding, and synthesize a TypeScript stub module whose exports are throw-on-call functions plus a throwing default export. The synthesized source flows through the exact same parse/lower/codegen pipeline as the #5223 text-asset / JSON synthetic modules. Malformed binary → default-only stub, never a crash. Non-identifier export names are skipped (reachable only via real instantiation, #5234). - `collect_modules.rs`: detect `.wasm`, feed `raw_source` from the stub instead of reading the file as text; record the site via the shared `record_deferred_aot_site(".wasm import", loc)` sink so it appears in the end-of-compile notice. - Strict mode (broad `perry.strict` / `--strict-dynamic-import` / `perry.dynamicImport = "error"`) makes a `.wasm` import a hard compile error; `PERRY_ALLOW_EVAL=1` forces defer (shared AOT escape hatch). Real `.wasm` ESM instantiation is the companion issue #5234; the stub error message references it. Tests: 6 unit tests in wasm_asset.rs (export parse, default-only fallback, LEB128, ident filter) + integration suite issue_5235_deferred_wasm_import.rs (default defer compiles + prints notice + import-only runs + call throws catchable Error referencing #5234; strict flag and perry.strict both refuse). --- .../src/commands/compile/collect_modules.rs | 48 +++- .../compile/collect_modules/wasm_asset.rs | 271 ++++++++++++++++++ .../tests/issue_5235_deferred_wasm_import.rs | 215 ++++++++++++++ 3 files changed, 531 insertions(+), 3 deletions(-) create mode 100644 crates/perry/src/commands/compile/collect_modules/wasm_asset.rs create mode 100644 crates/perry/tests/issue_5235_deferred_wasm_import.rs diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index f0119a25fb..d633b8a05c 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -37,6 +37,7 @@ mod dynamic_glob; mod feature_detect; mod native_addon; mod parse_error; +mod wasm_asset; #[cfg(test)] mod tests; @@ -44,6 +45,7 @@ use create_require_transform::transform_create_require_literal_requires; use dynamic_glob::expand_dynamic_import_glob; use native_addon::refuse_compile_package_native_addon; use parse_error::annotate_parse_error; +use wasm_asset::{is_wasm_asset, synthesize_wasm_stub_module}; const MAX_CROSS_MODULE_INLINE_PRIOR_MODULES: usize = 128; @@ -383,6 +385,11 @@ fn collect_module_one( // default export is the file contents as a JS string (see the text branch // below, mirroring the JSON-module path). `.wasm` is out of scope. let is_text_asset = is_recognized_text_asset(&canonical); + // #5235: `.wasm` ESM import. The file is binary (not valid UTF-8), so it + // must NOT be read as a string. We read the bytes, parse the export section, + // and synthesize a throwing-stub module (see the wasm branch below). Real + // `.wasm` ESM instantiation is the companion issue #5234. + let is_wasm = is_wasm_asset(&canonical); let is_in_node_modules = canonical.to_string_lossy().contains("node_modules"); let is_perry_native = is_in_node_modules && is_in_perry_native_package(&canonical); let is_in_compiled_pkg = (is_in_node_modules && is_in_compile_package(&canonical, &ctx.compile_packages)) @@ -517,9 +524,44 @@ fn collect_module_one( }); } - // It's a TypeScript file to compile natively - let raw_source = fs::read_to_string(&canonical) - .map_err(|e| anyhow!("Failed to read {}: {}", canonical.display(), e))?; + // #5235: `.wasm` ESM import — defer. Read the BYTES (never as UTF-8; the + // file is binary), parse the WebAssembly export section, and synthesize a + // TypeScript stub module whose exports are throw-on-call functions. Strict + // mode makes it a hard error; the default policy defers it (records the + // shared end-of-compile notice and keeps building) so a build with a + // peripheral `.wasm` dep compiles + runs its core — the wasm feature throws + // only if reached. Real `.wasm` ESM instantiation is the companion #5234. + // + // The synthesized source flows through the exact same parse/lower/codegen + // pipeline as the #5223 text-asset and JSON synthetic modules below — we + // just feed `raw_source` from the stub instead of reading the file as text. + let raw_source = if is_wasm { + let display_name = canonical + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("module.wasm"); + let loc = canonical.to_string_lossy().to_string(); + // Strict mode (broad `perry.strict` / `--strict-dynamic-import` / + // `perry.dynamicImport = "error"`) turns the deferred `.wasm` import into + // a hard compile error. `PERRY_ALLOW_EVAL=1` forces defer (shared AOT + // escape hatch), mirroring the dynamic-import deferral (#5230). + if ctx.strict_dynamic_import && !perry_hir::eval_classifier::eval_override_enabled() { + return Err(anyhow!( + ".wasm import {} cannot run in an ahead-of-time compiled binary \ + — full .wasm ESM instantiation is tracked in #5234 (strict mode)", + loc + )); + } + let bytes = fs::read(&canonical) + .map_err(|e| anyhow!("Failed to read {}: {}", canonical.display(), e))?; + let stub = synthesize_wasm_stub_module(&bytes, display_name); + perry_hir::record_deferred_aot_site(".wasm import", loc); + stub.source + } else { + // It's a TypeScript (or synthetic JSON/text) file to compile natively. + fs::read_to_string(&canonical) + .map_err(|e| anyhow!("Failed to read {}: {}", canonical.display(), e))? + }; // JSON module import: turn the data file into a native ESM module whose // default export is the parsed value. JSON is a syntactic subset of a JS // expression, so `export default ;` parses and lowers like any other diff --git a/crates/perry/src/commands/compile/collect_modules/wasm_asset.rs b/crates/perry/src/commands/compile/collect_modules/wasm_asset.rs new file mode 100644 index 0000000000..b4d91fee98 --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/wasm_asset.rs @@ -0,0 +1,271 @@ +//! #5235: defer `.wasm` ESM imports. +//! +//! An `import ... from "./x.wasm"` cannot run in an ahead-of-time compiled +//! binary today — real `.wasm` ESM instantiation is tracked as the companion +//! issue #5234. Rather than hard-failing the whole build (the file isn't valid +//! UTF-8, so the normal TS read aborts with `stream did not contain valid +//! UTF-8`), we *defer* the import: we parse the WebAssembly **export section** +//! for the export names and synthesize a tiny TypeScript module whose exports +//! are **throw-on-call stubs**. The build proceeds past peripheral `.wasm` +//! dependencies; the wasm feature throws a descriptive `Error` only if actually +//! reached. +//! +//! This mirrors the #5206 / #5230 deferred-AOT-site policy: the site is recorded +//! in the shared end-of-compile notice (`record_deferred_aot_site`), and strict +//! mode (`perry.strict` / `--strict-dynamic-import`) turns it into a hard +//! compile error instead. +//! +//! The export-section walk is a trivial, defensive binary parse — on *any* +//! malformed input we fall back to synthesizing just a throwing default export +//! (and still record the deferred site) rather than crashing. + +/// True when `path` is a `.wasm` file (case-insensitive extension). +pub(crate) fn is_wasm_asset(path: &std::path::Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("wasm")) + .unwrap_or(false) +} + +/// Decode one unsigned LEB128 integer from `bytes` starting at `*pos`. +/// Advances `*pos` past the consumed bytes. Returns `None` on truncation or an +/// over-long encoding (more than 5 bytes for the u32 range we care about — the +/// wasm spec caps section/name/index encodings at u32). +fn read_uleb128(bytes: &[u8], pos: &mut usize) -> Option { + let mut result: u64 = 0; + let mut shift = 0u32; + loop { + let byte = *bytes.get(*pos)?; + *pos += 1; + result |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + break; + } + shift += 7; + // u32 LEB128 is at most 5 bytes; guard against unbounded/over-long input. + if shift >= 35 { + return None; + } + } + u32::try_from(result).ok() +} + +/// Parsed export-section result. +struct WasmExports { + /// Export names found in section id 7. Empty when the section is absent. + names: Vec, +} + +/// Walk a `.wasm` binary and collect the names in its export section (id 7). +/// +/// Returns `None` when the header is absent/malformed (caller then synthesizes a +/// default-only stub). Returns `Some(WasmExports { names })` — possibly with an +/// empty `names` vec — when the header is valid; a parse error *inside* a +/// section stops the walk but keeps whatever names were collected so far. +fn parse_wasm_exports(bytes: &[u8]) -> Option { + // Header: 4-byte magic `\0asm` + 4-byte version `01 00 00 00`. + const MAGIC: [u8; 4] = [0x00, 0x61, 0x73, 0x6d]; + if bytes.len() < 8 || bytes[0..4] != MAGIC { + return None; + } + // We don't enforce the exact version bytes — any 8-byte-or-longer module + // with the right magic is walked; an unknown version simply won't contain a + // recognizable export section and yields an empty name list. + + let mut names: Vec = Vec::new(); + let mut pos = 8usize; + while pos < bytes.len() { + let section_id = bytes[pos]; + pos += 1; + let size = match read_uleb128(bytes, &mut pos) { + Some(s) => s as usize, + None => break, // truncated section header — stop, keep what we have + }; + let section_start = pos; + let section_end = match section_start.checked_add(size) { + Some(end) if end <= bytes.len() => end, + _ => break, // section claims more bytes than the file has + }; + if section_id == 7 { + // Export section: uleb count, then `count` entries of + // (uleb name_len, name_len bytes, 1 byte kind, uleb index). + if let Some(found) = parse_export_section(&bytes[section_start..section_end]) { + names = found; + } + // Export section appears at most once; we can stop after it. + break; + } + pos = section_end; + } + Some(WasmExports { names }) +} + +/// Parse the payload of an export section (everything after the section id + +/// size header). Returns the collected export names, or `None` on a parse error +/// (caller keeps the prior — empty — name list and falls back to default-only). +fn parse_export_section(payload: &[u8]) -> Option> { + let mut pos = 0usize; + let count = read_uleb128(payload, &mut pos)?; + let mut names = Vec::with_capacity(count as usize); + for _ in 0..count { + let name_len = read_uleb128(payload, &mut pos)? as usize; + let name_end = pos.checked_add(name_len)?; + if name_end > payload.len() { + return None; + } + let name = std::str::from_utf8(&payload[pos..name_end]).ok()?.to_string(); + pos = name_end; + // 1-byte export kind (0 = func, 1 = table, 2 = mem, 3 = global), then + // the uleb index. We include *every* kind as a throwing function stub — + // simplest, and member access only throws when actually invoked. + let _kind = *payload.get(pos)?; + pos += 1; + let _index = read_uleb128(payload, &mut pos)?; + // Skip empty / duplicate names defensively. + if !name.is_empty() && !names.contains(&name) { + names.push(name); + } + } + Some(names) +} + +/// Result of synthesizing a deferred `.wasm` stub module. +pub(crate) struct WasmStubModule { + /// Synthesized TypeScript source — flows through the normal parse/lower + /// pipeline exactly like the #5223 text-asset / JSON synthetic modules. + pub(crate) source: String, +} + +/// Build a throwing-stub TypeScript module for a `.wasm` import (#5235). +/// +/// `bytes` is the raw `.wasm` file content; `display_name` is the file name (or +/// path) used in the thrown error message. Every export name parsed from the +/// module's export section becomes a named export whose value is a function that +/// throws a descriptive `Error` when called; a throwing **default** export is +/// always provided too (covers `import w from "./x.wasm"`). On a malformed +/// binary we synthesize the default-only stub. +/// +/// Returns the synthesized source. Does not record the deferred site or consult +/// strict mode — the caller does both, so it can decide between erroring and +/// deferring. +pub(crate) fn synthesize_wasm_stub_module(bytes: &[u8], display_name: &str) -> WasmStubModule { + let names = parse_wasm_exports(bytes) + .map(|e| e.names) + .unwrap_or_default(); + // The descriptive runtime message. JS-string-escaped via serde so the file + // name (which may contain quotes/odd chars) is safe to embed. + let msg = format!( + "wasm module {} cannot run in an ahead-of-time compiled binary \ + — full .wasm ESM instantiation is tracked in #5234", + display_name + ); + let msg_lit = serde_json::to_string(&msg).unwrap_or_else(|_| "\"wasm module unavailable\"".into()); + + let mut src = String::new(); + src.push_str("// #5235: synthesized deferred stub for a .wasm import.\n"); + src.push_str("// Each export throws only when actually called; real .wasm ESM\n"); + src.push_str("// instantiation is tracked in #5234.\n"); + // A single shared thrower keeps the synthesized module compact regardless of + // export count. + src.push_str(&format!( + "function __perry_wasm_unavailable(): never {{ throw new Error({}); }}\n", + msg_lit + )); + for name in &names { + if !is_valid_js_export_ident(name) { + // Names that aren't valid bare JS identifiers can't be exported as + // `export function `. Skip them — they're reachable through + // the namespace object's string keys only via real instantiation + // (#5234); for the deferred stub, omitting them is fine. The default + // export still throws. + continue; + } + src.push_str(&format!( + "export function {}(...args: any[]): any {{ return __perry_wasm_unavailable(); }}\n", + name + )); + } + // Throwing default export: a function so `import w from "./x.wasm"; w()` + // throws on call, and bare `import w from "./x.wasm"` (no call) is fine. + src.push_str("export default function (...args: any[]): any { return __perry_wasm_unavailable(); }\n"); + + WasmStubModule { source: src } +} + +/// A name is exportable as `export function ` only if it's a valid bare +/// ECMAScript identifier: first char is a letter / `_` / `$`, the rest are +/// alphanumeric / `_` / `$`. (The wasm export-name grammar is far broader.) +fn is_valid_js_export_ident(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The fixture from #5235: a 41-byte wasm module exporting `add`. + fn add_wasm() -> Vec { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode("AGFzbQEAAAABBwFgAn9/AX8DAgEABwcBA2FkZAAACgkBBwAgACABags=") + .unwrap() + } + + #[test] + fn parses_add_export() { + let bytes = add_wasm(); + assert_eq!(bytes.len(), 41, "fixture should be 41 bytes"); + let exports = parse_wasm_exports(&bytes).expect("valid header"); + assert_eq!(exports.names, vec!["add".to_string()]); + } + + #[test] + fn synthesizes_named_and_default_stub() { + let src = synthesize_wasm_stub_module(&add_wasm(), "add.wasm").source; + assert!(src.contains("export function add("), "named stub present"); + assert!(src.contains("export default function"), "default stub present"); + assert!(src.contains("#5234"), "references real-integration issue"); + assert!(src.contains("add.wasm"), "names the file in the message"); + } + + #[test] + fn malformed_header_falls_back_to_default_only() { + // Wrong magic → no header. + assert!(parse_wasm_exports(b"not a wasm file at all").is_none()); + let src = synthesize_wasm_stub_module(b"garbage", "bad.wasm").source; + // Default export still throws; no named exports synthesized. + assert!(src.contains("export default function")); + assert!(!src.contains("export function ")); + } + + #[test] + fn no_export_section_yields_empty_names() { + // Valid header + version, no sections. + let bytes = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; + let exports = parse_wasm_exports(&bytes).expect("valid header"); + assert!(exports.names.is_empty()); + } + + #[test] + fn uleb128_multibyte() { + // 624485 = 0xE5 0x8E 0x26 in uleb128 (the canonical spec example). + let bytes = [0xE5u8, 0x8E, 0x26]; + let mut pos = 0; + assert_eq!(read_uleb128(&bytes, &mut pos), Some(624485)); + assert_eq!(pos, 3); + } + + #[test] + fn rejects_non_ident_export_names() { + assert!(is_valid_js_export_ident("add")); + assert!(is_valid_js_export_ident("_$foo9")); + assert!(!is_valid_js_export_ident("9bad")); + assert!(!is_valid_js_export_ident("has-dash")); + assert!(!is_valid_js_export_ident("")); + } +} diff --git a/crates/perry/tests/issue_5235_deferred_wasm_import.rs b/crates/perry/tests/issue_5235_deferred_wasm_import.rs new file mode 100644 index 0000000000..520f31069b --- /dev/null +++ b/crates/perry/tests/issue_5235_deferred_wasm_import.rs @@ -0,0 +1,215 @@ +//! Regression test for #5235: an `import ... from "./x.wasm"` no longer +//! hard-fails the build (the file is binary, not UTF-8). It applies the same +//! defer / notice / strict policy as #5206 (eval) and #5230 (dynamic import). +//! +//! Default (non-strict) behavior: +//! 1. compilation SUCCEEDS — the `.wasm` is read as bytes, its export section +//! parsed, and a throwing-stub module synthesized, +//! 2. a visible end-of-compile NOTICE lists the degraded site under the SAME +//! header as deferred eval / dynamic-import sites (kind `.wasm import`), +//! 3. importing the module but never calling its exports runs fine, and +//! 4. calling an export (`w.add(2,3)`) throws a descriptive, catchable +//! `Error` referencing #5234 — not a crash/segfault, not a silent no-op. +//! +//! Strict mode (`--strict-dynamic-import` or the broad `perry.strict = true`) +//! turns the `.wasm` import into a hard compile error. + +use std::path::PathBuf; +use std::process::Command; +use std::sync::Once; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn target_debug_dir() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")) + .join("debug") +} + +/// Build `libperry_runtime.a` once so the compiled binaries can link (mirrors +/// the #5206 / #5230 tests; CI's `cargo-test` job doesn't pre-build the staticlib). +fn ensure_runtime_archive() { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let build = Command::new(cargo) + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime") + .output() + .expect("run cargo build -p perry-runtime"); + assert!( + build.status.success(), + "cargo build -p perry-runtime failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr) + ); + }); +} + +fn runtime_dir() -> PathBuf { + ensure_runtime_archive(); + target_debug_dir() +} + +/// The 41-byte `add.wasm` fixture from #5235 — exports a single `add` function. +const ADD_WASM_BASE64: &str = "AGFzbQEAAAABBwFgAn9/AX8DAgEABwcBA2FkZAAACgkBBwAgACABags="; + +/// Fixture program: imports the wasm namespace, calls `add` (which must throw), +/// catches it, and prints — proving the deferred export throws only on call. +const MAIN_FIXTURE: &str = r#" +import * as w from "./add.wasm"; + +console.log("IMPORTED"); + +if (process.argv.indexOf("--call") !== -1) { + try { + const r = (w as any).add(2, 3); + console.log("NO_THROW:" + r); + } catch (e: any) { + console.log("CAUGHT:" + (e && e.message)); + } +} +console.log("DONE"); +"#; + +fn write_fixture(root: &std::path::Path) { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(ADD_WASM_BASE64) + .expect("decode add.wasm fixture"); + assert_eq!(bytes.len(), 41, "add.wasm fixture must be 41 bytes"); + std::fs::write(root.join("add.wasm"), &bytes).expect("write add.wasm"); + std::fs::write(root.join("main.ts"), MAIN_FIXTURE).expect("write main.ts"); +} + +fn compile(root: &std::path::Path, extra_args: &[&str]) -> std::process::Output { + let entry = root.join("main.ts"); + let output = root.join("main_bin"); + let mut cmd = Command::new(perry_bin()); + cmd.current_dir(root) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache"); + for a in extra_args { + cmd.arg(a); + } + cmd.env("PERRY_NO_AUTO_OPTIMIZE", "1"); + cmd.env("PERRY_RUNTIME_DIR", runtime_dir()); + cmd.output().expect("run perry compile") +} + +#[test] +fn default_defer_compiles_prints_notice_runs_and_throws_on_call() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + + let out = compile(root, &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "default compile must succeed for a .wasm import; stderr:\n{stderr}" + ); + + // Shared end-of-compile notice (same header as deferred eval/dyn-import). + assert!( + stderr.contains("ahead-of-time-unsupported site"), + "expected the shared deferred-site notice; stderr:\n{stderr}" + ); + assert!( + stderr.contains(".wasm import"), + "notice must name the .wasm import kind; stderr:\n{stderr}" + ); + + // Importing but never calling runs fine. + let bin = root.join("main_bin"); + let run = Command::new(&bin).output().expect("run compiled binary"); + assert!( + run.status.success(), + "binary must run when no wasm export is called" + ); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!( + stdout.contains("IMPORTED") && stdout.contains("DONE") && !stdout.contains("CAUGHT:"), + "import-only program must run to completion without throwing; got:\n{stdout}" + ); + + // Calling the export throws a descriptive, catchable Error referencing #5234. + let run2 = Command::new(&bin) + .arg("--call") + .output() + .expect("run compiled binary --call"); + assert!( + run2.status.success(), + "the binary must not crash when a wasm export is called" + ); + let stdout2 = String::from_utf8_lossy(&run2.stdout); + assert!( + stdout2.contains("CAUGHT:"), + "the called wasm export must throw a catchable Error; got:\n{stdout2}" + ); + assert!( + stdout2.contains("cannot run in an ahead-of-time compiled binary") + && stdout2.contains("#5234"), + "the thrown Error must be descriptive and reference #5234; got:\n{stdout2}" + ); + assert!( + !stdout2.contains("NO_THROW"), + "the called wasm export must NOT silently return a value; got:\n{stdout2}" + ); +} + +#[test] +fn strict_dynamic_import_flag_refuses_wasm_at_compile_time() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + + let out = compile(root, &["--strict-dynamic-import"]); + assert!( + !out.status.success(), + "--strict-dynamic-import must fail the build for a .wasm import" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains(".wasm import") && stderr.contains("strict mode"), + "strict mode must print the .wasm refusal; stderr:\n{stderr}" + ); +} + +#[test] +fn perry_strict_config_covers_wasm_import() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + std::fs::write( + root.join("package.json"), + r#"{ "name": "strict-wasm-cfg", "perry": { "strict": true } }"#, + ) + .expect("write package.json"); + + let out = compile(root, &[]); + assert!( + !out.status.success(), + "perry.strict = true must fail the build for a .wasm import" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains(".wasm import"), + "perry.strict must restore the .wasm refusal; stderr:\n{stderr}" + ); +}