Skip to main content

room_cli/oneshot/
transport.rs

1use std::path::Path;
2
3use tokio::{
4    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
5    net::UnixStream,
6};
7
8use crate::message::Message;
9
10// ── SocketTarget ──────────────────────────────────────────────────────────────
11
12/// Resolved connection target for a broker.
13///
14/// All connections go through the multi-room daemon (`roomd`). The client
15/// prepends `ROOM:<room_id>:` before every handshake token so the daemon can
16/// route the connection to the correct room.
17///
18/// `daemon_room` is always `Some(room_id)` in production. `None` is only used
19/// by test fixtures that connect directly to a standalone `Broker` instance.
20#[derive(Debug, Clone)]
21pub struct SocketTarget {
22    /// Path to the UDS socket.
23    pub path: std::path::PathBuf,
24    /// If `Some(room_id)`, prepend `ROOM:<room_id>:` before each handshake token.
25    pub daemon_room: Option<String>,
26}
27
28impl SocketTarget {
29    /// Construct the full first line to send for a given handshake token.
30    ///
31    /// - Per-room: `TOKEN:<uuid>` → `"TOKEN:<uuid>"`
32    /// - Daemon: `TOKEN:<uuid>` → `"ROOM:<room_id>:TOKEN:<uuid>"`
33    fn handshake_line(&self, token_line: &str) -> String {
34        match &self.daemon_room {
35            Some(room_id) => format!("ROOM:{room_id}:{token_line}"),
36            None => token_line.to_owned(),
37        }
38    }
39}
40
41// ── Socket target resolution ──────────────────────────────────────────────────
42
43/// Resolve the effective socket target for a given room.
44///
45/// All rooms are managed by the daemon. If `explicit` is given, use it as the
46/// daemon socket path; otherwise use the platform-native daemon socket
47/// (`effective_socket_path()`).
48pub fn resolve_socket_target(room_id: &str, explicit: Option<&Path>) -> SocketTarget {
49    let path = explicit
50        .map(|p| p.to_owned())
51        .unwrap_or_else(|| crate::paths::effective_socket_path(None));
52    SocketTarget {
53        path,
54        daemon_room: Some(room_id.to_owned()),
55    }
56}
57
58// ── Daemon auto-start ─────────────────────────────────────────────────────────
59
60const DAEMON_POLL_INTERVAL_MS: u64 = 50;
61const DAEMON_START_TIMEOUT_MS: u64 = 5_000;
62
63/// Ensure the multi-room daemon is running.
64///
65/// If the daemon socket is not connectable, spawns `room daemon` as a detached
66/// background process, writes its PID to `~/.room/roomd.pid`, and polls until
67/// the socket accepts connections (up to 15 seconds).
68///
69/// This is a no-op when the caller passes an explicit `--socket` override — in
70/// that case the caller is targeting a specific socket and the daemon should not
71/// be auto-started on their behalf.
72///
73/// # Errors
74///
75/// Returns an error if the process cannot be spawned or if the socket does not
76/// become connectable within the timeout.
77pub async fn ensure_daemon_running() -> anyhow::Result<()> {
78    let exe = resolve_daemon_binary()?;
79    // Respect ROOM_SOCKET env var when deciding where to start/find the daemon.
80    ensure_daemon_running_impl(&crate::paths::effective_socket_path(None), &exe).await
81}
82
83/// Resolve which binary to spawn as the daemon.
84///
85/// Resolution order:
86/// 1. `ROOM_BINARY` env var (explicit override for testing).
87/// 2. `which room` — the installed binary on `$PATH`.
88/// 3. `current_exe()` — fallback to the running binary.
89///
90/// Using the installed binary (not `current_exe()`) ensures all agents
91/// converge on a single shared daemon regardless of which git worktree
92/// they run from.
93fn resolve_daemon_binary() -> anyhow::Result<std::path::PathBuf> {
94    // 1. Explicit override.
95    if let Ok(p) = std::env::var("ROOM_BINARY") {
96        let path = std::path::PathBuf::from(&p);
97        if path.exists() {
98            return Ok(path);
99        }
100    }
101
102    // 2. Installed binary on PATH.
103    if let Ok(output) = std::process::Command::new("which").arg("room").output() {
104        if output.status.success() {
105            let path_str = String::from_utf8_lossy(&output.stdout);
106            let path = std::path::PathBuf::from(path_str.trim());
107            if path.exists() {
108                return Ok(path);
109            }
110        }
111    }
112
113    // 3. Fallback to current executable.
114    std::env::current_exe().map_err(|e| anyhow::anyhow!("cannot resolve daemon binary: {e}"))
115}
116
117/// Test-visible variant: accepts explicit socket and exe paths so tests can
118/// target temp paths without relying on `current_exe()`.
119#[cfg(test)]
120pub(crate) async fn ensure_daemon_running_at(
121    socket: &Path,
122    exe: &std::path::Path,
123) -> anyhow::Result<()> {
124    ensure_daemon_running_impl(socket, exe).await
125}
126
127async fn ensure_daemon_running_impl(socket: &Path, exe: &Path) -> anyhow::Result<()> {
128    // Fast path: daemon is already running.
129    if UnixStream::connect(socket).await.is_ok() {
130        return Ok(());
131    }
132
133    let child = std::process::Command::new(exe)
134        .arg("daemon")
135        .arg("--socket")
136        .arg(socket)
137        .stdin(std::process::Stdio::null())
138        .stdout(std::process::Stdio::null())
139        .stderr(std::process::Stdio::null())
140        .spawn()
141        .map_err(|e| anyhow::anyhow!("failed to spawn daemon ({}): {e}", exe.display()))?;
142
143    // Persist PID so the user (or cleanup scripts) can identify the process.
144    let pid_path = crate::paths::room_pid_path();
145    let _ = std::fs::write(&pid_path, child.id().to_string());
146
147    // Poll until the socket accepts connections.
148    let deadline =
149        tokio::time::Instant::now() + tokio::time::Duration::from_millis(DAEMON_START_TIMEOUT_MS);
150
151    loop {
152        if UnixStream::connect(socket).await.is_ok() {
153            return Ok(());
154        }
155        if tokio::time::Instant::now() >= deadline {
156            anyhow::bail!(
157                "daemon failed to start within {}ms (socket: {})",
158                DAEMON_START_TIMEOUT_MS,
159                socket.display()
160            );
161        }
162        tokio::time::sleep(tokio::time::Duration::from_millis(DAEMON_POLL_INTERVAL_MS)).await;
163    }
164}
165
166// ── Transport functions ───────────────────────────────────────────────────────
167
168/// Connect to a running broker and deliver a single message without joining the room.
169/// Returns the broadcast echo (with broker-assigned id/ts) so callers have the message ID.
170///
171/// # Deprecation
172///
173/// Uses the `SEND:<username>` handshake which bypasses token authentication.
174/// Use [`send_message_with_token`] instead — obtain a token via `room join` first.
175#[deprecated(
176    since = "3.1.0",
177    note = "SEND: handshake is unauthenticated; use send_message_with_token instead"
178)]
179pub async fn send_message(
180    socket_path: &Path,
181    username: &str,
182    content: &str,
183) -> anyhow::Result<Message> {
184    let stream = UnixStream::connect(socket_path).await.map_err(|e| {
185        anyhow::anyhow!("cannot connect to broker at {}: {e}", socket_path.display())
186    })?;
187    let (r, mut w) = stream.into_split();
188    w.write_all(format!("SEND:{username}\n").as_bytes()).await?;
189    w.write_all(format!("{content}\n").as_bytes()).await?;
190
191    let mut reader = BufReader::new(r);
192    let mut line = String::new();
193    reader.read_line(&mut line).await?;
194    let msg: Message = serde_json::from_str(line.trim())
195        .map_err(|e| anyhow::anyhow!("broker returned invalid JSON: {e}: {:?}", line.trim()))?;
196    Ok(msg)
197}
198
199/// Connect to a running broker and deliver a single message authenticated by token.
200///
201/// When `target.daemon_room` is `Some(room_id)`, sends
202/// `ROOM:<room_id>:TOKEN:<token>` as the handshake so the daemon routes
203/// the connection to the correct room. For a per-room socket the handshake
204/// is simply `TOKEN:<token>`.
205pub async fn send_message_with_token(
206    socket_path: &Path,
207    token: &str,
208    content: &str,
209) -> anyhow::Result<Message> {
210    send_message_with_token_target(
211        &SocketTarget {
212            path: socket_path.to_owned(),
213            daemon_room: None,
214        },
215        token,
216        content,
217    )
218    .await
219}
220
221/// Variant of [`send_message_with_token`] that takes a fully-resolved
222/// [`SocketTarget`], including daemon routing prefix when required.
223pub async fn send_message_with_token_target(
224    target: &SocketTarget,
225    token: &str,
226    content: &str,
227) -> anyhow::Result<Message> {
228    let stream = UnixStream::connect(&target.path).await.map_err(|e| {
229        anyhow::anyhow!("cannot connect to broker at {}: {e}", target.path.display())
230    })?;
231    let (r, mut w) = stream.into_split();
232    let handshake = target.handshake_line(&format!("TOKEN:{token}"));
233    w.write_all(format!("{handshake}\n").as_bytes()).await?;
234    // content is already a JSON envelope from cmd_send; newlines are escaped by serde.
235    w.write_all(format!("{content}\n").as_bytes()).await?;
236
237    let mut reader = BufReader::new(r);
238    let mut line = String::new();
239    reader.read_line(&mut line).await?;
240    // Broker may return an error envelope instead of a broadcast echo.
241    let v: serde_json::Value = serde_json::from_str(line.trim())
242        .map_err(|e| anyhow::anyhow!("broker returned invalid JSON: {e}: {:?}", line.trim()))?;
243    if v["type"] == "error" {
244        let code = v["code"].as_str().unwrap_or("unknown");
245        if code == "invalid_token" {
246            anyhow::bail!("invalid token — run: room join {}", target.path.display());
247        }
248        anyhow::bail!("broker error: {code}");
249    }
250    let msg: Message = serde_json::from_value(v)
251        .map_err(|e| anyhow::anyhow!("broker returned unexpected JSON: {e}"))?;
252    Ok(msg)
253}
254
255/// Register a username with the broker and obtain a session token.
256///
257/// The broker checks for username collisions. On success it returns a token
258/// envelope; on collision it returns an error envelope.
259pub async fn join_session(socket_path: &Path, username: &str) -> anyhow::Result<(String, String)> {
260    join_session_target(
261        &SocketTarget {
262            path: socket_path.to_owned(),
263            daemon_room: None,
264        },
265        username,
266    )
267    .await
268}
269
270/// Variant of [`join_session`] that takes a fully-resolved [`SocketTarget`].
271pub async fn join_session_target(
272    target: &SocketTarget,
273    username: &str,
274) -> anyhow::Result<(String, String)> {
275    let stream = UnixStream::connect(&target.path).await.map_err(|e| {
276        anyhow::anyhow!("cannot connect to broker at {}: {e}", target.path.display())
277    })?;
278    let (r, mut w) = stream.into_split();
279    let handshake = target.handshake_line(&format!("JOIN:{username}"));
280    w.write_all(format!("{handshake}\n").as_bytes()).await?;
281
282    let mut reader = BufReader::new(r);
283    let mut line = String::new();
284    reader.read_line(&mut line).await?;
285    let v: serde_json::Value = serde_json::from_str(line.trim())
286        .map_err(|e| anyhow::anyhow!("broker returned invalid JSON: {e}: {:?}", line.trim()))?;
287    if v["type"] == "error" {
288        let code = v["code"].as_str().unwrap_or("unknown");
289        if code == "username_taken" {
290            anyhow::bail!("username '{}' is already in use in this room", username);
291        }
292        anyhow::bail!("broker error: {code}");
293    }
294    let token = v["token"]
295        .as_str()
296        .ok_or_else(|| anyhow::anyhow!("broker response missing 'token' field"))?
297        .to_owned();
298    let returned_user = v["username"]
299        .as_str()
300        .ok_or_else(|| anyhow::anyhow!("broker response missing 'username' field"))?
301        .to_owned();
302    Ok((returned_user, token))
303}
304
305/// Global user registration: sends `JOIN:<username>` directly to the daemon.
306///
307/// Unlike [`join_session`] which routes through `ROOM:<room_id>:JOIN:<username>`,
308/// this sends `JOIN:<username>` at daemon level — no room association.
309/// Returns the existing token if the username is already registered.
310pub async fn global_join_session(
311    socket_path: &Path,
312    username: &str,
313) -> anyhow::Result<(String, String)> {
314    let stream = UnixStream::connect(socket_path).await.map_err(|e| {
315        anyhow::anyhow!("cannot connect to daemon at {}: {e}", socket_path.display())
316    })?;
317    let (r, mut w) = stream.into_split();
318    w.write_all(format!("JOIN:{username}\n").as_bytes()).await?;
319
320    let mut reader = BufReader::new(r);
321    let mut line = String::new();
322    reader.read_line(&mut line).await?;
323    let v: serde_json::Value = serde_json::from_str(line.trim())
324        .map_err(|e| anyhow::anyhow!("daemon returned invalid JSON: {e}: {:?}", line.trim()))?;
325    if v["type"] == "error" {
326        let code = v["code"].as_str().unwrap_or("unknown");
327        anyhow::bail!("daemon error: {code}");
328    }
329    let token = v["token"]
330        .as_str()
331        .ok_or_else(|| anyhow::anyhow!("daemon response missing 'token' field"))?
332        .to_owned();
333    let returned_user = v["username"]
334        .as_str()
335        .ok_or_else(|| anyhow::anyhow!("daemon response missing 'username' field"))?
336        .to_owned();
337    Ok((returned_user, token))
338}
339
340// ── Room creation ────────────────────────────────────────────────────────────
341
342/// Connect to a daemon socket and create a new room via `CREATE:<room_id>`.
343///
344/// Sends the room ID on the first line and the config JSON (with `token` field)
345/// on the second. Returns the daemon's response JSON on success
346/// (`{"type":"room_created",...}`).
347///
348/// The `config_json` should include a `"token"` field for authentication.
349/// Use [`inject_token_into_config`] to add it if not already present.
350pub async fn create_room(
351    socket_path: &Path,
352    room_id: &str,
353    config_json: &str,
354) -> anyhow::Result<serde_json::Value> {
355    let stream = UnixStream::connect(socket_path).await.map_err(|e| {
356        anyhow::anyhow!("cannot connect to daemon at {}: {e}", socket_path.display())
357    })?;
358    let (r, mut w) = stream.into_split();
359    w.write_all(format!("CREATE:{room_id}\n").as_bytes())
360        .await?;
361    w.write_all(format!("{config_json}\n").as_bytes()).await?;
362
363    let mut reader = BufReader::new(r);
364    let mut line = String::new();
365    reader.read_line(&mut line).await?;
366    let v: serde_json::Value = serde_json::from_str(line.trim())
367        .map_err(|e| anyhow::anyhow!("daemon returned invalid JSON: {e}: {:?}", line.trim()))?;
368    if v["type"] == "error" {
369        let message = v["message"].as_str().unwrap_or("unknown error");
370        anyhow::bail!("{message}");
371    }
372    Ok(v)
373}
374
375// ── Room destruction ─────────────────────────────────────────────────────────
376
377/// Connect to a daemon socket and destroy a room via `DESTROY:<room_id>`.
378///
379/// Sends the room ID on the first line and the authentication token on the
380/// second. Returns the daemon's response JSON on success
381/// (`{"type":"room_destroyed",...}`).
382pub async fn destroy_room(
383    socket_path: &Path,
384    room_id: &str,
385    token: &str,
386) -> anyhow::Result<serde_json::Value> {
387    let stream = UnixStream::connect(socket_path).await.map_err(|e| {
388        anyhow::anyhow!("cannot connect to daemon at {}: {e}", socket_path.display())
389    })?;
390    let (r, mut w) = stream.into_split();
391    w.write_all(format!("DESTROY:{room_id}\n").as_bytes())
392        .await?;
393    w.write_all(format!("{token}\n").as_bytes()).await?;
394
395    let mut reader = BufReader::new(r);
396    let mut line = String::new();
397    reader.read_line(&mut line).await?;
398    let v: serde_json::Value = serde_json::from_str(line.trim())
399        .map_err(|e| anyhow::anyhow!("daemon returned invalid JSON: {e}: {:?}", line.trim()))?;
400    if v["type"] == "error" {
401        let message = v["message"]
402            .as_str()
403            .unwrap_or(v["code"].as_str().unwrap_or("unknown error"));
404        anyhow::bail!("{message}");
405    }
406    Ok(v)
407}
408
409/// Inject a `"token"` field into a JSON config string.
410///
411/// If the config is valid JSON, merges the token into the object.
412/// If the config is empty or not an object, wraps the token in a minimal config.
413pub fn inject_token_into_config(config_json: &str, token: &str) -> String {
414    if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(config_json) {
415        if let Some(obj) = v.as_object_mut() {
416            obj.insert(
417                "token".to_owned(),
418                serde_json::Value::String(token.to_owned()),
419            );
420            return serde_json::to_string(&v).unwrap_or_default();
421        }
422    }
423    // Fallback: wrap in a new object.
424    serde_json::json!({"token": token}).to_string()
425}
426
427// ── Tests ─────────────────────────────────────────────────────────────────────
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use std::path::PathBuf;
433
434    fn per_room_target(room_id: &str) -> SocketTarget {
435        SocketTarget {
436            path: PathBuf::from(format!("/tmp/room-{room_id}.sock")),
437            daemon_room: None,
438        }
439    }
440
441    fn daemon_target(room_id: &str) -> SocketTarget {
442        SocketTarget {
443            path: PathBuf::from("/tmp/roomd.sock"),
444            daemon_room: Some(room_id.to_owned()),
445        }
446    }
447
448    // ── SocketTarget::handshake_line ──────────────────────────────────────────
449
450    #[test]
451    fn per_room_token_handshake_no_prefix() {
452        let t = per_room_target("myroom");
453        assert_eq!(t.handshake_line("TOKEN:abc-123"), "TOKEN:abc-123");
454    }
455
456    #[test]
457    fn daemon_token_handshake_has_room_prefix() {
458        let t = daemon_target("myroom");
459        assert_eq!(
460            t.handshake_line("TOKEN:abc-123"),
461            "ROOM:myroom:TOKEN:abc-123"
462        );
463    }
464
465    #[test]
466    fn per_room_join_handshake_no_prefix() {
467        let t = per_room_target("chat");
468        assert_eq!(t.handshake_line("JOIN:alice"), "JOIN:alice");
469    }
470
471    #[test]
472    fn daemon_join_handshake_has_room_prefix() {
473        let t = daemon_target("chat");
474        assert_eq!(t.handshake_line("JOIN:alice"), "ROOM:chat:JOIN:alice");
475    }
476
477    #[test]
478    fn daemon_handshake_with_hyphen_room_id() {
479        let t = daemon_target("agent-room-2");
480        assert_eq!(
481            t.handshake_line("TOKEN:uuid"),
482            "ROOM:agent-room-2:TOKEN:uuid"
483        );
484    }
485
486    // ── ensure_daemon_running_at ──────────────────────────────────────────────
487    //
488    // WARNING: these two tests spawn real `room` daemon processes.
489    // They are marked #[ignore] so they don't run in normal `cargo test`.
490    // Run explicitly with: `cargo test -p room-cli -- --ignored ensure_daemon`
491
492    /// Resolve the `room` binary from the test binary's location.
493    /// In cargo test layout: `target/debug/deps/../room` → `target/debug/room`.
494    fn room_bin() -> PathBuf {
495        let bin = std::env::current_exe()
496            .unwrap()
497            .parent()
498            .unwrap()
499            .parent()
500            .unwrap()
501            .join("room");
502        assert!(bin.exists(), "room binary not found at {}", bin.display());
503        bin
504    }
505
506    /// Verify that `ensure_daemon_running_at` is a no-op when a live socket already exists.
507    /// Ignored by default — spawns a real daemon process. Run with `cargo test -- --ignored`.
508    #[tokio::test]
509    #[ignore = "spawns a real daemon process; run explicitly with `cargo test -- --ignored`"]
510    async fn ensure_daemon_noop_when_socket_connectable() {
511        // Start a real daemon at a temp socket, verify ensure_daemon_running_at
512        // returns Ok without spawning a second process.
513        let dir = tempfile::TempDir::new().unwrap();
514        let socket = dir.path().join("roomd.sock");
515        let exe = room_bin();
516
517        let mut child = tokio::process::Command::new(&exe)
518            .args(["daemon", "--socket"])
519            .arg(&socket)
520            .stdin(std::process::Stdio::null())
521            .stdout(std::process::Stdio::null())
522            .stderr(std::process::Stdio::null())
523            .spawn()
524            .expect("failed to spawn room daemon");
525
526        // Wait for socket to become connectable (up to 10s under parallel load).
527        for _ in 0..200 {
528            if tokio::net::UnixStream::connect(&socket).await.is_ok() {
529                break;
530            }
531            tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
532        }
533        assert!(
534            tokio::net::UnixStream::connect(&socket).await.is_ok(),
535            "daemon socket not ready"
536        );
537
538        // Calling again must be a no-op (does not error).
539        ensure_daemon_running_at(&socket, &exe).await.unwrap();
540
541        child.kill().await.ok();
542    }
543
544    /// Verify that `ensure_daemon_running_at` auto-starts a daemon when none is running.
545    /// Ignored by default — spawns a real daemon process. Run with `cargo test -- --ignored`.
546    #[tokio::test]
547    #[ignore = "spawns a real daemon process; run explicitly with `cargo test -- --ignored`"]
548    async fn ensure_daemon_starts_daemon_and_writes_pid() {
549        let dir = tempfile::TempDir::new().unwrap();
550        let socket = dir.path().join("autostart.sock");
551        let exe = room_bin();
552
553        // No daemon running yet — ensure_daemon_running_at must start one.
554        ensure_daemon_running_at(&socket, &exe).await.unwrap();
555
556        // Socket should now be connectable.
557        assert!(
558            tokio::net::UnixStream::connect(&socket).await.is_ok(),
559            "daemon socket not connectable after auto-start"
560        );
561        // Note: PID file is written to the global room_pid_path() which other
562        // parallel tests may also write/clean up. Asserting its contents here
563        // would be racy. Verifying socket connectivity is sufficient.
564        //
565        // TempDir drop cleans up the socket; the daemon will exit when it can
566        // no longer accept connections.
567    }
568
569    // ── resolve_socket_target ─────────────────────────────────────────────────
570
571    #[test]
572    fn resolve_explicit_socket_is_daemon() {
573        let daemon_sock = PathBuf::from("/tmp/roomd.sock");
574        let target = resolve_socket_target("myroom", Some(&daemon_sock));
575        assert_eq!(target.path, daemon_sock);
576        assert_eq!(target.daemon_room.as_deref(), Some("myroom"));
577    }
578
579    #[test]
580    fn resolve_explicit_custom_path_is_daemon() {
581        let custom = PathBuf::from("/var/run/roomd-test.sock");
582        let target = resolve_socket_target("chat", Some(&custom));
583        assert_eq!(target.path, custom);
584        assert_eq!(target.daemon_room.as_deref(), Some("chat"));
585    }
586
587    #[test]
588    fn resolve_auto_uses_daemon_socket() {
589        let target = resolve_socket_target("myroom", None);
590        // Should always use daemon socket path with daemon_room set
591        assert_eq!(target.daemon_room.as_deref(), Some("myroom"));
592    }
593
594    // ── resolve_daemon_binary ────────────────────────────────────────────────
595
596    /// Env var access is process-global; serialize tests that mutate it.
597    static TRANSPORT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
598
599    #[test]
600    fn resolve_daemon_binary_uses_room_binary_env() {
601        let _lock = TRANSPORT_ENV_LOCK.lock().unwrap();
602        let key = "ROOM_BINARY";
603        let prev = std::env::var(key).ok();
604
605        // Point ROOM_BINARY at a real binary that exists.
606        let target = std::env::current_exe().unwrap();
607        std::env::set_var(key, &target);
608        let result = resolve_daemon_binary().unwrap();
609        assert_eq!(result, target, "should use ROOM_BINARY when set");
610
611        match prev {
612            Some(v) => std::env::set_var(key, v),
613            None => std::env::remove_var(key),
614        }
615    }
616
617    #[test]
618    fn resolve_daemon_binary_ignores_nonexistent_room_binary() {
619        let _lock = TRANSPORT_ENV_LOCK.lock().unwrap();
620        let key = "ROOM_BINARY";
621        let prev = std::env::var(key).ok();
622
623        std::env::set_var(key, "/nonexistent/path/to/room");
624        let result = resolve_daemon_binary().unwrap();
625        // Should NOT be the nonexistent path — falls through to which/current_exe.
626        assert_ne!(
627            result,
628            std::path::PathBuf::from("/nonexistent/path/to/room"),
629            "should skip ROOM_BINARY when path does not exist"
630        );
631
632        match prev {
633            Some(v) => std::env::set_var(key, v),
634            None => std::env::remove_var(key),
635        }
636    }
637
638    #[test]
639    fn resolve_daemon_binary_falls_back_without_env() {
640        let _lock = TRANSPORT_ENV_LOCK.lock().unwrap();
641        let key = "ROOM_BINARY";
642        let prev = std::env::var(key).ok();
643
644        std::env::remove_var(key);
645        let result = resolve_daemon_binary().unwrap();
646        // Should resolve to either `which room` or current_exe — either way a real path.
647        assert!(result.exists(), "resolved binary should exist: {result:?}");
648
649        match prev {
650            Some(v) => std::env::set_var(key, v),
651            None => std::env::remove_var(key),
652        }
653    }
654}