Skip to content

Commit 651b0d7

Browse files
authored
fix(fbuild-python): prefer venv-adjacent fbuild-daemon over PATH (#275) (#311)
When the PyO3 binding spawned the daemon, it relied on the OS PATH search to resolve `fbuild-daemon`. On Windows venvs whose `.venv/Scripts` is not at the front of PATH, that picked up a stale user-level binary at `~/.local/bin/fbuild-daemon.exe` instead of the venv-shipped daemon. The stale daemon could miss features the in-process `_native` extension depends on (notably `.S` source support in FastLED `library.json` srcFilter), producing wrong builds. Resolve `sys.executable` via PyO3, look for `fbuild-daemon[.exe]` in its parent directory, and prefer that absolute path when present. Fall back to the bare PATH-relative name when no venv-adjacent binary is found so `pip install --user` / system installs keep working unchanged. The async `AsyncDaemon::ensure_running` resolves the path before entering the future to avoid holding the GIL across `.await`.
1 parent 8c8163d commit 651b0d7

3 files changed

Lines changed: 154 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fbuild-python/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,6 @@ tokio-tungstenite = { workspace = true }
3535
futures = { workspace = true }
3636
base64 = { workspace = true }
3737
uuid = { workspace = true }
38+
39+
[dev-dependencies]
40+
tempfile = { workspace = true }

crates/fbuild-python/src/daemon.rs

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,71 @@
11
//! Synchronous `Daemon` and asynchronous `AsyncDaemon` PyO3 bindings.
22
3+
use std::path::{Path, PathBuf};
4+
35
use pyo3::prelude::*;
46

7+
/// Filename of the daemon binary on this platform.
8+
///
9+
/// Windows ships `fbuild-daemon.exe`; Unix-like systems ship `fbuild-daemon`.
10+
/// Kept as a single constant so the venv-adjacent lookup and the PATH
11+
/// fallback agree on the name.
12+
#[cfg(windows)]
13+
const DAEMON_BIN_NAME: &str = "fbuild-daemon.exe";
14+
#[cfg(not(windows))]
15+
const DAEMON_BIN_NAME: &str = "fbuild-daemon";
16+
17+
/// Look for `fbuild-daemon[.exe]` next to `sys.executable` (FastLED/fbuild#275).
18+
///
19+
/// When `fbuild-python` is imported from a venv whose `Scripts/` (Windows) or
20+
/// `bin/` (Unix) directory is NOT at the front of `PATH`, the bare
21+
/// `Command::new("fbuild-daemon")` spawn picks up a stale user-level binary
22+
/// (e.g. `~/.local/bin/fbuild-daemon.exe`) instead of the one shipped with the
23+
/// venv. That mismatch can produce wrong builds — the stale daemon may miss
24+
/// features the in-process `_native` extension depends on (e.g. `.S` source
25+
/// support exposed in FastLED's `library.json` srcFilter).
26+
///
27+
/// Strategy: query `sys.executable` via the GIL, look for a sibling daemon
28+
/// binary, return its absolute path if present. The caller falls back to the
29+
/// PATH-relative `DAEMON_BIN_NAME` when this returns `None`, preserving the
30+
/// previous behavior in non-venv installs.
31+
fn venv_adjacent_daemon() -> Option<PathBuf> {
32+
Python::with_gil(|py| {
33+
let sys = py.import_bound("sys").ok()?;
34+
let exe_obj = sys.getattr("executable").ok()?;
35+
let exe_str: String = exe_obj.extract().ok()?;
36+
if exe_str.is_empty() {
37+
return None;
38+
}
39+
let exe_path = PathBuf::from(exe_str);
40+
let dir = exe_path.parent()?;
41+
daemon_in_dir(dir)
42+
})
43+
}
44+
45+
/// Return the absolute path to `fbuild-daemon[.exe]` in `dir` if the file
46+
/// exists. Split out from `venv_adjacent_daemon` so the resolution rule can
47+
/// be exercised in unit tests without spinning up a Python interpreter.
48+
fn daemon_in_dir(dir: &Path) -> Option<PathBuf> {
49+
let candidate = dir.join(DAEMON_BIN_NAME);
50+
if candidate.is_file() {
51+
Some(candidate)
52+
} else {
53+
None
54+
}
55+
}
56+
57+
/// Build the spawn target for the daemon: prefer the venv-adjacent absolute
58+
/// path (`Some`) and fall back to the PATH-relative bare name (`None`).
59+
///
60+
/// Returning an `Option<PathBuf>` rather than always materializing a
61+
/// `PathBuf` keeps the PATH search semantics intact when no venv-adjacent
62+
/// binary is found — passing a bare `"fbuild-daemon"` to `Command::new`
63+
/// triggers the OS-level executable search, which is the legacy behavior
64+
/// we must preserve for `pip install --user` / system installs.
65+
fn daemon_spawn_target() -> Option<PathBuf> {
66+
venv_adjacent_daemon()
67+
}
68+
569
/// Python-visible Daemon class (high-level API).
670
#[pyclass]
771
pub(crate) struct Daemon;
@@ -24,7 +88,14 @@ impl Daemon {
2488
// group, so `spawn()` is already uncontained; see the matching
2589
// comment in fbuild-cli/src/daemon_client.rs.
2690
// allow-direct-spawn: daemon must outlive the Python interpreter.
27-
let mut cmd = std::process::Command::new("fbuild-daemon");
91+
//
92+
// Prefer the daemon binary sitting next to `sys.executable`
93+
// (FastLED/fbuild#275) so a venv install never gets shadowed by a
94+
// stale user-level daemon on PATH.
95+
let mut cmd = match daemon_spawn_target() {
96+
Some(path) => std::process::Command::new(path),
97+
None => std::process::Command::new(DAEMON_BIN_NAME),
98+
};
2899
if fbuild_paths::is_dev_mode() {
29100
cmd.arg("--dev");
30101
}
@@ -146,6 +217,10 @@ impl AsyncDaemon {
146217
fn ensure_running(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> {
147218
let url = format!("{}/health", fbuild_paths::get_daemon_url());
148219
let dev_mode = fbuild_paths::is_dev_mode();
220+
// Resolve `sys.executable` BEFORE entering the future: the GIL
221+
// must not be held across `.await`, and the venv-adjacent lookup
222+
// is cheap (one attribute read + one stat).
223+
let spawn_target = daemon_spawn_target();
149224

150225
pyo3_async_runtimes::tokio::future_into_py(py, async move {
151226
let client = reqwest::Client::new();
@@ -164,8 +239,14 @@ impl AsyncDaemon {
164239

165240
// INTENTIONALLY DETACHED (FastLED/fbuild#32): see the
166241
// matching comment in `Daemon::ensure_running` above.
242+
// Venv-adjacent preference (FastLED/fbuild#275): resolved
243+
// synchronously above the future so we never touch the GIL
244+
// from inside this async block.
167245
// allow-direct-spawn: daemon must outlive the Python interpreter.
168-
let mut cmd = std::process::Command::new("fbuild-daemon");
246+
let mut cmd = match spawn_target {
247+
Some(path) => std::process::Command::new(path),
248+
None => std::process::Command::new(DAEMON_BIN_NAME),
249+
};
169250
if dev_mode {
170251
cmd.arg("--dev");
171252
}
@@ -212,3 +293,70 @@ impl AsyncDaemon {
212293
})
213294
}
214295
}
296+
297+
#[cfg(test)]
298+
mod tests {
299+
//! Tests for the venv-adjacent daemon resolution (FastLED/fbuild#275).
300+
//!
301+
//! We exercise `daemon_in_dir` rather than `venv_adjacent_daemon`
302+
//! because the latter reads `sys.executable` at runtime — the
303+
//! resolution rule (filename + `is_file()` check) is what we own and
304+
//! what the bug hinged on. Mocking the Python interpreter just to
305+
//! re-test stdlib attribute access would dilute the signal.
306+
use super::{daemon_in_dir, DAEMON_BIN_NAME};
307+
use std::fs;
308+
309+
#[test]
310+
fn daemon_bin_name_matches_platform() {
311+
// The lookup file name must agree with what gets installed by
312+
// maturin / pip — on Windows that is `fbuild-daemon.exe`, on
313+
// Unix it is unsuffixed.
314+
#[cfg(windows)]
315+
assert_eq!(DAEMON_BIN_NAME, "fbuild-daemon.exe");
316+
#[cfg(not(windows))]
317+
assert_eq!(DAEMON_BIN_NAME, "fbuild-daemon");
318+
}
319+
320+
#[test]
321+
fn daemon_in_dir_returns_path_when_file_present() {
322+
let tmp = tempfile::tempdir().expect("create tempdir");
323+
let target = tmp.path().join(DAEMON_BIN_NAME);
324+
fs::write(&target, b"dummy").expect("write dummy daemon");
325+
326+
let resolved = daemon_in_dir(tmp.path()).expect("must find venv daemon");
327+
assert_eq!(resolved, target);
328+
assert!(
329+
resolved.is_absolute(),
330+
"resolved daemon path must be absolute so Command::new bypasses PATH"
331+
);
332+
}
333+
334+
#[test]
335+
fn daemon_in_dir_returns_none_when_file_absent() {
336+
let tmp = tempfile::tempdir().expect("create tempdir");
337+
// Deliberately do not create any file — this mimics a venv whose
338+
// scripts dir exists but does not ship the daemon binary
339+
// (e.g. partial install, dev checkout without maturin build).
340+
assert!(daemon_in_dir(tmp.path()).is_none());
341+
}
342+
343+
#[test]
344+
fn daemon_in_dir_ignores_directory_with_matching_name() {
345+
// `is_file()` must reject the case where a directory shares the
346+
// daemon's name — otherwise we'd hand Command::new a non-
347+
// executable path and lose the legitimate PATH fallback.
348+
let tmp = tempfile::tempdir().expect("create tempdir");
349+
fs::create_dir(tmp.path().join(DAEMON_BIN_NAME)).expect("mkdir");
350+
assert!(daemon_in_dir(tmp.path()).is_none());
351+
}
352+
353+
#[test]
354+
fn daemon_in_dir_returns_none_for_nonexistent_dir() {
355+
// Defensive: if `sys.executable.parent()` ever points somewhere
356+
// that no longer exists (e.g. a deleted venv), the lookup must
357+
// gracefully fall back to PATH rather than surface an error.
358+
let tmp = tempfile::tempdir().expect("create tempdir");
359+
let missing = tmp.path().join("does-not-exist");
360+
assert!(daemon_in_dir(&missing).is_none());
361+
}
362+
}

0 commit comments

Comments
 (0)