Skip to content
Open
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
355 changes: 347 additions & 8 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions libshpool/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ notify = { version = "7", features = ["crossbeam-channel"] } # watch config fil
libproc = "0.14.8" # sniffing shells by examining the subprocess
daemonize = "0.5" # autodaemonization
shpool-protocol = { version = "0.3.4", path = "../shpool-protocol" } # client-server protocol
ratatui = "0.29" # TUI framework for `shpool tui`
crossterm = "0.28" # terminal backend for ratatui

# rusty wrapper for unix apis
[dependencies.nix]
Expand All @@ -60,3 +62,4 @@ features = ["std", "fmt", "tracing-log", "smallvec"]
[dev-dependencies]
ntest = "0.9" # test timeouts
assert_matches = "1.5" # assert_matches macro
insta = "1" # snapshot tests for TUI view rendering
19 changes: 3 additions & 16 deletions libshpool/src/kill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,18 @@
// See the License for the specific language governing permissions and
// limitations under the License.

use std::{io, path::Path};
use std::path::Path;

use anyhow::{anyhow, Context};
use shpool_protocol::{ConnectHeader, KillReply, KillRequest};

use crate::{common, protocol, protocol::ClientResult};
use crate::{common, protocol};

pub fn run<P>(mut sessions: Vec<String>, socket: P) -> anyhow::Result<()>
where
P: AsRef<Path>,
{
let mut client = match protocol::Client::new(socket) {
Ok(ClientResult::JustClient(c)) => c,
Ok(ClientResult::VersionMismatch { warning, client }) => {
eprintln!("warning: {warning}, try restarting your daemon");
client
}
Err(err) => {
let io_err = err.downcast::<io::Error>()?;
if io_err.kind() == io::ErrorKind::NotFound {
eprintln!("could not connect to daemon");
}
return Err(io_err).context("connecting to daemon");
}
};
let mut client = protocol::connect_cli(socket)?;

common::resolve_sessions(&mut sessions, "kill")?;

Expand Down
12 changes: 12 additions & 0 deletions libshpool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ mod session_restore;
mod set_log_level;
mod test_hooks;
mod tty;
mod tui;
mod user;

/// The command line arguments that shpool expects.
Expand Down Expand Up @@ -209,6 +210,10 @@ needs debugging, but would be clobbered by a restart.")]
#[clap(help = "new log level")]
level: shpool_protocol::LogLevel,
},

#[clap(about = "TUI session manager")]
#[non_exhaustive]
Tui {},
}

impl Args {
Expand Down Expand Up @@ -381,6 +386,13 @@ pub fn run(args: Args, hooks: Option<Box<dyn hooks::Hooks + Send + Sync>>) -> an
Commands::Kill { sessions } => kill::run(sessions, socket),
Commands::List { json } => list::run(socket, json),
Commands::SetLogLevel { level } => set_log_level::run(level, socket),
Commands::Tui {} => {
// Forward the parent invocation's top-level flags so the
// `shpool attach` children spawned from the TUI see the
// same config / log destination / verbosity. See
// tui::run's docstring.
tui::run(socket, args.config_file.clone(), args.log_file.clone(), args.verbose)
}
};

if let Err(err) = res {
Expand Down
19 changes: 3 additions & 16 deletions libshpool/src/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.

use std::{io, path::PathBuf, time};
use std::{path::PathBuf, time};

use anyhow::Context;
use chrono::{DateTime, Utc};
use shpool_protocol::{ConnectHeader, ListReply};

use crate::{protocol, protocol::ClientResult};
use crate::protocol;

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 }) => {
eprintln!("warning: {warning}, try restarting your daemon");
client
}
Err(err) => {
let io_err = err.downcast::<io::Error>()?;
if io_err.kind() == io::ErrorKind::NotFound {
eprintln!("could not connect to daemon");
}
return Err(io_err).context("connecting to daemon");
}
};
let mut client = protocol::connect_cli(socket)?;

client.write_connect_header(ConnectHeader::List).context("sending list connect header")?;
let reply: ListReply = client.read_reply().context("reading reply")?;
Expand Down
29 changes: 29 additions & 0 deletions libshpool/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,35 @@ impl Client {
}
}

/// Connect to the shpool daemon, returning the Client and any
/// version-mismatch warning the caller should surface to the user.
pub fn connect(socket: impl AsRef<Path>) -> anyhow::Result<(Client, Option<String>)> {
match Client::new(socket)? {
ClientResult::JustClient(c) => Ok((c, None)),
ClientResult::VersionMismatch { warning, client } => Ok((client, Some(warning))),
}
}

/// CLI-style connect: warnings go to stderr, NotFound produces the
/// standard "could not connect to daemon" hint, and the wrapped error
/// is returned with a "connecting to daemon" context.
pub fn connect_cli(socket: impl AsRef<Path>) -> anyhow::Result<Client> {
match connect(socket) {
Ok((client, None)) => Ok(client),
Ok((client, Some(warning))) => {
eprintln!("warning: {warning}, try restarting your daemon");
Ok(client)
}
Err(err) => {
let io_err = err.downcast::<io::Error>()?;
if io_err.kind() == io::ErrorKind::NotFound {
eprintln!("could not connect to daemon");
}
Err(io_err).context("connecting to daemon")
}
}
}

#[cfg(test)]
mod test {
use super::*;
Expand Down
205 changes: 205 additions & 0 deletions libshpool/src/tui/attach.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
//! Spawn `shpool attach [-f] <name>` as a child process.
//!
//! We shell out (to ourselves, via `argv[0]`) rather than call
//! [`crate::attach::run`] in-process because attach owns the terminal
//! in raw mode and spawns its own stdin/stdout threads — see
//! libshpool/src/protocol.rs pipe_bytes(). A subprocess boundary is
//! the simplest way to make sure that all cleans up before the TUI
//! reclaims the screen.

use std::{
env,
path::PathBuf,
process::{Command, Stdio},
};

use anyhow::{anyhow, Context, Result};

/// Parent-invocation flags to forward to the spawned `shpool attach`.
///
/// When the user runs `shpool --config-file foo.toml tui`, the spawned
/// attaches need to see the same `--config-file foo.toml` so the
/// attached shell picks up the right config (prompt template, hooks,
/// TTL defaults, etc.). Same reasoning for `--log-file` and `-v`.
/// `--socket` lives here too so every child talks to the same daemon.
///
/// What's deliberately *not* forwarded: `--daemonize`. The spawned
/// attach is always given `--no-daemonize`, even if the parent
/// `shpool tui` was launched with `--daemonize` on. Rationale:
/// silently auto-spawning a daemon from inside an alt-screen attach
/// is worse UX than a visible "daemon's gone, please restart" error,
/// and `shpool tui`'s framing is "manage existing sessions" —
/// starting new infrastructure mid-session doesn't fit. The CLI-flag
/// asymmetry here is small and deliberate.
#[derive(Debug)]
pub struct AttachEnv {
pub socket: PathBuf,
pub config_file: Option<String>,
pub log_file: Option<String>,
pub verbose: u8,
}

/// Spawn `shpool attach [-f] <name>` and block until it exits.
///
/// The caller (suspend.rs) is responsible for putting the terminal
/// back in cooked mode and leaving the alt screen first. Here we
/// just fork+exec+wait.
///
/// Returns `true` if the child exited cleanly (status 0), `false`
/// otherwise. Errors are reserved for "we couldn't even spawn it".
pub fn spawn_attach(name: &str, force: bool, env: &AttachEnv) -> Result<bool> {
// Use argv[0] of the current process rather than a hardcoded
// "shpool". This matters for:
// 1. Running from a non-installed build (cargo run) where `shpool` on PATH
// might be an older system install.
// 2. Tests that invoke the binary with an explicit path.
// 3. Any packaging that names the binary differently.
//
// libshpool already does this in daemonize::maybe_fork_daemon,
// so we're consistent with the rest of the crate.
let arg0 = env::args()
.next()
.ok_or_else(|| anyhow!("argv[0] missing — cannot find shpool binary to re-exec"))?;

let mut cmd = build_command(&arg0, name, force, env);

// Inherit stdin/stdout/stderr so the child owns the terminal.
// (This is the default for Command, but being explicit prevents
// future confusion if someone adds `.stdout(Stdio::piped())`
// thinking it's harmless.)
cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());

let status = cmd.status().context("spawning `shpool attach`")?;
Ok(status.success())
}

/// Build the `shpool attach` Command, including all forwarded flags.
///
/// Split out from `spawn_attach` so we can unit-test the arg list
/// without actually fork+exec'ing. stdio inheritance is applied by
/// `spawn_attach`; this function does not touch it.
fn build_command(arg0: &str, name: &str, force: bool, env: &AttachEnv) -> Command {
let mut cmd = Command::new(arg0);

// Forward top-level invocation flags so the child behaves the
// same as the parent `shpool tui` was launched with:
// --config-file: the child re-reads config for shell setup
// (prompt prefix, hooks, TTL defaults). Without
// forwarding, a user who ran
// `shpool --config-file custom.toml tui` sees
// attached sessions using the *default* config.
// --log-file: match the parent's log destination, else the
// child's diagnostics go nowhere useful.
// -v (xN): match the parent's verbosity.
// --socket: always required — the child has to talk to the
// same daemon we're managing.
// --no-daemonize: always forced; see the AttachEnv docstring.
if let Some(path) = &env.config_file {
cmd.arg("--config-file").arg(path);
}
if let Some(path) = &env.log_file {
cmd.arg("--log-file").arg(path);
}
for _ in 0..env.verbose {
cmd.arg("-v");
}
cmd.arg("--socket").arg(&env.socket).arg("--no-daemonize").arg("attach");
if force {
cmd.arg("-f");
}
cmd.arg(name);
cmd
}

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

fn args_of(cmd: &Command) -> Vec<String> {
cmd.get_args().map(|s| s.to_string_lossy().into_owned()).collect()
}

fn base_env() -> AttachEnv {
AttachEnv {
socket: PathBuf::from("/tmp/shpool.sock"),
config_file: None,
log_file: None,
verbose: 0,
}
}

#[test]
fn build_command_minimal() {
let env = base_env();
let args = args_of(&build_command("shpool", "main", false, &env));
assert_eq!(args, vec!["--socket", "/tmp/shpool.sock", "--no-daemonize", "attach", "main"],);
}

#[test]
fn build_command_with_force() {
let env = base_env();
let args = args_of(&build_command("shpool", "main", true, &env));
// -f comes after the `attach` subcommand; name is last.
assert!(
args.windows(2).any(|w| w[0] == "attach" && w[1] == "-f"),
"expected `attach -f` pair; got {args:?}",
);
assert_eq!(args.last().map(String::as_str), Some("main"));
}

#[test]
fn build_command_forwards_config_file() {
let mut env = base_env();
env.config_file = Some("/etc/shpool/custom.toml".into());
let args = args_of(&build_command("shpool", "main", false, &env));
assert!(
args.windows(2).any(|w| w[0] == "--config-file" && w[1] == "/etc/shpool/custom.toml"),
"expected --config-file with path; got {args:?}",
);
}

#[test]
fn build_command_forwards_log_file() {
let mut env = base_env();
env.log_file = Some("/var/log/shpool.log".into());
let args = args_of(&build_command("shpool", "main", false, &env));
assert!(
args.windows(2).any(|w| w[0] == "--log-file" && w[1] == "/var/log/shpool.log"),
"expected --log-file with path; got {args:?}",
);
}

#[test]
fn build_command_forwards_verbose_count() {
let mut env = base_env();
env.verbose = 3;
let args = args_of(&build_command("shpool", "main", false, &env));
assert_eq!(args.iter().filter(|a| *a == "-v").count(), 3);
}

#[test]
fn build_command_no_verbose_when_zero() {
let env = base_env();
let args = args_of(&build_command("shpool", "main", false, &env));
assert!(!args.iter().any(|a| a == "-v"));
}

#[test]
fn build_command_global_flags_precede_subcommand() {
// Clap requires global flags (those declared on `Args`) to
// appear before the subcommand (`attach`). Check the ordering
// holds when every forwardable flag is set.
let env = AttachEnv {
socket: PathBuf::from("/tmp/s"),
config_file: Some("/c".into()),
log_file: Some("/l".into()),
verbose: 1,
};
let args = args_of(&build_command("shpool", "main", false, &env));
let attach_pos = args.iter().position(|a| a == "attach").expect("attach present");
for flag in ["--config-file", "--log-file", "-v", "--socket", "--no-daemonize"] {
let pos = args.iter().position(|a| a == flag).expect(flag);
assert!(pos < attach_pos, "{flag} should come before `attach`; got {args:?}");
}
}
}
30 changes: 30 additions & 0 deletions libshpool/src/tui/command.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//! `Command` — side effects the executor carries out after `update`.

/// A side effect the main loop should perform. Produced by `update`,
/// consumed by `mod.rs::execute`.
#[derive(Debug, PartialEq)]
pub enum Command {
/// Refetch the session list from the daemon. Results come back as
/// [`super::event::Event::SessionsRefreshed`] or
/// [`super::event::Event::RefreshFailed`].
Refresh,

/// Spawn `shpool attach [-f] <name>` as a child process, suspend
/// the TUI while it runs, and resume when it exits. Result comes
/// back as [`super::event::Event::AttachExited`].
Attach { name: String, force: bool },

/// Like Attach but creates a brand-new session. In shpool this
/// is the same daemon call as Attach (the daemon create-or-
/// attaches), but we keep them distinct so update/view can
/// enforce "name must not already exist" vs "name must already
/// exist" pre-flight checks.
Create(String),

/// Kill the named session via the shpool protocol. Result comes
/// back as [`super::event::Event::KillFinished`].
Kill(String),

/// Stop the main loop. No follow-up event.
Quit,
}
Loading
Loading