11//! Synchronous `Daemon` and asynchronous `AsyncDaemon` PyO3 bindings.
22
3+ use std:: path:: { Path , PathBuf } ;
4+
35use 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]
771pub ( 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