From db7ef0cc59087faa95793bf9382e675a12f7427c Mon Sep 17 00:00:00 2001 From: noperator Date: Fri, 23 Jan 2026 15:30:16 -0500 Subject: [PATCH 1/2] feat(list): add --json flag with connect/disconnect timestamps Track when sessions were last connected and disconnected. Add --json flag to output all session data as JSON for scripting. Example: shpool list --json | jq '.sessions | sort_by([ ({"Disconnected": 0, "Attached": 1}[.status]), (if .status == "Attached" then .connected_at_unix_ms else .disconnected_at_unix_ms end) ])' Default tabular output unchanged. --- Cargo.lock | 1 + libshpool/Cargo.toml | 1 + libshpool/src/daemon/server.rs | 36 ++++++++++++++++++++++++++++++++-- libshpool/src/daemon/shell.rs | 2 ++ libshpool/src/lib.rs | 7 +++++-- libshpool/src/list.rs | 19 +++++++++++------- shpool-protocol/src/lib.rs | 4 ++++ 7 files changed, 59 insertions(+), 11 deletions(-) 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..c6b47a00 100644 --- a/libshpool/src/daemon/server.rs +++ b/libshpool/src/daemon/server.rs @@ -313,8 +313,18 @@ 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 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.disconnected_at.lock().unwrap() = 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 +376,7 @@ 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.connected_at.lock().unwrap() = Some(time::SystemTime::now()); if inner .shell_to_client_join_h @@ -432,6 +443,7 @@ impl Server { matches!(motd, MotdDisplayMode::Dump), )?; + *session.connected_at.lock().unwrap() = 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 +538,8 @@ impl Server { info!("detached session({}), status = {:?}", session, status); if let shell::ClientConnectionStatus::DetachNone = status { not_attached_sessions.push(session); + } else { + *s.disconnected_at.lock().unwrap() = Some(time::SystemTime::now()); } } else { not_found_sessions.push(session); @@ -607,10 +621,26 @@ impl Server { Err(_) => SessionStatus::Attached, }; + let connected_at_unix_ms = v + .connected_at + .lock() + .unwrap() + .map(|t| t.duration_since(time::UNIX_EPOCH).map(|d| d.as_millis() as i64)) + .transpose()?; + + let disconnected_at_unix_ms = v + .disconnected_at + .lock() + .unwrap() + .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, + connected_at_unix_ms, + disconnected_at_unix_ms, status, }) }) @@ -957,6 +987,8 @@ impl Server { child_pid, child_exit_notifier, started_at: time::SystemTime::now(), + connected_at: Mutex::new(None), + disconnected_at: Mutex::new(None), inner: Arc::new(Mutex::new(session_inner)), }) } diff --git a/libshpool/src/daemon/shell.rs b/libshpool/src/daemon/shell.rs index 5aaffde9..0951b088 100644 --- a/libshpool/src/daemon/shell.rs +++ b/libshpool/src/daemon/shell.rs @@ -62,6 +62,8 @@ const SHELL_TO_CLIENT_CTL_TIMEOUT: time::Duration = time::Duration::from_millis( #[derive(Debug)] pub struct Session { pub started_at: time::SystemTime, + pub connected_at: Mutex>, + pub disconnected_at: 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..88f9c722 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")] + 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..d8efa070 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 connected_at_unix_ms: Option, + #[serde(default)] + pub disconnected_at_unix_ms: Option, + #[serde(default)] pub status: SessionStatus, } From a72acc71e5f3d4ba14aa0b7813f695d041fdfee9 Mon Sep 17 00:00:00 2001 From: noperator Date: Fri, 23 Jan 2026 17:03:07 -0500 Subject: [PATCH 2/2] refactor: address PR feedback for --json flag - Rename connected_at -> last_connected_at, disconnected_at -> last_disconnected_at - Combine timestamps into SessionLifecycleTimestamps behind single Mutex - Update help text to "Output as JSON, includes extra fields" --- libshpool/src/daemon/server.rs | 34 +++++++++++++++++----------------- libshpool/src/daemon/shell.rs | 11 +++++++++-- libshpool/src/lib.rs | 2 +- shpool-protocol/src/lib.rs | 4 ++-- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/libshpool/src/daemon/server.rs b/libshpool/src/daemon/server.rs index c6b47a00..19aa860e 100644 --- a/libshpool/src/daemon/server.rs +++ b/libshpool/src/daemon/server.rs @@ -314,12 +314,13 @@ impl Server { .context("within shell->client thread after child exit")?; } } else { - // Client disconnected but shell is still running - set disconnected_at + // 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.disconnected_at.lock().unwrap() = Some(time::SystemTime::now()); + session.lifecycle_timestamps.lock().unwrap().last_disconnected_at = + Some(time::SystemTime::now()); } } if let Err(err) = self.hooks.on_client_disconnect(&header.name) { @@ -376,7 +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.connected_at.lock().unwrap() = Some(time::SystemTime::now()); + session.lifecycle_timestamps.lock().unwrap().last_connected_at = + Some(time::SystemTime::now()); if inner .shell_to_client_join_h @@ -443,7 +445,8 @@ impl Server { matches!(motd, MotdDisplayMode::Dump), )?; - *session.connected_at.lock().unwrap() = Some(time::SystemTime::now()); + 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) { @@ -539,7 +542,8 @@ impl Server { if let shell::ClientConnectionStatus::DetachNone = status { not_attached_sessions.push(session); } else { - *s.disconnected_at.lock().unwrap() = Some(time::SystemTime::now()); + s.lifecycle_timestamps.lock().unwrap().last_disconnected_at = + Some(time::SystemTime::now()); } } else { not_found_sessions.push(session); @@ -621,17 +625,14 @@ impl Server { Err(_) => SessionStatus::Attached, }; - let connected_at_unix_ms = v - .connected_at - .lock() - .unwrap() + 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 disconnected_at_unix_ms = v - .disconnected_at - .lock() - .unwrap() + 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()?; @@ -639,8 +640,8 @@ impl Server { name: k.to_string(), started_at_unix_ms: v.started_at.duration_since(time::UNIX_EPOCH)?.as_millis() as i64, - connected_at_unix_ms, - disconnected_at_unix_ms, + last_connected_at_unix_ms, + last_disconnected_at_unix_ms, status, }) }) @@ -987,8 +988,7 @@ impl Server { child_pid, child_exit_notifier, started_at: time::SystemTime::now(), - connected_at: Mutex::new(None), - disconnected_at: Mutex::new(None), + 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 0951b088..fde4b3f0 100644 --- a/libshpool/src/daemon/shell.rs +++ b/libshpool/src/daemon/shell.rs @@ -58,12 +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 connected_at: Mutex>, - pub disconnected_at: Mutex>, + 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 88f9c722..02c6b57b 100644 --- a/libshpool/src/lib.rs +++ b/libshpool/src/lib.rs @@ -189,7 +189,7 @@ will be used if it is present in the environment.")] #[clap(about = "lists all the running shell sessions")] #[non_exhaustive] List { - #[clap(short, long, help = "Output as JSON")] + #[clap(short, long, help = "Output as JSON, includes extra fields")] json: bool, }, diff --git a/shpool-protocol/src/lib.rs b/shpool-protocol/src/lib.rs index d8efa070..b1bc30d3 100644 --- a/shpool-protocol/src/lib.rs +++ b/shpool-protocol/src/lib.rs @@ -249,9 +249,9 @@ pub struct Session { #[serde(default)] pub started_at_unix_ms: i64, #[serde(default)] - pub connected_at_unix_ms: Option, + pub last_connected_at_unix_ms: Option, #[serde(default)] - pub disconnected_at_unix_ms: Option, + pub last_disconnected_at_unix_ms: Option, #[serde(default)] pub status: SessionStatus, }