From 8022034ce44e7be67ca1b233d8c7b2e91f242922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 13:56:57 +0200 Subject: [PATCH 1/2] fix(dynamic-import): preserve aliased live bindings --- crates/perry-codegen/src/codegen/artifacts.rs | 38 +++++++++++++- crates/perry-codegen/src/codegen/helpers.rs | 49 +++++++++++++------ .../src/runtime_decls/strings_part2.rs | 2 +- crates/perry-hir/src/lower/module_decl.rs | 2 + .../src/object/namespace_create.rs | 32 +++++++++++- test-files/dynamic_import_alias_binding.ts | 22 +++++++++ .../test_gap_dynamic_import_alias_binding.ts | 29 +++++++++++ 7 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 test-files/dynamic_import_alias_binding.ts create mode 100644 test-files/test_gap_dynamic_import_alias_binding.ts diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 6db959f3d1..1e93b7c0a4 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -17,7 +17,8 @@ use super::closure::{ use super::ctor_arity::synthesized_ctor_param_count; use super::entry::compile_module_entry; use super::helpers::{ - function_body_returns_generator_object, sanitize, scoped_fn_name, unknown_func_wrapper_name, + function_body_returns_generator_object, namespace_live_getter_wrapper_symbol, sanitize, + scoped_fn_name, unknown_func_wrapper_name, }; use super::indexed_method_artifacts::{compile_indexed_method_clones, IndexedMethodArtifactsCtx}; use super::method::{ @@ -1347,9 +1348,42 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { let ns_name = format!("__perry_ns_{}", module_prefix); // Hex double literal for TAG_UNDEFINED (0x7FFC_0000_0000_0001). llmod.add_global(&ns_name, DOUBLE, "0x7FFC000000000001"); - for entry in &cross_module.namespace_entries { + for (entry_index, entry) in cross_module.namespace_entries.iter().enumerate() { let (gname, byte_len) = llmod.add_string_constant(&entry.name); namespace_key_globals.push((gname, byte_len)); + + let wrapper_name = namespace_live_getter_wrapper_symbol(module_prefix, entry_index); + let getter_name = match &entry.kind { + crate::NamespaceEntryKind::LocalVar { global_name } => { + let wrapper = llmod.define_function( + &wrapper_name, + DOUBLE, + vec![(I64, "%this_closure".to_string())], + ); + let _ = wrapper.create_block("entry"); + let blk = wrapper.block_mut(0).unwrap(); + let value = blk.load(DOUBLE, &format!("@{global_name}")); + blk.ret(DOUBLE, &value); + continue; + } + crate::NamespaceEntryKind::ForeignVar { + source_prefix, + source_local, + } => format!("perry_fn_{}__{}", source_prefix, sanitize(source_local)), + _ => continue, + }; + if !llmod.has_function(&getter_name) { + llmod.declare_function(&getter_name, DOUBLE, &[]); + } + let wrapper = llmod.define_function( + &wrapper_name, + DOUBLE, + vec![(I64, "%this_closure".to_string())], + ); + let _ = wrapper.create_block("entry"); + let blk = wrapper.block_mut(0).unwrap(); + let value = blk.call(DOUBLE, &getter_name, &[]); + blk.ret(DOUBLE, &value); } } // For each `Expr::DynamicImport` target this module dispatches to, diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index bb860ad6e7..39e1bc74e5 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use crate::module::LlModule; -use crate::types::{DOUBLE, I32, I64, PTR}; +use crate::types::{DOUBLE, I32, I64, I8, PTR}; use super::opts::{NamespaceEntry, NamespaceEntryKind}; @@ -1348,14 +1348,15 @@ pub(super) fn register_module_globals_as_gc_roots( /// /// The IR sequence per call: /// -/// 1. Alloca three parallel stack arrays sized `[N x ?]` — keys (ptr), -/// key_lens (i32), values (double). +/// 1. Alloca four parallel stack arrays sized `[N x ?]` — keys (ptr), +/// key_lens (i32), values (double), live-binding flags (i8). /// 2. For each entry i in `namespace_entries`: /// - Store `getelementptr inbounds [L x i8], ptr @.strK, i64 0, i64 0` /// into `keys[i]` and `L` into `key_lens[i]`. /// - Compute the value JSValue per `NamespaceEntryKind` and store /// into `values[i]`. -/// 3. Call `js_create_namespace(N, ptr keys, ptr key_lens, ptr values)`. +/// 3. Call `js_create_namespace(N, ptr keys, ptr key_lens, ptr values, +/// ptr live_flags)`. /// 4. Store the result into `@__perry_ns_`. /// /// Always emits the `js_create_namespace` call + store, even when @@ -1364,6 +1365,13 @@ pub(super) fn register_module_globals_as_gc_roots( /// non-NaN `@__perry_ns_` to load). The runtime tolerates /// `n == 0` and returns an empty NaN-boxed object. The caller is /// responsible for ensuring `key_globals.len() == entries.len()`. +pub(super) fn namespace_live_getter_wrapper_symbol( + module_prefix: &str, + entry_index: usize, +) -> String { + format!("__perry_ns_get_{module_prefix}__{entry_index}") +} + pub(super) fn emit_namespace_populator( ctx: &mut crate::expr::FnCtx<'_>, entries: &[NamespaceEntry], @@ -1382,13 +1390,15 @@ pub(super) fn emit_namespace_populator( let buf_len = n.max(1); let blk = ctx.block(); - // Alloca the three parallel buffers. + // Alloca the four parallel buffers. let keys_buf = blk.next_reg(); blk.emit_raw(format!("{} = alloca [{} x ptr]", keys_buf, buf_len)); let lens_buf = blk.next_reg(); blk.emit_raw(format!("{} = alloca [{} x i32]", lens_buf, buf_len)); let vals_buf = blk.next_reg(); blk.emit_raw(format!("{} = alloca [{} x double]", vals_buf, buf_len)); + let live_buf = blk.next_reg(); + blk.emit_raw(format!("{} = alloca [{} x i8]", live_buf, buf_len)); // #7210 (2): `vals_buf` is a plain stack alloca, not a shadow slot the // collector scans. Each entry's value is a NaN-boxed JSValue that can be @@ -1418,12 +1428,26 @@ pub(super) fn emit_namespace_populator( let len_slot = blk.gep(I32, &lens_buf, &[(I64, &idx_str)]); blk.store(I32, &format!("{}", key_len), &len_slot); + let is_live_binding = matches!( + entry.kind, + NamespaceEntryKind::LocalVar { .. } | NamespaceEntryKind::ForeignVar { .. } + ); + let live_slot = blk.gep(I8, &live_buf, &[(I64, &idx_str)]); + blk.store(I8, if is_live_binding { "1" } else { "0" }, &live_slot); + // Materialise the value per kind. We drop the `blk` borrow so // each sub-emission can re-borrow ctx mutably for runtime calls // / declares; then root it in this scope's group. let val_str = match &entry.kind { - NamespaceEntryKind::LocalVar { global_name } => { - ctx.block().load(DOUBLE, &format!("@{}", global_name)) + NamespaceEntryKind::LocalVar { .. } | NamespaceEntryKind::ForeignVar { .. } => { + let wrapper = namespace_live_getter_wrapper_symbol(module_prefix, i); + let blk = ctx.block(); + let handle = blk.call( + I64, + "js_closure_alloc_singleton", + &[(PTR, &format!("@{}", wrapper))], + ); + crate::expr::nanbox_pointer_inline(blk, &handle) } NamespaceEntryKind::LocalFunction { wrap_symbol } => { let blk = ctx.block(); @@ -1440,14 +1464,6 @@ pub(super) fn emit_namespace_populator( let bits = crate::nanbox::INT32_TAG | (*class_id as u64 & 0xFFFF_FFFF); crate::nanbox::double_literal(f64::from_bits(bits)) } - NamespaceEntryKind::ForeignVar { - source_prefix, - source_local, - } => { - let getter = format!("perry_fn_{}__{}", source_prefix, sanitize(source_local)); - ctx.pending_declares.push((getter.clone(), DOUBLE, vec![])); - ctx.block().call(DOUBLE, &getter, &[]) - } NamespaceEntryKind::ForeignFunction { source_prefix, source_local, @@ -1518,7 +1534,7 @@ pub(super) fn emit_namespace_populator( }) .expect("emit_namespace_populator's rooted group body is infallible"); - // Call `js_create_namespace(n, keys, key_lens, values)` and store + // Call `js_create_namespace(n, keys, key_lens, values, live_flags)` and store // the result into the namespace global. The result is a NaN-boxed // POINTER_TAG ObjectHeader; the global is already GC-rooted by // `register_module_globals_as_gc_roots` is NOT — namespace globals @@ -1534,6 +1550,7 @@ pub(super) fn emit_namespace_populator( (PTR, &keys_buf), (PTR, &lens_buf), (PTR, &vals_buf), + (PTR, &live_buf), ], ); let ns_name = format!("__perry_ns_{}", module_prefix); diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 97d14748e1..33752aeb9d 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -911,7 +911,7 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { // module's `__perry_ns_` global) and from `Expr::DynamicImport` // (returned wrapped in `js_promise_resolved`). See // `crates/perry-runtime/src/object.rs::js_create_namespace`. - module.declare_function("js_create_namespace", DOUBLE, &[I32, PTR, PTR, PTR]); + module.declare_function("js_create_namespace", DOUBLE, &[I32, PTR, PTR, PTR, PTR]); module.declare_function("js_finalize_namespace", DOUBLE, &[DOUBLE]); module.declare_function("js_promise_then", I64, &[I64, I64, I64]); module.declare_function("js_promise_resolved_then", I64, &[DOUBLE, I64, I64]); diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 3f63f68aa7..66a1843fa1 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -1569,6 +1569,8 @@ pub(crate) fn lower_module_decl( Expr::Closure { .. } | Expr::Object(_) | Expr::Array(_) + | Expr::SetNew + | Expr::SetNewFromArray(_) | Expr::Call { .. } | Expr::New { .. } | Expr::JsNew { .. } diff --git a/crates/perry-runtime/src/object/namespace_create.rs b/crates/perry-runtime/src/object/namespace_create.rs index fc31b8b11b..d6fc2d7bf4 100644 --- a/crates/perry-runtime/src/object/namespace_create.rs +++ b/crates/perry-runtime/src/object/namespace_create.rs @@ -29,7 +29,8 @@ pub extern "C" fn js_finalize_namespace(value: f64) -> f64 { /// Issue #100: build a module-namespace object (the value an `await /// import("./foo.ts")` resolves to) from parallel arrays of keys and -/// values. +/// values. Entries whose parallel `live_flags` byte is non-zero carry a +/// zero-argument getter closure instead of a snapshot value. /// /// Keys are length-prefixed UTF-8 (Perry strings are not guaranteed /// null-terminated), passed as parallel `*const *const u8` (data @@ -58,6 +59,7 @@ pub extern "C" fn js_create_namespace( keys: *const *const u8, key_lens: *const i32, values: *const f64, + live_flags: *const u8, ) -> f64 { let count = if n < 0 { 0 } else { n as usize }; unsafe { @@ -106,7 +108,33 @@ pub extern "C" fn js_create_namespace( let key_hdr = crate::string::js_string_from_bytes(key_data, key_len_u); obj = obj_handle.get_raw_mut_ptr::(); let val = value_handles[i].get_nanbox_f64(); - js_object_set_field_by_name(obj, key_hdr, val); + if !live_flags.is_null() && *live_flags.add(i) != 0 { + let key_handle = scope.root_string_ptr(key_hdr); + obj_handle.with_mut_ptr::(|current_obj| { + key_handle.with_const_ptr::(|current_key| { + js_object_define_accessor( + crate::value::js_nanbox_pointer(current_obj as i64), + crate::value::js_nanbox_string(current_key as i64), + val, + f64::from_bits(crate::value::TAG_UNDEFINED), + ); + }); + }); + let key = String::from_utf8_lossy(std::slice::from_raw_parts( + key_data, + key_len_u as usize, + )) + .into_owned(); + obj_handle.with_mut_ptr::(|current_obj| { + set_property_attrs( + current_obj as usize, + key, + PropertyAttrs::new(false, true, false), + ); + }); + } else { + js_object_set_field_by_name(obj, key_hdr, val); + } } // NaN-box POINTER_TAG and return. diff --git a/test-files/dynamic_import_alias_binding.ts b/test-files/dynamic_import_alias_binding.ts new file mode 100644 index 0000000000..4cfc65914f --- /dev/null +++ b/test-files/dynamic_import_alias_binding.ts @@ -0,0 +1,22 @@ +var aliasedVar = new Set(["var-before"]); +let aliasedLet = new Set(["let-before"]); +const aliasedConst = new Set(["const"]); + +export const directConst = new Set(["direct"]); + +function check(values: Set, expected: string): boolean { + return values.has(expected); +} + +function reassign(): void { + aliasedVar = new Set(["var-after"]); + aliasedLet = new Set(["let-after"]); +} + +export { + aliasedVar as VAR_SET, + aliasedLet as LET_SET, + aliasedConst as CONST_SET, + check as checkAlias, + reassign, +}; diff --git a/test-files/test_gap_dynamic_import_alias_binding.ts b/test-files/test_gap_dynamic_import_alias_binding.ts new file mode 100644 index 0000000000..ef2d35e430 --- /dev/null +++ b/test-files/test_gap_dynamic_import_alias_binding.ts @@ -0,0 +1,29 @@ +// parity-env: PERRY_GC_SCHEDULE_SEED=9778 PERRY_GC_SCHEDULE_RATE=0.25 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_PROTECT_FROMSPACE=1 + +async function main(): Promise { + const ns = await import("./dynamic_import_alias_binding.ts"); + + console.log( + typeof ns.VAR_SET, + typeof ns.LET_SET, + typeof ns.CONST_SET, + typeof ns.directConst, + typeof ns.checkAlias, + ); + console.log( + ns.checkAlias(ns.VAR_SET, "var-before"), + ns.checkAlias(ns.LET_SET, "let-before"), + ns.checkAlias(ns.CONST_SET, "const"), + ns.checkAlias(ns.directConst, "direct"), + ); + + ns.reassign(); + console.log( + ns.checkAlias(ns.VAR_SET, "var-after"), + ns.checkAlias(ns.LET_SET, "let-after"), + ns.checkAlias(ns.VAR_SET, "var-before"), + ns.checkAlias(ns.LET_SET, "let-before"), + ); +} + +main(); From d324285911b1b52450c23ae1f223ed5be6b6bce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 13:57:40 +0200 Subject: [PATCH 2/2] chore: add PR 9879 changeset --- changelog.d/9879-dynamic-import-live-bindings.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/9879-dynamic-import-live-bindings.md diff --git a/changelog.d/9879-dynamic-import-live-bindings.md b/changelog.d/9879-dynamic-import-live-bindings.md new file mode 100644 index 0000000000..970e90c882 --- /dev/null +++ b/changelog.d/9879-dynamic-import-live-bindings.md @@ -0,0 +1,3 @@ +Dynamic imports now expose aliased local `var`, `let`, and `const` exports +instead of resolving them as `undefined`. Namespace reads also preserve live +bindings when an exported mutable variable is reassigned after import.