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, }