From af746680dbea697249540a678b66237cf66bce54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 21 Aug 2026 15:18:20 +0200 Subject: [PATCH 1/2] fix(runtime): preserve entry source in process argv --- ...0000-import-meta-direct-execution-guard.md | 1 + crates/perry-codegen/src/codegen/entry.rs | 24 ++++++++++++++ .../perry-codegen/src/codegen/entry/tests.rs | 26 ++++++++++++++++ crates/perry-codegen/src/codegen/opts.rs | 6 ++++ crates/perry-codegen/src/runtime_decls/mod.rs | 3 ++ crates/perry-runtime/src/os.rs | 31 ++++++++++++++++--- .../src/commands/compile/object_cache.rs | 7 +++++ .../object_cache/object_cache_tests.rs | 12 +++++++ .../src/commands/compile/run_pipeline.rs | 9 +++++- ..._gap_import_meta_direct_execution_guard.ts | 9 ++++++ 10 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 changelog.d/0000-import-meta-direct-execution-guard.md create mode 100644 test-files/test_gap_import_meta_direct_execution_guard.ts diff --git a/changelog.d/0000-import-meta-direct-execution-guard.md b/changelog.d/0000-import-meta-direct-execution-guard.md new file mode 100644 index 0000000000..c0df1de1b5 --- /dev/null +++ b/changelog.d/0000-import-meta-direct-execution-guard.md @@ -0,0 +1 @@ +Fixed compiled executables so `process.argv[1]` names the TypeScript entry module, allowing the conventional `import.meta.url`/`process.argv[1]` direct-execution guard to work as it does under Node and Bun. diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 92ad4cce85..31314a54d2 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -395,6 +395,22 @@ pub(super) fn compile_module_entry( .filter(|s| !s.is_empty()) .map(|blob| llmod.add_string_constant(blob)) }; + // Perry executables have no separate runtime script argument, so the + // compiler embeds the canonical entry module path and gives it to the + // runtime before user/module initialization. This preserves argv[0] + // as the executable while making argv[1] the TypeScript entry path, + // as Node/Bun code (including the canonical direct-execution guard) + // expects. + let process_entry_path: Option<(String, usize)> = if is_dylib { + None + } else { + cross_module + .app_metadata + .entry_source_path + .as_deref() + .filter(|path| !path.is_empty()) + .map(|path| llmod.add_string_constant(path)) + }; // i18n startup init: when the project configures `[i18n]`, bake the // configured locale-code list (and the optional `[i18n.currencies]` // map) into `main`'s prelude as a single `perry_i18n_init` call — @@ -492,6 +508,14 @@ pub(super) fn compile_module_entry( let _ = main.create_block("entry"); { let blk = main.block_mut(0).unwrap(); + if let Some((const_name, byte_len)) = process_entry_path.as_ref() { + let path_ptr = format!("@{}", const_name); + let len_str = byte_len.to_string(); + blk.call_void( + "js_set_process_entry_path", + &[(PTR, path_ptr.as_str()), (I32, len_str.as_str())], + ); + } blk.call_void("js_gc_init", &[]); if write_barriers_enabled() { blk.call_void("js_gc_write_barriers_emitted", &[(I32, "1")]); diff --git a/crates/perry-codegen/src/codegen/entry/tests.rs b/crates/perry-codegen/src/codegen/entry/tests.rs index 0fc47c1dd9..e329f49756 100644 --- a/crates/perry-codegen/src/codegen/entry/tests.rs +++ b/crates/perry-codegen/src/codegen/entry/tests.rs @@ -96,6 +96,13 @@ fn emitted_ir(output_type: &str) -> String { .expect("LLVM IR should be UTF-8") } +fn emitted_process_entry_ir(output_type: &str) -> String { + let mut opts = entry_opts(output_type); + opts.app_metadata.entry_source_path = Some("/tmp/perry/repro.ts".to_string()); + String::from_utf8(compile_module(&empty_module(), opts).unwrap()) + .expect("LLVM IR should be UTF-8") +} + fn emitted_path_init_ir(output_type: &str) -> String { let mut opts = entry_opts(output_type); opts.non_entry_module_prefixes = vec!["lazy_chunk_js".to_string()]; @@ -199,6 +206,25 @@ fn dylib_entry_does_not_release_process_owned_collection_storage() { ); } +#[test] +fn executable_seeds_process_argv_script_path_but_dylib_does_not() { + let executable_ir = emitted_process_entry_ir("executable"); + assert!( + executable_ir.contains("call void @js_set_process_entry_path("), + "executable entry must seed process.argv[1]\n{executable_ir}" + ); + assert!( + executable_ir.contains("/tmp/perry/repro.ts"), + "executable entry must embed the canonical source path\n{executable_ir}" + ); + + let dylib_ir = emitted_process_entry_ir("dylib"); + assert!( + !dylib_ir.contains("call void @js_set_process_entry_path("), + "a library initializer must not replace its host process argv\n{dylib_ir}" + ); +} + #[test] fn executable_and_app_dylib_both_register_lazy_path_initializers() { for output_type in ["executable", "dylib"] { diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index 27f2ba9e7c..9f5bd0c63f 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -27,6 +27,11 @@ pub struct AppMetadata { /// binary that configures no updates is byte-identical to one built before /// this existed, and `entry.rs`'s absence test asserts it. pub update_config: Option, + /// Canonical path of the TypeScript entry module. The CLI supplies this + /// only for executable entry modules so generated `main` can seed + /// `process.argv[1]` with the script path, matching Node/Bun's argv shape. + /// It is compiler metadata rather than a user-configurable manifest field. + pub entry_source_path: Option, } impl Default for AppMetadata { @@ -37,6 +42,7 @@ impl Default for AppMetadata { bundle_id: "com.perry.app".to_string(), app_group: None, update_config: None, + entry_source_path: None, } } } diff --git a/crates/perry-codegen/src/runtime_decls/mod.rs b/crates/perry-codegen/src/runtime_decls/mod.rs index 6bd3e13a2b..76338f75f7 100644 --- a/crates/perry-codegen/src/runtime_decls/mod.rs +++ b/crates/perry-codegen/src/runtime_decls/mod.rs @@ -39,6 +39,9 @@ pub fn declare_phase1(module: &mut LlModule) { // GC / runtime bootstrap. module.declare_function("js_gc_init", VOID, &[]); module.declare_function("js_typed_feedback_maybe_dump_trace", VOID, &[]); + // Executable entry metadata: generated `main` seeds the source module path + // before any module init can observe `process.argv`. + module.declare_function("js_set_process_entry_path", VOID, &[PTR, I32]); // Handle-method dispatcher wiring (issue #86). Stdlib provides the // real impl; when only runtime is linked, it's a no-op stub. module.declare_function("js_stdlib_init_dispatch", VOID, &[]); diff --git a/crates/perry-runtime/src/os.rs b/crates/perry-runtime/src/os.rs index 3ba5ce7bdc..3bcc56cc4b 100644 --- a/crates/perry-runtime/src/os.rs +++ b/crates/perry-runtime/src/os.rs @@ -561,6 +561,21 @@ pub extern "C" fn js_process_cwd() -> *mut StringHeader { js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) } +static PROCESS_ENTRY_PATH: OnceLock = OnceLock::new(); + +/// Seed the source entry used for `process.argv[1]` in a compiled executable. +/// Generated `main` calls this before any module initialization. +#[no_mangle] +pub unsafe extern "C" fn js_set_process_entry_path(ptr: *const u8, len: u32) { + if ptr.is_null() { + return; + } + let bytes = unsafe { std::slice::from_raw_parts(ptr, len as usize) }; + if let Ok(path) = std::str::from_utf8(bytes) { + let _ = PROCESS_ENTRY_PATH.set(path.to_owned()); + } +} + /// Get command line arguments as an array of strings /// Returns: string[] (array of NaN-boxed string pointers) #[no_mangle] @@ -570,10 +585,11 @@ pub extern "C" fn js_process_argv() -> *mut ArrayHeader { let args: Vec = std::env::args().collect(); // Match Node.js behavior: argv[0] = binary path (like node path), - // argv[1] = binary path again (like script path), argv[2+] = user args. + // argv[1] = source entry path (like script path), argv[2+] = user args. // Node.js: ["/usr/bin/node", "/path/to/script.js", ...user_args] - // Compiled: ["/path/to/binary", ...user_args] - // We insert the binary path twice to shift user args to index 2+. + // Compiled: ["/path/to/binary", "/path/to/entry.ts", ...user_args] + // If an older/foreign codegen does not seed the entry path, retain the + // historical binary-path fallback while keeping user args at index 2+. let arr = js_array_alloc((args.len() + 1) as u32); let mut result = arr; @@ -583,8 +599,13 @@ pub extern "C" fn js_process_argv() -> *mut ArrayHeader { let str_ptr = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); let nanboxed = js_nanbox_string(str_ptr as i64); result = js_array_push_f64(result, nanboxed); - // argv[1]: binary path again (mimics script path) - let str_ptr2 = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + // argv[1]: compiler-seeded source entry path. + let entry_path = PROCESS_ENTRY_PATH + .get() + .map(String::as_str) + .unwrap_or(binary_path); + let entry_bytes = entry_path.as_bytes(); + let str_ptr2 = js_string_from_bytes(entry_bytes.as_ptr(), entry_bytes.len() as u32); let nanboxed2 = js_nanbox_string(str_ptr2 as i64); result = js_array_push_f64(result, nanboxed2); } diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index c886e1131e..e83860d767 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -346,6 +346,13 @@ fn compute_object_cache_key_with_env( "update_config", opts.app_metadata.update_config.as_deref().unwrap_or(""), ); + // The entry path is baked into `main` and changes `process.argv[1]`. + // Without it, moving an otherwise-identical project could reuse an entry + // object containing the old checkout's source path. + h.field( + "entry_source_path", + opts.app_metadata.entry_source_path.as_deref().unwrap_or(""), + ); // Ordered lists (order is significant — topological init, FFI index, // bundled extension order, etc.) diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index 9fbd3db19b..b21dc6654b 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -376,6 +376,18 @@ fn key_stable_for_nested_type_hashmap_order() { ); } +#[test] +fn key_changes_with_embedded_entry_source_path() { + let mut a = empty_opts(); + let mut b = empty_opts(); + a.app_metadata.entry_source_path = Some("/checkout-a/src/main.ts".to_string()); + b.app_metadata.entry_source_path = Some("/checkout-b/src/main.ts".to_string()); + assert_ne!( + compute_object_cache_key(&a, 1, "0.5.156"), + compute_object_cache_key(&b, 1, "0.5.156") + ); +} + #[test] fn key_changes_with_imported_class_signature() { let mut a = empty_opts(); diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 4d319319a8..1ff3dc3840 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -4512,7 +4512,14 @@ pub fn run_with_parse_cache( i18n_table: i18n_snapshot.clone(), fast_math: ctx.fast_math, fp_contract_mode: ctx.fp_contract_mode, - app_metadata: ctx.app_metadata.clone(), + app_metadata: perry_codegen::AppMetadata { + entry_source_path: if is_entry && args.output_type == "executable" { + Some(path.to_string_lossy().into_owned()) + } else { + None + }, + ..ctx.app_metadata.clone() + }, // Issue #100: namespace_entries empty unless this // module is a dynamic-import target; the consumer-side // dispatch map is empty unless this module performs diff --git a/test-files/test_gap_import_meta_direct_execution_guard.ts b/test-files/test_gap_import_meta_direct_execution_guard.ts new file mode 100644 index 0000000000..a096f01a8d --- /dev/null +++ b/test-files/test_gap_import_meta_direct_execution_guard.ts @@ -0,0 +1,9 @@ +// The conventional ESM direct-execution guard must survive native +// compilation: argv[1] names the source entry while argv[0] names the binary. +function main() { + console.log("direct execution guard fired"); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} From a3a8818d5073916f6715620623444bbf47276256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 21 Aug 2026 15:32:12 +0200 Subject: [PATCH 2/2] chore: key changelog fragment to PR 8543 --- ...cution-guard.md => 8543-import-meta-direct-execution-guard.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-import-meta-direct-execution-guard.md => 8543-import-meta-direct-execution-guard.md} (100%) diff --git a/changelog.d/0000-import-meta-direct-execution-guard.md b/changelog.d/8543-import-meta-direct-execution-guard.md similarity index 100% rename from changelog.d/0000-import-meta-direct-execution-guard.md rename to changelog.d/8543-import-meta-direct-execution-guard.md