From 702471640fdc56b62add998595778620faa9c6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 12 Jun 2026 15:13:23 +0200 Subject: [PATCH 1/4] feat(compile): size-optimization for out-of-tree installs + nativeLibrary feature forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PERRY_WORKSPACE_ROOT env override for workspace discovery: npm/homebrew installs place the binary outside the workspace, so auto-optimize silently fell back to the prebuilt full stdlib (sqlite/crypto/tokio — ~8MB of undeadstrippable code via the dynamic dispatch table). The fallback note is no longer verbose-gated. - [native-library.""] perry.toml table forwards cargo features / --no-default-features to perry.nativeLibrary crate builds (e.g. a 2D-only build of a 2D+3D engine). New link/native_features.rs + tests. - Ship a panic=abort prebuilt runtime variant (libperry_runtime_abort.a, release-packages.yml + stage-npm.sh) and select it in the no-workspace fallback for runtime-only apps with no catch_unwind callers: hello-world 8.9MB -> 7.7MB out of the box. - Step 1 of dispatch devirtualization: route NATIVE_MODULE_CLASS_ID method calls through a fn-pointer hook installed by js_create_native_module_namespace instead of statically referencing dispatch_native_module_method from the generic call path. Correctness verified (node:fs namespace dispatch works); the size win lands when the remaining native-module surface (method-value/get-field tables) gets the same vtable treatment. Measured on Bloom Jump (with engine-side feature gates): 26.1MB -> 17.3MB. --- .github/workflows/release-packages.yml | 17 ++- .../src/object/class_registry.rs | 4 +- .../src/object/native_call_method.rs | 4 +- .../perry-runtime/src/object/native_module.rs | 46 ++++++ .../src/commands/compile/library_search.rs | 16 ++ crates/perry/src/commands/compile/link/mod.rs | 26 ++++ .../commands/compile/link/native_features.rs | 138 ++++++++++++++++++ .../src/commands/compile/optimized_libs.rs | 32 ++++ crates/perry/src/commands/compile/resolve.rs | 16 ++ scripts/stage-npm.sh | 2 +- 10 files changed, 295 insertions(+), 6 deletions(-) create mode 100644 crates/perry/src/commands/compile/link/native_features.rs diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index bd43d5e967..79917e2179 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -270,6 +270,21 @@ jobs: cargo build --release --target ${{ matrix.target }} -p perry-runtime cargo build --release --target ${{ matrix.target }} -p perry-stdlib + - name: Build panic=abort runtime variant (Unix) + # Out-of-tree installs can't rebuild the runtime, so ship the + # panic=abort profile prebuilt: `perry compile` links it for + # runtime-only apps with no catch_unwind callers (games, CLIs), + # dropping unwind tables/landing pads (~12-18% of the binary). + # See optimized_libs.rs (find_runtime_abort_library selection). + # Separate CARGO_TARGET_DIR so the profile override doesn't + # invalidate the main build's incremental cache. + if: runner.os != 'Windows' + run: | + CARGO_TARGET_DIR=target-abort CARGO_PROFILE_RELEASE_PANIC=abort \ + cargo build --release --target ${{ matrix.target }} -p perry-runtime + cp "target-abort/${{ matrix.target }}/release/libperry_runtime.a" \ + "target/${{ matrix.target }}/release/libperry_runtime_abort.a" + - name: Build native ext libraries (Unix) # #2532 — the perry-ext-* wrapper crates ship the host functions for # node:http (server), ws, net, zlib, fastify, the db drivers, etc. @@ -430,7 +445,7 @@ jobs: mkdir -p staging cp target/${{ matrix.target }}/release/perry staging/ # Include static libraries for linking (runtime, stdlib, UI) - for lib in libperry_runtime.a libperry_stdlib.a libperry_ui_macos.a libperry_ui_gtk4.a; do + for lib in libperry_runtime.a libperry_runtime_abort.a libperry_stdlib.a libperry_ui_macos.a libperry_ui_gtk4.a; do if [ -f "target/${{ matrix.target }}/release/$lib" ]; then cp "target/${{ matrix.target }}/release/$lib" staging/ fi diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 09b33510d0..6f84270503 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -5178,7 +5178,7 @@ unsafe fn try_native_static_method_in_proto_chain( let module = b"buffer.Buffer"; let ns = js_create_native_module_namespace(module.as_ptr(), module.len()); let ns_obj = JSValue::from_bits(ns.to_bits()).as_pointer::(); - let result = dispatch_native_module_method(ns_obj, name, args_ptr, args_len); + let result = crate::object::native_module::call_native_module_dispatch_hook(ns_obj, name, args_ptr, args_len); if !JSValue::from_bits(result.to_bits()).is_undefined() { return Some(result); } @@ -5189,7 +5189,7 @@ unsafe fn try_native_static_method_in_proto_chain( if read_native_module_name(proto_obj as *const ObjectHeader).as_deref() == Some("buffer.Buffer") { - let result = dispatch_native_module_method(proto_obj, name, args_ptr, args_len); + let result = crate::object::native_module::call_native_module_dispatch_hook(proto_obj, name, args_ptr, args_len); if !JSValue::from_bits(result.to_bits()).is_undefined() { return Some(result); } diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 4cb5924ce1..9d1302b559 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -3453,7 +3453,7 @@ pub unsafe extern "C" fn js_native_call_method( // #853: the `is_valid_obj_ptr` guard that used to live after // this return was dead — the early return claims the path // unconditionally. Removed. - return dispatch_native_module_method(obj, method_name, args_ptr, args_len); + return crate::object::native_module::call_native_module_dispatch_hook(obj, method_name, args_ptr, args_len); } // Issue #1206: Buffer iterators returned from `buf.values()` etc. // have a dedicated class id so `.next()` lands here and dispatches @@ -3977,7 +3977,7 @@ pub unsafe extern "C" fn js_native_call_method( // Check for native module namespace if (*obj).class_id == NATIVE_MODULE_CLASS_ID { // #853: same dead-after-return as the first arm above. - return dispatch_native_module_method(obj, method_name, args_ptr, args_len); + return crate::object::native_module::call_native_module_dispatch_hook(obj, method_name, args_ptr, args_len); } // Issue #1206: same class-id check as the NaN-boxed path above // so a raw-pointer iterator value (uncommon, but possible after diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index a44d278d67..26de7c2eb8 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -897,6 +897,46 @@ pub extern "C" fn js_worker_threads_locks_query() -> f64 { worker_threads_locks_query(std::ptr::null()) } +/// Linker-strippability indirection for the native-module method +/// dispatcher (see #466 size work). `dispatch_native_module_method` is a +/// 600+-arm match that statically references every module's runtime +/// implementation; a direct call from the generic method path pins all of +/// it in every binary, `-dead_strip` notwithstanding. Namespace objects +/// (NATIVE_MODULE_CLASS_ID) can only be created by +/// `js_create_native_module_namespace`, so installing the pointer there +/// makes the dispatcher — and everything only it references — dead-strippable +/// in programs that never import a native module. Relaxed ordering is +/// sufficient: the store happens-before any namespace object can reach a +/// call site on the creating thread, and cross-thread publication of the +/// object pointer itself already synchronizes. +pub(crate) static NATIVE_MODULE_DISPATCH_HOOK: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +type NativeModuleDispatchFn = + unsafe fn(*const ObjectHeader, &str, *const f64, usize) -> f64; + +/// Route a NATIVE_MODULE_CLASS_ID method call through the installed hook. +/// A zero hook means no namespace object was ever created, so no such +/// object can exist to dispatch on — unreachable in practice. +#[inline] +pub(crate) unsafe fn call_native_module_dispatch_hook( + obj: *const ObjectHeader, + method_name: &str, + args_ptr: *const f64, + args_len: usize, +) -> f64 { + let raw = NATIVE_MODULE_DISPATCH_HOOK.load(Ordering::Relaxed); + debug_assert!( + raw != 0, + "native-module method call before any namespace was created" + ); + if raw == 0 { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let f: NativeModuleDispatchFn = std::mem::transmute(raw); + f(obj, method_name, args_ptr, args_len) +} + /// Create a native module namespace object /// This is used for `import * as X from 'module'` patterns /// The returned object identifies itself as an object (typeof returns "object") @@ -910,6 +950,12 @@ pub extern "C" fn js_create_native_module_namespace( module_name_ptr: *const u8, module_name_len: usize, ) -> f64 { + // Install the dispatch hook the moment the first namespace exists — + // the only static reference to the dispatcher in the crate. + NATIVE_MODULE_DISPATCH_HOOK.store( + crate::object::dispatch_native_module_method as usize, + Ordering::Relaxed, + ); let module_name = unsafe { std::str::from_utf8(std::slice::from_raw_parts(module_name_ptr, module_name_len)) .unwrap_or("") diff --git a/crates/perry/src/commands/compile/library_search.rs b/crates/perry/src/commands/compile/library_search.rs index 7f5b11fed8..4ae90fd4cb 100644 --- a/crates/perry/src/commands/compile/library_search.rs +++ b/crates/perry/src/commands/compile/library_search.rs @@ -898,6 +898,22 @@ pub(super) fn find_runtime_library(target: Option<&str>) -> Result { }) } +/// Find the panic=abort prebuilt runtime variant (optional — shipped by +/// release packaging for runtime-only apps; selected by the out-of-tree +/// fallback in `optimized_libs.rs` when no `catch_unwind` callers are +/// reachable and stdlib is not linked). Unix-only: Windows always links +/// stdlib, which is built panic=unwind. +pub(super) fn find_runtime_abort_library(target: Option<&str>) -> Option { + if matches!(target, Some("windows") | Some("windows-winui")) { + return None; + } + #[cfg(target_os = "windows")] + if target.is_none() { + return None; + } + find_library("libperry_runtime_abort.a", target) +} + /// Find the stdlib library for linking (optional - only needed for native modules) pub(super) fn find_stdlib_library(target: Option<&str>) -> Option { let lib_name = match target { diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index 08617f2dbb..696d0b7414 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -40,6 +40,7 @@ use super::{ }; mod link_cache; +mod native_features; mod platform_cmd; mod windows_link; @@ -1435,6 +1436,31 @@ pub(super) fn build_and_run_link( .arg("--manifest-path") .arg(&cargo_toml); + // Per-project feature forwarding: a + // `[native-library.""]` table in perry.toml maps + // onto cargo `--features` / `--no-default-features` so + // apps can select a build profile of the native crate + // (e.g. a 2D-only build of a 2D+3D engine). + if let Some(ovr) = native_features::lookup_native_library_override( + &ctx.project_root, + &native_lib.module, + ) { + if !ovr.default_features { + cargo_cmd.arg("--no-default-features"); + } + if !ovr.features.is_empty() { + cargo_cmd.arg("--features").arg(ovr.features.join(",")); + } + if matches!(format, OutputFormat::Text) { + println!( + " native-library features for {}: default-features={} features=[{}]", + native_lib.module, + ovr.default_features, + ovr.features.join(", ") + ); + } + } + if let Some(triple) = rust_target_triple(target) { cargo_cmd.arg("--target").arg(triple); } diff --git a/crates/perry/src/commands/compile/link/native_features.rs b/crates/perry/src/commands/compile/link/native_features.rs new file mode 100644 index 0000000000..3a1111413f --- /dev/null +++ b/crates/perry/src/commands/compile/link/native_features.rs @@ -0,0 +1,138 @@ +//! Per-project cargo feature forwarding for `perry.nativeLibrary` crates. +//! +//! Native-library crates are built with `cargo build --release` and the +//! crate's own default feature set. Engines that serve more than one app +//! profile (e.g. a pure-2D game on a 2D+3D engine) gate optional +//! subsystems behind cargo features, but apps had no way to pick a +//! profile: feature flags were never forwarded to the nativeLibrary +//! build (see the workaround note in bloom-engine's +//! `native/macos/Cargo.toml`, which default-enables Jolt for exactly +//! this reason). Apps can now declare, in their `perry.toml`: +//! +//! ```toml +//! [native-library."@bloomengine/engine"] +//! default-features = false +//! features = ["renderer2d"] +//! ``` +//! +//! The table key is the npm package name. A module spec like +//! `@bloomengine/engine/core` matches its package's entry (longest key +//! wins when nested). `features` / `default-features` map 1:1 onto +//! `cargo build --features …` / `--no-default-features`. +//! +//! Misconfiguration surfaces as a normal cargo error (unknown feature) +//! or an undefined-symbol link error when the app imports an FFI +//! function the chosen feature set compiled out — both name the crate, +//! so the failure is attributable. + +use std::path::Path; + +/// Feature overrides for one native-library package, as declared in the +/// project's `perry.toml`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct NativeLibraryBuildOverride { + /// Cargo features to enable (`--features a,b,c`). + pub features: Vec, + /// When false, pass `--no-default-features`. + pub default_features: bool, +} + +/// Look up the `[native-library.""]` override matching `module` +/// (a module spec such as `@bloomengine/engine/core`) in the project's +/// `perry.toml`. Returns `None` when the file, table, or a matching key +/// is absent — the build then proceeds exactly as before this feature +/// existed. +pub(super) fn lookup_native_library_override( + project_root: &Path, + module: &str, +) -> Option { + let content = std::fs::read_to_string(project_root.join("perry.toml")).ok()?; + let doc: toml::Table = content.parse().ok()?; + lookup_in_table(&doc, module) +} + +fn lookup_in_table(doc: &toml::Table, module: &str) -> Option { + let table = doc.get("native-library")?.as_table()?; + // Longest matching key wins so `@scope/pkg/sub` beats `@scope/pkg` + // if someone ever publishes a nested package name. + let mut best: Option<(&str, &toml::Value)> = None; + for (key, value) in table { + let matches = module == key || module.starts_with(&format!("{key}/")); + if matches && best.is_none_or(|(prev, _)| key.len() > prev.len()) { + best = Some((key, value)); + } + } + let (_, value) = best?; + let entry = value.as_table()?; + let features = entry + .get("features") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let default_features = entry + .get("default-features") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + Some(NativeLibraryBuildOverride { + features, + default_features, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(s: &str) -> toml::Table { + s.parse().unwrap() + } + + #[test] + fn module_spec_matches_package_key() { + let doc = parse( + r#" +[native-library."@bloomengine/engine"] +default-features = false +features = ["renderer2d", "mp3"] +"#, + ); + let ovr = lookup_in_table(&doc, "@bloomengine/engine/core").unwrap(); + assert!(!ovr.default_features); + assert_eq!(ovr.features, vec!["renderer2d", "mp3"]); + // Exact package name (no submodule) matches too. + assert!(lookup_in_table(&doc, "@bloomengine/engine").is_some()); + // Unrelated packages don't. + assert!(lookup_in_table(&doc, "@other/pkg/core").is_none()); + // Prefix match must respect path boundaries. + assert!(lookup_in_table(&doc, "@bloomengine/engine-extras/core").is_none()); + } + + #[test] + fn absent_table_or_fields_default_cleanly() { + let doc = parse("[project]\nname = \"x\"\n"); + assert!(lookup_in_table(&doc, "@bloomengine/engine/core").is_none()); + + let doc = parse("[native-library.\"@bloomengine/engine\"]\n"); + let ovr = lookup_in_table(&doc, "@bloomengine/engine/core").unwrap(); + assert!(ovr.default_features); + assert!(ovr.features.is_empty()); + } + + #[test] + fn longest_key_wins() { + let doc = parse( + r#" +[native-library."@scope/pkg"] +features = ["outer"] +[native-library."@scope/pkg/sub"] +features = ["inner"] +"#, + ); + let ovr = lookup_in_table(&doc, "@scope/pkg/sub/mod").unwrap(); + assert_eq!(ovr.features, vec!["inner"]); + } +} diff --git a/crates/perry/src/commands/compile/optimized_libs.rs b/crates/perry/src/commands/compile/optimized_libs.rs index fbe9f6e2fe..05dbf04024 100644 --- a/crates/perry/src/commands/compile/optimized_libs.rs +++ b/crates/perry/src/commands/compile/optimized_libs.rs @@ -576,6 +576,18 @@ pub(super) fn build_optimized_libs( let workspace_root = match find_perry_workspace_root() { Some(p) => p, None => { + // Not verbose-gated: the fallback links the full-feature + // prebuilt stdlib (sqlite/crypto/tokio/…), which typically + // adds 5MB+ of code the linker cannot dead-strip (the + // dynamic dispatch table pins every module). Users should + // know why the binary is big and how to opt back in. + if matches!(format, OutputFormat::Text) && verbose == 0 { + eprintln!( + " note: Perry workspace source not found — linking the prebuilt \ + full stdlib (larger binary). Set PERRY_WORKSPACE_ROOT to a \ + source checkout to enable size-optimized rebuilds." + ); + } if matches!(format, OutputFormat::Text) && verbose > 0 { let (rt_name, std_name) = match target { Some("windows") | Some("windows-winui") => { @@ -610,7 +622,27 @@ pub(super) fn build_optimized_libs( } else { Vec::new() }; + // Out-of-tree size salvage: release packaging ships a + // panic=abort prebuilt runtime variant alongside the unwind + // one (stage-npm.sh / release-packages.yml). When the app + // links runtime-only (no stdlib) and pulls in nothing that + // needs `catch_unwind`, prefer it — same ~12-18% saving the + // workspace rebuild gets from panic=abort, no source needed. + // Unix-only by construction: Windows always links stdlib + // (codegen declares all stdlib externs there), and mixing an + // abort runtime with the unwind stdlib is not supported. + let runtime = if panic_abort_safe && !ctx.needs_stdlib { + let found = + super::library_search::find_runtime_abort_library(target); + if found.is_some() && matches!(format, OutputFormat::Text) && verbose > 0 { + eprintln!(" auto-optimize: using prebuilt panic=abort runtime"); + } + found + } else { + None + }; return OptimizedLibs { + runtime, prefer_well_known_before_stdlib: !well_known_libs.is_empty(), well_known_libs, ..OptimizedLibs::empty() diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index d9c8754d7d..0a8e8677f3 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -85,6 +85,22 @@ fn workspace_root_from_exe(exe: &Path) -> Option { /// Find the Perry workspace root by searching upward from the executable location. pub fn find_perry_workspace_root() -> Option { + // Explicit override: npm/homebrew installs place the perry binary + // outside the workspace, so neither the exe walk nor the cwd walk + // below can ever find the source tree — auto-optimize then silently + // falls back to the prebuilt full-feature runtime/stdlib and every + // binary ships the whole stdlib (sqlite, crypto, tokio, …). Users + // who keep a workspace checkout can point at it explicitly. + if let Ok(root) = std::env::var("PERRY_WORKSPACE_ROOT") { + let path = PathBuf::from(root); + if is_perry_workspace_root(&path) { + return Some(path); + } + eprintln!( + "warning: PERRY_WORKSPACE_ROOT is set but does not look like a \ + Perry workspace (missing crates/perry-runtime); ignoring it" + ); + } // First try: relative to the perry executable if let Ok(exe) = std::env::current_exe() { if let Some(root) = workspace_root_from_exe(&exe) { diff --git a/scripts/stage-npm.sh b/scripts/stage-npm.sh index d8c02a8c92..f7bd3a5dc7 100755 --- a/scripts/stage-npm.sh +++ b/scripts/stage-npm.sh @@ -73,7 +73,7 @@ PLATFORMS=( # Unix libs shared across platforms (runtime + stdlib). The UI lib is # handled per-platform above. Windows equivalents are baked into the case # block further down. -UNIX_CORE_LIBS=(libperry_runtime.a libperry_stdlib.a) +UNIX_CORE_LIBS=(libperry_runtime.a libperry_runtime_abort.a libperry_stdlib.a) WIN_CORE_LIBS=(perry_runtime.lib perry_stdlib.lib) # ----------------------------------------------------------------------------- From b716fa654045e9174ac09592905d8e3cfc2c54bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 12 Jun 2026 16:14:02 +0200 Subject: [PATCH 2/4] =?UTF-8?q?refactor(runtime):=20native-module=20vtable?= =?UTF-8?q?=20=E2=80=94=20cut=20static=20refs=20from=20generic=20object=20?= =?UTF-8?q?paths=20(step=202=20of=20dispatch=20devirtualization)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the step-1 single dispatch hook with a NativeModuleVtable covering every native-module behavior reachable from always-linked generic paths: method dispatch, own-field reads (relocated vt_get_own_field), Object.keys (relocated vt_own_keys_array), and has/in checks. Installed by js_create_native_module_namespace, the NativeModuleRef fast path (js_native_module_property_by_name), bound_native_callable_export_value, and the node_v8/perf_hooks NATIVE_MODULE_CLASS_ID allocators — null vtable means no namespace value can exist, and generic paths fall through to default behavior. Correctness verified: node:fs namespace dispatch round-trip, runtime test suite at parity with origin/main (the native_module_stream combined-filter failures pre-exist on main: 11/2 there vs 12/1 here). NOTE: no binary-size win lands yet. populate_global_this_builtins eagerly creates console/process namespaces in every program (global_this.rs:4772), keeping the installer — and the (module, method) tables — statically reachable. The follow-up that monetizes this substrate is splitting dispatch_native_module_method (and the constant/keys/callable-export tables) per module with a name→fn map referenced only by js_create_native_module_namespace, and giving the globalThis console/process creation direct per-module constructors. Then a no-stdlib-import program pins only console+process arms. --- crates/perry-runtime/src/node_v8.rs | 4 +- .../perry-runtime/src/object/field_get_set.rs | 110 ++-------- crates/perry-runtime/src/object/mod.rs | 1 + .../perry-runtime/src/object/native_module.rs | 199 ++++++++++++++---- crates/perry-runtime/src/object/object_ops.rs | 6 +- crates/perry-runtime/src/perf_hooks.rs | 2 + 6 files changed, 191 insertions(+), 131 deletions(-) diff --git a/crates/perry-runtime/src/node_v8.rs b/crates/perry-runtime/src/node_v8.rs index 5d1be5be73..d15e101d21 100644 --- a/crates/perry-runtime/src/node_v8.rs +++ b/crates/perry-runtime/src/node_v8.rs @@ -375,6 +375,7 @@ pub extern "C" fn js_v8_write_heap_snapshot(filename: f64, options: f64) -> f64 /// Build a 2-field native-module namespace object: field[0] = `module` tag, /// field[1] = registry `id`. NOT cached (every `new` must be a fresh instance). unsafe fn build_v8_instance(module: &str, id: usize) -> f64 { + crate::object::install_native_module_vtable(); let obj = crate::object::js_object_alloc(crate::object::NATIVE_MODULE_CLASS_ID, 2); let mname = js_string_from_bytes(module.as_ptr(), module.len() as u32); crate::object::js_object_set_field(obj, 0, JSValue::string_ptr(mname)); @@ -617,7 +618,8 @@ pub extern "C" fn js_v8_promise_hook_register() -> f64 { pub extern "C" fn js_v8_gc_profiler_new() -> f64 { unsafe { let module = "v8.GCProfiler"; - let obj = crate::object::js_object_alloc(crate::object::NATIVE_MODULE_CLASS_ID, 2); + crate::object::install_native_module_vtable(); + let obj = crate::object::js_object_alloc(crate::object::NATIVE_MODULE_CLASS_ID, 2); let module_name = js_string_from_bytes(module.as_ptr(), module.len() as u32); crate::object::js_object_set_field(obj, 0, JSValue::string_ptr(module_name)); crate::object::js_object_set_field(obj, 1, JSValue::bool(false)); diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 437b9c1bfb..9537970dee 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -1775,28 +1775,11 @@ pub extern "C" fn js_object_keys(obj: *const ObjectHeader) -> *mut ArrayHeader { } unsafe { if (*obj).class_id == NATIVE_MODULE_CLASS_ID { - if let Some(module_name) = read_native_module_name(obj) { - if let Some(keys) = native_module_enumerable_keys(&module_name) { - let include_permission = matches!( - module_name.as_str(), - "process" | "process.namespace" | "process.default" - ) && crate::process::process_permission_enabled(); - let out = - crate::array::js_array_alloc(keys.len() as u32 + include_permission as u32); - for key_bytes in keys { - let key_str = crate::string::js_string_from_bytes( - key_bytes.as_ptr(), - key_bytes.len() as u32, - ); - crate::array::js_array_push(out, JSValue::string_ptr(key_str)); - } - if include_permission { - let key_str = crate::string::js_string_from_bytes( - b"permission".as_ptr(), - b"permission".len() as u32, - ); - crate::array::js_array_push(out, JSValue::string_ptr(key_str)); - } + // Relocated to native_module.rs::vt_own_keys_array so the + // module key tables are reachable only through the vtable + // (linker-strippable when no namespace object exists). + if let Some(vt) = super::native_module::native_module_vtable() { + if let Some(out) = (vt.own_keys_array)(obj) { return out; } } @@ -2329,7 +2312,8 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { .as_deref() .zip(super::has_own_helpers::str_from_string_header(key_ptr)) .map(|(module, key)| { - super::native_module::native_module_has_enumerable_key(module, key) + super::native_module::native_module_vtable() + .is_some_and(|vt| (vt.has_enumerable_key)(module, key)) }) .unwrap_or(false); return if present { nanbox_true } else { nanbox_false }; @@ -2401,7 +2385,8 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { }; let present = unsafe { read_native_module_name(obj_ptr) } .as_deref() - .is_some_and(|module_name| native_module_has_enumerable_key(module_name, key_name)); + .is_some_and(|module_name| super::native_module::native_module_vtable() + .is_some_and(|vt| (vt.has_enumerable_key)(module_name, key_name))); return if present { nanbox_true } else { nanbox_false }; } @@ -2658,7 +2643,7 @@ fn reified_function_method_name(name: &str) -> Option<&'static [u8]> { } } -unsafe fn native_module_own_field_by_key( +pub(super) unsafe fn native_module_own_field_by_key( obj: *const ObjectHeader, key: *const crate::StringHeader, ) -> Option { @@ -4616,74 +4601,17 @@ pub extern "C" fn js_object_get_field_by_name( // lookup fell through to the field-bag scan (which only stores // `__module__`) and returned undefined. Now we route through // `get_native_module_constant` directly. + // Issue #649 / #3687 / #894: native-module own-field reads + // (sub-namespaces, process IPC props, callable exports). Body + // relocated to native_module.rs::vt_get_own_field so the + // (module, method) tables are reachable only through the vtable. + // `None` (no module name / vtable uninstalled) falls through to + // the generic scans below, matching the pre-relocation flow. if (*obj).class_id == NATIVE_MODULE_CLASS_ID && !key.is_null() { - let key_ptr = (key as *const u8).add(std::mem::size_of::()); - let key_len = (*key).byte_len as usize; - let nb_ptr = crate::value::js_nanbox_pointer(obj as i64); - let module_name = get_module_name_from_namespace(nb_ptr); - if !module_name.is_empty() { - let property_name = - std::str::from_utf8(std::slice::from_raw_parts(key_ptr, key_len)).unwrap_or(""); - if matches!( - module_name, - "process" | "process.namespace" | "process.default" - ) { - if let Some(value) = crate::process::process_ipc_property(property_name) { - return JSValue::from_bits(value.to_bits()); - } - } - if let Some(value) = native_module_own_field_by_key(obj, key) { - return value; - } - // #3687: node:cluster default-import EventEmitter methods on the - // distinct `cluster.default` namespace. Mirror the - // NativeModuleRef fast path (`js_native_module_property_by_name`) - // — this dynamic `obj[key]` read must resolve `on`/`emit`/… to - // bound methods *before* `get_native_module_constant` (which - // normalizes to `cluster` and returns `undefined` for `on`). - if module_name == "cluster.default" - && super::is_cluster_emitter_method(property_name) - { - return JSValue::from_bits( - super::bound_native_callable_export_value(module_name, property_name) - .to_bits(), - ); - } - if let Some(val) = get_native_module_constant(module_name, property_name, nb_ptr) { - return JSValue::from_bits(val.to_bits()); - } - if module_name == "crypto.webcrypto" { - if let Some(value) = super::global_this::webcrypto_method_value(property_name) { - return JSValue::from_bits(value.to_bits()); - } - } - if module_name == "crypto.subtle" { - if let Some(value) = - super::global_this::subtle_crypto_method_value(property_name) - { - return JSValue::from_bits(value.to_bits()); - } - } - // Issue #894: parity with the direct-NativeModuleRef - // fast path (`js_native_module_property_by_name`). For - // (module, prop) pairs whose property-read should - // produce a callable handle — e.g. - // `("events", "EventEmitter")` — synthesize the same - // BOUND_METHOD_FUNC_PTR closure so the require-then- - // member-access shape (`const { EventEmitter } = - // require("node:events")`) matches the direct - // namespace-import shape (`import { EventEmitter } from - // "node:events"`). Pre-fix the slow path returned - // undefined here, and the downstream - // `EventEmitter.prototype` read tripped the spec - // "Cannot read properties of undefined" throw. - if is_native_module_callable_export(module_name, property_name) { - return JSValue::from_bits( - super::bound_native_callable_export_value(module_name, property_name) - .to_bits(), - ); + if let Some(vt) = super::native_module::native_module_vtable() { + if let Some(v) = (vt.get_own_field)(obj, key) { + return v; } - return JSValue::undefined(); } } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index d824e4edb6..8ea825efb5 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -48,6 +48,7 @@ pub(crate) mod iterator_prototypes; mod namespace_create; mod native_call_method; mod native_module; +pub(crate) use native_module::install_native_module_vtable; mod native_module_crypto_key_object; mod native_module_crypto_random; mod native_module_dispatch; diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 26de7c2eb8..e5a5fd0f83 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -897,27 +897,64 @@ pub extern "C" fn js_worker_threads_locks_query() -> f64 { worker_threads_locks_query(std::ptr::null()) } -/// Linker-strippability indirection for the native-module method -/// dispatcher (see #466 size work). `dispatch_native_module_method` is a -/// 600+-arm match that statically references every module's runtime -/// implementation; a direct call from the generic method path pins all of -/// it in every binary, `-dead_strip` notwithstanding. Namespace objects -/// (NATIVE_MODULE_CLASS_ID) can only be created by -/// `js_create_native_module_namespace`, so installing the pointer there -/// makes the dispatcher — and everything only it references — dead-strippable -/// in programs that never import a native module. Relaxed ordering is -/// sufficient: the store happens-before any namespace object can reach a -/// call site on the creating thread, and cross-thread publication of the -/// object pointer itself already synchronizes. -pub(crate) static NATIVE_MODULE_DISPATCH_HOOK: std::sync::atomic::AtomicUsize = - std::sync::atomic::AtomicUsize::new(0); - -type NativeModuleDispatchFn = - unsafe fn(*const ObjectHeader, &str, *const f64, usize) -> f64; - -/// Route a NATIVE_MODULE_CLASS_ID method call through the installed hook. -/// A zero hook means no namespace object was ever created, so no such -/// object can exist to dispatch on — unreachable in practice. +/// Linker-strippability vtable for every native-module behavior reachable +/// from the always-linked generic object paths (method dispatch, own-field +/// reads, Object.keys, has/in checks). All of these bottom out in large +/// static (module, method) tables that reference every module's runtime +/// implementation; a direct call from a generic path pins all of it in +/// every binary, `-dead_strip` notwithstanding. Namespace-class objects +/// (NATIVE_MODULE_CLASS_ID) are only created by +/// `js_create_native_module_namespace` and a handful of in-crate +/// allocators (node_v8 serializer, perf_hooks observer), all of which +/// install this vtable first — so a program that never creates one lets +/// the linker drop the tables wholesale. Relaxed ordering is sufficient: +/// the store happens-before any namespace object can reach a call site on +/// the creating thread, and cross-thread publication of the object +/// pointer itself already synchronizes. +pub(crate) struct NativeModuleVtable { + pub dispatch: unsafe fn(*const ObjectHeader, &str, *const f64, usize) -> f64, + pub get_own_field: + unsafe fn(*const ObjectHeader, *const crate::StringHeader) -> Option, + pub own_keys_array: unsafe fn(*const ObjectHeader) -> Option<*mut crate::array::ArrayHeader>, + pub has_enumerable_key: fn(&str, &str) -> bool, +} + +static NATIVE_MODULE_VTABLE_IMPL: NativeModuleVtable = NativeModuleVtable { + dispatch: dispatch_native_module_method, + get_own_field: vt_get_own_field, + own_keys_array: vt_own_keys_array, + has_enumerable_key: native_module_has_enumerable_key, +}; + +static NATIVE_MODULE_VTABLE_PTR: AtomicPtr = + AtomicPtr::new(std::ptr::null_mut()); + +/// Make the native-module vtable reachable. Must be called by every code +/// path that creates a NATIVE_MODULE_CLASS_ID object — this is the only +/// static reference to the dispatch/table machinery in the crate. +pub(crate) fn install_native_module_vtable() { + NATIVE_MODULE_VTABLE_PTR.store( + &NATIVE_MODULE_VTABLE_IMPL as *const NativeModuleVtable as *mut NativeModuleVtable, + Ordering::Relaxed, + ); +} + +/// `None` until the first namespace object exists; generic paths treat +/// that as "no native module can be involved" and fall through to their +/// default behavior. +#[inline] +pub(crate) fn native_module_vtable() -> Option<&'static NativeModuleVtable> { + let p = NATIVE_MODULE_VTABLE_PTR.load(Ordering::Relaxed); + if p.is_null() { + None + } else { + Some(unsafe { &*(p as *const NativeModuleVtable) }) + } +} + +/// Route a NATIVE_MODULE_CLASS_ID method call through the vtable. A null +/// vtable means no namespace object was ever created, so no such object +/// can exist to dispatch on — unreachable in practice. #[inline] pub(crate) unsafe fn call_native_module_dispatch_hook( obj: *const ObjectHeader, @@ -925,19 +962,16 @@ pub(crate) unsafe fn call_native_module_dispatch_hook( args_ptr: *const f64, args_len: usize, ) -> f64 { - let raw = NATIVE_MODULE_DISPATCH_HOOK.load(Ordering::Relaxed); - debug_assert!( - raw != 0, - "native-module method call before any namespace was created" - ); - if raw == 0 { - return f64::from_bits(crate::value::TAG_UNDEFINED); + match native_module_vtable() { + Some(vt) => (vt.dispatch)(obj, method_name, args_ptr, args_len), + None => { + debug_assert!(false, "native-module method call before any namespace was created"); + f64::from_bits(crate::value::TAG_UNDEFINED) + } } - let f: NativeModuleDispatchFn = std::mem::transmute(raw); - f(obj, method_name, args_ptr, args_len) } -/// Create a native module namespace object +/// Create a native module namespace object/// Create a native module namespace object /// This is used for `import * as X from 'module'` patterns /// The returned object identifies itself as an object (typeof returns "object") /// and stores the module name for debugging purposes @@ -950,12 +984,9 @@ pub extern "C" fn js_create_native_module_namespace( module_name_ptr: *const u8, module_name_len: usize, ) -> f64 { - // Install the dispatch hook the moment the first namespace exists — - // the only static reference to the dispatcher in the crate. - NATIVE_MODULE_DISPATCH_HOOK.store( - crate::object::dispatch_native_module_method as usize, - Ordering::Relaxed, - ); + // Install the vtable the moment the first namespace exists — the only + // static reference to the dispatch/table machinery in the crate. + install_native_module_vtable(); let module_name = unsafe { std::str::from_utf8(std::slice::from_raw_parts(module_name_ptr, module_name_len)) .unwrap_or("") @@ -3140,6 +3171,10 @@ pub unsafe extern "C" fn js_native_module_property_by_name( property_name_ptr: *const u8, property_name_len: usize, ) -> f64 { + // Codegen NativeModuleRef fast path — can mint native-module-backed + // values without a namespace object; the vtable must be live for the + // generic paths that later touch them. + install_native_module_vtable(); let module_name = std::str::from_utf8(std::slice::from_raw_parts(module_name_ptr, module_name_len)) .unwrap_or(""); @@ -3262,6 +3297,11 @@ pub unsafe extern "C" fn js_native_module_property_by_name( } pub(crate) fn bound_native_callable_export_value(module_name: &str, property_name: &str) -> f64 { + // Bound-native closures carry (module, method) metadata that the + // generic property/call paths resolve through the vtable — and they + // can be minted via the codegen NativeModuleRef fast path without any + // namespace object existing. Install here too. + install_native_module_vtable(); let module_name = cjs_default_base_module(module_name).unwrap_or(module_name); let module_name = assert_instance_base_module(module_name).unwrap_or(module_name); let property_name = canonical_native_callable_property(module_name, property_name); @@ -8376,3 +8416,88 @@ unsafe fn create_fs_constants_object() -> f64 { ); result } + +// ─── Vtable impls relocated from field_get_set.rs (EN size work) ─────── +// Bodies moved verbatim so their table references are reachable only +// through the installed vtable. See `NativeModuleVtable`. + +/// Own-field read on a namespace object (`fs.constants`, method values, +/// process IPC props, …). Returns `None` when the receiver carries no +/// module name — the caller falls through to the generic field scan. +unsafe fn vt_get_own_field( + obj: *const ObjectHeader, + key: *const crate::StringHeader, +) -> Option { + let key_ptr = (key as *const u8).add(std::mem::size_of::()); + let key_len = (*key).byte_len as usize; + let nb_ptr = crate::value::js_nanbox_pointer(obj as i64); + let module_name = get_module_name_from_namespace(nb_ptr); + if module_name.is_empty() { + return None; + } + let property_name = + std::str::from_utf8(std::slice::from_raw_parts(key_ptr, key_len)).unwrap_or(""); + if matches!( + module_name, + "process" | "process.namespace" | "process.default" + ) { + if let Some(value) = crate::process::process_ipc_property(property_name) { + return Some(JSValue::from_bits(value.to_bits())); + } + } + if let Some(value) = super::field_get_set::native_module_own_field_by_key(obj, key) { + return Some(value); + } + // #3687: node:cluster default-import EventEmitter methods on the + // distinct `cluster.default` namespace (see original comment at the + // pre-relocation site in field_get_set.rs history). + if module_name == "cluster.default" && super::is_cluster_emitter_method(property_name) { + return Some(JSValue::from_bits( + bound_native_callable_export_value(module_name, property_name).to_bits(), + )); + } + if let Some(val) = get_native_module_constant(module_name, property_name, nb_ptr) { + return Some(JSValue::from_bits(val.to_bits())); + } + if module_name == "crypto.webcrypto" { + if let Some(value) = super::global_this::webcrypto_method_value(property_name) { + return Some(JSValue::from_bits(value.to_bits())); + } + } + if module_name == "crypto.subtle" { + if let Some(value) = super::global_this::subtle_crypto_method_value(property_name) { + return Some(JSValue::from_bits(value.to_bits())); + } + } + // Issue #894: callable exports (`("events", "EventEmitter")` …) get a + // bound-method closure for require-then-member-access parity. + if is_native_module_callable_export(module_name, property_name) { + return Some(JSValue::from_bits( + bound_native_callable_export_value(module_name, property_name).to_bits(), + )); + } + Some(JSValue::undefined()) +} + +/// `Object.keys(namespace)` — fresh array of the module's enumerable +/// keys. `None` when the module is unknown; caller falls back to the +/// generic keys_array path. +unsafe fn vt_own_keys_array(obj: *const ObjectHeader) -> Option<*mut crate::array::ArrayHeader> { + let module_name = read_native_module_name(obj)?; + let keys = native_module_enumerable_keys(&module_name)?; + let include_permission = matches!( + module_name.as_str(), + "process" | "process.namespace" | "process.default" + ) && crate::process::process_permission_enabled(); + let out = crate::array::js_array_alloc(keys.len() as u32 + include_permission as u32); + for key_bytes in keys { + let key_str = crate::string::js_string_from_bytes(key_bytes.as_ptr(), key_bytes.len() as u32); + crate::array::js_array_push(out, JSValue::string_ptr(key_str)); + } + if include_permission { + let key_str = + crate::string::js_string_from_bytes(b"permission".as_ptr(), b"permission".len() as u32); + crate::array::js_array_push(out, JSValue::string_ptr(key_str)); + } + Some(out) +} diff --git a/crates/perry-runtime/src/object/object_ops.rs b/crates/perry-runtime/src/object/object_ops.rs index c7e21d66d1..20f8c65c17 100644 --- a/crates/perry-runtime/src/object/object_ops.rs +++ b/crates/perry-runtime/src/object/object_ops.rs @@ -889,7 +889,8 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { .as_deref() .zip(super::has_own_helpers::str_from_string_header(key_str)) .map(|(module, key)| { - super::native_module::native_module_has_enumerable_key(module, key) + super::native_module::native_module_vtable() + .is_some_and(|vt| (vt.has_enumerable_key)(module, key)) }) .unwrap_or(false); return f64::from_bits(if present { TAG_TRUE } else { TAG_FALSE }); @@ -913,7 +914,8 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { }; let present = read_native_module_name(obj) .as_deref() - .is_some_and(|module_name| native_module_has_enumerable_key(module_name, key_name)); + .is_some_and(|module_name| super::native_module::native_module_vtable() + .is_some_and(|vt| (vt.has_enumerable_key)(module_name, key_name))); return f64::from_bits(if present { TAG_TRUE } else { TAG_FALSE }); } diff --git a/crates/perry-runtime/src/perf_hooks.rs b/crates/perry-runtime/src/perf_hooks.rs index 64b19b59f2..387bfad9a7 100644 --- a/crates/perry-runtime/src/perf_hooks.rs +++ b/crates/perry-runtime/src/perf_hooks.rs @@ -1143,6 +1143,7 @@ thread_local! { /// Build the `perf_observer` namespace object carrying the registry index. unsafe fn make_observer_object(id: usize) -> f64 { + crate::object::install_native_module_vtable(); let obj = crate::object::js_object_alloc(crate::object::NATIVE_MODULE_CLASS_ID, 2); let module = b"perf_observer"; let mname = crate::string::js_string_from_bytes(module.as_ptr(), module.len() as u32); @@ -1524,6 +1525,7 @@ pub fn scan_perf_entries_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<' /// `is_native_module_callable_export` (methods) and /// `get_native_module_constant` (numeric accessors). unsafe fn make_histogram_object() -> f64 { + crate::object::install_native_module_vtable(); let obj = crate::object::js_object_alloc(crate::object::NATIVE_MODULE_CLASS_ID, 1); let module = b"perf_histogram"; let mname = crate::string::js_string_from_bytes(module.as_ptr(), module.len() as u32); From f305f9818dcb01462341128363105ee231643b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 12 Jun 2026 17:28:32 +0200 Subject: [PATCH 3/4] style: rustfmt pass over size-work files --- crates/perry-runtime/src/node_v8.rs | 2 +- crates/perry-runtime/src/object/class_registry.rs | 8 ++++++-- crates/perry-runtime/src/object/field_get_set.rs | 6 ++++-- .../perry-runtime/src/object/native_call_method.rs | 14 ++++++++++++-- crates/perry-runtime/src/object/native_module.rs | 8 ++++++-- crates/perry-runtime/src/object/object_ops.rs | 6 ++++-- .../perry/src/commands/compile/optimized_libs.rs | 3 +-- 7 files changed, 34 insertions(+), 13 deletions(-) diff --git a/crates/perry-runtime/src/node_v8.rs b/crates/perry-runtime/src/node_v8.rs index d15e101d21..3d1de9e375 100644 --- a/crates/perry-runtime/src/node_v8.rs +++ b/crates/perry-runtime/src/node_v8.rs @@ -619,7 +619,7 @@ pub extern "C" fn js_v8_gc_profiler_new() -> f64 { unsafe { let module = "v8.GCProfiler"; crate::object::install_native_module_vtable(); - let obj = crate::object::js_object_alloc(crate::object::NATIVE_MODULE_CLASS_ID, 2); + let obj = crate::object::js_object_alloc(crate::object::NATIVE_MODULE_CLASS_ID, 2); let module_name = js_string_from_bytes(module.as_ptr(), module.len() as u32); crate::object::js_object_set_field(obj, 0, JSValue::string_ptr(module_name)); crate::object::js_object_set_field(obj, 1, JSValue::bool(false)); diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 6f84270503..993de20828 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -5178,7 +5178,9 @@ unsafe fn try_native_static_method_in_proto_chain( let module = b"buffer.Buffer"; let ns = js_create_native_module_namespace(module.as_ptr(), module.len()); let ns_obj = JSValue::from_bits(ns.to_bits()).as_pointer::(); - let result = crate::object::native_module::call_native_module_dispatch_hook(ns_obj, name, args_ptr, args_len); + let result = crate::object::native_module::call_native_module_dispatch_hook( + ns_obj, name, args_ptr, args_len, + ); if !JSValue::from_bits(result.to_bits()).is_undefined() { return Some(result); } @@ -5189,7 +5191,9 @@ unsafe fn try_native_static_method_in_proto_chain( if read_native_module_name(proto_obj as *const ObjectHeader).as_deref() == Some("buffer.Buffer") { - let result = crate::object::native_module::call_native_module_dispatch_hook(proto_obj, name, args_ptr, args_len); + let result = crate::object::native_module::call_native_module_dispatch_hook( + proto_obj, name, args_ptr, args_len, + ); if !JSValue::from_bits(result.to_bits()).is_undefined() { return Some(result); } diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 9537970dee..44f7b1f597 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -2385,8 +2385,10 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { }; let present = unsafe { read_native_module_name(obj_ptr) } .as_deref() - .is_some_and(|module_name| super::native_module::native_module_vtable() - .is_some_and(|vt| (vt.has_enumerable_key)(module_name, key_name))); + .is_some_and(|module_name| { + super::native_module::native_module_vtable() + .is_some_and(|vt| (vt.has_enumerable_key)(module_name, key_name)) + }); return if present { nanbox_true } else { nanbox_false }; } diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 9d1302b559..9159468b67 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -3453,7 +3453,12 @@ pub unsafe extern "C" fn js_native_call_method( // #853: the `is_valid_obj_ptr` guard that used to live after // this return was dead — the early return claims the path // unconditionally. Removed. - return crate::object::native_module::call_native_module_dispatch_hook(obj, method_name, args_ptr, args_len); + return crate::object::native_module::call_native_module_dispatch_hook( + obj, + method_name, + args_ptr, + args_len, + ); } // Issue #1206: Buffer iterators returned from `buf.values()` etc. // have a dedicated class id so `.next()` lands here and dispatches @@ -3977,7 +3982,12 @@ pub unsafe extern "C" fn js_native_call_method( // Check for native module namespace if (*obj).class_id == NATIVE_MODULE_CLASS_ID { // #853: same dead-after-return as the first arm above. - return crate::object::native_module::call_native_module_dispatch_hook(obj, method_name, args_ptr, args_len); + return crate::object::native_module::call_native_module_dispatch_hook( + obj, + method_name, + args_ptr, + args_len, + ); } // Issue #1206: same class-id check as the NaN-boxed path above // so a raw-pointer iterator value (uncommon, but possible after diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index e5a5fd0f83..7b1d9b2b92 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -965,7 +965,10 @@ pub(crate) unsafe fn call_native_module_dispatch_hook( match native_module_vtable() { Some(vt) => (vt.dispatch)(obj, method_name, args_ptr, args_len), None => { - debug_assert!(false, "native-module method call before any namespace was created"); + debug_assert!( + false, + "native-module method call before any namespace was created" + ); f64::from_bits(crate::value::TAG_UNDEFINED) } } @@ -8491,7 +8494,8 @@ unsafe fn vt_own_keys_array(obj: *const ObjectHeader) -> Option<*mut crate::arra ) && crate::process::process_permission_enabled(); let out = crate::array::js_array_alloc(keys.len() as u32 + include_permission as u32); for key_bytes in keys { - let key_str = crate::string::js_string_from_bytes(key_bytes.as_ptr(), key_bytes.len() as u32); + let key_str = + crate::string::js_string_from_bytes(key_bytes.as_ptr(), key_bytes.len() as u32); crate::array::js_array_push(out, JSValue::string_ptr(key_str)); } if include_permission { diff --git a/crates/perry-runtime/src/object/object_ops.rs b/crates/perry-runtime/src/object/object_ops.rs index 20f8c65c17..c19be3f18a 100644 --- a/crates/perry-runtime/src/object/object_ops.rs +++ b/crates/perry-runtime/src/object/object_ops.rs @@ -914,8 +914,10 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { }; let present = read_native_module_name(obj) .as_deref() - .is_some_and(|module_name| super::native_module::native_module_vtable() - .is_some_and(|vt| (vt.has_enumerable_key)(module_name, key_name))); + .is_some_and(|module_name| { + super::native_module::native_module_vtable() + .is_some_and(|vt| (vt.has_enumerable_key)(module_name, key_name)) + }); return f64::from_bits(if present { TAG_TRUE } else { TAG_FALSE }); } diff --git a/crates/perry/src/commands/compile/optimized_libs.rs b/crates/perry/src/commands/compile/optimized_libs.rs index 05dbf04024..37b707bdfc 100644 --- a/crates/perry/src/commands/compile/optimized_libs.rs +++ b/crates/perry/src/commands/compile/optimized_libs.rs @@ -632,8 +632,7 @@ pub(super) fn build_optimized_libs( // (codegen declares all stdlib externs there), and mixing an // abort runtime with the unwind stdlib is not supported. let runtime = if panic_abort_safe && !ctx.needs_stdlib { - let found = - super::library_search::find_runtime_abort_library(target); + let found = super::library_search::find_runtime_abort_library(target); if found.is_some() && matches!(format, OutputFormat::Text) && verbose > 0 { eprintln!(" auto-optimize: using prebuilt panic=abort runtime"); } From 74c423d6cac8788b6fb77ca2c52095dd3f12f668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 12 Jun 2026 17:42:23 +0200 Subject: [PATCH 4/4] style(link): keep mod.rs under the 2000-line cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the feature-forwarding cargo-args logic into native_features.rs (apply_native_library_override) and extract the inline optional_framework_dir_tests mod to a sibling file — the forwarding block had pushed link/mod.rs from 1996 to 2024 lines. --- crates/perry/src/commands/compile/link/mod.rs | 82 ++----------------- .../commands/compile/link/native_features.rs | 30 +++++++ .../link/optional_framework_dir_tests.rs | 51 ++++++++++++ 3 files changed, 88 insertions(+), 75 deletions(-) create mode 100644 crates/perry/src/commands/compile/link/optional_framework_dir_tests.rs diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index 696d0b7414..ce5ad94cb4 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -1436,30 +1436,14 @@ pub(super) fn build_and_run_link( .arg("--manifest-path") .arg(&cargo_toml); - // Per-project feature forwarding: a - // `[native-library.""]` table in perry.toml maps - // onto cargo `--features` / `--no-default-features` so - // apps can select a build profile of the native crate - // (e.g. a 2D-only build of a 2D+3D engine). - if let Some(ovr) = native_features::lookup_native_library_override( + // perry.toml `[native-library.""]` feature + // forwarding — see native_features.rs. + native_features::apply_native_library_override( + &mut cargo_cmd, &ctx.project_root, &native_lib.module, - ) { - if !ovr.default_features { - cargo_cmd.arg("--no-default-features"); - } - if !ovr.features.is_empty() { - cargo_cmd.arg("--features").arg(ovr.features.join(",")); - } - if matches!(format, OutputFormat::Text) { - println!( - " native-library features for {}: default-features={} features=[{}]", - native_lib.module, - ovr.default_features, - ovr.features.join(", ") - ); - } - } + matches!(format, OutputFormat::Text), + ); if let Some(triple) = rust_target_triple(target) { cargo_cmd.arg("--target").arg(triple); @@ -1969,56 +1953,4 @@ pub(super) fn build_and_run_link( } #[cfg(test)] -mod optional_framework_dir_tests { - use super::*; - - /// Lay out a temp project: `/perry.toml` + `/src/main.ts`, - /// with the perry.toml `[google_auth]` table set to `toml_body`. - /// Returns (tempdir, entry-ts-path). - fn scaffold(toml_body: &str) -> (tempfile::TempDir, PathBuf) { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("perry.toml"), toml_body).unwrap(); - let src = dir.path().join("src"); - fs::create_dir_all(&src).unwrap(); - let entry = src.join("main.ts"); - fs::write(&entry, "export {}\n").unwrap(); - (dir, entry) - } - - #[test] - fn resolves_framework_dir_relative_to_project_root() { - let (dir, entry) = - scaffold("[google_auth]\nframework_dir = \"vendor/google-sign-in/frameworks\"\n"); - // Use a uniquely-named env var that is guaranteed unset. - let env_name = "PERRY_TEST_GA_FRAMEWORK_DIR_UNSET_A"; - let resolved = resolve_optional_framework_dir(env_name, &entry).unwrap(); - // Compare against the canonicalized root — `find_project_root_for` - // canonicalizes the entry, so the resolved path is symlink-resolved - // (e.g. /var/folders → /private/var on macOS). - assert_eq!( - resolved, - dir.path() - .canonicalize() - .unwrap() - .join("vendor/google-sign-in/frameworks") - ); - } - - #[test] - fn returns_none_when_no_framework_dir_key() { - let (_dir, entry) = scaffold("[google_auth]\nios_client_id = \"abc\"\n"); - let env_name = "PERRY_TEST_GA_FRAMEWORK_DIR_UNSET_B"; - assert!(resolve_optional_framework_dir(env_name, &entry).is_none()); - } - - #[test] - fn env_var_takes_precedence_over_perry_toml() { - let (_dir, entry) = scaffold("[google_auth]\nframework_dir = \"vendor/from-toml\"\n"); - // Unique name so we don't race other tests sharing process env. - let env_name = "PERRY_TEST_GA_FRAMEWORK_DIR_SET_C"; - std::env::set_var(env_name, "/absolute/from/env"); - let resolved = resolve_optional_framework_dir(env_name, &entry).unwrap(); - std::env::remove_var(env_name); - assert_eq!(resolved, PathBuf::from("/absolute/from/env")); - } -} +mod optional_framework_dir_tests; diff --git a/crates/perry/src/commands/compile/link/native_features.rs b/crates/perry/src/commands/compile/link/native_features.rs index 3a1111413f..e5c36ec282 100644 --- a/crates/perry/src/commands/compile/link/native_features.rs +++ b/crates/perry/src/commands/compile/link/native_features.rs @@ -26,6 +26,7 @@ //! so the failure is attributable. use std::path::Path; +use std::process::Command; /// Feature overrides for one native-library package, as declared in the /// project's `perry.toml`. @@ -83,6 +84,35 @@ fn lookup_in_table(doc: &toml::Table, module: &str) -> Option"]` override (if any) to a +/// native-library cargo invocation: `--no-default-features` / +/// `--features a,b`, logging the selection in text mode. No-op when the +/// project declares nothing for this package. +pub(super) fn apply_native_library_override( + cargo_cmd: &mut Command, + project_root: &Path, + module: &str, + text_output: bool, +) { + let Some(ovr) = lookup_native_library_override(project_root, module) else { + return; + }; + if !ovr.default_features { + cargo_cmd.arg("--no-default-features"); + } + if !ovr.features.is_empty() { + cargo_cmd.arg("--features").arg(ovr.features.join(",")); + } + if text_output { + println!( + " native-library features for {}: default-features={} features=[{}]", + module, + ovr.default_features, + ovr.features.join(", ") + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/perry/src/commands/compile/link/optional_framework_dir_tests.rs b/crates/perry/src/commands/compile/link/optional_framework_dir_tests.rs new file mode 100644 index 0000000000..ec4bfcdec9 --- /dev/null +++ b/crates/perry/src/commands/compile/link/optional_framework_dir_tests.rs @@ -0,0 +1,51 @@ +use super::*; + +/// Lay out a temp project: `/perry.toml` + `/src/main.ts`, +/// with the perry.toml `[google_auth]` table set to `toml_body`. +/// Returns (tempdir, entry-ts-path). +fn scaffold(toml_body: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("perry.toml"), toml_body).unwrap(); + let src = dir.path().join("src"); + fs::create_dir_all(&src).unwrap(); + let entry = src.join("main.ts"); + fs::write(&entry, "export {}\n").unwrap(); + (dir, entry) +} + +#[test] +fn resolves_framework_dir_relative_to_project_root() { + let (dir, entry) = + scaffold("[google_auth]\nframework_dir = \"vendor/google-sign-in/frameworks\"\n"); + // Use a uniquely-named env var that is guaranteed unset. + let env_name = "PERRY_TEST_GA_FRAMEWORK_DIR_UNSET_A"; + let resolved = resolve_optional_framework_dir(env_name, &entry).unwrap(); + // Compare against the canonicalized root — `find_project_root_for` + // canonicalizes the entry, so the resolved path is symlink-resolved + // (e.g. /var/folders → /private/var on macOS). + assert_eq!( + resolved, + dir.path() + .canonicalize() + .unwrap() + .join("vendor/google-sign-in/frameworks") + ); +} + +#[test] +fn returns_none_when_no_framework_dir_key() { + let (_dir, entry) = scaffold("[google_auth]\nios_client_id = \"abc\"\n"); + let env_name = "PERRY_TEST_GA_FRAMEWORK_DIR_UNSET_B"; + assert!(resolve_optional_framework_dir(env_name, &entry).is_none()); +} + +#[test] +fn env_var_takes_precedence_over_perry_toml() { + let (_dir, entry) = scaffold("[google_auth]\nframework_dir = \"vendor/from-toml\"\n"); + // Unique name so we don't race other tests sharing process env. + let env_name = "PERRY_TEST_GA_FRAMEWORK_DIR_SET_C"; + std::env::set_var(env_name, "/absolute/from/env"); + let resolved = resolve_optional_framework_dir(env_name, &entry).unwrap(); + std::env::remove_var(env_name); + assert_eq!(resolved, PathBuf::from("/absolute/from/env")); +}