Found while profiling for #1414. This is a correctness bug, not a performance one: fbuild builds a different project than the one you are standing in, reports success, and prints artifact paths that do not exist.
Reproduction
Two sibling project directories, projA and projB, same platformio.ini. Build A, then immediately build B while A's daemon is still alive:
$ cd projA && fbuild build
build succeeded in 7.0s
$ cd ../projB && pwd
.../projB
$ ls -a
. .. lib platformio.ini src # no .fbuild
$ fbuild build
No-op fingerprint matched; reusing existing ESP32 artifacts.
Flash: 343.34KB / 16.00MB (2.1%)
Artifact: ./.fbuild/build/esp32_esp32_esp32s3_opi/release/firmware.elf (6.14MB)
build succeeded in 0.5s
$ find . -name '*.o' | wc -l
0 # still no .fbuild directory
projB was never built. The daemon built projA again, took its fast path, and reported projA's sizes and artifacts as ./.fbuild/... relative to a directory that has no such file.
Cause
The CLI sends the project as a relative path plus a caller_cwd:
// crates/fbuild-cli/src/cli/build.rs:98
let (caller_pid, caller_cwd) = daemon_client::caller_info();
The daemon resolves every other client path against caller_cwd via resolve_client_path — build_dir_override, symbol_analysis_path, output_dir. The project itself is the one that is not:
// crates/fbuild-daemon/src/handlers/operations/build.rs:148
let project_dir = PathBuf::from(&req.project_dir);
So "." resolves against the daemon's cwd. The daemon inherits its cwd from whichever CLI invocation spawned it (--spawner-cwd=...), so every request that arrives while that daemon is alive is interpreted relative to the first project.
ps during the repro above:
fbuild-daemon --spawner-cwd=/…/projA
and the daemon log identifies the project by the unresolved string:
INFO fbuild_daemon::handlers::operations::build: waiting for project lock on .
The project lock is therefore also taken on "." — two different projects share one lock, and neither is the path the user asked for.
Why it usually hides
The daemon self-evicts after ~30s idle. Work on one project at a time, or leave a gap between projects, and each build gets a daemon whose cwd happens to match — which is why this has not shown up in CI or normal single-project use. It reproduces reliably when a second project is built within the idle window.
Impact
fbuild build in project B silently produces nothing and claims success.
- Worse for
deploy: deploy.rs sends caller_cwd the same way, so a deploy from project B flashes project A's firmware while reporting project B's build as successful. That is a wrong-firmware-on-hardware bug.
- Any tool that builds several sketches in a loop (FastLED's example sweeps,
Design Studio for FastLED) is in the failure window by construction.
Fix sketch
Route req.project_dir through the same resolve_client_path(p, req.caller_cwd.as_deref(), ...) the other client paths use, and log the resolved absolute path rather than the raw string so the lock and the log name the real project. A regression test should assert that a request carrying project_dir: "." plus a caller_cwd pointing elsewhere builds the caller_cwd project, with the daemon's own cwd set to a third directory.
Worth checking deploy, monitor, and the emulator handlers for the same unresolved project_dir before closing.
Found while profiling for #1414. This is a correctness bug, not a performance one: fbuild builds a different project than the one you are standing in, reports success, and prints artifact paths that do not exist.
Reproduction
Two sibling project directories,
projAandprojB, sameplatformio.ini. Build A, then immediately build B while A's daemon is still alive:projBwas never built. The daemon builtprojAagain, took its fast path, and reportedprojA's sizes and artifacts as./.fbuild/...relative to a directory that has no such file.Cause
The CLI sends the project as a relative path plus a
caller_cwd:The daemon resolves every other client path against
caller_cwdviaresolve_client_path—build_dir_override,symbol_analysis_path,output_dir. The project itself is the one that is not:So
"."resolves against the daemon's cwd. The daemon inherits its cwd from whichever CLI invocation spawned it (--spawner-cwd=...), so every request that arrives while that daemon is alive is interpreted relative to the first project.psduring the repro above:and the daemon log identifies the project by the unresolved string:
The project lock is therefore also taken on
"."— two different projects share one lock, and neither is the path the user asked for.Why it usually hides
The daemon self-evicts after ~30s idle. Work on one project at a time, or leave a gap between projects, and each build gets a daemon whose cwd happens to match — which is why this has not shown up in CI or normal single-project use. It reproduces reliably when a second project is built within the idle window.
Impact
fbuild buildin project B silently produces nothing and claims success.deploy:deploy.rssendscaller_cwdthe same way, so a deploy from project B flashes project A's firmware while reporting project B's build as successful. That is a wrong-firmware-on-hardware bug.Design Studio for FastLED) is in the failure window by construction.Fix sketch
Route
req.project_dirthrough the sameresolve_client_path(p, req.caller_cwd.as_deref(), ...)the other client paths use, and log the resolved absolute path rather than the raw string so the lock and the log name the real project. A regression test should assert that a request carryingproject_dir: "."plus acaller_cwdpointing elsewhere builds thecaller_cwdproject, with the daemon's own cwd set to a third directory.Worth checking
deploy,monitor, and the emulator handlers for the same unresolvedproject_dirbefore closing.