Skip to content
Merged
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
3 changes: 2 additions & 1 deletion crates/perry/src/commands/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ use resolve::{
use strip_dedup::{
dedup_native_lib_for_tier3, dedup_runtime_for_tier3, dedup_stdlib_for_tier3,
localize_stdlib_stub_symbols, localize_stdlib_stub_symbols_for_windows,
strip_duplicate_objects_from_lib, strip_duplicate_objects_from_well_known_lib,
strip_bundled_runtime_from_well_known_lib, strip_duplicate_objects_from_lib,
strip_duplicate_objects_from_well_known_lib,
};
use targets::{
apple_sdk_version, find_visionos_swift_runtime, find_watchos_swift_runtime,
Expand Down
22 changes: 21 additions & 1 deletion crates/perry/src/commands/compile/link/build_and_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,27 @@ pub(crate) fn build_and_run_link(
well_known_libs
.iter()
.map(|wk| {
strip_duplicate_objects_from_well_known_lib(wk).unwrap_or_else(|_| wk.clone())
// Wrappers precede stdlib here, so a wrapper's bundled
// perry-runtime copy would win first-definition over stdlib's
// and split the runtime's mutable globals in two (stdlib code
// keeps its own copy via LTO-internal refs) — spawned async
// tasks then starve because the event pump's wait-driver slot
// is registered in one copy and read from the other. Drop the
// bundled runtime members so stdlib's copy is the single
// provider; the standalone runtime archive linked after
// stdlib still fills any DCE gaps.
let wk = match stdlib_lib {
Some(stdlib) => strip_bundled_runtime_from_well_known_lib(wk, stdlib)
.unwrap_or_else(|e| {
eprintln!(
"[strip-dedup] bundled-runtime drop skipped for {} (non-fatal): {e}",
wk.display()
);
wk.clone()
}),
None => wk.clone(),
};
strip_duplicate_objects_from_well_known_lib(&wk).unwrap_or(wk)
})
.collect()
} else {
Expand Down
5 changes: 3 additions & 2 deletions crates/perry/src/commands/compile/link/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ use super::{
find_msvc_lib_paths, find_msvc_link_exe, find_perry_windows_sdk, find_stdlib_library,
find_ui_library, find_visionos_swift_runtime, find_watchos_swift_runtime,
localize_stdlib_stub_symbols, localize_stdlib_stub_symbols_for_windows, rust_target_triple,
strip_duplicate_objects_from_lib, strip_duplicate_objects_from_well_known_lib,
windows_pe_subsystem_flag, windows_subsystem_needs_ui, CompilationContext,
strip_bundled_runtime_from_well_known_lib, strip_duplicate_objects_from_lib,
strip_duplicate_objects_from_well_known_lib, windows_pe_subsystem_flag,
windows_subsystem_needs_ui, CompilationContext,
};

mod build_and_run;
Expand Down
195 changes: 195 additions & 0 deletions crates/perry/src/commands/compile/strip_dedup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,28 @@ fn collect_archive_symbols_flat(
.unwrap_or_default()
}

/// Run `nm --undefined-only` on an archive and parse the output into a
/// per-member map of the symbols each member *references* but does not define.
/// Same parse as [`collect_archive_symbols_by_member`]; returns `None` if nm
/// fails so callers can fall back to keeping the archive untouched.
fn collect_archive_undefined_by_member(
llvm_nm: &Path,
archive: &Path,
) -> Option<std::collections::HashMap<String, std::collections::HashSet<String>>> {
let out = Command::new(llvm_nm)
.arg("--undefined-only")
.arg("--format=bsd")
.arg(archive)
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(parse_nm_archive_output(&String::from_utf8_lossy(
&out.stdout,
)))
}

/// On Windows, build a trimmed UI lib using the rlib (not staticlib).
///
/// perry-ui-windows builds as both rlib and staticlib. The staticlib bundles
Expand Down Expand Up @@ -797,6 +819,179 @@ pub(super) fn strip_duplicate_objects_from_well_known_lib(lib_path: &PathBuf) ->
Ok(trimmed_lib)
}

/// Drop a well-known wrapper's bundled `perry_runtime-*` codegen unit(s) when
/// the perry-stdlib archive that follows on the link line bundles the same
/// unit.
///
/// Wrapper staticlibs (perry-ext-http, …) bundle their whole Rust dep graph,
/// including a full copy of perry-runtime. In the wrappers-BEFORE-stdlib link
/// shapes (`prefer_well_known_before_stdlib`: out-of-tree prebuilt stdlib and
/// the auto-optimize archives-fresh fast path), that bundled copy becomes the
/// first-definition winner for every extern runtime symbol the user object
/// references (`js_wait_for_event`, `js_promise_run_microtasks`, …). Meanwhile
/// perry-stdlib's own code keeps using ITS bundled runtime copy through
/// LTO-promoted internal references (`.llvm.`-suffixed names resolve only
/// intra-archive). The process then runs TWO disjoint copies of the runtime's
/// mutable globals — two event-pump wait-driver slots, two microtask queues,
/// two exception states. Concretely: an async task spawned by stdlib code
/// (fetch) registers its wait-driver in stdlib's copy, the main loop's
/// `js_wait_for_event` — resolved from the wrapper's copy — reads a
/// never-written slot, falls back to the condvar park, and every spawned task
/// starves forever.
///
/// Decision rule (evidence-based, per the v0.5.331 dedup standard — see
/// [`strip_duplicate_objects_from_lib`]): a `perry_runtime-*` member is
/// dropped only when BOTH hold:
/// 1. the stdlib archive bundles the same codegen unit — matched by member
/// name containment, since stdlib's packaging renames members to
/// `perry_stdlib-<hash>.<original-member-name>.rcgu.o` (same crate + cgu
/// hash ⇒ same rlib input, identical extern surface);
/// 2. every symbol it defines that a *sibling* member references is also
/// defined by the stdlib archive (a sibling referencing one of the copy's
/// LTO-promoted `.llvm.` internals would go undefined — keep the member).
/// Anything the user object needs beyond stdlib's copy is provided by the
/// standalone `libperry_runtime.a` gap-filler linked after stdlib (the
/// long-standing DCE-fallback contract in `build_and_run_link`).
///
/// Non-fatal by construction: any nm/ar failure or rule miss returns the
/// original archive unchanged.
pub(super) fn strip_bundled_runtime_from_well_known_lib(
lib_path: &PathBuf,
stdlib_lib: &Path,
) -> Result<PathBuf> {
let lib_name = lib_path.file_name().and_then(|f| f.to_str()).unwrap_or("?");

let llvm_ar = find_llvm_tool("llvm-ar")
.or_else(|| find_path_tool("ar"))
.ok_or_else(|| anyhow::anyhow!("ar not found"))?;
let nm = find_nightly_llvm_tool("llvm-nm")
.or_else(|| find_llvm_tool("llvm-nm"))
.or_else(|| find_path_tool("nm"))
.ok_or_else(|| anyhow::anyhow!("nm not found"))?;

let abs_lib = std::fs::canonicalize(lib_path)?;
let abs_stdlib = std::fs::canonicalize(stdlib_lib)?;

let list_members = |archive: &Path| -> Result<Vec<String>> {
let out = Command::new(&llvm_ar).arg("t").arg(archive).output()?;
if !out.status.success() {
return Err(anyhow::anyhow!(
"failed to list members of {}",
archive.display()
));
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.to_string())
.collect())
};

let members = list_members(&abs_lib)?;
let candidates: Vec<String> = members
.iter()
.filter(|m| m.starts_with("perry_runtime-"))
.cloned()
.collect();
if candidates.is_empty() {
return Ok(lib_path.clone());
}

// Rule 1: stdlib must bundle the same codegen unit (renamed member
// contains the original member name verbatim).
let stdlib_members = list_members(&abs_stdlib)?;
let candidates: Vec<String> = candidates
.into_iter()
.filter(|c| stdlib_members.iter().any(|s| s.contains(c.as_str())))
.collect();
if candidates.is_empty() {
return Ok(lib_path.clone());
}

// Rule 2: no sibling member may depend on a symbol only this copy defines.
let defined_by_member = collect_archive_symbols_by_member(&nm, &abs_lib)
.ok_or_else(|| anyhow::anyhow!("failed to inspect defined symbols of {lib_name}"))?;
let undefined_by_member = collect_archive_undefined_by_member(&nm, &abs_lib)
.ok_or_else(|| anyhow::anyhow!("failed to inspect undefined symbols of {lib_name}"))?;
let stdlib_defined = collect_archive_symbols_flat(&nm, &abs_stdlib);
if stdlib_defined.is_empty() {
return Err(anyhow::anyhow!(
"failed to inspect stdlib symbols (empty set)"
));
}
let candidate_set: std::collections::BTreeSet<&String> = candidates.iter().collect();
let sibling_undefined: std::collections::HashSet<&String> = undefined_by_member
.iter()
.filter(|(m, _)| !candidate_set.contains(m))
.flat_map(|(_, syms)| syms.iter())
.collect();
let empty = std::collections::HashSet::new();
let removable: Vec<&String> = candidates
.iter()
.filter(|c| {
let defined = defined_by_member.get(*c).unwrap_or(&empty);
let unsatisfied: Vec<&&String> = sibling_undefined
.iter()
.filter(|s| defined.contains(**s) && !stdlib_defined.contains(**s))
.collect();
if !unsatisfied.is_empty() {
eprintln!(
"[strip-dedup] {lib_name}: keeping bundled {c} — {} sibling-referenced \
symbol(s) not provided by stdlib (e.g. {})",
unsatisfied.len(),
unsatisfied[0]
);
}
unsatisfied.is_empty()
})
.collect();
if removable.is_empty() {
return Ok(lib_path.clone());
}

let tmp_base = std::env::temp_dir().join(format!("perry_strip_{}", std::process::id()));
std::fs::create_dir_all(&tmp_base).ok();
let extract_dir = tmp_base.join(format!("_{lib_name}_noruntime_extract"));
let _ = std::fs::remove_dir_all(&extract_dir);
std::fs::create_dir_all(&extract_dir)?;
let trimmed_lib = tmp_base.join(format!("_{lib_name}_noruntime.lib"));
let _ = std::fs::remove_file(&trimmed_lib);

let extract_out = Command::new(&llvm_ar)
.arg("x")
.arg(&abs_lib)
.current_dir(&extract_dir)
.output()?;
if !extract_out.status.success() {
let stderr = String::from_utf8_lossy(&extract_out.stderr);
return Err(anyhow::anyhow!("failed to extract {lib_name}: {stderr}"));
}

let remove_set: std::collections::BTreeSet<&String> = removable.iter().copied().collect();
let mut ar_cmd = Command::new(&llvm_ar);
ar_cmd.arg("crs").arg(&trimmed_lib);
for member in &members {
if remove_set.contains(member) {
continue;
}
ar_cmd.arg(extract_dir.join(member));
}
let ar_out = ar_cmd.output()?;
if !ar_out.status.success() {
let stderr = String::from_utf8_lossy(&ar_out.stderr);
return Err(anyhow::anyhow!(
"failed to create runtime-stripped archive for {lib_name}: {stderr}"
));
}

eprintln!(
"[strip-dedup] {lib_name}: dropped {} bundled perry-runtime member(s) \
(stdlib provides the single runtime copy)",
remove_set.len()
);
let _ = std::fs::remove_dir_all(&extract_dir);
Ok(trimmed_lib)
}

/// Symbols defined by perry-runtime's `stdlib_stubs` module (the
/// `#[cfg(not(feature = "stdlib"))]` no-op fallbacks). The standalone
/// `perry_runtime.lib` ships with these so runtime-only Windows builds still
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! Regression test for #5920: spawned async tasks starved after a recompile
//! with fresh auto-optimize archives.
//!
//! The archives-fresh fast path links the well-known wrapper archives BEFORE
//! `libperry_stdlib.a` (`prefer_well_known_before_stdlib`), so the wrapper's
//! bundled `perry_runtime` codegen unit used to become the first-definition
//! winner for every extern runtime symbol — while perry-stdlib's own code kept
//! using ITS bundled runtime copy through LTO-promoted `.llvm.` internals. Two
//! copies of the event pump's mutable state: `spawn()` registered the
//! wait-driver in stdlib's copy, `js_wait_for_event` read the wrapper's
//! never-written copy, parked on the condvar fallback, and every spawned task
//! starved. The FIRST compile of the same source (full auto-optimize rebuild,
//! wrappers after stdlib) was unaffected — the bug only appeared on
//! recompiles, once the auto-opt archives were warm.
//!
//! The program below exercises exactly the starving shape: a fire-and-forget
//! `fetch().then(...)` (no top-level await, so completion depends on the
//! event loop driving the spawned task via the wait-driver, not on a
//! `block_on`) against an in-process `node:http` server (which pulls the
//! perry-ext-http wrapper into the link). A 250 ms interval watchdog turns a
//! starved fetch into a deterministic `FAIL` exit within 10 s. Compiling
//! TWICE and running BOTH binaries covers both link shapes regardless of
//! whether a previous test already warmed the archives.

use std::path::PathBuf;
use std::process::Command;

fn perry_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
}

const SOURCE: &str = r#"
const http = require('http');

const server = http.createServer((_req: any, res: any) => {
res.end('pong');
});

server.listen(0, () => {
const port = (server as any).address().port;
let done = false;

// Fire-and-forget: the resolution depends on the main loop driving the
// spawned task — the exact path that starved pre-fix (#5920).
fetch('http://127.0.0.1:' + port + '/')
.then((r: any) => r.text())
.then((body: string) => {
done = true;
console.log('FETCH-DONE', body);
})
.catch((e: any) => {
console.log('FETCH-ERR', String(e));
process.exit(1);
});

let ticks = 0;
const iv = setInterval(() => {
ticks++;
if (done) {
clearInterval(iv);
server.close();
console.log('PASS');
process.exit(0);
}
if (ticks >= 40) {
clearInterval(iv);
server.close();
console.log('FAIL: fetch starved');
process.exit(1);
}
}, 250);
});
"#;

fn compile(dir: &std::path::Path, entry: &std::path::Path, output: &std::path::Path) -> String {
let compile = Command::new(perry_bin())
.current_dir(dir)
.arg("compile")
.arg(entry)
.arg("-o")
.arg(output)
.output()
.expect("run perry compile");
assert!(
compile.status.success(),
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&compile.stdout),
String::from_utf8_lossy(&compile.stderr)
);
String::from_utf8_lossy(&compile.stderr).into_owned()
}

fn run(dir: &std::path::Path, output: &std::path::Path, shape: &str) {
let run = Command::new(output)
.current_dir(dir)
.output()
.expect("run compiled binary");
let stdout = String::from_utf8_lossy(&run.stdout);
assert!(
run.status.success() && stdout.contains("PASS"),
"{shape}: spawned fetch starved (#5920)\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}",
run.status,
stdout,
String::from_utf8_lossy(&run.stderr)
);
}

/// Compile the identical source twice and run both binaries. Pre-fix, the
/// second compile (auto-opt archives fresh → wrappers linked before stdlib)
/// produced a binary whose fire-and-forget fetch never resolved.
#[test]
fn fire_and_forget_fetch_survives_recompile() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("main.ts");
std::fs::write(&entry, SOURCE).expect("write entry");

let first = dir.path().join("main_first");
compile(dir.path(), &entry, &first);
run(dir.path(), &first, "first compile");

let second = dir.path().join("main_second");
let _ = compile(dir.path(), &entry, &second);
// The behavioral gate IS the regression signal: pre-fix, this second
// binary (archives-fresh → wrappers-before-stdlib link) ticked to the
// watchdog FAIL with the fetch never resolving. No assertion on the
// strip-dedup log wording — the exact messages are not a contract.
run(
dir.path(),
&second,
"second compile (archives-fresh link shape)",
);
}
Loading