From ecfc506617da6174b630be5132112d377486c231 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 18 Sep 2026 10:28:43 -0700 Subject: [PATCH 1/3] fix(daemon): resolve a relative project dir against the caller's cwd `fbuild ci/kitchensink build` sent the daemon the relative string "ci/kitchensink", and the build handler used it as-is. Every path derived from it stayed relative -- the build dir, the downloaded libraries, and their -I flags -- while the compiler runs from the absolute project dir. So a registry library's headers were on the include path under a name that pointed nowhere: fatal error: ArduinoJson.h: No such file or directory (symlink:// libraries were already canonicalised, which is why FastLED itself was found). It also read the relative path against the daemon's own cwd, which only matched the caller's by accident. - CLI: resolve_project_dir() sends an absolute path (std::path::absolute, lexical, no symlink resolution). - Daemon: the build, deploy and install-deps handlers resolve a relative project_dir against the request's caller_cwd (resolve_request_project_dir), so other clients get the same result. Fixes #1441 Co-Authored-By: Claude Opus 5 (1M context) --- crates/fbuild-cli/src/cli/args.rs | 12 +++++- .../src/handlers/operations/build.rs | 3 +- .../src/handlers/operations/common.rs | 20 ++++++++++ .../src/handlers/operations/deploy.rs | 4 +- .../src/handlers/operations/install_deps.rs | 4 +- .../src/handlers/operations/tests.rs | 40 +++++++++++++++++++ 6 files changed, 76 insertions(+), 7 deletions(-) diff --git a/crates/fbuild-cli/src/cli/args.rs b/crates/fbuild-cli/src/cli/args.rs index cf975e8f7..b0ae08555 100644 --- a/crates/fbuild-cli/src/cli/args.rs +++ b/crates/fbuild-cli/src/cli/args.rs @@ -1066,9 +1066,17 @@ pub fn resolve_project_dir( subcommand_dir: Option, top_level_dir: &Option, ) -> String { - subcommand_dir + let raw = subcommand_dir .or_else(|| top_level_dir.clone()) - .unwrap_or_else(|| ".".to_string()) + .unwrap_or_else(|| ".".to_string()); + // Send the daemon an absolute path. A relative one is read against the + // daemon's working directory, and every path derived from it stays + // relative while the compiler runs from the project dir -- which is how + // `fbuild ci/kitchensink build` lost its libraries' include paths + // (FastLED/fbuild#1441). Lexical only: symlinks are not resolved. + std::path::absolute(&raw) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or(raw) } /// Known subcommand names for arg rewriting. diff --git a/crates/fbuild-daemon/src/handlers/operations/build.rs b/crates/fbuild-daemon/src/handlers/operations/build.rs index 4d9029a1d..dc629d190 100644 --- a/crates/fbuild-daemon/src/handlers/operations/build.rs +++ b/crates/fbuild-daemon/src/handlers/operations/build.rs @@ -2,6 +2,7 @@ use super::common::{ OperationGuard, export_artifacts_bundle, resolve_build_dir, resolve_client_path, + resolve_request_project_dir, }; use crate::context::DaemonContext; use crate::models::{BuildRequest, OperationResponse}; @@ -146,7 +147,7 @@ pub async fn build( let request_id = req .request_id .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let project_dir = PathBuf::from(&req.project_dir); + let project_dir = resolve_request_project_dir(&req.project_dir, req.caller_cwd.as_deref()); let stream = req.stream; if !project_dir.exists() { diff --git a/crates/fbuild-daemon/src/handlers/operations/common.rs b/crates/fbuild-daemon/src/handlers/operations/common.rs index 1106eb169..01365b582 100644 --- a/crates/fbuild-daemon/src/handlers/operations/common.rs +++ b/crates/fbuild-daemon/src/handlers/operations/common.rs @@ -335,6 +335,26 @@ pub(crate) fn parse_deploy_route( } } +/// A request's `project_dir` as an absolute path. +/// +/// A relative project dir must be read against the *caller's* working +/// directory, not the daemon's. Left relative, every path derived from it -- +/// the build dir, downloaded libraries, their `-I` flags -- stays relative, +/// while the compiler runs from the absolute project dir, so those flags point +/// nowhere (FastLED/fbuild#1441: `fbuild ci/kitchensink build` could not find +/// a registry library's headers). Lexical only: symlinks are not resolved. +pub(crate) fn resolve_request_project_dir(raw: &str, caller_cwd: Option<&str>) -> PathBuf { + let path = PathBuf::from(raw); + if path.is_absolute() { + return path; + } + let joined = match caller_cwd { + Some(cwd) => PathBuf::from(cwd).join(path), + None => path, + }; + std::path::absolute(&joined).unwrap_or(joined) +} + pub(crate) fn resolve_client_path( raw: &str, caller_cwd: Option<&str>, diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy.rs b/crates/fbuild-daemon/src/handlers/operations/deploy.rs index 0ce7d6463..34298416f 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy.rs @@ -3,7 +3,7 @@ use super::common::{ DeployRoute, EmulatorKind, OperationGuard, compute_esp32_image_hash, export_artifacts_bundle, infer_default_emulator_kind, parse_deploy_route, qemu_extra_build_flags, resolve_build_dir, - resolve_client_path, trust_device_hash_enabled, + resolve_client_path, resolve_request_project_dir, trust_device_hash_enabled, }; use super::deploy_port::{append_warning_to_stderr, choose_deploy_port}; use super::monitor::{MonitorOutcome, run_monitor_loop}; @@ -73,7 +73,7 @@ pub async fn deploy( .request_id .clone() .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let project_dir = PathBuf::from(&req.project_dir); + let project_dir = resolve_request_project_dir(&req.project_dir, req.caller_cwd.as_deref()); if !project_dir.exists() { return ( diff --git a/crates/fbuild-daemon/src/handlers/operations/install_deps.rs b/crates/fbuild-daemon/src/handlers/operations/install_deps.rs index 319839c29..873693729 100644 --- a/crates/fbuild-daemon/src/handlers/operations/install_deps.rs +++ b/crates/fbuild-daemon/src/handlers/operations/install_deps.rs @@ -1,7 +1,7 @@ //! `POST /api/install-deps` — fetch toolchains, frameworks, and libraries //! without building. -use super::common::OperationGuard; +use super::common::{OperationGuard, resolve_request_project_dir}; use crate::context::DaemonContext; use crate::models::{InstallDepsRequest, OperationResponse}; use axum::Json; @@ -21,7 +21,7 @@ pub async fn install_deps( let request_id = req .request_id .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let project_dir = PathBuf::from(&req.project_dir); + let project_dir = resolve_request_project_dir(&req.project_dir, req.caller_cwd.as_deref()); if !project_dir.exists() { return ( diff --git a/crates/fbuild-daemon/src/handlers/operations/tests.rs b/crates/fbuild-daemon/src/handlers/operations/tests.rs index 2e2e1a35e..0fecf466a 100644 --- a/crates/fbuild-daemon/src/handlers/operations/tests.rs +++ b/crates/fbuild-daemon/src/handlers/operations/tests.rs @@ -194,3 +194,43 @@ mod image_hash_memo_tests { ); } } + +mod request_project_dir_tests { + //! A request's project dir must come out absolute, read against the + //! caller's working directory (FastLED/fbuild#1441). + use super::super::common::resolve_request_project_dir; + use std::path::{Path, PathBuf}; + + #[test] + fn relative_dir_is_joined_onto_caller_cwd() { + let cwd = std::env::temp_dir().join("caller"); + let resolved = resolve_request_project_dir("ci/kitchensink", Some(cwd.to_str().unwrap())); + assert!(resolved.is_absolute()); + assert_eq!(resolved, cwd.join("ci").join("kitchensink")); + } + + #[test] + fn absolute_dir_is_kept_as_is() { + let abs = std::env::temp_dir().join("proj"); + let resolved = resolve_request_project_dir(abs.to_str().unwrap(), Some("/elsewhere")); + assert_eq!(resolved, abs); + } + + #[test] + fn relative_dir_without_caller_cwd_is_still_absolute() { + let resolved = resolve_request_project_dir("proj", None); + assert!(resolved.is_absolute()); + assert!(resolved.ends_with(Path::new("proj"))); + } + + #[test] + fn dot_resolves_to_the_caller_cwd_itself() { + let cwd = std::env::temp_dir().join("caller"); + let resolved = resolve_request_project_dir(".", Some(cwd.to_str().unwrap())); + assert!(resolved.is_absolute()); + // `std::path::absolute` keeps a trailing `.` component lexically on + // some hosts; compare by components that matter. + let normalized: PathBuf = resolved.components().collect(); + assert_eq!(normalized, cwd); + } +} From 60e9b3fe5f0bd539d812173539e4aaea2698a497 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 18 Sep 2026 10:37:32 -0700 Subject: [PATCH 2/3] fix(daemon): drop the PathBuf imports resolve_request_project_dir replaced Co-Authored-By: Claude Opus 5 (1M context) --- crates/fbuild-daemon/src/handlers/operations/build.rs | 1 - crates/fbuild-daemon/src/handlers/operations/install_deps.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/crates/fbuild-daemon/src/handlers/operations/build.rs b/crates/fbuild-daemon/src/handlers/operations/build.rs index dc629d190..6838065cf 100644 --- a/crates/fbuild-daemon/src/handlers/operations/build.rs +++ b/crates/fbuild-daemon/src/handlers/operations/build.rs @@ -10,7 +10,6 @@ use axum::Json; use axum::extract::State; use axum::http::StatusCode; use fbuild_core::channel::{UnboundedReceiver, UnboundedSender, unbounded}; -use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::Notify; diff --git a/crates/fbuild-daemon/src/handlers/operations/install_deps.rs b/crates/fbuild-daemon/src/handlers/operations/install_deps.rs index 873693729..486adbad7 100644 --- a/crates/fbuild-daemon/src/handlers/operations/install_deps.rs +++ b/crates/fbuild-daemon/src/handlers/operations/install_deps.rs @@ -7,7 +7,6 @@ use crate::models::{InstallDepsRequest, OperationResponse}; use axum::Json; use axum::extract::State; use axum::http::StatusCode; -use std::path::PathBuf; use std::sync::Arc; /// POST /api/install-deps From a789d361b4a9fc51a31dac04aca2ec9c7de9a178 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 18 Sep 2026 11:13:27 -0700 Subject: [PATCH 3/3] test(daemon): compare path components without std PathBuf (ban_std_pathbuf) Co-Authored-By: Claude Opus 5 (1M context) --- crates/fbuild-daemon/src/handlers/operations/tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/fbuild-daemon/src/handlers/operations/tests.rs b/crates/fbuild-daemon/src/handlers/operations/tests.rs index 0fecf466a..a6137054f 100644 --- a/crates/fbuild-daemon/src/handlers/operations/tests.rs +++ b/crates/fbuild-daemon/src/handlers/operations/tests.rs @@ -199,7 +199,7 @@ mod request_project_dir_tests { //! A request's project dir must come out absolute, read against the //! caller's working directory (FastLED/fbuild#1441). use super::super::common::resolve_request_project_dir; - use std::path::{Path, PathBuf}; + use std::path::Path; #[test] fn relative_dir_is_joined_onto_caller_cwd() { @@ -230,7 +230,8 @@ mod request_project_dir_tests { assert!(resolved.is_absolute()); // `std::path::absolute` keeps a trailing `.` component lexically on // some hosts; compare by components that matter. - let normalized: PathBuf = resolved.components().collect(); - assert_eq!(normalized, cwd); + let components: Vec<_> = resolved.components().collect(); + let expected: Vec<_> = cwd.components().collect(); + assert_eq!(components, expected); } }