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
15 changes: 13 additions & 2 deletions crates/fbuild-daemon/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::status_manager::StatusManager;
use dashmap::DashMap;
use fbuild_core::DaemonState;
use fbuild_core::install_status::InstallStatus;
use fbuild_core::path::NormalizedPath;
use fbuild_serial::SharedSerialManager;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize};
Expand Down Expand Up @@ -162,7 +163,17 @@ pub struct DaemonContext {
/// Latest dependency/package install status emitted by lower-level crates.
pub dependency_install: Arc<std::sync::RwLock<Option<InstallStatus>>>,
/// Per-project build locks to serialize builds on the same project.
pub project_locks: DashMap<PathBuf, Arc<Mutex<()>>>,
///
/// Keyed on [`NormalizedPath`], not raw `PathBuf`: the project dir
/// arrives as an unnormalized client-supplied string
/// (`PathBuf::from(&req.project_dir)` in the build/deploy handlers), so
/// two requests naming one project with different casing or slash
/// direction (`C:\proj` vs `c:/proj`) would key distinct raw-path
/// entries and get *different* locks — letting two builds run on the
/// same project concurrently. `NormalizedPath`'s case-folded,
/// slash-normalized key collapses those to one lock. See
/// FastLED/fbuild#1274 (and the #436/#437 identity bug class).
pub project_locks: DashMap<NormalizedPath, Arc<Mutex<()>>>,
/// Device lease manager.
///
/// Wrapped in `Arc` (FastLED/fbuild#808) so refresh paths that call
Expand Down Expand Up @@ -441,7 +452,7 @@ impl DaemonContext {
/// Get or create a per-project lock.
pub fn project_lock(&self, project_dir: &std::path::Path) -> Arc<Mutex<()>> {
self.project_locks
.entry(project_dir.to_path_buf())
.entry(NormalizedPath::from(project_dir))
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
Expand Down
41 changes: 37 additions & 4 deletions crates/fbuild-daemon/src/handlers/locks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,9 @@ pub async fn clear_locks(
#[cfg(test)]
mod tests {
use super::*;
use fbuild_core::path::NormalizedPath;
use fbuild_serial::{SerialClientMetadata, SerialSession};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tokio::sync::Mutex;

fn test_context() -> Arc<DaemonContext> {
Expand Down Expand Up @@ -367,7 +368,7 @@ mod tests {
#[tokio::test]
async fn lock_status_reports_held_project_locks_without_stale_entries() {
let ctx = test_context();
let project = PathBuf::from("/tmp/fbuild-held-project");
let project = NormalizedPath::from(PathBuf::from("/tmp/fbuild-held-project"));
let lock = Arc::new(Mutex::new(()));
let guard = lock.lock().await;
ctx.project_locks.insert(project.clone(), lock.clone());
Expand Down Expand Up @@ -429,8 +430,8 @@ mod tests {
#[tokio::test]
async fn clear_locks_removes_only_unheld_project_lock_entries() {
let ctx = test_context();
let unheld = PathBuf::from("/tmp/fbuild-unheld-project");
let held = PathBuf::from("/tmp/fbuild-held-project");
let unheld = NormalizedPath::from(PathBuf::from("/tmp/fbuild-unheld-project"));
let held = NormalizedPath::from(PathBuf::from("/tmp/fbuild-held-project"));
let held_lock = Arc::new(Mutex::new(()));
let guard = held_lock.lock().await;
ctx.project_locks
Expand All @@ -449,6 +450,38 @@ mod tests {
drop(guard);
}

/// Regression for FastLED/fbuild#1274 (identity bug class #436/#437):
/// the build-serialization lock is keyed on the *raw* client-supplied
/// project dir, which is never normalized. Two spellings of one project
/// must resolve to the SAME lock or two builds could run concurrently.
#[tokio::test]
async fn project_lock_keys_on_normalized_identity_not_raw_bytes() {
let ctx = test_context();
let mixed = ctx.project_lock(Path::new("/Tmp/FBuild/Proj"));
let lower = ctx.project_lock(Path::new("/tmp/fbuild/proj"));

if cfg!(any(windows, target_os = "macos")) {
// Case-insensitive filesystem: one logical project, so the two
// spellings must share a single serialization lock. Before the
// fix these keyed distinct raw-`PathBuf` entries and returned
// different `Arc`s — the concurrency hole this test guards.
assert!(
Arc::ptr_eq(&mixed, &lower),
"same project in different case must share one lock"
);
assert_eq!(ctx.project_locks.len(), 1, "must not create a second lock");
} else {
// Case-sensitive filesystem: these are genuinely different
// directories and correctly get independent locks.
assert!(!Arc::ptr_eq(&mixed, &lower));
}

// Re-requesting the exact same path always returns the same lock,
// on every platform.
let again = ctx.project_lock(Path::new("/tmp/fbuild/proj"));
assert!(Arc::ptr_eq(&lower, &again));
}

#[tokio::test]
async fn clear_locks_refuses_live_serial_session_without_force() {
let ctx = test_context();
Expand Down
Loading