diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index dda516c511..11e031de29 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -25,17 +25,22 @@ crate-type = ["rlib"] default = ["full", "regex-engine", "temporal", "url-engine", "string-normalize", "intl-segmenter", "intl-namespace", "global-math", "global-json", "global-reflect", "global-atomics", "global-url", "global-text", "global-websocket", "global-webcrypto", "global-webfetch", "proc-ipc", "intl-locale", "intl-datetime", "diagnostics", "mod-dgram", "mod-http2-constants", "mod-node-test", "dyn-eval", "keepalive-anchors", "alloc-mimalloc"] # Compile the ~490 `#[used]` keep-alive anchor statics (`KEEP_*`, `keep!`, # `K*`/`G*` fn-pointer statics) that pin codegen-facing `#[no_mangle]` entry -# points as dead-strip roots. They exist for the whole-program bitcode-LTO -# link (`PERRY_LLVM_BITCODE_LINK=1`), where the merged module could otherwise -# internalize + drop symbols only referenced from the user's separate `.o`. -# The CLASSIC link path never needs them: every reachable runtime symbol is -# kept by a real undefined reference from the program's objects, and the -# anchors only defeat `-dead_strip` (measured: a hello-world binary carries -# the entire fs/child_process/vm/node_submodules surface through ONE anchored -# chain — KEEP_JS_MODULE_DYNAMIC_IMPORT_DEFERRED → dynamic-import fallback → -# js_nm_install_all → every dispatch bucket). Kept in `default` so the -# prebuilt/full archives and `cargo test` are unchanged; the auto-optimize -# rebuild omits it unless the bitcode link was requested. +# points so they survive into the staticlib archive. Without these anchors, +# rustc dead-code-eliminates `#[no_mangle] pub extern "C" fn` symbols that are +# only called from the program's generated object files (not from within the +# perry-runtime crate itself), producing an incomplete `libperry_runtime.a` +# that fails to link with "Undefined symbols" when the codegen emits calls to +# them (e.g. `js_box_release`, `js_bool_box_release`, +# `js_closure_set_box_capture_ptr`, `js_link_path_module_parent`). +# +# In a staticlib archive (`.a`) the linker only pulls in object files that +# resolve an undefined reference, so `#[used]` anchors only become +# `-dead_strip` roots when their object file is pulled in — i.e. when the +# program actually references a symbol from that unit. The size cost is +# therefore limited to the transitive callees of symbols the program uses +# (which would be kept anyway), not the entire runtime surface. Kept in +# `default` so the prebuilt/full archives and `cargo test` are unchanged; +# the auto-optimize rebuild always re-adds it (see optimized_libs.rs). keepalive-anchors = [] # Route every Rust heap allocation through mimalloc (#62) — ~3-5× faster # per-alloc than macOS `malloc` on hot paths, at ~140 KB of binary. In diff --git a/crates/perry/src/commands/compile/optimized_libs/freshness.rs b/crates/perry/src/commands/compile/optimized_libs/freshness.rs index 8268ba68c1..543f0280e9 100644 --- a/crates/perry/src/commands/compile/optimized_libs/freshness.rs +++ b/crates/perry/src/commands/compile/optimized_libs/freshness.rs @@ -157,7 +157,10 @@ pub(crate) fn auto_optimized_cache_key( "" } ), - std::env::var("PERRY_LLVM_BITCODE_LINK").ok().as_deref() == Some("1"), + // keepalive-anchors is now always enabled (see auto_optimized_cross_features), + // so the cache key always reflects anchors=true. The field stays in the key + // so a future change to gate it again would get its own cache dir. + true, env!("CARGO_PKG_VERSION"), ) } @@ -286,15 +289,32 @@ pub(crate) fn auto_optimized_cross_features( // system-allocator ranges, the feature stays force-on; the cfg gate in // perry-runtime remains for that future audit. cross_features.push("perry-runtime/alloc-mimalloc".to_string()); - // The `#[used]` keep-alive anchors exist for the whole-program bitcode - // LTO path only (see perry-runtime's `keepalive-anchors` feature docs). - // The classic link keeps every reachable symbol via real undefined - // references, so the anchors are omitted there — that is what lets - // `-dead_strip` drop the never-imported node-module surface from small - // programs. Re-enable them whenever the bitcode link was requested. - if std::env::var("PERRY_LLVM_BITCODE_LINK").ok().as_deref() == Some("1") { - cross_features.push("perry-runtime/keepalive-anchors".to_string()); - } + // `#[used]` keep-alive anchors must be present on EVERY auto-optimize + // rebuild, not just the bitcode-LTO path. The anchors pin codegen-only + // `#[no_mangle] pub extern "C" fn` symbols (e.g. `js_box_release`, + // `js_bool_box_release`, `js_closure_set_box_capture_ptr`, + // `js_link_path_module_parent`) that are never called from within the + // perry-runtime crate itself — only from the program's generated object + // files. Without `#[used]`, rustc dead-code-eliminates these unreferenced + // symbols during staticlib archive creation, so they are absent from + // `libperry_runtime.a` and the final link fails with "Undefined symbols + // for architecture arm64" when the program's codegen emits calls to them. + // + // #6917 gated the anchors behind `keepalive-anchors` (bitcode-LTO only) + // under the assumption that "the classic link keeps every reachable + // runtime symbol via real undefined references from the program's + // objects." That assumption is wrong: the program's undefined references + // can only resolve symbols that are IN the archive, and the archive + // doesn't contain symbols that were DCE'd during archive creation. + // + // In a staticlib archive (`.a`) the linker only pulls in object files + // that resolve an undefined reference, so `#[used]` anchors only become + // `-dead_strip` roots when their object file is pulled in — i.e. when + // the program actually references a symbol from that unit. The size + // regression is therefore limited to the transitive callees of symbols + // the program uses (which would be kept anyway), not the entire runtime + // surface. Re-enable unconditionally; the bitcode-LTO path already did. + cross_features.push("perry-runtime/keepalive-anchors".to_string()); // Compile OUT perry-runtime's no-op fetch stubs (`js_fetch_with_options` / // `js_headers_new` / `js_request_new`, gated `#[cfg(not(feature = // "external-fetch-symbols"))]`) whenever the program uses fetch — perry-stdlib's diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index b24c810174..cf636b0d9e 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -1053,6 +1053,60 @@ fn retain_workspace_declared_features_keeps_all_without_manifests() { assert_eq!(cross_features.len(), 2); } +/// Regression: the auto-optimize rebuild must always include +/// `perry-runtime/keepalive-anchors` in the cross-feature set. #6917 gated +/// the `#[used]` keepalive anchors behind the feature and only enabled it for +/// the bitcode-LTO path, under the assumption that the classic link path +/// "keeps every reachable runtime symbol via real undefined references from +/// the program's objects." That assumption is wrong: `#[no_mangle] pub extern +/// "C" fn` symbols that are only called from codegen (not from within the +/// perry-runtime crate) are dead-code-eliminated during staticlib archive +/// creation when no `#[used]` anchor pins them. The resulting +/// `libperry_runtime.a` is missing core symbols (`js_box_release`, +/// `js_bool_box_release`, `js_closure_set_box_capture_ptr`, +/// `js_link_path_module_parent`) and programs whose codegen emits calls to +/// them fail to link with "Undefined symbols for architecture arm64." +#[test] +fn auto_optimize_always_includes_keepalive_anchors() { + let dir = tempfile::tempdir().expect("tempdir"); + let empty_features: std::collections::BTreeSet<&'static str> = + std::collections::BTreeSet::new(); + let ctx = CompilationContext::new(dir.path().to_path_buf()); + let cross = auto_optimized_cross_features(&ctx, &empty_features, &[]); + assert!( + cross.iter().any(|f| f == "perry-runtime/keepalive-anchors"), + "auto_optimized_cross_features must always include \ + perry-runtime/keepalive-anchors so codegen-only #[no_mangle] symbols \ + survive into the staticlib archive, got {cross:?}" + ); +} + +/// The `keepalive-anchors` feature must NOT be conditional on +/// `PERRY_LLVM_BITCODE_LINK` — the classic link path needs it too. +#[test] +fn auto_optimize_keepalive_anchors_not_bitcode_only() { + let _guard = env_lock(); + let old_bitcode = std::env::var_os("PERRY_LLVM_BITCODE_LINK"); + std::env::remove_var("PERRY_LLVM_BITCODE_LINK"); + + let dir = tempfile::tempdir().expect("tempdir"); + let empty_features: std::collections::BTreeSet<&'static str> = + std::collections::BTreeSet::new(); + let ctx = CompilationContext::new(dir.path().to_path_buf()); + let cross = auto_optimized_cross_features(&ctx, &empty_features, &[]); + + set_env_var( + "PERRY_LLVM_BITCODE_LINK", + old_bitcode.as_deref().and_then(|v| v.to_str()), + ); + + assert!( + cross.iter().any(|f| f == "perry-runtime/keepalive-anchors"), + "keepalive-anchors must be present even without PERRY_LLVM_BITCODE_LINK, \ + got {cross:?}" + ); +} + /// The well-known flip drops `compression-brotli`/`compression-zstd` from the /// stdlib rebuild on a stated premise: "The ext crate carries all codecs, so /// nothing is lost by dropping them here."