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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion crates/perry-codegen/src/expr/dyn_extern_i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,31 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
&[(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(<str>)`
// 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
Expand Down
35 changes: 27 additions & 8 deletions crates/perry-hir/src/eval_classifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,11 +323,17 @@ thread_local! {
/// module lowered more than once isn't counted twice.
static EVAL_DEFERRED_SITES: Mutex<Vec<DeferredEvalSite>> = 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,
Expand All @@ -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<String>, location: impl Into<String>) {
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) {
Expand All @@ -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<DeferredEvalSite> {
Expand Down
14 changes: 14 additions & 0 deletions crates/perry-hir/src/ir/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2518,6 +2518,20 @@ pub enum Expr {
DynamicImport {
paths: Vec<String>,
arg: Box<Expr>,
/// 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<String>,
},
/// Compile-time-resolved `new Worker(filename, options?)` from
/// `node:worker_threads`. The filename expression follows the same
Expand Down
5 changes: 3 additions & 2 deletions crates/perry-hir/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-hir/src/lower/expr_call/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,8 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result<E
Ok(Expr::DynamicImport {
paths: Vec::new(),
arg: Box::new(arg),
byte_offset: call.span.lo.0,
deferred_error: None,
})
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-hir/src/stable_hash/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,7 @@ impl SH for Expr {
Expr::WebAssemblyModuleCustomSections { module, name } => { 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); }
Expand Down
28 changes: 16 additions & 12 deletions crates/perry/src/commands/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(<dynamic body>)`) 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(<dynamic body>)` 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);
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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!();
}
Expand Down
85 changes: 71 additions & 14 deletions crates/perry/src/commands/compile/collect_modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -818,9 +818,28 @@ fn collect_module_one(
&module_const_locals,
&dynamic_param_literals,
);
let mut dynamic_path_sets: Vec<Vec<String>> = 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<String>),
Deferred(String),
}
let mut dynamic_path_sets: Vec<DynImportOutcome> = 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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
}
}
}
Expand Down Expand Up @@ -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 => {}
}
}
}
Expand All @@ -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
Expand Down
34 changes: 32 additions & 2 deletions crates/perry/src/commands/compile/host_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading