diff --git a/src/app.rs b/src/app.rs index 6b63972..dc6df21 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,7 +19,6 @@ use crate::path; use crate::protocol::{ Command, Event, Lifecycle, MouseBtn, MouseKind, ScreenView, ScrollAction, TaskView, }; -use crate::session; use crate::supervisor::Supervisor; use crate::transport::{ExitIntent, SocketTransport, ThreadTransport, Transport}; use crate::ui; @@ -484,6 +483,12 @@ impl App { self.focused_screen = Some(s); } Event::Status(s) => self.status = Some(s), + Event::Sessions(names) => { + // A shorter list can land while the picker is open; clamp + // the selection before it can index past the end. + self.session_sel = self.session_sel.min(names.len().saturating_sub(1)); + self.session_names = names; + } } } } @@ -744,7 +749,12 @@ impl App { self.mode = Mode::SaveSession; } KeyCode::Char('o') => { - self.session_names = session::list(); + // The core owns the session dir (it resolves against the + // connection's launch context, not this process's env), so the + // names round-trip through it. The picker opens immediately and + // shows "(no saved sessions)" until the reply lands next sync. + self.transport.send(Command::ListSessions); + self.session_names.clear(); self.session_sel = 0; self.mode = Mode::LoadSession; } @@ -938,7 +948,7 @@ impl App { } /// Clipboard paste, routed by mode. Attached: shipped whole to the core, - /// which encodes it against the child's negotiated paste state — pushing + /// which encodes it against the child's negotiated paste state. Pushing /// it through `key_to_bytes` would turn every newline into a submit. /// Text-entry modes: inserted as one string with control characters /// stripped, so a multi-line clipboard can't fake an Enter press. @@ -1237,6 +1247,16 @@ mod tests { }) } + /// `new_local` with an explicit launch context, for tests that must pin + /// the core's session root instead of inheriting this process's env. + fn new_local_with_ctx(rows: u16, cols: u16, ctx: crate::protocol::LaunchContext) -> App { + App::assemble(rows, cols, move |pr, c, _wait_tx| { + let mut sup = Supervisor::new(pr, c); + sup.set_launch_context(ctx); + Box::new(LocalTransport::new(sup)) + }) + } + /// Drive one core sync so `views` reflects the latest spawns and reaps: /// the test-side equivalent of one run-loop tick. fn pump(&mut self) { @@ -1391,6 +1411,89 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Scratch config dir with the given pre-written (empty) session recipes. + fn session_scratch(tag: &str, names: &[&str]) -> PathBuf { + let dir = std::env::temp_dir().join(format!("fleetcom_app_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sessions")).unwrap(); + for n in names { + std::fs::write(dir.join("sessions").join(format!("{n}.json")), "{}").unwrap(); + } + dir + } + + /// An App whose core's session root is pinned to `dir` via the launch + /// context, so these tests never read this process's real config dir. + fn app_with_config_dir(dir: &Path) -> App { + App::new_local_with_ctx( + 30, + 100, + crate::protocol::LaunchContext { + env: vec![( + "FLEETCOM_CONFIG_DIR".into(), + dir.to_path_buf().into_os_string(), + )], + cwd: dir.to_path_buf(), + }, + ) + } + + /// `o` never touches the local filesystem: it sends `ListSessions`, opens + /// the picker empty, and the core's `Sessions` reply fills it. + #[test] + fn o_key_round_trips_the_session_list_through_the_core() { + let dir = session_scratch("sess_list", &["b", "a"]); + let mut app = app_with_config_dir(&dir); + app.session_sel = 3; // stale from a previous picker visit + + app.on_key_dashboard(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + assert!(matches!(app.mode, Mode::LoadSession)); + assert!( + app.session_names.is_empty(), + "the picker opens empty until the reply lands" + ); + assert_eq!( + app.session_sel, 0, + "opening the picker resets the selection" + ); + + app.pump(); + assert_eq!( + app.session_names, + vec!["a".to_string(), "b".to_string()], + "the Sessions reply populates the picker, sorted" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A shorter list arriving while the picker is open clamps the selection so + /// Enter cannot index past the new end. + #[test] + fn session_selection_clamps_when_a_shorter_list_arrives() { + let dir = session_scratch("sess_clamp", &["a", "b", "c"]); + let mut app = app_with_config_dir(&dir); + app.on_key_dashboard(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + app.pump(); + assert_eq!(app.session_names.len(), 3); + app.session_sel = 2; + + // Two recipes vanish; a refresh lands while the picker is still open. + std::fs::remove_file(dir.join("sessions").join("b.json")).unwrap(); + std::fs::remove_file(dir.join("sessions").join("c.json")).unwrap(); + app.transport.send(Command::ListSessions); + app.pump(); + assert_eq!(app.session_names, vec!["a".to_string()]); + assert_eq!(app.session_sel, 0, "selection must clamp to the new length"); + + // The empty list parks the selection at 0 too. + std::fs::remove_file(dir.join("sessions").join("a.json")).unwrap(); + app.transport.send(Command::ListSessions); + app.pump(); + assert!(app.session_names.is_empty()); + assert_eq!(app.session_sel, 0); + let _ = std::fs::remove_dir_all(&dir); + } + /// The `@` recent list is the distinct task cwds, newest first. #[test] fn recent_dirs_are_distinct_and_newest_first() { diff --git a/src/daemon.rs b/src/daemon.rs index dbf47da..fe0c3e7 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -134,7 +134,7 @@ fn is_timeout(e: &io::Error) -> bool { /// Map a failed hello-reply read to an actionable error. EOF means the daemon /// went away mid-handshake (a racing `--kill` or shutdown): rerunning -/// autostarts a fresh one, so say that — not "kill and retry", which would be +/// autostarts a fresh one, so say that, not "kill and retry", which would be /// advice to destroy a fleet the next paragraph says no longer exists. fn hello_read_error(e: io::Error) -> io::Error { if e.kind() == ErrorKind::UnexpectedEof { @@ -170,7 +170,7 @@ fn check_hello_ack(kind: u8, payload: &[u8]) -> io::Result<()> { /// /// The daemon serves one client at a time, so a slow handshake means "queued /// behind another client", not failure: announce it and wait without a -/// deadline — the documented behavior. The announcement comes from a one-shot +/// deadline (the documented behavior). The announcement comes from a one-shot /// timer thread rather than a read timeout because the stall can be in the /// *write*: a large env can overfill the unaccepted connection's buffer, and a /// timed-out partial `write_all` would corrupt the framing. Callers run this @@ -399,8 +399,8 @@ pub fn run_daemon() -> io::Result<()> { // A signalled daemon shuts down *cleanly*: TERM each job's group with a // KILL after the grace, remove the socket. Dying without that cleanup - // would still kill the fleet — closing the PTY masters hangs up every - // job's terminal (see the module docs) — but rudely: no TERM, no grace, + // would still kill the fleet (closing the PTY masters hangs up every + // job's terminal; see the module docs), but rudely: no TERM, no grace, // and HUP-immune jobs would leak unowned. The flag is checked in the idle // branch below and inside `run_loop` while a client is being served; both // observe it within ~200 ms. @@ -587,7 +587,7 @@ fn serve_client(sup: &mut Supervisor, stream: UnixStream, stop: &AtomicBool) -> write_frame(&mut write, kind, &payload).is_ok() }) })); - // Cleanup sits *after* the catch so every exit — return or panic — passes + // Cleanup sits *after* the catch so every exit (return or panic) passes // through it: a stale waker points task reader threads at a dead channel, // and a stale watch would stream the next client Screen frames it never // asked for. diff --git a/src/protocol.rs b/src/protocol.rs index 16a732e..b7f350b 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -11,7 +11,7 @@ use base64::engine::general_purpose::STANDARD as B64; use crate::frame::{KIND_CONTROL, KIND_HELLO, KIND_SCREEN}; /// Wire-protocol version; the handshake rejects mismatched peers. -pub const PROTOCOL_VERSION: u32 = 4; +pub const PROTOCOL_VERSION: u32 = 5; /// Environment and working directory supplied by the launching client. #[derive(Debug, Clone, PartialEq)] @@ -78,6 +78,9 @@ pub enum Command { SaveSession { name: String }, /// Spawn every command in a named recipe, each in its (existing) dir. LoadSession { name: String }, + /// Ask for the saved recipe names; answered with `Event::Sessions`. Listing + /// is core-side like save/load, so the picker shows the same dir they use. + ListSessions, /// Kill every task (the quit path). Shutdown, } @@ -122,6 +125,8 @@ pub enum Event { Screen(ScreenView), /// A one-line notice for the status line (save/load result, spawn error). Status(String), + /// Saved session-recipe names, sorted: the reply to `ListSessions`. + Sessions(Vec), } /// Process-derived lifecycle state, independent of the user's `tagged` intent. @@ -351,6 +356,9 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { let _ = o.insert("t", "load"); let _ = o.insert("name", name.as_str()); } + Command::ListSessions => { + let _ = o.insert("t", "list"); + } Command::Shutdown => { let _ = o.insert("t", "shutdown"); } @@ -359,7 +367,7 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { } /// Parse a command from a received frame. `None` on a wrong kind, non-UTF-8/ -/// non-JSON payload, unknown discriminant, or a missing/mistyped field — +/// non-JSON payload, unknown discriminant, or a missing/mistyped field, /// including out-of-range numerics and invalid base64. The daemon drops a /// malformed command rather than trusting it. pub fn decode_command(kind: u8, payload: &[u8]) -> Option { @@ -443,6 +451,7 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { "load" => Command::LoadSession { name: v["name"].as_str()?.to_string(), }, + "list" => Command::ListSessions, "shutdown" => Command::Shutdown, _ => return None, }; @@ -483,6 +492,16 @@ pub fn encode_event(ev: &Event) -> (u8, Vec) { let _ = o.insert("msg", msg.as_str()); (KIND_CONTROL, o.dump().into_bytes()) } + Event::Sessions(names) => { + let mut arr = jzon::JsonValue::new_array(); + for n in names { + let _ = arr.push(n.as_str()); + } + let mut o = jzon::JsonValue::new_object(); + let _ = o.insert("t", "sessions"); + let _ = o.insert("names", arr); + (KIND_CONTROL, o.dump().into_bytes()) + } Event::Screen(sv) => { let mut header = jzon::JsonValue::new_object(); let _ = header.insert("id", sv.id); @@ -534,6 +553,15 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { Some(Event::Tasks(views)) } "status" => Some(Event::Status(v["msg"].as_str()?.to_string())), + "sessions" => { + // Preserve the one-to-one mapping between encoded and + // decoded names. A non-string member invalidates the event. + let mut names = Vec::with_capacity(v["names"].len()); + for n in v["names"].members() { + names.push(n.as_str()?.to_string()); + } + Some(Event::Sessions(names)) + } _ => None, } } @@ -649,6 +677,7 @@ mod tests { Command::LoadSession { name: "home".into(), }, + Command::ListSessions, Command::Shutdown, ]; for c in cases { @@ -774,8 +803,8 @@ mod tests { p } - /// A mistyped member in `lines` or `tasks` rejects the whole event, keeping - /// decoded rows aligned with their encoded positions. + /// A mistyped member in `lines`, `tasks`, or `names` rejects the whole + /// event, keeping decoded rows aligned with their encoded positions. #[test] fn mistyped_event_members_are_rejected() { for header in [ @@ -795,6 +824,8 @@ mod tests { // The cwd must be a base64 string. r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"/x","tagged":true,"life":"ok","preview":"","started_ms":0}]}"#, r#"{"t":"tasks","tasks":["flat"]}"#, + // Numeric member in `names`. + r#"{"t":"sessions","names":["ok",5]}"#, ] { assert_eq!( decode_event(KIND_CONTROL, json.as_bytes()), @@ -836,6 +867,22 @@ mod tests { assert_eq!(decode_event(k, &p), Some(status)); } + /// `Sessions` carries the picker's names verbatim: several names, an empty + /// list, and names with spaces and non-ASCII all round-trip. + #[test] + fn sessions_event_round_trips() { + for names in [ + vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()], + Vec::new(), + vec!["my session".to_string(), "café ☕".to_string()], + ] { + let ev = Event::Sessions(names); + let (k, p) = encode_event(&ev); + assert_eq!(k, KIND_CONTROL); + assert_eq!(decode_event(k, &p).as_ref(), Some(&ev), "round-trip {ev:?}"); + } + } + /// The `Screen` event keeps its formatted bytes intact through the raw tail, /// including non-UTF-8 bytes (0xFF) an ANSI stream really contains. #[test] diff --git a/src/session.rs b/src/session.rs index e98645c..fe376f1 100644 --- a/src/session.rs +++ b/src/session.rs @@ -60,7 +60,9 @@ fn from_json(text: &str) -> io::Result { Ok(cfg) } -// --- fs surface, taking an explicit dir so tests avoid the real config path -- +// --- fs surface: callers supply the root. The supervisor resolves it from the +// connection's launch context; `sessions_dir` above is only its process-env +// fallback. Tests point it at scratch dirs the same way. ----------------------- pub fn save_in(dir: &Path, name: &str, cfg: &SessionConfig) -> io::Result { fs::create_dir_all(dir)?; @@ -90,24 +92,6 @@ pub fn list_in(dir: &Path) -> Vec { names } -// --- convenience wrappers over the real config dir --------------------------- - -fn no_dir() -> io::Error { - io::Error::other("no config directory available") -} - -pub fn save(name: &str, cfg: &SessionConfig) -> io::Result { - save_in(&sessions_dir().ok_or_else(no_dir)?, name, cfg) -} - -pub fn load(name: &str) -> io::Result { - load_in(&sessions_dir().ok_or_else(no_dir)?, name) -} - -pub fn list() -> Vec { - sessions_dir().map(|d| list_in(&d)).unwrap_or_default() -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/supervisor.rs b/src/supervisor.rs index 66f6bb1..7cc5ee8 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -206,6 +206,7 @@ impl Supervisor { } Command::SaveSession { name } => self.save_session(&name), Command::LoadSession { name } => self.load_session(&name), + Command::ListSessions => self.list_sessions(), Command::Shutdown => self.shutdown_all(), } } @@ -421,23 +422,57 @@ impl Supervisor { cfg } + /// Session-recipe root for this connection: `FLEETCOM_CONFIG_DIR` from the + /// installed launch context's env, else this process's + /// [`session::sessions_dir`]. The launch context wins because the daemon's + /// own env is frozen from whichever client first autostarted it, so save, + /// load, and list must all read the *connecting* client's override. A + /// client whose `HOME` alone differs still falls to the daemon's + /// `dirs::config_dir()`: resolving `dirs` against a foreign env would mean + /// reimplementing it, and `FLEETCOM_CONFIG_DIR` is the supported override. + fn sessions_root(&self) -> Option { + if let Some(ctx) = &self.launch + && let Some((_, dir)) = ctx.env.iter().find(|(k, _)| k == "FLEETCOM_CONFIG_DIR") + { + return Some(PathBuf::from(dir).join("sessions")); + } + session::sessions_dir() + } + fn save_session(&mut self, name: &str) { let cfg = self.session_config(); let count: usize = cfg.values().map(Vec::len).sum(); - let status = match session::save(name, &cfg) { - Ok(_) => format!("saved '{name}': {count} command(s)"), - Err(e) => format!("save failed: {e}"), + let status = match self + .sessions_root() + .map(|root| session::save_in(&root, name, &cfg)) + { + Some(Ok(_)) => format!("saved '{name}': {count} command(s)"), + Some(Err(e)) => format!("save failed: {e}"), + None => "save failed: no config directory available".to_string(), }; self.events.push(Event::Status(status)); } + /// Answer `ListSessions` with the recipe names under this connection's + /// session root (sorted by `list_in`); no root reads as no sessions. + fn list_sessions(&mut self) { + let names = self + .sessions_root() + .map(|root| session::list_in(&root)) + .unwrap_or_default(); + self.events.push(Event::Sessions(names)); + } + /// Spawn every command in the named session, each in its (existing) dir. /// Missing dirs are skipped rather than spawning tasks doomed to fail on /// chdir. fn load_session(&mut self, name: &str) { - let cfg = match session::load(name) { - Ok(c) => c, - Err(_) => { + let cfg = match self + .sessions_root() + .map(|root| session::load_in(&root, name)) + { + Some(Ok(c)) => c, + _ => { self.events .push(Event::Status(format!("session '{name}' not found"))); return; @@ -1001,7 +1036,7 @@ mod tests { } /// Poll `reap` until `pred` holds or the deadline passes. The sweep paths - /// are all reap-driven, so tests must go through `reap()` — a `Drop`-driven + /// are all reap-driven, so tests must go through `reap()`: a `Drop`-driven /// test would pass while the reap-side escalation was broken. fn reap_until( s: &mut Supervisor, @@ -1208,6 +1243,52 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Session paths follow the connection's launch context: a hello env + /// carrying `FLEETCOM_CONFIG_DIR` decides where save, list, and load look. + /// The context env holds *only* the override, so anything this process's + /// env says about config locations is provably ignored. + #[test] + fn session_commands_use_the_launch_context_config_dir() { + let dir = scratch("sess_root"); + let config = dir.join("config"); + let mut s = Supervisor::new(24, 80); + s.set_launch_context(LaunchContext { + env: vec![( + "FLEETCOM_CONFIG_DIR".into(), + config.clone().into_os_string(), + )], + cwd: dir.clone(), + }); + + s.apply(Command::SaveSession { name: "ctx".into() }); + assert!( + config.join("sessions").join("ctx.json").is_file(), + "save must land under the launch context's config dir" + ); + assert!( + s.drain() + .iter() + .any(|e| matches!(e, Event::Status(m) if m.starts_with("saved 'ctx'"))), + ); + + s.apply(Command::ListSessions); + let evs = s.drain(); + assert!( + evs.iter() + .any(|e| matches!(e, Event::Sessions(n) if n == &["ctx".to_string()])), + "list must see the recipe save just wrote; got {evs:?}" + ); + + s.apply(Command::LoadSession { name: "ctx".into() }); + let evs = s.drain(); + assert!( + evs.iter() + .any(|e| matches!(e, Event::Status(m) if m.starts_with("loaded 'ctx'"))), + "load must find the recipe under the same root; got {evs:?}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + /// Spawns inherit only the installed launch-context environment. #[test] fn spawn_uses_the_launch_context_env_not_the_process_env() { @@ -1237,8 +1318,8 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// A supervisor with no launch context refuses to launch — spawn, rerun, - /// and session load alike — with a status notice. + /// A supervisor with no launch context refuses every launch path (spawn, + /// rerun, session load) with a status notice. #[test] fn launch_without_context_is_refused() { let mut s = Supervisor::new(24, 80); diff --git a/src/task.rs b/src/task.rs index f3e1b95..d23cc14 100644 --- a/src/task.rs +++ b/src/task.rs @@ -46,7 +46,7 @@ const PASTE_END: &[u8] = b"\x1b[201~"; /// Encode a clipboard paste for a child whose DECSET 2004 state is /// `bracketed`. Opted in: wrap in `200~`/`201~` markers with embedded /// terminators stripped, content otherwise verbatim. Legacy: no markers, and -/// line endings (`\r\n` and bare `\n`) become `\r` — the byte Enter sends — +/// line endings (`\r\n` and bare `\n`) become `\r` (the byte Enter sends), /// because a legacy line editor reads `\n` as ^J, not as end-of-line. pub fn paste_bytes(bracketed: bool, content: &[u8]) -> Vec { if bracketed { @@ -246,7 +246,7 @@ impl Task { // The launch context's shell, not the daemon's: a zsh client attached // to a bash-started daemon still gets zsh word-splitting. No fallback - // through this process's own SHELL — for an autostarted daemon that is + // through this process's own SHELL: for an autostarted daemon that is // the *first* client's env, the exact coupling per-connection context // exists to remove. A client env without SHELL gets the portable // default. @@ -261,7 +261,7 @@ impl Task { cmd.arg("-c"); cmd.arg(command); // The job runs under the *client's* environment, verbatim: clear the - // builder's captured base (the daemon's own env — whatever the client + // builder's captured base (the daemon's own env, whatever the client // that first autostarted it happened to have) so nothing leaks through // where the client's env lacks a key. cmd.env_clear(); @@ -358,7 +358,7 @@ impl Task { }) } - /// Latch the exit code and finish time if the leader has exited — without + /// Latch the exit code and finish time if the leader has exited, without /// reaping it. `WNOWAIT` leaves the zombie in place, which is what keeps /// the pid (and therefore the pgid) reserved so the group stays signalable /// for the task's whole life; see the `reaped` field. The zombie is @@ -570,7 +570,7 @@ impl Task { } /// Ask the whole job to exit: SIGTERM to the process *group*, not just the - /// direct child, so every group member gets it — including background + /// direct child, so every group member gets it, including background /// children a `cmd &` left behind (a non-interactive shell's `&` creates no /// new group, so they never leave this one). TERM, not KILL: the job gets a /// chance to flush and clean up. The supervisor owns the escalation: @@ -828,7 +828,7 @@ mod tests { /// Wheel routing follows the child's own escape sequences: nothing for an /// inline child, alternate-scroll arrows for a full-screen one, real mouse - /// events once a protocol is requested — in the negotiated encoding. + /// events once a protocol is requested, in the negotiated encoding. #[test] fn wheel_routes_by_child_state() { let up = MouseKind::WheelUp; @@ -971,7 +971,9 @@ mod tests { } thread::sleep(Duration::from_millis(10)); } - let first = contents.find("zqfirstqz").expect("first message never echoed"); + let first = contents + .find("zqfirstqz") + .expect("first message never echoed"); let second = contents .find("zqsecondqz") .expect("second message never echoed"); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3c89269..62c7a5f 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -15,7 +15,7 @@ use std::time::{Duration, Instant}; /// The protocol version this test suite speaks; must track /// `protocol::PROTOCOL_VERSION` (drift fails the handshake, loudly). -pub const PROTOCOL_VERSION: u32 = 4; +pub const PROTOCOL_VERSION: u32 = 5; /// One frame of the given kind: `[u32 len][kind][payload]`. pub fn frame(kind: u8, payload: &[u8]) -> Vec { @@ -126,7 +126,7 @@ impl Drop for KillOnDrop { } /// Start a `fleetcom --daemon` against an isolated runtime dir and connect a -/// raw socket to it — no handshake, for tests that exercise the handshake +/// raw socket to it with no handshake, for tests that exercise the handshake /// itself. `configure` tweaks the daemon's `Command` (extra env vars) before /// spawn. pub fn start_daemon_raw( diff --git a/tests/daemon_env.rs b/tests/daemon_env.rs index 233406a..47ea091 100644 --- a/tests/daemon_env.rs +++ b/tests/daemon_env.rs @@ -1,5 +1,5 @@ //! Launch environment is per-connection, not per-daemon: a spawn runs under -//! the env the client sent in its hello — including non-UTF-8 entries — and +//! the env the client sent in its hello (including non-UTF-8 entries), and //! the daemon's own (first-client) environment does not leak through. mod common; diff --git a/tests/daemon_handshake.rs b/tests/daemon_handshake.rs index 620301a..2db55b5 100644 --- a/tests/daemon_handshake.rs +++ b/tests/daemon_handshake.rs @@ -104,7 +104,7 @@ fn silent_client_cannot_wedge_the_daemon() { } /// The documented single-client semantics: a second client's hello gets no -/// reply while the first is attached (it queues — the client side waits, it +/// reply while the first is attached (it queues; the client side waits and /// must never be told to `--kill` a healthy daemon), and is served the moment /// the first detaches. #[test] diff --git a/tests/daemon_session.rs b/tests/daemon_session.rs index 855cbe3..826ea4d 100644 --- a/tests/daemon_session.rs +++ b/tests/daemon_session.rs @@ -1,13 +1,16 @@ //! Session-recipe dirs resolve against the *loading client's* cwd (from its -//! hello), not the daemon's own working directory — the daemon's cwd is +//! hello), not the daemon's own working directory: the daemon's cwd is //! whatever the first client's happened to be, frozen for its lifetime. mod common; use std::io::Write; -use std::time::Duration; +use std::time::{Duration, Instant}; -use common::{control_frame, start_daemon, wait_until}; +use common::{ + PROTOCOL_VERSION, control_frame, hello_frame, read_frame, start_daemon, start_daemon_raw, + wait_until, +}; #[test] fn load_session_resolves_relative_dirs_against_the_client_cwd() { @@ -49,3 +52,89 @@ fn load_session_resolves_relative_dirs_against_the_client_cwd() { let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&config); } + +/// The session root follows the *hello's* env, not the daemon's: with the +/// daemon's own `FLEETCOM_CONFIG_DIR` pointing elsewhere, save must land under +/// the dir the connecting client sent, and list must answer from it. A decoy +/// recipe only the daemon's env can see must never surface. +#[test] +fn session_commands_follow_the_hello_config_dir() { + let pid = std::process::id(); + let daemon_cfg = std::env::temp_dir().join(format!("fleetcom_it_sess_dcfg_{pid}")); + let client_cfg = std::env::temp_dir().join(format!("fleetcom_it_sess_ccfg_{pid}")); + for d in [&daemon_cfg, &client_cfg] { + let _ = std::fs::remove_dir_all(d); + std::fs::create_dir_all(d.join("sessions")).unwrap(); + } + std::fs::write(daemon_cfg.join("sessions").join("daemononly.json"), "{}").unwrap(); + + let (dir, mut daemon, mut stream) = start_daemon_raw("sesscfg", |cmd| { + cmd.env("FLEETCOM_CONFIG_DIR", &daemon_cfg); + }); + // Hand-rolled hello whose env carries the client-side config override. + let cwd = dir.display().to_string(); + let client_cfg_str = client_cfg.display().to_string(); + let env: Vec<(&[u8], &[u8])> = + vec![(b"FLEETCOM_CONFIG_DIR".as_slice(), client_cfg_str.as_bytes())]; + stream + .write_all(&hello_frame(PROTOCOL_VERSION, &env, &cwd)) + .unwrap(); + let (_, payload) = read_frame(&mut stream).expect("no reply to hello"); + assert!( + String::from_utf8_lossy(&payload).contains("hello_ok"), + "hello was refused: {}", + String::from_utf8_lossy(&payload) + ); + + stream + .write_all(&control_frame(r#"{"t":"save","name":"where"}"#)) + .unwrap(); + assert!( + wait_until(Duration::from_secs(5), || client_cfg + .join("sessions") + .join("where.json") + .is_file()), + "save must land under the hello's FLEETCOM_CONFIG_DIR" + ); + assert!( + !daemon_cfg.join("sessions").join("where.json").exists(), + "save must not touch the daemon's own config dir" + ); + + stream.write_all(&control_frame(r#"{"t":"list"}"#)).unwrap(); + // The reply shares the stream with periodic Tasks snapshots; skip to it. + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let sessions = loop { + assert!(Instant::now() < deadline, "no sessions event arrived"); + let (kind, payload) = + read_frame(&mut stream).expect("stream closed before the sessions event"); + let text = String::from_utf8_lossy(&payload).into_owned(); + if kind == 1 && text.contains(r#""t":"sessions""#) { + break text; + } + }; + assert!( + sessions.contains(r#""where""#), + "list must see the hello dir's recipe: {sessions}" + ); + assert!( + !sessions.contains("daemononly"), + "list must not see the daemon-env dir's recipe: {sessions}" + ); + + nix::sys::signal::kill( + nix::unistd::Pid::from_raw(daemon.0.id() as i32), + nix::sys::signal::Signal::SIGTERM, + ) + .unwrap(); + let exited = wait_until(Duration::from_secs(10), || { + daemon.0.try_wait().map(|s| s.is_some()).unwrap_or(false) + }); + assert!(exited, "daemon did not exit on SIGTERM"); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&daemon_cfg); + let _ = std::fs::remove_dir_all(&client_cfg); +}