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
1 change: 1 addition & 0 deletions changelog.d/8543-import-meta-direct-execution-guard.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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")]);
Expand Down
26 changes: 26 additions & 0 deletions crates/perry-codegen/src/codegen/entry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()];
Expand Down Expand Up @@ -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"] {
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-codegen/src/codegen/opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
}

impl Default for AppMetadata {
Expand All @@ -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,
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/runtime_decls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &[]);
Expand Down
31 changes: 26 additions & 5 deletions crates/perry-runtime/src/os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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]
Expand All @@ -570,10 +585,11 @@ pub extern "C" fn js_process_argv() -> *mut ArrayHeader {

let args: Vec<String> = 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;
Expand All @@ -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);
}
Expand Down
7 changes: 7 additions & 0 deletions crates/perry/src/commands/compile/object_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 8 additions & 1 deletion crates/perry/src/commands/compile/run_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions test-files/test_gap_import_meta_direct_execution_guard.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading