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
97 changes: 96 additions & 1 deletion crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::expr::FnCtx;
use crate::module::LlModule;
use crate::stmt;
use crate::strings::StringPool;
use crate::types::{DOUBLE, I32, I8, PTR, VOID};
use crate::types::{DOUBLE, I32, I64, I8, PTR, VOID};

use super::helpers::{
emit_namespace_populator, enable_module_init_shadow_frame, init_static_fields_early,
Expand All @@ -18,6 +18,82 @@ use super::helpers::{
};
use super::opts::CrossModuleCtx;

/// Collect the entry module's top-level `process.env.<NAME> = "<literal>"`
/// assignments so they can be applied to the OS environment BEFORE eager
/// module init (see the call site in `compile_module_entry`).
///
/// Node runs the entry script top-to-bottom, so a `process.env.NODE_ENV =
/// 'production'` on line 1 is observed by every `require()`d dependency's
/// init. Perry hoists `require`s to eager imports that init before the entry
/// body runs, so without this the dependency observes the unmodified env —
/// e.g. `react-dom/index.js` branches on `process.env.NODE_ENV === 'production'`
/// to pick the production vs development bundle, and the development file is
/// pruned from a Next.js standalone build, so the wrong branch yields an empty
/// module and a downstream `ReactDOMSharedInternals.d` crash.
///
/// Only *unconditional module-top-level* assignments are collected: the entry
/// init statements, plus one+ levels into a cjs-wrap IIFE (`_cjs =
/// (function(){ ... })()`), which is where the wrapped entry's top-level
/// statements live. Assignments nested in conditionals or inner functions are
/// deliberately skipped — those run conditionally/lazily, exactly as in Node.
fn collect_entry_env_literals(init: &[perry_hir::Stmt]) -> Vec<(String, String)> {
use perry_hir::{Expr, Stmt};

fn record(expr: &Expr, out: &mut Vec<(String, String)>) {
// `process.env.X = "lit"` lowers to either form depending on path.
if let Expr::PutValueSet {
target, key, value, ..
} = expr
{
if matches!(target.as_ref(), Expr::ProcessEnv) {
if let (Expr::String(k), Expr::String(v)) = (key.as_ref(), value.as_ref()) {
out.push((k.clone(), v.clone()));
}
}
}
if let Expr::PropertySet {
object,
property,
value,
} = expr
{
if matches!(object.as_ref(), Expr::ProcessEnv) {
if let Expr::String(v) = value.as_ref() {
out.push((property.clone(), v.clone()));
}
}
}
}

fn descend_iife(expr: &Expr, out: &mut Vec<(String, String)>, depth: u32) {
if depth >= 4 {
return;
}
if let Expr::Call { callee, .. } = expr {
if let Expr::Closure { body, .. } = callee.as_ref() {
scan(body, out, depth + 1);
}
}
}

fn scan(stmts: &[Stmt], out: &mut Vec<(String, String)>, depth: u32) {
for s in stmts {
match s {
Stmt::Expr(e) => {
record(e, out);
descend_iife(e, out, depth);
}
Stmt::Let { init: Some(e), .. } => descend_iife(e, out, depth),
_ => {}
}
}
}

let mut out = Vec::new();
scan(init, &mut out, 0);
out
}

/// Emit the module's entry function.
///
/// For the **entry module**: emits `int main()` that bootstraps GC, runs
Expand Down Expand Up @@ -203,6 +279,25 @@ pub(super) fn compile_module_entry(
let blk = main.block_mut(0).unwrap();
// Entry module's own string pool first.
blk.call_void(&strings_init_name, &[]);
// Apply the entry module's top-level `process.env.<NAME> =
// "<literal>"` assignments NOW — after the string pool is live but
// BEFORE any dependency's `__init` runs — so eager-inited deps that
// branch on `process.env` at init time observe what the entry sets,
// matching Node's require-is-lazy ordering. See
// `collect_entry_env_literals`. The "NODE_ENV"/"production" string
// handles are interned here and populated by the strings-init call
// above (the entry body also references them, so they share slots).
for (name, value) in collect_entry_env_literals(&hir.init) {
let name_idx = strings.intern(&name);
let value_idx = strings.intern(&value);
let name_global = format!("@{}", strings.entry(name_idx).handle_global);
let value_global = format!("@{}", strings.entry(value_idx).handle_global);
let name_box = blk.load(DOUBLE, &name_global);
let name_bits = blk.bitcast_double_to_i64(&name_box);
let name_handle = blk.and(I64, &name_bits, crate::nanbox::POINTER_MASK_I64);
let value_box = blk.load(DOUBLE, &value_global);
blk.call_void("js_setenv", &[(I64, &name_handle), (DOUBLE, &value_box)]);
}
// Then every non-entry module's init in order. Each
// non-entry module's `<prefix>__init` runs its own string
// pool init internally before its top-level statements.
Expand Down
60 changes: 60 additions & 0 deletions crates/perry-codegen/src/expr/dyn_extern_i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,33 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
} else {
double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
};
// An empty `paths` list means collect_modules could not resolve
// the filename statically (it warned at compile time). Many real
// packages construct Workers only on cold paths (e.g. Next.js
// build-time worker pools) — throw if one is actually reached at
// runtime instead of failing the whole compile.
if paths.is_empty() {
let msg = "worker_threads Worker filename was not statically \
resolvable at compile time; constructing this Worker \
is unsupported in the compiled binary";
let msg_idx = ctx.strings.intern(msg);
let msg_entry = ctx.strings.entry(msg_idx);
let msg_bytes_global = format!("@{}", msg_entry.bytes_global);
let msg_len_str = msg_entry.byte_len.to_string();
let blk = ctx.block();
blk.call_void(
"js_throw_error_with_code",
&[
(PTR, &msg_bytes_global),
(I64, &msg_len_str),
(PTR, &"null".to_string()),
(I64, &"0".to_string()),
(I32, &"0".to_string()),
],
);
blk.unreachable();
return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
}
if paths.len() != 1 {
bail!(
"worker_threads Worker requires exactly one compile-time-resolved filename, got {}",
Expand Down Expand Up @@ -364,6 +391,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
));
}
if let Some(source_prefix) = ctx.import_function_prefixes.get(name).cloned() {
// Next.js lazy-require: a `_lazyreq_N` binding is the CJS require
// shim's handle to a FUNCTION-LOCAL `require('S')`. S is
// `Deferred` (never eager-initialized), so before reading its
// default-export getter, fire `<S>__init()` — idempotent, so
// re-reads cost a guard check. This is the moment Node would run
// S's module body: when `require('S')` is actually called.
if name.starts_with("_lazyreq_") {
let init_fn = format!("{}__init", source_prefix);
ctx.pending_declares
.push((init_fn.clone(), crate::types::VOID, vec![]));
ctx.block().call_void(&init_fn, &[]);
}
// Issue #678 followup: a V8-fallback import used as a value
// (rather than called directly) has no native singleton
// wrapper to point at — the `__perry_wrap_extern_*` for V8
Expand Down Expand Up @@ -550,6 +589,27 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
&[(PTR, &name_bytes_global), (I64, &name_len)],
));
}
// A default-import alias of a Node builtin module used as a VALUE
// (`const nodeTimers = require('node:timers')`, adopted to an
// import by the CJS wrap) — materialize the real native-module
// namespace object so member reads, monkey-patch writes, and
// enumeration behave. Previously fell through to TAG_TRUE:
// `typeof nodeTimers === "boolean"` and Next.js's
// fast-set-immediate extension threw on
// `nodeTimers.setImmediate = patched` at startup.
if let Some(source) = ctx.imported_class_sources.get(name) {
let bare = source.strip_prefix("node:").unwrap_or(source).to_string();
if perry_hir::is_node_builtin_module(&bare) {
let module_label = emit_string_literal_global(ctx, &bare);
let module_len = bare.len();
let blk = ctx.block();
return Ok(blk.call(
DOUBLE,
"js_create_native_module_namespace",
&[(PTR, &module_label), (I64, &module_len.to_string())],
));
}
}
Ok(double_literal(f64::from_bits(crate::nanbox::TAG_TRUE)))
}

Expand Down
6 changes: 5 additions & 1 deletion crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1469,7 +1469,9 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
Expr::New { .. } | Expr::NewDynamic { .. } | Expr::NewDynamicSpread { .. } => {
new_dynamic::lower(ctx, expr)
}
Expr::This | Expr::NewTarget | Expr::SuperCall(..) => this_super_call::lower(ctx, expr),
Expr::This | Expr::NewTarget | Expr::SuperCall(..) | Expr::SuperCallSpread(..) => {
this_super_call::lower(ctx, expr)
}
Expr::IsNaN(..)
| Expr::MathPow(..)
| Expr::MathImul(..)
Expand Down Expand Up @@ -1892,6 +1894,8 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
Expr::StaticFieldGet { .. }
| Expr::StaticFieldSet { .. }
| Expr::RegisterClassParentDynamic { .. }
| Expr::RegisterClassCaptures { .. }
| Expr::ClassCaptureValue { .. }
| Expr::RegisterClassStaticSymbol { .. }
| Expr::RegisterClassComputedMethod { .. }
| Expr::RegisterClassComputedAccessor { .. }
Expand Down
59 changes: 59 additions & 0 deletions crates/perry-codegen/src/expr/static_field_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,65 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// observable to user code.
Ok(double_literal(f64::from_bits(0x7FFC_0000_0000_0001)))
}
// Snapshot a function-nested class's captured outer locals into the
// runtime CLASS_CAPTURE_VALUES table at the decl site, so DYNAMIC
// construction of the class value (`exports.C = C; new mod.C()` —
// the webpack/zod bundle pattern) can fill the synthesized
// `__perry_cap_<id>` ctor params. Mirrors RegisterClassParentDynamic
// placement; static `new C()` sites pass captures inline and never
// consult the table.
Expr::RegisterClassCaptures {
class_name,
captures,
} => {
let mut lowered: Vec<String> = Vec::with_capacity(captures.len());
for c in captures {
lowered.push(lower_expr(ctx, c)?);
}
if let Some(&class_id) = ctx.class_ids.get(class_name) {
if class_id != 0 && !lowered.is_empty() {
let n = lowered.len();
let buf = ctx.func.alloca_entry_array(DOUBLE, n);
for (i, v) in lowered.iter().enumerate() {
let slot =
ctx.block()
.gep(DOUBLE, &buf, &[(crate::types::I64, &i.to_string())]);
ctx.block().store(DOUBLE, v, &slot);
}
let ptr_reg = ctx.block().next_reg();
ctx.block().emit_raw(format!(
"{} = getelementptr [{} x double], ptr {}, i64 0, i64 0",
ptr_reg, n, buf
));
let cid_str = class_id.to_string();
let len_str = n.to_string();
ctx.block().call_void(
"js_class_register_capture_values",
&[
(crate::types::I32, &cid_str),
(crate::types::PTR, &ptr_reg),
(crate::types::I64, &len_str),
],
);
}
}
Ok(double_literal(f64::from_bits(0x7FFC_0000_0000_0001)))
}
// Read slot `index` of the class's decl-site capture snapshot —
// STATIC method prologue rebinds (no instance to carry the
// `__perry_cap_*` fields).
Expr::ClassCaptureValue { class_name, index } => {
if let Some(&class_id) = ctx.class_ids.get(class_name) {
let cid_str = class_id.to_string();
let idx_str = index.to_string();
return Ok(ctx.block().call(
DOUBLE,
"js_class_capture_value",
&[(crate::types::I32, &cid_str), (crate::types::I32, &idx_str)],
));
}
Ok(double_literal(f64::from_bits(0x7FFC_0000_0000_0001)))
Comment on lines +131 to +181

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Class-capture snapshots are keyed too coarsely.

RegisterClassCaptures writes the snapshot into CLASS_CAPTURE_VALUES[class_id], and ClassCaptureValue later reads it back with the same compile-time class_id. That means the second evaluation of a nested class overwrites the first one, so previously returned/exported class values start constructing or rebinding statics against the newest closed-over environment. A shape like const A = make(1); const B = make(2); A.staticGet() will read 2 here. This needs to be keyed off the concrete class value / fresh class object, not the shared template id.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/expr/static_field_meta.rs` around lines 131 - 181,
The bug is that RegisterClassCaptures and ClassCaptureValue use the compile-time
ctx.class_ids(class_name) as the snapshot key, causing different class instances
to collide; fix by keying snapshots on the concrete class object pointer/value
at runtime instead of class_id: in Expr::RegisterClassCaptures change the
js_class_register_capture_values call to take a PTR key (pass the class object
register/pointer for class_name rather than the I32 class_id) and adapt the
argument types from I32 to PTR; in Expr::ClassCaptureValue change the
js_class_capture_value call to take the same PTR key (pass the same class object
register/pointer and use I32 only for the index), and remove/stop relying on
ctx.class_ids for lookup (instead obtain the runtime class pointer/register for
class_name—e.g., look up whatever place holds the freshly created class value or
load it at runtime—and use that register as the first arg). Ensure the two
helpers js_class_register_capture_values and js_class_capture_value now use a
PTR key consistently so snapshots are keyed by the concrete class object.

}
// Issue #894: `static [Symbol.for("k")] = init` inside a
// class expression returned from a factory function. Emitted
// by HIR lowering as a `Sequence([…, RegisterClassStaticSymbol,
Expand Down
Loading
Loading