diff --git a/Cargo.lock b/Cargo.lock index 5cf19bf0..d0a18dde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -546,6 +546,7 @@ dependencies = [ "rmp-serde", "serde", "serde_derive", + "serde_json", "shell-words", "shpool-protocol", "shpool_pty", diff --git a/libshpool/Cargo.toml b/libshpool/Cargo.toml index 265c8395..68e603a5 100644 --- a/libshpool/Cargo.toml +++ b/libshpool/Cargo.toml @@ -24,6 +24,7 @@ anyhow = "1" # dynamic, unstructured errors chrono = "0.4" # getting current time and formatting it serde = "1" # config parsing, connection header formatting serde_derive = "1" # config parsing, connection header formatting +serde_json = "1" # JSON output for list command toml = "0.8" # config parsing byteorder = "1" # endianness signal-hook = "0.3" # signal handling diff --git a/libshpool/src/daemon/server.rs b/libshpool/src/daemon/server.rs index d8be0ca6..19aa860e 100644 --- a/libshpool/src/daemon/server.rs +++ b/libshpool/src/daemon/server.rs @@ -313,8 +313,19 @@ impl Server { .map_err(|e| anyhow!("joining shell->client after child exit: {:?}", e))? .context("within shell->client thread after child exit")?; } - } else if let Err(err) = self.hooks.on_client_disconnect(&header.name) { - warn!("client_disconnect hook: {:?}", err); + } else { + // Client disconnected but shell is still running - set last_disconnected_at + { + let _s = span!(Level::INFO, "disconnect_lock(shells)").entered(); + let shells = self.shells.lock().unwrap(); + if let Some(session) = shells.get(&header.name) { + session.lifecycle_timestamps.lock().unwrap().last_disconnected_at = + Some(time::SystemTime::now()); + } + } + if let Err(err) = self.hooks.on_client_disconnect(&header.name) { + warn!("client_disconnect hook: {:?}", err); + } } info!("finished attach streaming section"); @@ -366,6 +377,8 @@ impl Server { // the channel is still open so the subshell is still running info!("taking over existing session inner"); inner.client_stream = Some(stream.try_clone()?); + session.lifecycle_timestamps.lock().unwrap().last_connected_at = + Some(time::SystemTime::now()); if inner .shell_to_client_join_h @@ -432,6 +445,8 @@ impl Server { matches!(motd, MotdDisplayMode::Dump), )?; + session.lifecycle_timestamps.lock().unwrap().last_connected_at = + Some(time::SystemTime::now()); shells.insert(header.name.clone(), Box::new(session)); // fallthrough to bidi streaming } else if let Err(err) = self.hooks.on_reattach(&header.name) { @@ -526,6 +541,9 @@ impl Server { info!("detached session({}), status = {:?}", session, status); if let shell::ClientConnectionStatus::DetachNone = status { not_attached_sessions.push(session); + } else { + s.lifecycle_timestamps.lock().unwrap().last_disconnected_at = + Some(time::SystemTime::now()); } } else { not_found_sessions.push(session); @@ -607,10 +625,23 @@ impl Server { Err(_) => SessionStatus::Attached, }; + let timestamps = v.lifecycle_timestamps.lock().unwrap(); + let last_connected_at_unix_ms = timestamps + .last_connected_at + .map(|t| t.duration_since(time::UNIX_EPOCH).map(|d| d.as_millis() as i64)) + .transpose()?; + + let last_disconnected_at_unix_ms = timestamps + .last_disconnected_at + .map(|t| t.duration_since(time::UNIX_EPOCH).map(|d| d.as_millis() as i64)) + .transpose()?; + Ok(Session { name: k.to_string(), started_at_unix_ms: v.started_at.duration_since(time::UNIX_EPOCH)?.as_millis() as i64, + last_connected_at_unix_ms, + last_disconnected_at_unix_ms, status, }) }) @@ -957,6 +988,7 @@ impl Server { child_pid, child_exit_notifier, started_at: time::SystemTime::now(), + lifecycle_timestamps: Mutex::new(shell::SessionLifecycleTimestamps::default()), inner: Arc::new(Mutex::new(session_inner)), }) } diff --git a/libshpool/src/daemon/shell.rs b/libshpool/src/daemon/shell.rs index 5aaffde9..fde4b3f0 100644 --- a/libshpool/src/daemon/shell.rs +++ b/libshpool/src/daemon/shell.rs @@ -58,10 +58,19 @@ const SHELL_TO_CLIENT_POLL_MS: u16 = 100; // shell->client thread. const SHELL_TO_CLIENT_CTL_TIMEOUT: time::Duration = time::Duration::from_millis(300); +/// Timestamps tracking when sessions were last connected/disconnected. +/// Combined behind a single lock to avoid taking multiple locks. +#[derive(Debug, Default)] +pub struct SessionLifecycleTimestamps { + pub last_connected_at: Option, + pub last_disconnected_at: Option, +} + /// Session represent a shell session #[derive(Debug)] pub struct Session { pub started_at: time::SystemTime, + pub lifecycle_timestamps: Mutex, pub child_pid: libc::pid_t, pub child_exit_notifier: Arc, pub shell_to_client_ctl: Arc>, diff --git a/libshpool/src/lib.rs b/libshpool/src/lib.rs index 5bfdc1d6..02c6b57b 100644 --- a/libshpool/src/lib.rs +++ b/libshpool/src/lib.rs @@ -188,7 +188,10 @@ will be used if it is present in the environment.")] #[clap(about = "lists all the running shell sessions")] #[non_exhaustive] - List, + List { + #[clap(short, long, help = "Output as JSON, includes extra fields")] + json: bool, + }, #[clap(about = "Dynamically change daemon log level @@ -370,7 +373,7 @@ pub fn run(args: Args, hooks: Option>) -> an } Commands::Detach { sessions } => detach::run(sessions, socket), Commands::Kill { sessions } => kill::run(sessions, socket), - Commands::List => list::run(socket), + Commands::List { json } => list::run(socket, json), Commands::SetLogLevel { level } => set_log_level::run(level, socket), }; diff --git a/libshpool/src/list.rs b/libshpool/src/list.rs index 4388cad7..2492ab05 100644 --- a/libshpool/src/list.rs +++ b/libshpool/src/list.rs @@ -15,11 +15,12 @@ use std::{io, path::PathBuf, time}; use anyhow::Context; +use chrono::{DateTime, Utc}; use shpool_protocol::{ConnectHeader, ListReply}; use crate::{protocol, protocol::ClientResult}; -pub fn run(socket: PathBuf) -> anyhow::Result<()> { +pub fn run(socket: PathBuf, json_output: bool) -> anyhow::Result<()> { let mut client = match protocol::Client::new(socket) { Ok(ClientResult::JustClient(c)) => c, Ok(ClientResult::VersionMismatch { warning, client }) => { @@ -38,12 +39,16 @@ pub fn run(socket: PathBuf) -> anyhow::Result<()> { client.write_connect_header(ConnectHeader::List).context("sending list connect header")?; let reply: ListReply = client.read_reply().context("reading reply")?; - println!("NAME\tSTARTED_AT\tSTATUS"); - for session in reply.sessions.iter() { - let started_at = - time::UNIX_EPOCH + time::Duration::from_millis(session.started_at_unix_ms as u64); - let started_at = chrono::DateTime::::from(started_at); - println!("{}\t{}\t{}", session.name, started_at.to_rfc3339(), session.status); + if json_output { + println!("{}", serde_json::to_string_pretty(&reply)?); + } else { + println!("NAME\tSTARTED_AT\tSTATUS"); + for session in reply.sessions.iter() { + let started_at = + time::UNIX_EPOCH + time::Duration::from_millis(session.started_at_unix_ms as u64); + let started_at = DateTime::::from(started_at); + println!("{}\t{}\t{}", session.name, started_at.to_rfc3339(), session.status); + } } Ok(()) diff --git a/shpool-protocol/src/lib.rs b/shpool-protocol/src/lib.rs index a867e478..b1bc30d3 100644 --- a/shpool-protocol/src/lib.rs +++ b/shpool-protocol/src/lib.rs @@ -249,6 +249,10 @@ pub struct Session { #[serde(default)] pub started_at_unix_ms: i64, #[serde(default)] + pub last_connected_at_unix_ms: Option, + #[serde(default)] + pub last_disconnected_at_unix_ms: Option, + #[serde(default)] pub status: SessionStatus, } diff --git a/shpool/tests/list.rs b/shpool/tests/list.rs index c703faa8..9cb19374 100644 --- a/shpool/tests/list.rs +++ b/shpool/tests/list.rs @@ -1,6 +1,6 @@ use std::process::Command; -use anyhow::Context; +use anyhow::{anyhow, Context}; use ntest::timeout; use regex::Regex; @@ -179,3 +179,43 @@ fn two_sessions() -> anyhow::Result<()> { Ok(()) }) } + +#[test] +#[timeout(30000)] +fn json_output() -> anyhow::Result<()> { + support::dump_err(|| { + let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) + .context("starting daemon proc")?; + let bidi_enter_w = daemon_proc.events.take().unwrap().waiter(["daemon-bidi-stream-enter"]); + + let _sess1 = daemon_proc.attach("sh1", Default::default())?; + + daemon_proc.events = Some(bidi_enter_w.wait_final_event("daemon-bidi-stream-enter")?); + + let out = daemon_proc.list_json()?; + assert!(out.status.success(), "list --json proc did not exit successfully"); + + let stderr = String::from_utf8_lossy(&out.stderr[..]); + assert_eq!(stderr.len(), 0, "expected no stderr"); + + let stdout = String::from_utf8_lossy(&out.stdout[..]); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).context("parsing JSON output")?; + + let sessions = parsed + .get("sessions") + .ok_or_else(|| anyhow!("missing 'sessions' field in JSON output"))? + .as_array() + .ok_or_else(|| anyhow!("'sessions' is not an array"))?; + + assert!(!sessions.is_empty(), "expected at least one session"); + + let first_session = &sessions[0]; + assert!( + first_session.get("last_connected_at_unix_ms").is_some(), + "missing 'last_connected_at_unix_ms' field" + ); + + Ok(()) + }) +} diff --git a/shpool/tests/support/daemon.rs b/shpool/tests/support/daemon.rs index c3725db1..17cb3236 100644 --- a/shpool/tests/support/daemon.rs +++ b/shpool/tests/support/daemon.rs @@ -395,6 +395,23 @@ impl Proc { .context("spawning list proc") } + pub fn list_json(&mut self) -> anyhow::Result { + let log_file = self.tmp_dir.join(format!("list_{}.log", self.subproc_counter)); + eprintln!("spawning list --json proc with log {:?}", &log_file); + self.subproc_counter += 1; + + Command::new(shpool_bin()?) + .arg("-vv") + .arg("--log-file") + .arg(&log_file) + .arg("--socket") + .arg(&self.socket_path) + .arg("list") + .arg("--json") + .output() + .context("spawning list --json proc") + } + // launches a `shpool set-log-level` process pub fn set_log_level(&mut self, level: &str) -> anyhow::Result { let log_file = self.tmp_dir.join(format!("set_log_level_{}.log", self.subproc_counter));