Compiling the same file twice with the same compiler and the same flags produces different LLVM IR, and therefore different object files and binaries.
Reproduce
$ for i in 1 2 3 4; do
mkdir -p run$i && cp batch.ts run$i/
(cd run$i && perry batch.ts -o o --trace llvm >/dev/null 2>&1)
done
$ md5 -q run*/.perry-trace/llvm/batch_ts.ll | sort | uniq -c
1 c8f51e1f7ca5ba71e6cd785827e0e976
1 d5f7462479bf33726126117e038f8126
1 d67dc9790fa27305ab027a73285e67df
1 f34c01e832bac8092790c95e90945c96
Four runs, four distinct outputs. (benchmarks/app-patterns/kernels/batch.ts, added in #7037, is a convenient repro — any file with several closures works.)
What varies
Only the numbering assigned to retained closure-source string constants, and the order of the matching js_register_function_source calls in __perry_init_strings_<prefix>:
-@.str.10 = private unnamed_addr constant [292 x i8] c"(acc: Summary[], r: Row) => {...}"
-@.str.11 = private unnamed_addr constant [160 x i8] c"(a, b) => {...}"
-@.str.12 = private unnamed_addr constant [130 x i8] c"(r) => ({...})"
+@.str.10 = private unnamed_addr constant [160 x i8] c"(a, b) => {...}"
+@.str.11 = private unnamed_addr constant [130 x i8] c"(r) => ({...})"
+@.str.12 = private unnamed_addr constant [292 x i8] c"(acc: Summary[], r: Row) => {...}"
Canonicalising @.str.N by content makes all runs hash identically, so it is a pure permutation — semantics are unaffected and fn.toString() still resolves correctly.
Root cause
crates/perry-codegen/src/codegen/artifacts.rs:1861
for (func_id, src) in &hir.closure_source_text {
Module::closure_source_text is a std::collections::HashMap<FuncId, String>
(crates/perry-hir/src/ir/module.rs:132). Iterating it directly yields
RandomState-seeded order, which differs per process. The sibling loop just
above it walks hir.functions (a Vec) and is deterministic; only this second
loop, which handles inline closures, is order-dependent.
Why it matters
Fix
Sort by FuncId before emitting (or make the field a BTreeMap):
let mut closure_sources: Vec<_> = hir.closure_source_text.iter().collect();
closure_sources.sort_by_key(|(id, _)| **id);
for (func_id, src) in closure_sources {
Worth auditing for sibling cases: any other for … in &some_hashmap whose body
emits IR in loop order.
Acceptance
N compiles of the same input at the same commit produce byte-identical .ll
and .o. A regression test that compiles a multi-closure fixture twice in one
cargo test process would not catch it (the RandomState seed is per process,
not per HashMap instantiation — actually it is per-HashMap, so it may; verify
before relying on it), so an in-CI check should compile in two separate
processes and diff.
Compiling the same file twice with the same compiler and the same flags produces different LLVM IR, and therefore different object files and binaries.
Reproduce
Four runs, four distinct outputs. (
benchmarks/app-patterns/kernels/batch.ts, added in #7037, is a convenient repro — any file with several closures works.)What varies
Only the numbering assigned to retained closure-source string constants, and the order of the matching
js_register_function_sourcecalls in__perry_init_strings_<prefix>:Canonicalising
@.str.Nby content makes all runs hash identically, so it is a pure permutation — semantics are unaffected andfn.toString()still resolves correctly.Root cause
crates/perry-codegen/src/codegen/artifacts.rs:1861Module::closure_source_textis astd::collections::HashMap<FuncId, String>(
crates/perry-hir/src/ir/module.rs:132). Iterating it directly yieldsRandomState-seeded order, which differs per process. The sibling loop justabove it walks
hir.functions(aVec) and is deterministic; only this secondloop, which handles inline closures, is order-dependent.
Why it matters
perry verify --attest(security: reproducible builds + binary attestation #504), which attests a SHA-256 of the binary.Fix
Sort by
FuncIdbefore emitting (or make the field aBTreeMap):Worth auditing for sibling cases: any other
for … in &some_hashmapwhose bodyemits IR in loop order.
Acceptance
N compiles of the same input at the same commit produce byte-identical
.lland
.o. A regression test that compiles a multi-closure fixture twice in onecargo testprocess would not catch it (theRandomStateseed is per process,not per HashMap instantiation — actually it is per-HashMap, so it may; verify
before relying on it), so an in-CI check should compile in two separate
processes and diff.