Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 106 additions & 3 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
}
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down
10 changes: 5 additions & 5 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
55 changes: 51 additions & 4 deletions src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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<String>),
}

/// Process-derived lifecycle state, independent of the user's `tagged` intent.
Expand Down Expand Up @@ -351,6 +356,9 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec<u8>) {
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");
}
Expand All @@ -359,7 +367,7 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec<u8>) {
}

/// 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<Command> {
Expand Down Expand Up @@ -443,6 +451,7 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option<Command> {
"load" => Command::LoadSession {
name: v["name"].as_str()?.to_string(),
},
"list" => Command::ListSessions,
"shutdown" => Command::Shutdown,
_ => return None,
};
Expand Down Expand Up @@ -483,6 +492,16 @@ pub fn encode_event(ev: &Event) -> (u8, Vec<u8>) {
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);
Expand Down Expand Up @@ -534,6 +553,15 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option<Event> {
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,
}
}
Expand Down Expand Up @@ -649,6 +677,7 @@ mod tests {
Command::LoadSession {
name: "home".into(),
},
Command::ListSessions,
Command::Shutdown,
];
for c in cases {
Expand Down Expand Up @@ -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 [
Expand All @@ -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()),
Expand Down Expand Up @@ -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]
Expand Down
22 changes: 3 additions & 19 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ fn from_json(text: &str) -> io::Result<SessionConfig> {
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<PathBuf> {
fs::create_dir_all(dir)?;
Expand Down Expand Up @@ -90,24 +92,6 @@ pub fn list_in(dir: &Path) -> Vec<String> {
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<PathBuf> {
save_in(&sessions_dir().ok_or_else(no_dir)?, name, cfg)
}

pub fn load(name: &str) -> io::Result<SessionConfig> {
load_in(&sessions_dir().ok_or_else(no_dir)?, name)
}

pub fn list() -> Vec<String> {
sessions_dir().map(|d| list_in(&d)).unwrap_or_default()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading
Loading