From 6fb00423587c69de172095b2579a07706c1c8062 Mon Sep 17 00:00:00 2001 From: zackees Date: Fri, 21 Aug 2026 15:05:35 -0700 Subject: [PATCH 1/4] feat(paths): export dev daemon-identity namespace stamp Co-located dev checkouts share ~/.fbuild/dev, and the zccache compile daemons they spawn identify themselves by the zccache binary's content hash - identical across checkouts - so two dev checkouts rendezvous on one compile daemon and displace each other as stale on every invocation (displace-stale war, root-caused in zackees/soldr#2352). Each binary entry point (fbuild, fbuild-daemon) now derives "-" once and exports the VALUE as ZCCACHE_DAEMON_NAMESPACE, so every child - including the spawned daemon - inherits it instead of re-hashing. Inherited stamps win without re-hashing; official (non-dev) builds export nothing and keep single-daemon-on-upgrade semantics; hash failures are reported, never silently downgraded. The CLI-to-daemon spawn env_clear passes the stamp through explicitly. The variable is inert until fbuild pins a zccache release that honors it (zccache#1362 is on zccache main, unreleased); exporting it now makes that repin the only remaining step for #1285. Refs FastLED/fbuild#1285 Co-Authored-By: Claude --- Cargo.lock | 1 + ci/platform_boundary_research.tsv | 1 + crates/fbuild-cli/src/daemon_client.rs | 12 + crates/fbuild-cli/src/main.rs | 20 ++ crates/fbuild-daemon/src/main.rs | 20 ++ crates/fbuild-paths/Cargo.toml | 3 + .../fbuild-paths/src/dev_daemon_namespace.rs | 222 ++++++++++++++++++ crates/fbuild-paths/src/lib.rs | 1 + 8 files changed, 280 insertions(+) create mode 100644 crates/fbuild-paths/src/dev_daemon_namespace.rs diff --git a/Cargo.lock b/Cargo.lock index ec45b467a..be11cdc58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1401,6 +1401,7 @@ dependencies = [ name = "fbuild-paths" version = "2.5.19" dependencies = [ + "blake3", "fbuild-core", "serde", "serde_json", diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index f15eacbdb..00b88b0ab 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -92,6 +92,7 @@ crates/fbuild-deploy/src/rp2040_topology.rs 119 attr_cfg #[cfg(windows)] device crates/fbuild-deploy/src/rp2040_topology.rs 123 native_path std::os::windows::ffi::OsStrExt device host_mechanic crates/fbuild-deploy/src/rp2040_topology.rs 506 attr_cfg #[cfg(not(windows))] device host_mechanic crates/fbuild-library/src/library/library_spec.rs 266 attr_cfg #[cfg(windows)] host host_mechanic +crates/fbuild-paths/src/dev_daemon_namespace.rs 83 native_path std::env::current_exe host_executable host_mechanic crates/fbuild-serial/Cargo.toml 32 target_dependency_table [target.'cfg(windows)'.dependencies] device host_mechanic crates/fbuild-serial/Cargo.toml 33 native_dependency windows-sys device host_mechanic crates/fbuild-serial/src/port_class.rs 105 attr_cfg #[cfg(target_os=)] device host_mechanic diff --git a/crates/fbuild-cli/src/daemon_client.rs b/crates/fbuild-cli/src/daemon_client.rs index f96e9a089..7063b1bc6 100644 --- a/crates/fbuild-cli/src/daemon_client.rs +++ b/crates/fbuild-cli/src/daemon_client.rs @@ -1060,6 +1060,18 @@ async fn spawn_daemon_process() -> fbuild_core::Result<()> { cmd.env("VIRTUAL_ENV", venv); } + // FastLED/fbuild#1285: propagate the dev daemon-identity stamp this CLI + // computed so the daemon — and through it every compiler and zccache + // child — shares one namespace instead of colliding across checkouts. + if let Ok(namespace) = + std::env::var(fbuild_paths::dev_daemon_namespace::ZCCACHE_DAEMON_NAMESPACE_ENV) + { + cmd.env( + fbuild_paths::dev_daemon_namespace::ZCCACHE_DAEMON_NAMESPACE_ENV, + namespace, + ); + } + // Redirect stderr to log file so daemon logs are persisted let daemon_dir = fbuild_paths::get_daemon_dir(); let _ = std::fs::create_dir_all(&daemon_dir); diff --git a/crates/fbuild-cli/src/main.rs b/crates/fbuild-cli/src/main.rs index 4440c343e..5c4eb41c0 100644 --- a/crates/fbuild-cli/src/main.rs +++ b/crates/fbuild-cli/src/main.rs @@ -22,6 +22,26 @@ fn main() { } fn async_main_entry() { + // FastLED/fbuild#1285: derive the dev daemon-identity stamp once, at the + // top level, and export the value so every child — including the spawned + // daemon — inherits it instead of re-hashing per invocation. Official + // (non-dev) invocations export nothing. A hash failure is reported and + // otherwise ignored: dev builds must keep working. + match fbuild_paths::dev_daemon_namespace::namespace_to_export() { + Ok(Some(namespace)) => unsafe { + // SAFETY: single-threaded startup — before the tokio runtime or + // any environment reader exists. + std::env::set_var( + fbuild_paths::dev_daemon_namespace::ZCCACHE_DAEMON_NAMESPACE_ENV, + namespace, + ) + }, + Ok(None) => {} + Err(error) => { + eprintln!("warning: failed to derive dev daemon-identity namespace: {error}") + } + } + let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() diff --git a/crates/fbuild-daemon/src/main.rs b/crates/fbuild-daemon/src/main.rs index 516823704..9d4a40231 100644 --- a/crates/fbuild-daemon/src/main.rs +++ b/crates/fbuild-daemon/src/main.rs @@ -41,6 +41,26 @@ async fn main() { unsafe { std::env::set_var("FBUILD_DEV_MODE", "1") }; } + // FastLED/fbuild#1285: adopt the dev daemon-identity stamp the spawning + // CLI exported, or derive one from this binary's content — once per + // daemon, inherited by every build child. Official (non-dev) runs export + // nothing. A hash failure is reported and otherwise ignored: dev builds + // must keep working. + match fbuild_paths::dev_daemon_namespace::namespace_to_export() { + Ok(Some(namespace)) => unsafe { + // SAFETY: daemon startup — before worker threads or any + // environment reader exists. + std::env::set_var( + fbuild_paths::dev_daemon_namespace::ZCCACHE_DAEMON_NAMESPACE_ENV, + namespace, + ) + }, + Ok(None) => {} + Err(error) => { + eprintln!("warning: failed to derive dev daemon-identity namespace: {error}") + } + } + // Install the process-wide containment group as early as possible so // every subprocess the daemon spawns (compilers, linkers, esptool, // avrdude, qemu, simavr, node, npm, …) is born inside a Windows Job diff --git a/crates/fbuild-paths/Cargo.toml b/crates/fbuild-paths/Cargo.toml index 84d01b07a..f42b95d92 100644 --- a/crates/fbuild-paths/Cargo.toml +++ b/crates/fbuild-paths/Cargo.toml @@ -10,6 +10,9 @@ license.workspace = true publish = false [dependencies] +# Dev daemon-identity stamping hashes the running executable's bytes +# (FastLED/fbuild#1285). +blake3 = { workspace = true } fbuild-core = { path = "../fbuild-core" } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/fbuild-paths/src/dev_daemon_namespace.rs b/crates/fbuild-paths/src/dev_daemon_namespace.rs new file mode 100644 index 000000000..4fc60e011 --- /dev/null +++ b/crates/fbuild-paths/src/dev_daemon_namespace.rs @@ -0,0 +1,222 @@ +//! Development daemon-identity namespace stamping (FastLED/fbuild#1285). +//! +//! In dev (`FBUILD_DEV_MODE=1`) co-located checkouts of fbuild share one +//! home root (`~/.fbuild/dev`). The zccache compile daemons those checkouts +//! spawn identify themselves by the *zccache binary's* content hash — +//! identical across checkouts — so two dev checkouts rendezvous on the same +//! compile daemon and each displaces the other as "stale" on every +//! invocation (the `displace-stale` war, root-caused in zackees/soldr#2352). +//! +//! The fix is a per-checkout namespace stamp exported as +//! `ZCCACHE_DAEMON_NAMESPACE` (the variable zccache honors once its own +//! adoption lands — zccache#1362; an inherited value always wins there, so +//! this export is inert, and harmless, until fbuild repins a zccache +//! release containing it): +//! +//! ```text +//! stamp = "-" +//! ``` +//! +//! The stamp is content-based (a rebuilt checkout gets a fresh identity) +//! and computed **once per process tree**: each binary entry point (`fbuild`, +//! `fbuild-daemon`) derives it through this module and exports the *value*; +//! every child — including the spawned daemon — inherits it instead of +//! re-hashing. Propagating the value (not a path) also keeps the identity +//! stable across the Windows self-update lock-rename dance. +//! +//! Official (non-dev) invocations export nothing: release builds keep the +//! bare namespace and single-daemon-on-upgrade semantics — only dev pays. +//! A valid inherited stamp wins even outside dev mode, so a release CLI +//! under a dev parent stays in its family's namespace. +//! +//! Hash failure is reported, never silently swallowed — a silent downgrade +//! would quietly reintroduce the shared-daemon war this module exists to +//! prevent. Entry points log the warning and continue (today's behavior), +//! matching the repo-wide rule of never gating progress on a broken +//! filesystem. + +use crate::is_dev_mode; + +/// The variable zccache reads to namespace its daemons (zccache#1362). +pub const ZCCACHE_DAEMON_NAMESPACE_ENV: &str = "ZCCACHE_DAEMON_NAMESPACE"; + +/// Number of hex digits taken from the blake3 digest. +const HASH_PREFIX_HEX: usize = 16; + +/// Derive the namespace this process should export. +/// +/// * A valid (non-blank) inherited stamp wins without hashing — one hash +/// per process tree, and a CLI-spawned daemon stays in its spawner's +/// namespace. +/// * Otherwise, only dev mode stamps, keyed on the current executable's +/// content. +/// * Otherwise (official builds) no stamp is exported. +pub(crate) fn namespace_for_process( + inherited: Option<&str>, + dev_mode: bool, + hash_current_exe: F, +) -> std::io::Result> +where + F: FnOnce() -> std::io::Result<[u8; 32]>, +{ + if let Some(stamp) = inherited.map(str::trim).filter(|stamp| !stamp.is_empty()) { + return Ok(Some(stamp.to_string())); + } + if !dev_mode { + return Ok(None); + } + let digest = blake3::Hash::from_bytes(hash_current_exe()?); + let hex = digest.to_hex(); + Ok(Some(format!( + "{}-{}", + env!("CARGO_PKG_VERSION"), + &hex.as_str()[..HASH_PREFIX_HEX] + ))) +} + +/// Hash the running executable's bytes with blake3. +/// +/// Content-based by design: the loaded image would be a per-run nonce +/// (ASLR/IAT/`.data` mutation), while the on-disk bytes identify the +/// checkout build. +fn hash_current_exe() -> std::io::Result<[u8; 32]> { + let exe = std::env::current_exe()?; + let mut file = std::fs::File::open(&exe)?; + let mut hasher = blake3::Hasher::new(); + std::io::copy(&mut file, &mut hasher)?; + Ok(*hasher.finalize().as_bytes()) +} + +/// The namespace this process should export into its own environment +/// before spawning children, or `None` for official (non-dev) builds. +/// +/// Binary entry points (`main.rs` only — see the +/// `ban_env_var_set_after_import` dylint) call this and perform the +/// `set_var` themselves. +pub fn namespace_to_export() -> std::io::Result> { + let inherited = std::env::var(ZCCACHE_DAEMON_NAMESPACE_ENV).ok(); + namespace_for_process(inherited.as_deref(), is_dev_mode(), hash_current_exe) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Restores a single env var on drop; tests here mutate process state + /// that outlives a single test. + struct EnvVarGuard { + name: &'static str, + prior: Option, + } + + impl EnvVarGuard { + fn set(name: &'static str, value: &str) -> Self { + let prior = std::env::var(name).ok(); + unsafe { std::env::set_var(name, value) }; + Self { name, prior } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.prior { + Some(value) => unsafe { std::env::set_var(self.name, value) }, + None => unsafe { std::env::remove_var(self.name) }, + } + } + } + + fn hash(byte: u8) -> [u8; 32] { + [byte; 32] + } + + #[test] + fn inherited_stamp_wins_without_hashing() { + let hashed = std::cell::Cell::new(false); + let namespace = namespace_for_process(Some("checkout-a-0123456789abcdef"), true, || { + hashed.set(true); + Ok(hash(0xaa)) + }) + .unwrap(); + + assert_eq!(namespace, Some("checkout-a-0123456789abcdef".to_string())); + assert!( + !hashed.get(), + "an inherited stamp must avoid re-hashing per process" + ); + } + + #[test] + fn inherited_stamp_wins_even_outside_dev_mode() { + let namespace = namespace_for_process(Some("checkout-a-0123456789abcdef"), false, || { + Err(std::io::Error::other("must not be called")) + }) + .unwrap(); + + assert_eq!(namespace, Some("checkout-a-0123456789abcdef".to_string())); + } + + #[test] + fn official_build_exports_nothing() { + let hashed = std::cell::Cell::new(false); + let namespace = namespace_for_process(None, false, || { + hashed.set(true); + Ok(hash(0xaa)) + }) + .unwrap(); + + assert_eq!(namespace, None); + assert!( + !hashed.get(), + "official releases retain upgrade semantics without paying the hash" + ); + } + + #[test] + fn dev_build_stamps_version_and_first_sixteen_hex_digits() { + let namespace = namespace_for_process(None, true, || Ok(hash(0xab))).unwrap(); + + assert_eq!( + namespace.as_deref(), + Some(concat!(env!("CARGO_PKG_VERSION"), "-abababababababab")) + ); + } + + #[test] + fn blank_inherited_stamp_is_not_an_identity() { + let namespace = namespace_for_process(Some(" "), true, || Ok(hash(0x12))).unwrap(); + + assert_eq!( + namespace.as_deref(), + Some(concat!(env!("CARGO_PKG_VERSION"), "-1212121212121212")) + ); + } + + #[test] + fn hash_failure_is_not_silently_downgraded() { + let error = namespace_for_process(None, true, || { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "locked", + )) + }) + .unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + } + + /// The dev-mode branches of `namespace_to_export` are covered by the + /// pure-function tests above; an env-level test for them would race the + /// parallel tests in this crate that also flip `FBUILD_DEV_MODE` + /// process-globally. Inheritance is the one composition worth proving + /// end-to-end, and it is dev-flag-independent. + #[test] + fn export_honors_an_inherited_stamp() { + let _ns = EnvVarGuard::set(ZCCACHE_DAEMON_NAMESPACE_ENV, "checkout-b-fedcba9876543210"); + + assert_eq!( + namespace_to_export().unwrap(), + Some("checkout-b-fedcba9876543210".to_string()) + ); + } +} diff --git a/crates/fbuild-paths/src/lib.rs b/crates/fbuild-paths/src/lib.rs index 3574435d7..cab788320 100644 --- a/crates/fbuild-paths/src/lib.rs +++ b/crates/fbuild-paths/src/lib.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use fbuild_core::BuildProfile; pub mod daemon_ownership; +pub mod dev_daemon_namespace; pub mod running_process; /// Check if running in development mode. From edfb43cf9d2b08d021410d8255a529ed18bafb86 Mon Sep 17 00:00:00 2001 From: zackees Date: Fri, 21 Aug 2026 15:23:58 -0700 Subject: [PATCH 2/4] chore: retrigger CI (original push event lost) From 3009ea17d703f047999f4d274ab51435d2913fcb Mon Sep 17 00:00:00 2001 From: zackees Date: Fri, 21 Aug 2026 18:22:46 -0700 Subject: [PATCH 3/4] fix(ci): register dev-daemon-identity current_exe in boundary ledgers The research inventory regeneration added the new dev_daemon_namespace.rs native_path row but the enforcement ledger and Dylint baseline were not regenerated with it, so the consistency check reported 'baseline and independent scanner disagree' and actual Dylint runs flagged a new occurrence. Co-Authored-By: Claude --- ci/platform_boundary_ledger.tsv | 1 + dylints/enforce_platform_boundary/src/baseline.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/ci/platform_boundary_ledger.tsv b/ci/platform_boundary_ledger.tsv index 122e91ac8..50066b0cc 100644 --- a/ci/platform_boundary_ledger.tsv +++ b/ci/platform_boundary_ledger.tsv @@ -44,6 +44,7 @@ crates/fbuild-deploy/src/rp2040_topology.rs attr_cfg #[cfg(windows)] 0 device ho crates/fbuild-deploy/src/rp2040_topology.rs attr_cfg #[cfg(windows)] 1 device host_mechanic crates/fbuild-deploy/src/rp2040_topology.rs native_path std::os::windows::ffi::OsStrExt 0 device host_mechanic crates/fbuild-library/src/library/library_spec.rs attr_cfg #[cfg(windows)] 0 host host_mechanic +crates/fbuild-paths/src/dev_daemon_namespace.rs native_path std::env::current_exe 0 host_executable host_mechanic crates/fbuild-serial/Cargo.toml native_dependency windows-sys 0 device host_mechanic crates/fbuild-serial/Cargo.toml target_dependency_table [target.'cfg(windows)'.dependencies] 0 device host_mechanic crates/fbuild-serial/src/port_class.rs attr_cfg #[cfg(not(any(target_os=,target_os=,target_os=)))] 0 device host_mechanic diff --git a/dylints/enforce_platform_boundary/src/baseline.txt b/dylints/enforce_platform_boundary/src/baseline.txt index 405536117..5872c7eb1 100644 --- a/dylints/enforce_platform_boundary/src/baseline.txt +++ b/dylints/enforce_platform_boundary/src/baseline.txt @@ -42,6 +42,7 @@ crates/fbuild-deploy/src/rp2040_topology.rs attr_cfg windows 8 crates/fbuild-deploy/src/rp2040_topology.rs attr_cfg windows 9 crates/fbuild-deploy/src/rp2040_topology.rs native_import std::os::windows 0 crates/fbuild-library/src/library/library_spec.rs attr_cfg windows 0 +crates/fbuild-paths/src/dev_daemon_namespace.rs native_import std::env::current_exe 0 crates/fbuild-serial/src/port_class.rs attr_cfg target_os 0 crates/fbuild-serial/src/port_class.rs attr_cfg target_os 1 crates/fbuild-serial/src/port_class.rs attr_cfg target_os 2 From f7c0dcb7386b81426b81b606a1b9e2c58ab3dbd1 Mon Sep 17 00:00:00 2001 From: zackees Date: Sat, 22 Aug 2026 01:58:08 -0700 Subject: [PATCH 4/4] test(ci): bump pinned boundary ledger count to 95 The dev-daemon-identity current_exe registration adds one exact-occurrence ledger row; the scanner test pins the committed ledger size. Co-Authored-By: Claude --- ci/test_enforce_platform_boundary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/test_enforce_platform_boundary.py b/ci/test_enforce_platform_boundary.py index cdfadf61a..95573d9d1 100644 --- a/ci/test_enforce_platform_boundary.py +++ b/ci/test_enforce_platform_boundary.py @@ -14,7 +14,7 @@ def setUpClass(cls) -> None: cls.observed = boundary.rows_from_findings(boundary.research.inventory()) def test_committed_exact_occurrence_ledger_matches_whole_tree(self) -> None: - self.assertEqual(len(self.expected), 94) + self.assertEqual(len(self.expected), 95) self.assertFalse(boundary.validate_ledger(self.expected)) self.assertFalse(boundary.compare(self.expected, self.observed))