Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions changelog.d/8816-runtime-library-build-stamp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fixed stale or mismatched `libperry_runtime` archives passing `perry doctor` and
then failing during native linking with undefined runtime symbols. Runtime
archives now carry a compiler build identity that `perry doctor` and compile
pipelines verify before linking, with actionable rebuild and reinstall guidance.
3 changes: 3 additions & 0 deletions crates/perry-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,9 @@ mach2 = "0.6"
# See `build.rs` and issue #395 for the rationale.
[build-dependencies]
perry-dispatch = { path = "../perry-dispatch" }
# Build-time only: fingerprints the compiler/runtime source contract embedded
# in libperry_runtime so the CLI can reject a stale archive before linking.
sha2 = "0.11"
# Build-time only (does NOT ship in the runtime binary): generates the WHATWG
# single-byte TextDecoder index tables so they are always spec-accurate. Only
# the generated `[u16; 128]` arrays land in the binary. Already vetted in the
Expand Down
165 changes: 165 additions & 0 deletions crates/perry-runtime/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,172 @@
//! line — see `src/stub_diag.rs` for the env-var policy.

use perry_dispatch::{ArgKind, MethodRow, ReturnKind};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Command;

/// Source trees that define the compiler <-> runtime contract. A clean git
/// checkout uses the commit as its build id; dirty/source-only builds hash
/// these inputs so rebuilding the compiler without rebuilding the archive is
/// still detected even though the package version did not change.
const RUNTIME_BUILD_INPUTS: &[&str] = &[
"Cargo.toml",
"Cargo.lock",
"crates/perry-dispatch/Cargo.toml",
"crates/perry-dispatch/src",
"crates/perry/Cargo.toml",
"crates/perry/src",
"crates/perry-codegen/Cargo.toml",
"crates/perry-codegen/src",
"crates/perry-hir/Cargo.toml",
"crates/perry-hir/src",
"crates/perry-transform/Cargo.toml",
"crates/perry-transform/src",
"crates/perry-runtime/Cargo.toml",
"crates/perry-runtime/build.rs",
"crates/perry-runtime/src",
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// `cargo package` builds the crate from an isolated directory without the
/// workspace siblings above. Hash the packaged runtime itself in that layout;
/// the compiler and static wrapper both consume this same crate artifact.
const PACKAGED_RUNTIME_BUILD_INPUTS: &[&str] = &["Cargo.toml", "build.rs", "src"];

fn command_stdout(root: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8(output.stdout).ok()?.trim().to_string())
}

fn sanitize_build_id(value: &str) -> String {
value
.chars()
.take(128)
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':') {
c
} else {
'_'
}
})
.collect()
}

fn collect_source_files(root: &Path, path: &Path, out: &mut Vec<PathBuf>) {
let Ok(metadata) = std::fs::metadata(path) else {
return;
};
if metadata.is_file() {
out.push(path.to_path_buf());
return;
}
let Ok(entries) = std::fs::read_dir(path) else {
return;
};
let mut entries: Vec<_> = entries.flatten().collect();
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let child = entry.path();
if child.strip_prefix(root).ok().is_some_and(|relative| {
relative
.components()
.any(|part| part.as_os_str() == "target" || part.as_os_str() == ".git")
}) {
continue;
}
collect_source_files(root, &child, out);
}
}

fn source_build_id(root: &Path, inputs: &[&str]) -> String {
let mut files = Vec::new();
for relative in inputs {
collect_source_files(root, &root.join(relative), &mut files);
}
files.sort();
files.dedup();

let mut hasher = Sha256::new();
hasher.update(b"perry-runtime-build-inputs-v1\0");
for path in files {
println!("cargo:rerun-if-changed={}", path.display());
let relative = path.strip_prefix(root).unwrap_or(&path);
hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes());
hasher.update(b"\0");
match std::fs::read(&path) {
Ok(bytes) => {
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
}
Err(_) => hasher.update(b"unreadable"),
}
hasher.update(b"\0");
}
let mut hex = String::with_capacity(64);
for byte in hasher.finalize() {
write!(hex, "{byte:02x}").expect("write source fingerprint");
}
format!("src:{hex}")
}

fn emit_runtime_build_id() {
println!("cargo:rerun-if-env-changed=PERRY_BUILD_COMMIT");
let manifest_dir =
PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
let workspace_candidate = manifest_dir.join("../..");
let workspace_layout = workspace_candidate
.join("crates/perry-runtime/Cargo.toml")
.is_file();
let (root, inputs) = if workspace_layout {
(workspace_candidate, RUNTIME_BUILD_INPUTS)
} else {
(manifest_dir, PACKAGED_RUNTIME_BUILD_INPUTS)
};
let root = root.canonicalize().unwrap_or(root);

// Make branch/commit changes rerun this build script even when the source
// files themselves are byte-identical (for example after a rebase).
if workspace_layout {
if let Some(git_head) = command_stdout(&root, &["rev-parse", "--git-path", "HEAD"]) {
println!("cargo:rerun-if-changed={}", root.join(git_head).display());
}
if let Some(symbolic_ref) = command_stdout(&root, &["symbolic-ref", "-q", "HEAD"]) {
if let Some(git_ref) =
command_stdout(&root, &["rev-parse", "--git-path", &symbolic_ref])
{
println!("cargo:rerun-if-changed={}", root.join(git_ref).display());
}
}
}

let explicit = std::env::var("PERRY_BUILD_COMMIT")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| format!("git:{}", sanitize_build_id(value.trim())));

let source_id = source_build_id(&root, inputs);
let clean_commit = workspace_layout
.then(|| command_stdout(&root, &["rev-parse", "--verify", "HEAD"]))
.flatten()
.filter(|_| {
let mut args = vec!["status", "--porcelain", "--untracked-files=normal", "--"];
args.extend_from_slice(inputs);
command_stdout(&root, &args).is_some_and(|status| status.is_empty())
})
.map(|commit| format!("git:{}", sanitize_build_id(&commit)));

let build_id = explicit.or(clean_commit).unwrap_or(source_id);
println!("cargo:rustc-env=PERRY_RUNTIME_BUILD_ID={build_id}");
}

fn arg_kind_rust_type(k: ArgKind) -> &'static str {
match k {
Expand Down Expand Up @@ -372,6 +536,7 @@ fn generate_single_byte_encodings(out_dir: &str) {
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../perry-dispatch/src/lib.rs");
emit_runtime_build_id();

let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
generate_single_byte_encodings(&out_dir);
Expand Down
30 changes: 30 additions & 0 deletions crates/perry-runtime/src/build_stamp.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//! Build identity embedded in every `libperry_runtime` archive.
//!
//! The compiler reads this marker before linking. Keeping it in the runtime
//! crate (rather than a packaging sidecar) means copied archives, Cargo-built
//! archives, compressed npm archives, and platform-suffixed archives all carry
//! their identity with them.

/// Revision/fingerprint produced by `build.rs` from the compiler/runtime
/// contract sources. Clean checkouts use `git:<commit>`; dirty or source-only
/// builds use `src:<sha256>`.
pub const PERRY_RUNTIME_BUILD_ID: &str = env!("PERRY_RUNTIME_BUILD_ID");

/// NUL-terminated record deliberately stored as plain ASCII so the CLI can
/// find it by streaming over either an ar archive (`.a`) or a COFF library
/// (`.lib`) without invoking platform-specific archive tools.
pub const PERRY_RUNTIME_BUILD_STAMP: &str = concat!(
"PERRY_RUNTIME_BUILD_STAMP_V1|version=",
env!("CARGO_PKG_VERSION"),
"|build=",
env!("PERRY_RUNTIME_BUILD_ID"),
"\0",
);

// `#[used]` keeps both this reference and its string data in the rlib object
// set copied by perry-runtime-static into libperry_runtime. The symbol stays
// mangled so linking a stdlib archive that also contains perry-runtime cannot
// create a duplicate public C symbol.
#[used]
#[doc(hidden)]
pub static PERRY_RUNTIME_BUILD_STAMP_EMBEDDED: &[u8] = PERRY_RUNTIME_BUILD_STAMP.as_bytes();
2 changes: 2 additions & 0 deletions crates/perry-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ pub mod atomics_futex;
pub mod bigint;
pub mod r#box;
pub mod buffer;
mod build_stamp;
pub use build_stamp::{PERRY_RUNTIME_BUILD_ID, PERRY_RUNTIME_BUILD_STAMP};
pub mod builtins;
pub mod bun_compat;
pub mod bun_ffi;
Expand Down
5 changes: 5 additions & 0 deletions crates/perry/src/commands/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ mod windows_target;
// reuses the subpath-imports + tsconfig-paths resolvers for `#` specifiers.
pub(crate) mod resolve;
mod resources;
mod runtime_compat;
mod sandbox_buildrs;
mod shared_tokio;
mod strip_dedup;
Expand Down Expand Up @@ -112,6 +113,10 @@ use resolve::{
is_recognized_text_asset, parse_native_library_manifest, parse_package_specifier,
resolve_import,
};
pub(crate) use runtime_compat::{
ensure_runtime_library_compatible, runtime_library_diagnostic, runtime_library_status,
RuntimeLibraryStatus,
};
use size_report::emit_size_report;
use strip_dedup::{
dedup_native_lib_for_tier3, dedup_runtime_for_tier3, dedup_stdlib_for_tier3,
Expand Down
2 changes: 1 addition & 1 deletion crates/perry/src/commands/compile/library_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1196,7 +1196,7 @@ pub(super) fn find_runtime_library(target: Option<&str>) -> Result<PathBuf> {
"Could not find {lib}{extra}.\n\
Searched:\n{list}\n\n\
Fixes:\n\
- From the perry workspace: cargo build --release -p perry-runtime{tf}\n\
- From the perry workspace: cargo build --release -p perry-runtime-static{tf}\n\
- Out-of-tree install: set PERRY_RUNTIME_DIR to the directory containing {lib}\n\
(e.g. export PERRY_RUNTIME_DIR=/path/to/perry/target/release)",
lib = lib_name,
Expand Down
17 changes: 13 additions & 4 deletions crates/perry/src/commands/compile/run_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6210,6 +6210,13 @@ pub fn run_with_parse_cache(
// emits `perry_module_init` instead of `main` (see is_dylib branch in
// codegen/entry.rs, which now also covers `staticlib`).
if is_staticlib {
let runtime_lib_for_manifest = optimized_libs
.runtime
.clone()
.or_else(|| find_runtime_library(target.as_deref()).ok());
if let Some(runtime) = &runtime_lib_for_manifest {
ensure_runtime_library_compatible(runtime)?;
}
let windows_target = is_windows_target(target.as_deref());
// Best-effort: drop a stale archive first so `ar` doesn't append to a
// previous build's contents.
Expand Down Expand Up @@ -6283,10 +6290,6 @@ pub fn run_with_parse_cache(
"path": abs.display().to_string(),
}));
};
let runtime_lib_for_manifest = optimized_libs
.runtime
.clone()
.or_else(|| find_runtime_library(target.as_deref()).ok());
if let Some(p) = &runtime_lib_for_manifest {
push_archive(&mut link_archives, "runtime", p);
}
Expand Down Expand Up @@ -6596,6 +6599,12 @@ pub fn run_with_parse_cache(
} else {
find_runtime_library(target.as_deref())?
};
// #8752: discovery only proves that an archive exists. A runtime copied
// from an older compiler build can be found successfully and then fail at
// the final link with undefined symbols for newly emitted entrypoints.
// Read its embedded build stamp now so the error names the stale archive
// and both builds before invoking the platform linker.
ensure_runtime_library_compatible(&runtime_lib)?;
// #1383 — under --enable-geisterhand, prefer the geisterhand-built stdlib
// over the auto-optimized one. `build_geisterhand_libs` (already run above
// when selecting `runtime_lib`) compiles perry-stdlib into target/geisterhand
Expand Down
Loading
Loading