From 27af6cac7b35bb3180c6b45589c5f8e75b5f2ba9 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 17 Aug 2026 22:16:59 -0400 Subject: [PATCH] fix(runtime): always enable keepalive-anchors in auto-optimize rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6917 gated the ~490 #[used] keep-alive anchor statics behind the keepalive-anchors feature and only enabled it for the bitcode-LTO path (PERRY_LLVM_BITCODE_LINK=1). The assumption was 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 itself) are dead-code-eliminated by rustc 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." This blocks sdxgen and any program whose codegen references these codegen-only entry points. Fix: always include perry-runtime/keepalive-anchors in the auto-optimize cross-feature set (not just when PERRY_LLVM_BITCODE_LINK=1). In a staticlib archive 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 — the size cost is limited to the transitive callees of symbols the program actually uses, not the entire runtime surface. Also update the cache key's anchors field to always true (was PERRY_LLVM_BITCODE_LINK == "1") so old incomplete archives get new hash dirs and are never reused. --- crates/perry-runtime/Cargo.toml | 27 ++++++---- .../compile/optimized_libs/freshness.rs | 40 ++++++++++---- .../commands/compile/optimized_libs/tests.rs | 54 +++++++++++++++++++ 3 files changed, 100 insertions(+), 21 deletions(-) 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."