diff --git a/changelog.d/8995-compiled-package-regexp.md b/changelog.d/8995-compiled-package-regexp.md new file mode 100644 index 0000000000..e3550e89a5 --- /dev/null +++ b/changelog.d/8995-compiled-package-regexp.md @@ -0,0 +1,4 @@ +Compiled packages retain RegExp method behavior when they receive a regular +expression created by application code, including on macOS allocations below +2 TB. This covers schema-library paths such as zod regex and datetime checks +when compiling with the full prebuilt stdlib. diff --git a/crates/perry-runtime/src/value/addr_class.rs b/crates/perry-runtime/src/value/addr_class.rs index 85d4f5905c..b128e48802 100644 --- a/crates/perry-runtime/src/value/addr_class.rs +++ b/crates/perry-runtime/src/value/addr_class.rs @@ -451,8 +451,12 @@ mod tests { fn macos_accepts_heap_addresses_below_two_tb() { // The Rust test harness has observed mimalloc allocations around // 45 GB. Classification is purely numeric and must not dereference - // this representative address. - assert!(is_valid_obj_ptr(0x0000_000a_0000_0000usize as *const u8)); + // this representative address. In #8905 this gate was reached by the + // RegExp header-brand fallback across prebuilt-stdlib runtime copies; + // rejecting the address made dependency-side `.test()` dispatch miss. + let low_macos_heap_addr = 0x0000_000a_0000_0000usize; + assert!(is_valid_obj_ptr(low_macos_heap_addr as *const u8)); + assert!(is_plausible_heap_addr(low_macos_heap_addr)); } #[cfg(all( diff --git a/crates/perry/tests/issue_8905_regexp_package_boundary.rs b/crates/perry/tests/issue_8905_regexp_package_boundary.rs new file mode 100644 index 0000000000..8bb8c5ea25 --- /dev/null +++ b/crates/perry/tests/issue_8905_regexp_package_boundary.rs @@ -0,0 +1,153 @@ +//! Regression test for #8905: a RegExp passed into a `compilePackages` +//! dependency must retain RegExp method behavior when the dependency reads it +//! back through an object property. + +use std::path::PathBuf; +use std::process::Command; +use std::sync::Once; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn target_debug_dir() -> PathBuf { + let target = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")); + if cfg!(windows) { + target.join("x86_64-pc-windows-msvc").join("debug") + } else { + target.join("debug") + } +} + +fn ensure_runtime_archives() { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let mut command = Command::new(cargo); + command + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime-static") + .arg("-p") + .arg("perry-stdlib-static"); + if cfg!(windows) { + command.arg("--target").arg("x86_64-pc-windows-msvc"); + } + let build = command.output().expect("build static runtime archives"); + assert!( + build.status.success(), + "runtime archive build failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr) + ); + }); +} + +#[test] +fn regexp_methods_survive_the_compiled_package_boundary() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + std::fs::write( + root.join("package.json"), + r#"{ + "name": "regexp-package-boundary", + "private": true, + "type": "module", + "perry": { + "compilePackages": ["regex-consumer"], + "allow": { "compilePackages": ["regex-consumer"] } + } +}"#, + ) + .expect("write consumer package.json"); + + let package = root.join("node_modules/regex-consumer"); + std::fs::create_dir_all(&package).expect("mkdir regex-consumer"); + std::fs::write( + package.join("package.json"), + r#"{ + "name": "regex-consumer", + "version": "1.0.0", + "type": "module", + "exports": "./index.js" +}"#, + ) + .expect("write dependency package.json"); + std::fs::write( + package.join("index.js"), + r#" +import { randomUUID } from "node:crypto"; + +export function makeRegexCheck(def) { + return (value) => { + def.pattern.lastIndex = 0; + return def.pattern.test(value); + }; +} + +export function stdlibMarker() { + return typeof randomUUID; +} +"#, + ) + .expect("write compiled dependency"); + + let entry = root.join("main.ts"); + std::fs::write( + &entry, + r#" +import { makeRegexCheck, stdlibMarker } from "regex-consumer"; + +const check = makeRegexCheck({ pattern: /^a+$/ }); +console.log(stdlibMarker(), check("aaa"), check("bbb")); +"#, + ) + .expect("write entry"); + + // The node:crypto import forces the full-stdlib link used by the reporter. + // With PERRY_NO_AUTO_OPTIMIZE that can put a second statically linked + // runtime on the compiled-package side of this RegExp method call. + ensure_runtime_archives(); + let output = root.join("main_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", target_debug_dir()) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "function true false\n" + ); +}