Description
crates/trusted-server-js/build.rs embeds the browser bundles into the Rust binary through one shared directory, crates/trusted-server-js/dist:
- It runs
npm run build (build.rs:73-85), which is node build-all.mjs.
build-all.mjs deletes and recreates that directory (crates/trusted-server-js/lib/build-all.mjs:24-29), then writes 13 bundles into it: core first, then the 12 integrations in parallel (:79-84). Rollup 4.57.1 writes each file in place with writeFile.
build.rs then lists tsjs-*.js in the same directory (build.rs:87-99), copies each file to OUT_DIR (:127-130), hashes the copies (:142-143) and embeds them with include_str! (:144-149).
Every cargo invocation in a checkout uses this directory. When two build scripts run at the same time, one deletes and rewrites dist while the other lists and copies it.
Nothing downstream notices a short or damaged set:
build.rs only checks that it found at least one bundle (:112-116). It does not know which modules to expect and accepts empty files.
- The
bundle.rs tests compare each embedded bundle with a hash computed from the same copied file (build.rs:143, crates/trusted-server-js/src/bundle.rs:141-153), so an empty bundle passes.
- At runtime an enabled integration without a bundle is skipped on purpose, because Rust-only integrations have none (
crates/trusted-server-core/src/integrations/registry.rs:1182-1197). concatenated_module_ids also skips any missing ID, including core (bundle.rs:65-83).
- Nothing watches
dist. rerun-if-changed covers only lib (build.rs:18-20), so a bad embed stays in that target directory until a file under lib changes.
The shared dist and the directory scan arrived with per-module builds in #242 (c06ae9cbd, 2026-02-19). Before that, build.rs copied one required tsjs-unified.js.
When two build scripts overlap
Cargo serializes builds that share a target directory and a profile. It does not serialize the others:
- Different target directories do not block each other.
- In one target directory, a dev build and a release build ran at the same time. Neither printed "Blocking waiting for file lock". Two dev builds for different
--target triples did block ("Blocking waiting for file lock on artifact directory").
Realistic overlaps:
fastly compute serve or fastly compute publish builds with --release (fastly.toml:14) while a dev-profile cargo build, cargo check or cargo test-* runs in the same checkout, for example from an editor.
- Any tool with its own target directory, such as an editor configured with a separate target dir or a
CARGO_TARGET_DIR override.
npm run build run by hand or by scripts/integration-tests-browser.sh:62-63 writes the same dist while cargo builds.
The build script reruns only after a change under lib, such as editing TypeScript or switching branches. So both commands must start soon after such a change.
The repository's own CI does not hit this. Each job has its own checkout and runs its cargo steps one after another (.github/workflows/test.yml).
Steps to reproduce
From the repository root, with Node and npm installed:
# Warm up both profiles once.
cargo build -p trusted-server-js
cargo build -p trusted-server-js --release
# Start a dev build, then a release build 0.6 s later, after a change under lib.
for i in 1 2 3 4 5 6 7 8 9 10; do
touch crates/trusted-server-js/lib/package.json
cargo build -p trusted-server-js > target/race-debug.log 2>&1 &
sleep 0.6
cargo build -p trusted-server-js --release > target/race-release.log 2>&1
release_exit=$?
wait $!
echo "round $i: dev exit=$? release exit=$release_exit"
grep -h "tsjs: Discovered" target/race-debug.log target/race-release.log | sed 's/.*tsjs: //'
# Any generated module table that embeds an empty bundle (SHA-256 of empty input):
grep -l 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' \
target/debug/build/trusted-server-js-*/out/tsjs_modules.rs \
target/release/build/trusted-server-js-*/out/tsjs_modules.rs
done
To see exactly what a build embedded, add crates/trusted-server-js/examples/print_modules.rs:
fn main() {
let ids = trusted_server_js::all_module_ids();
println!("embedded {} modules", ids.len());
for id in ids {
let bytes = trusted_server_js::module_bundle(id).map_or(0, str::len);
println!(" {id}: {bytes} bytes");
}
}
Run it with cargo run -q -p trusted-server-js --example print_modules, adding --release for the release profile. Cargo reuses the existing build script output, so it shows what that profile's last build embedded.
The right delay depends on the machine. It must start the second build script while the first is still building bundles. On the machine I used, a full build script run took about 2 seconds, and delays from 0.3 to 0.9 seconds reproduced it.
Observed at a4e01eb55 (macOS, Node 24.12.0, Rust 1.95.0). I ran this loop three times (30 rounds). Five rounds went wrong:
- In four rounds both builds exited 0, but one of them had embedded a partial set: 2, 3, 12 and 12 modules.
- In one round the dev build failed (exit 101).
Two of the exit-0 rounds, with the Discovered 13 module files lines left out:
round 2: dev exit=0 release exit=0
Discovered 2 module files: ["gpt", "prebid"]
round 9: dev exit=0 release exit=0
Discovered 3 module files: ["gpt", "prebid", "sourcepoint"]
A scripted variant of the same loop stopped at the first bad round. There the dev build exited 0 with 9 modules: no core, creative, didomi or osano. I then built a small example program against that target directory. It prints all_module_ids() and the length of each module_bundle(id), and cargo did not rerun the build script for it:
embedded 9 modules
datadome: 6488 bytes
google_tag_manager: 8310 bytes
gpt: 46560 bytes
gpt_diagnostics: 53370 bytes
lockr: 6928 bytes
permutive: 8157 bytes
prebid: 39214 bytes
sourcepoint: 9351 bytes
testlight: 2084 bytes
The release build is exposed the same way. With the order reversed (release first, dev 0.6 seconds later), the release build exited 0 with Discovered 2 module files: ["gpt", "prebid"] in round 4, and the example program built with --release printed embedded 2 modules.
With two dev builds in two target directories, staggered by 0.3, 0.6, 0.9 and 1.2 seconds for 5 rounds each (20 rounds), I saw:
- 3 builds that exited 0 with a bad set:
- 11 modules (
didomi and testlight missing);
- 13 modules with an empty
gpt bundle;
- 13 modules with an empty
gpt_diagnostics bundle.
- 2 builds that failed with
tsjs: no tsjs-*.js files found in .../crates/trusted-server-js/dist. That failure prints 34,013 lines, almost all cargo:rerun-if-changed, with the panic at line 34,011.
A separate 3-round run also failed once with tsjs: npm run build failed - refusing to use stale bundles. I did not capture the Node error for that round.
A reader that lists dist while two builds overlap, the way build.rs does, can see any count from 0 to 13. Across two samples of 9 and 12 seconds every count from 0 to 13 appeared, and in one of them 5,405 of 42,583 reads listed at least one 0-byte bundle.
The fallbacks that skip the build reuse whatever dist holds:
cargo build -p trusted-server-js # "Discovered 13 module files"
rm crates/trusted-server-js/dist/tsjs-gpt.js # stand-in for a stale or partial dist
touch crates/trusted-server-js/lib/package.json
TSJS_SKIP_BUILD=1 cargo build -p trusted-server-js # exit 0, "Discovered 12 module files"
cargo build -v -p trusted-server-js # "Fresh trusted-server-js": still 12 modules
The same stale dist with npm absent from PATH gives warning: tsjs: npm not found; will use existing dist if available, then Discovered 12 module files, and exit 0.
Expected behavior
- A cargo build embeds the complete, freshly built set of 13 non-empty bundles, or it fails with a clear message.
- Concurrent builds in one checkout do not affect each other.
- Skipping the Node build is an explicit choice, and it is checked the same way.
Actual behavior
- Concurrent builds can exit 0 with bundles missing, including
core, or with empty bundles. They can also fail with errors that point at the wrong cause.
TSJS_SKIP_BUILD=1 or a missing npm silently embeds whatever is in dist.
- A bad embed persists until something under
lib changes.
Root cause
- Shared, mutable output. The Node build cleans and rewrites
crates/trusted-server-js/dist (build-all.mjs:27-29), which every build script reads (build.rs:30, :87-99).
- Discovery without an expected set.
build.rs embeds whatever tsjs-*.js files exist at that moment (:87-99), requires only one (:112-116), and never checks for empty files.
- The runtime tolerates missing modules by design (
registry.rs:1182-1197, bundle.rs:65-83), so nothing fails later either.
The protection against stale output is partial:
- A failed
npm run build panics with "refusing to use stale bundles" (build.rs:81-84), and an empty dist panics (:112-116). Nothing else checks that the embedded set is complete or fresh.
npm not found: warns and uses the existing dist (:41-45).
TSJS_SKIP_BUILD=1: uses the existing dist (:23, :48, :62, :74). docs/guide/error-reference.md:615-619 recommends it as a workaround without that caveat.
node_modules is installed only when it is missing (:47-59). A node_modules that is out of date with package-lock.json is used as is. A failed npm ci only warns (:56-58). Two build scripts that both find no node_modules would both run npm ci in the same directory. I did not reproduce that case.
- There is no
rerun-if-env-changed. The build script output has none, so setting or clearing TSJS_SKIP_BUILD or TSJS_TEST does not rerun it.
rerun-if-changed lists 33,939 paths: lib and everything under it, including node_modules (build.rs:18-20, :201-221).
Impact
- Local builds, including local deploys.
fastly compute publish is a release build. A release built while another cargo command compiles can ship without some integrations' browser code, or without core. No build step or test fails. Pages then get a unified bundle without that code.
- Developers get confusing results: missing integrations in a local run, or panics that blame
npm run build or an empty dist.
- Likelihood: two build scripts must overlap within about two seconds of each other, right after a change under
lib. That is rare per build, but an editor plus a terminal, or a dev plus a release build, makes it reachable.
- CI is not affected.
Proposed fix
- Give each build script its own output. Add
--out-dir <dir> to build-all.mjs, keeping ../dist as the default for npm run build, the Playwright fixtures and CI. build.rs passes a directory under OUT_DIR and embeds from there. This removes the shared cleanup, the discovery race and the copy step.
- Check the set.
build.rs derives the expected modules the same way build-all.mjs:31-42 discovers them: core plus every lib/src/integrations/<name>/index.ts. It fails if any bundle is missing or empty.
- Make prebuilt bundles explicit. Replace the silent reuse of
dist for a missing npm and for TSJS_SKIP_BUILD=1 with an explicit directory variable (for example TSJS_PREBUILT_DIR), validated as in step 2. Without it, fail with instructions.
- Declare
cargo:rerun-if-env-changed for every variable build.rs reads.
- Detect stale dependencies. Compare
lib/package-lock.json with lib/node_modules/.package-lock.json and fail with "run npm ci" rather than reinstalling. An automatic npm ci deletes node_modules under any other build that is running.
- Optional: narrow
rerun-if-changed to the sources, configs and lockfile instead of 33,939 paths. This also keeps a failed build's output readable.
Alternative: a cross-process lock around the whole Node step (std::fs::File::lock is stable in the pinned Rust 1.95.0). It would serialize the build scripts, but keep the shared directory, and it does not cover npm run build run outside cargo. Private output is simpler.
Compatibility: npm run build still writes dist for Playwright and CI. Only the Rust build stops reading it. Anyone who relies on TSJS_SKIP_BUILD=1 moves to the explicit variable.
Done when
Affected area
JS build pipeline
Version
main at a4e01eb
Related
Description
crates/trusted-server-js/build.rsembeds the browser bundles into the Rust binary through one shared directory,crates/trusted-server-js/dist:npm run build(build.rs:73-85), which isnode build-all.mjs.build-all.mjsdeletes and recreates that directory (crates/trusted-server-js/lib/build-all.mjs:24-29), then writes 13 bundles into it: core first, then the 12 integrations in parallel (:79-84). Rollup 4.57.1 writes each file in place withwriteFile.build.rsthen liststsjs-*.jsin the same directory (build.rs:87-99), copies each file toOUT_DIR(:127-130), hashes the copies (:142-143) and embeds them withinclude_str!(:144-149).Every cargo invocation in a checkout uses this directory. When two build scripts run at the same time, one deletes and rewrites
distwhile the other lists and copies it.Nothing downstream notices a short or damaged set:
build.rsonly checks that it found at least one bundle (:112-116). It does not know which modules to expect and accepts empty files.bundle.rstests compare each embedded bundle with a hash computed from the same copied file (build.rs:143,crates/trusted-server-js/src/bundle.rs:141-153), so an empty bundle passes.crates/trusted-server-core/src/integrations/registry.rs:1182-1197).concatenated_module_idsalso skips any missing ID, includingcore(bundle.rs:65-83).dist.rerun-if-changedcovers onlylib(build.rs:18-20), so a bad embed stays in that target directory until a file underlibchanges.The shared
distand the directory scan arrived with per-module builds in #242 (c06ae9cbd, 2026-02-19). Before that,build.rscopied one requiredtsjs-unified.js.When two build scripts overlap
Cargo serializes builds that share a target directory and a profile. It does not serialize the others:
--targettriples did block ("Blocking waiting for file lock on artifact directory").Realistic overlaps:
fastly compute serveorfastly compute publishbuilds with--release(fastly.toml:14) while a dev-profilecargo build,cargo checkorcargo test-*runs in the same checkout, for example from an editor.CARGO_TARGET_DIRoverride.npm run buildrun by hand or byscripts/integration-tests-browser.sh:62-63writes the samedistwhile cargo builds.The build script reruns only after a change under
lib, such as editing TypeScript or switching branches. So both commands must start soon after such a change.The repository's own CI does not hit this. Each job has its own checkout and runs its cargo steps one after another (
.github/workflows/test.yml).Steps to reproduce
From the repository root, with Node and npm installed:
To see exactly what a build embedded, add
crates/trusted-server-js/examples/print_modules.rs:Run it with
cargo run -q -p trusted-server-js --example print_modules, adding--releasefor the release profile. Cargo reuses the existing build script output, so it shows what that profile's last build embedded.The right delay depends on the machine. It must start the second build script while the first is still building bundles. On the machine I used, a full build script run took about 2 seconds, and delays from 0.3 to 0.9 seconds reproduced it.
Observed at
a4e01eb55(macOS, Node 24.12.0, Rust 1.95.0). I ran this loop three times (30 rounds). Five rounds went wrong:Two of the exit-0 rounds, with the
Discovered 13 module fileslines left out:A scripted variant of the same loop stopped at the first bad round. There the dev build exited 0 with 9 modules: no
core,creative,didomiorosano. I then built a small example program against that target directory. It printsall_module_ids()and the length of eachmodule_bundle(id), and cargo did not rerun the build script for it:The release build is exposed the same way. With the order reversed (release first, dev 0.6 seconds later), the release build exited 0 with
Discovered 2 module files: ["gpt", "prebid"]in round 4, and the example program built with--releaseprintedembedded 2 modules.With two dev builds in two target directories, staggered by 0.3, 0.6, 0.9 and 1.2 seconds for 5 rounds each (20 rounds), I saw:
didomiandtestlightmissing);gptbundle;gpt_diagnosticsbundle.tsjs: no tsjs-*.js files found in .../crates/trusted-server-js/dist. That failure prints 34,013 lines, almost allcargo:rerun-if-changed, with the panic at line 34,011.A separate 3-round run also failed once with
tsjs: npm run build failed - refusing to use stale bundles. I did not capture the Node error for that round.A reader that lists
distwhile two builds overlap, the waybuild.rsdoes, can see any count from 0 to 13. Across two samples of 9 and 12 seconds every count from 0 to 13 appeared, and in one of them 5,405 of 42,583 reads listed at least one 0-byte bundle.The fallbacks that skip the build reuse whatever
distholds:The same stale
distwithnpmabsent fromPATHgiveswarning: tsjs: npm not found; will use existing dist if available, thenDiscovered 12 module files, and exit 0.Expected behavior
Actual behavior
core, or with empty bundles. They can also fail with errors that point at the wrong cause.TSJS_SKIP_BUILD=1or a missingnpmsilently embeds whatever is indist.libchanges.Root cause
crates/trusted-server-js/dist(build-all.mjs:27-29), which every build script reads (build.rs:30,:87-99).build.rsembeds whatevertsjs-*.jsfiles exist at that moment (:87-99), requires only one (:112-116), and never checks for empty files.registry.rs:1182-1197,bundle.rs:65-83), so nothing fails later either.The protection against stale output is partial:
npm run buildpanics with "refusing to use stale bundles" (build.rs:81-84), and an emptydistpanics (:112-116). Nothing else checks that the embedded set is complete or fresh.npmnot found: warns and uses the existingdist(:41-45).TSJS_SKIP_BUILD=1: uses the existingdist(:23,:48,:62,:74).docs/guide/error-reference.md:615-619recommends it as a workaround without that caveat.node_modulesis installed only when it is missing (:47-59). Anode_modulesthat is out of date withpackage-lock.jsonis used as is. A failednpm cionly warns (:56-58). Two build scripts that both find nonode_moduleswould both runnpm ciin the same directory. I did not reproduce that case.rerun-if-env-changed. The build script output has none, so setting or clearingTSJS_SKIP_BUILDorTSJS_TESTdoes not rerun it.rerun-if-changedlists 33,939 paths:liband everything under it, includingnode_modules(build.rs:18-20,:201-221).Impact
fastly compute publishis a release build. A release built while another cargo command compiles can ship without some integrations' browser code, or withoutcore. No build step or test fails. Pages then get a unified bundle without that code.npm run buildor an emptydist.lib. That is rare per build, but an editor plus a terminal, or a dev plus a release build, makes it reachable.Proposed fix
--out-dir <dir>tobuild-all.mjs, keeping../distas the default fornpm run build, the Playwright fixtures and CI.build.rspasses a directory underOUT_DIRand embeds from there. This removes the shared cleanup, the discovery race and the copy step.build.rsderives the expected modules the same waybuild-all.mjs:31-42discovers them:coreplus everylib/src/integrations/<name>/index.ts. It fails if any bundle is missing or empty.distfor a missingnpmand forTSJS_SKIP_BUILD=1with an explicit directory variable (for exampleTSJS_PREBUILT_DIR), validated as in step 2. Without it, fail with instructions.cargo:rerun-if-env-changedfor every variablebuild.rsreads.lib/package-lock.jsonwithlib/node_modules/.package-lock.jsonand fail with "runnpm ci" rather than reinstalling. An automaticnpm cideletesnode_modulesunder any other build that is running.rerun-if-changedto the sources, configs and lockfile instead of 33,939 paths. This also keeps a failed build's output readable.Alternative: a cross-process lock around the whole Node step (
std::fs::File::lockis stable in the pinned Rust 1.95.0). It would serialize the build scripts, but keep the shared directory, and it does not covernpm run buildrun outside cargo. Private output is simpler.Compatibility:
npm run buildstill writesdistfor Playwright and CI. Only the Rust build stops reading it. Anyone who relies onTSJS_SKIP_BUILD=1moves to the explicit variable.Done when
build.rsbuilds into a directory underOUT_DIRand never readscrates/trusted-server-js/dist.build.rsfails when an expected module is missing or empty, and a test covers both cases, for example a validation function run against a directory missing one bundle and a directory with one empty bundle.npm, skipped builds and stalenode_moduleshave defined behavior and clear errors, anddocs/guide/error-reference.mddescribes them.build.rsdeclaresrerun-if-env-changedfor each variable it reads.Affected area
JS build pipeline
Version
main at a4e01eb
Related
distcleanup and the directory scan.npm run ciinstead of install to prevent package-lock.json changes in diffs #378 and Use npm ci instead of npm install in build.rs #385: switchedbuild.rsfromnpm installtonpm ci.build.rsgenerates. Coordinate the edits.