From ad6cbc8ccb7cced9201b3f0cf6ac7792cf31de52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 9 Jun 2026 23:07:24 +0200 Subject: [PATCH] fix(codegen,ci): keep stale Apple cross runtimes from breaking iOS/tvOS links (#4856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layers, per the issue's suggested fixes: 1. codegen: emit the perry_macos_bundle_chdir() call in main's prelude only for macOS triples (apple-macosx / apple-darwin). The runtime fn is a documented no-op everywhere else, and referencing it from every executable made iOS/tvOS links depend on non-macOS runtime archives carrying a macOS-only symbol — a stale cross runtime then failed the link with 'undefined symbol: _perry_macos_bundle_chdir'. New IR regression test covers macOS-emits / non-macOS-omits per triple. 2. release CI: evict workspace-crate fingerprints from the restored rust-cache in both the build: and build-cross: jobs. rust-cache's workspace cleanup misses target// dirs (and cargo clean -p only covers the host layout), so a restored cache could let cargo reuse a pre-#4833 libperry_runtime.a — exactly how v0.5.1150 shipped stale Apple cross runtime bundles. External dependency precompiles (the expensive part of the cache) stay cached. 3. release CI: post-build symbol guard (scripts/check_runtime_symbols.sh) asserts every built runtime archive defines sentinel #[no_mangle] symbols (js_gc_init, perry_macos_bundle_chdir) by reading the archive symbol index — works on Mach-O/ELF/COFF and on thin-LTO bitcode members that Apple's nm can't parse. A stale archive now fails the release instead of shipping. Fixes #4856 --- .github/workflows/release-packages.yml | 64 +++++++++ crates/perry-codegen/src/codegen/entry.rs | 12 +- crates/perry-codegen/src/codegen/helpers.rs | 11 ++ .../tests/macos_bundle_chdir_gate.rs | 135 ++++++++++++++++++ scripts/check_runtime_symbols.sh | 82 +++++++++++ 5 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 crates/perry-codegen/tests/macos_bundle_chdir_gate.rs create mode 100755 scripts/check_runtime_symbols.sh diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 7bbf79d9bc..9a666e170f 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -191,6 +191,25 @@ jobs: with: shared-key: "release-${{ matrix.target }}" + # #4856 — evict workspace-crate fingerprints from the restored cache. + # rust-cache's own workspace cleanup misses cross-target dirs, so a + # restored target/ can make cargo treat perry-runtime & co. as + # up-to-date and silently reuse a staticlib built from older sources + # (v0.5.1150 shipped Apple cross runtimes missing + # perry_macos_bundle_chdir that way). Deleting the .fingerprint + # entries forces every workspace crate to rebuild from the checkout + # while keeping the expensive external-dependency precompiles cached. + # Note `cargo clean -p` is NOT equivalent: it only covers the host + # layout, not target// dirs (verified on cargo 1.8x). + - name: Evict workspace crates from restored cache (#4856) + shell: bash + run: | + set -euo pipefail + while IFS= read -r pkg; do + rm -rf target/release/.fingerprint/"$pkg"-* \ + target/*/release/.fingerprint/"$pkg"-* + done < <(cargo metadata --no-deps --format-version 1 | jq -r '.packages[].name') + # Closes #394: Homebrew bottle was missing libperry_ui_ios.a so # `perry compile foo.ts -o app --target ios[-simulator]` failed at link # with `perry/ui imported but libperry_ui_ios.a not found`. Both macOS @@ -388,6 +407,23 @@ jobs: cargo build --release --target aarch64-linux-android -p perry-runtime cargo build --release --target aarch64-linux-android -p perry-stdlib + # #4856 — defense in depth behind the cache eviction above: assert + # every runtime archive this leg built (host triple + any Apple/ + # Android cross triples) defines the sentinel #[no_mangle] symbols. + # A stale cached archive fails the release here instead of breaking + # consumer links with `undefined symbol: _perry_macos_bundle_chdir`. + - name: Verify runtime archives are fresh (#4856) + shell: bash + run: | + set -euo pipefail + libs=$(find target -path "*/release/libperry_runtime.a" -o -path "*/release/perry_runtime.lib") + if [ -z "$libs" ]; then + echo "::error::no built runtime archives found under target/ — nothing to verify" + exit 1 + fi + # shellcheck disable=SC2086 + ./scripts/check_runtime_symbols.sh $libs + - name: Package release archive (Unix) if: runner.os != 'Windows' run: | @@ -725,6 +761,22 @@ jobs: with: shared-key: "release-cross-${{ matrix.target }}" + # #4856 — evict workspace-crate fingerprints from the restored cache + # so perry-runtime/stdlib/UI always rebuild from the checkout. The + # v0.5.1150 Apple cross bundles shipped a stale pre-#4833 + # libperry_runtime.a (missing perry_macos_bundle_chdir) because the + # restored cache let cargo skip the rebuild. Mirrors the eviction + # step in `build:` — see the comment there for why `cargo clean -p` + # doesn't work (host layout only, misses target//). + - name: Evict workspace crates from restored cache (#4856) + shell: bash + run: | + set -euo pipefail + while IFS= read -r pkg; do + rm -rf target/release/.fingerprint/"$pkg"-* \ + target/*/release/.fingerprint/"$pkg"-* + done < <(cargo metadata --no-deps --format-version 1 | jq -r '.packages[].name') + # --- Per-runner toolchain wiring -------------------------------------- # Each of these blocks installs what the cross target needs that's NOT # already on the runner image. Apple runners get Xcode for free; Linux @@ -803,6 +855,18 @@ jobs: --target ${{ matrix.target }} \ -p perry-runtime -p perry-stdlib -p ${{ matrix.ui_crate }} + # #4856 — defense in depth behind the cache eviction above: a stale + # cached runtime archive fails the release here instead of breaking + # every consumer executable link on the build workers. + - name: Verify runtime archive is fresh (#4856) + shell: bash + run: | + set -euo pipefail + lib="target/${{ matrix.target }}/release/libperry_runtime.a" + # Windows MSVC emits perry_runtime.lib (no `lib` prefix). + [ -f "$lib" ] || lib="target/${{ matrix.target }}/release/perry_runtime.lib" + ./scripts/check_runtime_symbols.sh "$lib" + # --- Package + manifest ---------------------------------------------- # Manifest schema (per issue #1083): # { "perry_version": "", "target_triple": "", diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 6a368b52fc..b40b1d371d 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -13,7 +13,8 @@ use crate::types::{DOUBLE, I32, I8, PTR, VOID}; use super::helpers::{ emit_namespace_populator, enable_module_init_shadow_frame, init_static_fields_early, - init_static_fields_late, register_module_globals_as_gc_roots, write_barriers_enabled, + init_static_fields_late, is_macos_triple, register_module_globals_as_gc_roots, + write_barriers_enabled, }; use super::opts::CrossModuleCtx; @@ -145,7 +146,14 @@ pub(super) fn compile_module_entry( // launch starts at CWD=`/`. chdir there before any user code or // native engine init so relative asset paths (`assets/...`) resolve. // No-op on non-macOS and on non-bundle binaries (see the runtime fn). - blk.call_void("perry_macos_bundle_chdir", &[]); + // Emitted only for macOS triples (#4856): the runtime fn is a no-op + // everywhere else anyway, and referencing it from every `main` made + // iOS/tvOS links depend on non-macOS runtime archives carrying a + // macOS-only symbol — a stale cross runtime then failed the link + // with `undefined symbol: _perry_macos_bundle_chdir`. + if is_macos_triple(&cross_module.target_triple) { + blk.call_void("perry_macos_bundle_chdir", &[]); + } if let Some((const_name, byte_len)) = app_group_init.as_ref() { let suite_ptr = format!("@{}", const_name); let len_str = byte_len.to_string(); diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 69d6aa5a81..cebf4bc31f 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -419,6 +419,17 @@ pub fn resolve_target_triple(name: &str) -> Option { } } +/// True for macOS triples only (`*-apple-macosx*` LLVM-style, or +/// `*-apple-darwin*` rustc-style when a raw triple is passed through). +/// Deliberately false for every other Apple platform (`apple-ios`, +/// `apple-tvos`, `apple-xros`, `apple-watchos`): the `.app` CWD fix in +/// `perry_macos_bundle_chdir` is macOS-only, and emitting the call on +/// non-macOS targets makes their links depend on the runtime archive +/// carrying a macOS-only symbol (#4856). +pub(super) fn is_macos_triple(triple: &str) -> bool { + triple.contains("apple-macosx") || triple.contains("apple-darwin") +} + pub(super) fn emit_buffer_alias_metadata(llmod: &mut LlModule, count: u32) { if count == 0 { return; diff --git a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs new file mode 100644 index 0000000000..c992a38a7b --- /dev/null +++ b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs @@ -0,0 +1,135 @@ +//! #4856 — `perry_macos_bundle_chdir` must only be emitted into `main` for +//! macOS targets. The runtime fn is a documented no-op everywhere else, and +//! referencing it unconditionally made every iOS/tvOS executable link depend +//! on the Apple cross runtime archive carrying a macOS-only symbol — a stale +//! cross runtime then failed the link with +//! `undefined symbol: _perry_macos_bundle_chdir`. + +use perry_codegen::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::{Module, ModuleInitKind}; + +fn entry_opts(target: Option<&str>) -> CompileOptions { + CompileOptions { + target: target.map(str::to_string), + is_entry_module: true, + non_entry_module_prefixes: Vec::new(), + import_function_prefixes: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), + import_function_node_submodule: std::collections::HashMap::new(), + namespace_node_submodules: std::collections::HashMap::new(), + namespace_v8_specifiers: std::collections::HashMap::new(), + namespace_member_prefixes: std::collections::HashMap::new(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + namespace_reexport_named_imports: std::collections::HashSet::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: std::collections::HashSet::new(), + type_aliases: std::collections::HashMap::new(), + imported_func_param_counts: std::collections::HashMap::new(), + imported_func_has_rest: std::collections::HashSet::new(), + imported_func_synthetic_arguments: std::collections::HashSet::new(), + imported_func_return_types: std::collections::HashMap::new(), + imported_vars: std::collections::HashSet::new(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: false, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: perry_codegen::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: std::collections::HashMap::new(), + deferred_module_prefixes: std::collections::HashSet::new(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + } +} + +fn empty_entry_module() -> Module { + Module { + name: "chdir_gate.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: Vec::new(), + init: Vec::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +fn ir_for_target(target: &str) -> String { + String::from_utf8(compile_module(&empty_entry_module(), entry_opts(Some(target))).unwrap()) + .unwrap() +} + +const CHDIR_CALL: &str = "call void @perry_macos_bundle_chdir()"; + +#[test] +fn macos_targets_emit_bundle_chdir_in_main() { + for triple in [ + "arm64-apple-macosx15.0.0", + "x86_64-apple-macosx15.0.0", + "aarch64-apple-darwin", + ] { + let ir = ir_for_target(triple); + assert!( + ir.contains(CHDIR_CALL), + "expected {} in main for {}", + CHDIR_CALL, + triple + ); + } +} + +#[test] +fn non_macos_targets_do_not_reference_bundle_chdir() { + for triple in [ + "aarch64-apple-ios", + "arm64-apple-ios17.0-simulator", + "aarch64-apple-tvos", + "arm64-apple-tvos17.0-simulator", + "arm64-apple-xros1.0", + "arm64_32-apple-watchos", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "aarch64-unknown-linux-android", + "x86_64-pc-windows-msvc", + ] { + let ir = ir_for_target(triple); + assert!( + !ir.contains(CHDIR_CALL), + "{} must not call perry_macos_bundle_chdir from main (#4856)", + triple + ); + } +} diff --git a/scripts/check_runtime_symbols.sh b/scripts/check_runtime_symbols.sh new file mode 100755 index 0000000000..ca2da6684b --- /dev/null +++ b/scripts/check_runtime_symbols.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Post-build freshness guard for runtime static libraries (#4856). +# +# A Swatinem/rust-cache restore can leave `target/` fingerprints that make +# cargo treat workspace crates as up-to-date and silently reuse a +# `libperry_runtime.a` built from older sources — v0.5.1150 shipped Apple +# cross runtimes missing `perry_macos_bundle_chdir` exactly that way, which +# broke every iOS/tvOS executable link. This script asserts that a built +# runtime archive defines the sentinel `#[no_mangle]` symbols below, so a +# stale archive fails the release instead of shipping. +# +# Usage: check_runtime_symbols.sh ... +# +# When codegen starts referencing a new unconditional runtime symbol from +# every program's `main` prelude (see perry-codegen/src/codegen/entry.rs), +# add it to SENTINELS so a stale runtime missing it is caught here, not on +# a build worker at link time. + +set -euo pipefail + +if [ "$#" -lt 1 ]; then + echo "usage: $0 ..." >&2 + exit 2 +fi + +# Each sentinel must be defined unconditionally in perry-runtime — i.e. a +# `#[no_mangle] pub extern "C" fn` with no `#[cfg]` on the item or its +# module (a cfg-gated *body* is fine; the symbol still exists everywhere). +SENTINELS=( + js_gc_init + perry_macos_bundle_chdir # added by #4833; absence = pre-#4833 stale archive +) + +# Tool preference: rustup's llvm-tools nm (matches rustc's LLVM, reads the +# thin-LTO bitcode members) → PATH llvm-nm → system nm. The fallbacks may +# fail to parse bitcode members, but `--print-armap` below only needs the +# archive symbol index (ranlib map), which every archiver writes as plain +# data — readable regardless of member object format. +NM="" +if command -v rustc >/dev/null 2>&1; then + sysroot=$(rustc --print sysroot 2>/dev/null || true) + host=$(rustc -vV 2>/dev/null | sed -n 's/^host: //p') + if [ -n "$sysroot" ] && [ -n "$host" ] && [ -x "$sysroot/lib/rustlib/$host/bin/llvm-nm" ]; then + NM="$sysroot/lib/rustlib/$host/bin/llvm-nm" + fi +fi +if [ -z "$NM" ] && command -v llvm-nm >/dev/null 2>&1; then + NM=llvm-nm +fi +if [ -z "$NM" ] && command -v nm >/dev/null 2>&1; then + NM=nm +fi +if [ -z "$NM" ]; then + echo "::warning::check_runtime_symbols: no llvm-nm/nm available — skipping symbol guard" >&2 + exit 0 +fi + +status=0 +for lib in "$@"; do + if [ ! -f "$lib" ]; then + echo "::error::check_runtime_symbols: $lib does not exist" >&2 + status=1 + continue + fi + # `--print-armap` emits the archive symbol index ("sym in member.o") in + # addition to per-member listings; unreadable members only lose the + # latter. Tokenize, strip the Mach-O leading underscore, exact-match — + # no substring false positives (`foo_js_gc_init` ≠ `js_gc_init`). + tokens=$("$NM" --print-armap "$lib" 2>/dev/null | tr -d '\r' | tr ' \t' '\n\n' | sed 's/^_//' | sort -u || true) + missing=0 + for sym in "${SENTINELS[@]}"; do + if ! grep -qx "$sym" <<<"$tokens"; then + echo "::error::$lib does not define runtime symbol '$sym' — stale cached build artifact? (#4856)" >&2 + missing=1 + status=1 + fi + done + if [ "$missing" -eq 0 ]; then + echo "ok: $lib defines all ${#SENTINELS[@]} sentinel symbols" + fi +done +exit "$status"