diff --git a/Cargo.lock b/Cargo.lock index 4c003231..d261bb77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9698,6 +9698,7 @@ dependencies = [ "serde_json", "tempfile", "tty7-core", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8dc6c0b0..c116f44b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -201,10 +201,10 @@ workspace = true # ---- caches are shared and versions stay aligned. ---- [workspace] members = ["crates/*"] -# The root `tty7` package is a member implicitly; naming all three here is what -# makes a bare `cargo build` / `cargo test` at the root cover the whole +# The root `tty7` package is a member implicitly; naming every crate here is +# what makes a bare `cargo build` / `cargo test` at the root cover the whole # workspace, which is how CI invokes them. -default-members = [".", "crates/tty7-core", "crates/tty7-server"] +default-members = [".", "crates/tty7-core", "crates/tty7-server", "crates/tty7-cli"] [workspace.package] edition = "2024" diff --git a/crates/tty7-cli/Cargo.toml b/crates/tty7-cli/Cargo.toml index 38bdc543..a48a9d62 100644 --- a/crates/tty7-cli/Cargo.toml +++ b/crates/tty7-cli/Cargo.toml @@ -1,8 +1,7 @@ # The thin console CLI from docs/cli-design.md: the `tty7` on the user's PATH. # A control/pane-protocol client of tty7-server — the GUI is never required. # Kept apart from the GUI binary on purpose (subsystem, upgrade file locks, -# millisecond cold start); until the GUI bin is renamed, build this package -# alone (`cargo build -p tty7-cli`) so the two `tty7` bins never collide. +# millisecond cold start). [package] name = "tty7-cli" version.workspace = true @@ -35,6 +34,15 @@ libc = "0.2" [dev-dependencies] tempfile = "3" +# The e2e harness puts its throwaway daemon in a Job Object wired to +# KILL_ON_JOB_CLOSE, so a hard-killed test run cannot leak servers. +[target.'cfg(windows)'.dev-dependencies] +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_JobObjects", +] } + # The end-to-end suite drives the compiled tty7.exe against a real isolated # server; its own main doubles as that server process, so no libtest harness. [[test]] diff --git a/crates/tty7-cli/src/backend.rs b/crates/tty7-cli/src/backend.rs index 20938a22..864db449 100644 --- a/crates/tty7-cli/src/backend.rs +++ b/crates/tty7-cli/src/backend.rs @@ -30,7 +30,9 @@ pub trait Backend { fn attach_pane(&mut self, pane: u64) -> Result<()>; - fn run(&mut self, spec: RunSpec) -> Result; + fn run_spawn(&mut self, spec: RunSpec) -> Result; + + fn run_wait(&mut self) -> Result>; fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()>; } @@ -61,6 +63,7 @@ pub mod mock { pub procs_calls: Vec, pub procs_reply: PaneProcs, pub runs: Vec, + pub run_exit: Option, pub events: Vec, } @@ -78,6 +81,7 @@ pub mod mock { procs_calls: Vec::new(), procs_reply: PaneProcs::default(), runs: Vec::new(), + run_exit: Some(0), events: Vec::new(), } } @@ -140,9 +144,15 @@ pub mod mock { Err(anyhow!("the mock backend cannot attach")) } - fn run(&mut self, spec: RunSpec) -> Result { + fn run_spawn(&mut self, spec: RunSpec) -> Result { self.runs.push(spec); - Ok(0) + let id = self.next_spawn_id; + self.next_spawn_id += 1; + Ok(id) + } + + fn run_wait(&mut self) -> Result> { + Ok(self.run_exit) } fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> { diff --git a/crates/tty7-cli/src/backend/real.rs b/crates/tty7-cli/src/backend/real.rs index d77fcd53..be911fa8 100644 --- a/crates/tty7-cli/src/backend/real.rs +++ b/crates/tty7-cli/src/backend/real.rs @@ -1,10 +1,10 @@ use std::io::Write as _; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; use serde_json::json; -use tty7_core::client::{ControlClient, PaneClient}; +use tty7_core::client::{ControlClient, PaneClient, PaneSession}; use tty7_core::core::session::WorkspaceId; use tty7_core::daemon::control::{ ControlEvent, ControlHello, ControlHelloOk, ControlRequest, ReplyOk, RouteInfo, @@ -29,18 +29,27 @@ const NOT_RUNNING: &str = pub struct RealBackend { machine: Option, + socket: Option, route: Option, control: Option, panes: Option, + running: Option, +} + +struct RunningCommand { + session: PaneSession, + keep: bool, } impl RealBackend { - pub fn new(machine: Option) -> RealBackend { + pub fn new(machine: Option, socket: Option) -> RealBackend { RealBackend { machine, + socket, route: None, control: None, panes: None, + running: None, } } @@ -48,6 +57,18 @@ impl RealBackend { ControlHello::host_rpc(format!("tty7-cli-{}", std::process::id()), hostname()) } + fn local_control(&self, hello: &ControlHello) -> Result { + match &self.socket { + Some(path) => ControlClient::connect_at(Path::new(path), hello).with_context(|| { + format!( + "connecting to the server at {}={path}", + crate::address::ENV_SOCKET + ) + }), + None => ControlClient::connect(hello).context(NOT_RUNNING), + } + } + fn route(&mut self) -> Result> { let Some(name) = self.machine.clone() else { return Ok(None); @@ -55,7 +76,7 @@ impl RealBackend { if let Some(target) = &self.route { return Ok(Some(target.clone())); } - let local = ControlClient::connect(&Self::hello_msg()).context(NOT_RUNNING)?; + let local = self.local_control(&Self::hello_msg())?; let routes = match local .request(ControlRequest::Routes) .context("asking the local server for its machine links")? @@ -79,7 +100,7 @@ impl RealBackend { self.machine.as_deref().unwrap_or_default() ) })?, - None => ControlClient::connect(&hello).context(NOT_RUNNING)?, + None => self.local_control(&hello)?, }; self.control = Some(client); } @@ -90,7 +111,10 @@ impl RealBackend { if self.panes.is_none() { let client = match self.route()? { Some(target) => PaneClient::routed(target), - None => PaneClient::local(), + None => match &self.socket { + Some(path) => PaneClient::at(pane_endpoint_for(Path::new(path))), + None => PaneClient::local(), + }, }; self.panes = Some(client); } @@ -98,6 +122,15 @@ impl RealBackend { } } +fn pane_endpoint_for(control: &Path) -> PathBuf { + let file = if cfg!(windows) { + "daemon.port" + } else { + "daemon.sock" + }; + control.with_file_name(file) +} + impl Backend for RealBackend { fn control(&mut self, req: ControlRequest) -> Result { let reply = self.control_client()?.request(req)?; @@ -171,7 +204,7 @@ impl Backend for RealBackend { bail!("interactive `tty7 attach %pane` is not wired yet — it lands in the next slice") } - fn run(&mut self, spec: RunSpec) -> Result { + fn run_spawn(&mut self, spec: RunSpec) -> Result { let (program, args) = spec .command .split_first() @@ -181,7 +214,7 @@ impl Backend for RealBackend { args: args.to_vec(), args_are_tty7_defaults: false, }; - let mut session = self + let session = self .pane_client()? .spawn( spec.cwd.map(PathBuf::from), @@ -191,6 +224,19 @@ impl Backend for RealBackend { spec.workspace.map(|ws| ws.to_string()), ) .with_context(|| format!("spawning `{program}`"))?; + let pane = session.pane_id(); + self.running = Some(RunningCommand { + session, + keep: spec.keep, + }); + Ok(pane) + } + + fn run_wait(&mut self) -> Result> { + let RunningCommand { mut session, keep } = self + .running + .take() + .ok_or_else(|| anyhow!("run_wait without a spawned command"))?; let mut stdout = std::io::stdout().lock(); let code = loop { match session.recv() { @@ -198,12 +244,12 @@ impl Backend for RealBackend { stdout.write_all(&bytes)?; stdout.flush()?; } - Ok(DaemonMsg::Exited { code }) => break code.unwrap_or(1), + Ok(DaemonMsg::Exited { code }) => break code, Ok(_) => {} Err(e) => return Err(anyhow!(e).context("streaming the command's output")), } }; - if spec.keep { + if keep { session.detach()?; } else { session.kill()?; @@ -236,6 +282,12 @@ fn hostname() -> String { fn resolve_route(name: &str, routes: &[RouteInfo]) -> Result { let matches: Vec<&RouteInfo> = routes.iter().filter(|r| route_matches(name, r)).collect(); match matches.as_slice() { + [one] if !one.connected => bail!( + "the link to machine '{}' is down — the CLI will not dial a fresh connection of \ + its own (that would guess at auth instead of using the profile's credentials); \ + reconnect the link from the GUI or its SSH profile, then retry", + one.key + ), [one] => target_for(one), [] if routes.is_empty() => bail!( "the local server holds no machine links — connect one from the GUI first \ @@ -320,7 +372,7 @@ mod tests { fn a_machine_resolves_by_full_key_or_bare_host() { let routes = vec![ route("me@build-box:22", "ssh", true), - route("me@web-box:2222", "ssh", false), + route("me@web-box:2222", "ssh", true), ]; for name in ["me@build-box:22", "build-box"] { let RouteTarget::Ssh(spec) = resolve_route(name, &routes).unwrap() else { @@ -353,6 +405,25 @@ mod tests { assert!(err.contains("no machine links"), "{err}"); } + #[test] + fn a_down_link_is_refused_instead_of_dialed_fresh() { + let routes = vec![route("me@build-box:22", "ssh", false)]; + let err = resolve_route("build-box", &routes).unwrap_err().to_string(); + assert!(err.contains("down"), "{err}"); + assert!(err.contains("reconnect"), "{err}"); + assert!(err.contains("me@build-box:22"), "{err}"); + } + + #[test] + fn the_pane_endpoint_is_the_control_endpoints_sibling() { + let (control, pane) = if cfg!(windows) { + ("C:\\cfg\\tty7\\control.port", "C:\\cfg\\tty7\\daemon.port") + } else { + ("/run/user/1000/tty7/control.sock", "/run/user/1000/tty7/daemon.sock") + }; + assert_eq!(pane_endpoint_for(Path::new(control)), PathBuf::from(pane)); + } + #[test] fn chained_keys_are_refused_with_the_reason() { let routes = vec![route("me@inner:22|jump:me@bastion:22", "ssh", true)]; diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index 426f2118..dd0afb2d 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -60,7 +60,7 @@ pub enum Command { #[command(about = "Type text into a pane")] Send(SendArgs), - #[command(about = "Print what a pane is showing")] + #[command(about = "Print a pane's raw output (ANSI bytes): the newest scrollback segment by default")] Capture(CaptureArgs), #[command(about = "Processes and listening ports inside a pane")] @@ -108,7 +108,8 @@ pub struct RunArgs { #[arg( long, value_name = "WORKSPACE", - help = "Workspace the pane belongs to; defaults to $TTY7_WS inside a tty7 shell" + help = "Sets the pane's TTY7_WS and, with --keep, the workspace the kept pane is \ + filed into; defaults to $TTY7_WS inside a tty7 shell" )] pub ws: Option, @@ -154,7 +155,12 @@ pub struct CaptureArgs { #[arg(value_name = "%PANE", help = "Pane to read; defaults to $TTY7_PANE inside a tty7 shell")] pub target: Option, - #[arg(long, help = "Include the server-side scrollback ring, not just the screen")] + #[arg( + long, + help = "Print the whole scrollback ring, raw ANSI bytes; the ring splits into \ + segments on resize, and without this flag only the last segment is \ + printed (for a never-resized pane the two are identical)" + )] pub scrollback: bool, } diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index b7ff044d..ac46281f 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -28,6 +28,10 @@ pub enum Outcome { Exit(i32), } +pub const EXIT_CODE_UNKNOWN: &str = + "the command exited but its real exit code could not be determined — exiting 1 as a \ + stand-in, not as the command's own code"; + fn report(human: impl Into, json: Value) -> Result { Ok(Outcome::Report(Report { human: human.into(), @@ -37,6 +41,7 @@ fn report(human: impl Into, json: Value) -> Result { pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result { let json_mode = cli.json; + let machine = cli.machine.clone(); match cli.command { None => launch_gui(cli.path), Some(Command::Ls) | Some(Command::Ws(WsCmd::Ls)) => ws_ls(backend), @@ -77,14 +82,37 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result crate::server::start(), - Some(Command::Server(ServerCmd::Stop)) => crate::server::stop(), - Some(Command::Server(ServerCmd::Restart)) => crate::server::restart(), - Some(Command::Server(ServerCmd::Logs)) => crate::server::logs(), + Some(Command::Server(ServerCmd::Start)) => { + local_server(machine.as_deref(), "start", crate::server::start) + } + Some(Command::Server(ServerCmd::Stop)) => { + local_server(machine.as_deref(), "stop", crate::server::stop) + } + Some(Command::Server(ServerCmd::Restart)) => { + local_server(machine.as_deref(), "restart", crate::server::restart) + } + Some(Command::Server(ServerCmd::Logs)) => { + local_server(machine.as_deref(), "logs", crate::server::logs) + } Some(Command::Doctor) => doctor(ctx, backend), } } +fn local_server( + machine: Option<&str>, + verb: &str, + act: fn() -> Result, +) -> Result { + if let Some(machine) = machine { + bail!( + "`tty7 server {verb}` manages only the server on THIS machine — with -m {machine} \ + it would still have acted on the LOCAL server, so it was refused; a remote \ + machine's server lifecycle is handled by the install/reconnect flows, not the CLI" + ); + } + act() +} + fn launch_gui(path: Option) -> Result { match path { Some(p) => bail!("launching the GUI is not wired up yet (would open {p})"), @@ -224,7 +252,7 @@ fn attach(target: &str, backend: &mut dyn Backend) -> Result { match address::parse(target)? { Address::Pane(pane) => { backend.attach_pane(pane)?; - report("", json!({ "detached_from": pane })) + report("", json!({ "attached": pane })) } Address::Workspace(addr) => ws_attach(addr, backend), Address::Tab(_) => bail!("attach takes a %pane or a workspace, not a tab"), @@ -239,13 +267,39 @@ fn run(args: RunArgs, ctx: &Context, backend: &mut dyn Backend) -> Result ctx.ws.as_deref().and_then(|v| v.parse::().ok()), }; - let code = backend.run(RunSpec { + if args.keep && workspace.is_none() { + bail!( + "`run --keep` keeps the pane alive, so it must be filed into a workspace — \ + pass --ws, or run inside a tty7 shell where $TTY7_WS names one" + ); + } + let pane = backend.run_spawn(RunSpec { workspace, - cwd: args.cwd, + cwd: args.cwd.clone(), command: args.cmd, keep: args.keep, })?; - Ok(Outcome::Exit(code)) + if args.keep { + let workspace = workspace.expect("checked above: --keep requires a workspace"); + backend.control(ControlRequest::TabCreate { + workspace, + at: None, + pane: PaneSeed { + pane, + cwd: args.cwd, + ssh_spec: None, + agent: None, + }, + tab: None, + })?; + } + match backend.run_wait()? { + Some(code) => Ok(Outcome::Exit(code)), + None => { + eprintln!("tty7: {EXIT_CODE_UNKNOWN}"); + Ok(Outcome::Exit(1)) + } + } } fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { @@ -993,6 +1047,116 @@ mod tests { assert!(matches!(out, Outcome::Exit(0)), "run's outcome is the child's exit code"); } + #[test] + fn run_keep_spawns_first_then_files_the_pane_into_the_workspace() { + let mut backend = mock(); + let api = backend.machine.workspaces[0].id; + let out = execute( + cli(&[ + "tty7", "run", "--keep", "--ws", "api", "--cwd", "C:\\proj", "--", "cargo", + "watch", + ]), + &Context::default(), + &mut backend, + ) + .unwrap(); + assert_eq!( + backend.control_calls, + vec![ + ControlRequest::MachineGet, + ControlRequest::TabCreate { + workspace: api, + at: None, + pane: PaneSeed { + pane: 6, + cwd: Some("C:\\proj".into()), + ssh_spec: None, + agent: None, + }, + tab: None, + }, + ], + "the daemon-assigned pane id (6) lands in the tree op, so the spawn came first" + ); + assert_eq!( + backend.runs, + vec![RunSpec { + workspace: Some(api), + cwd: Some("C:\\proj".into()), + command: vec!["cargo".into(), "watch".into()], + keep: true, + }] + ); + assert!(matches!(out, Outcome::Exit(0))); + } + + #[test] + fn run_without_keep_files_nothing() { + let mut backend = mock(); + let out = execute( + cli(&["tty7", "run", "--", "cargo", "test"]), + &Context::default(), + &mut backend, + ) + .unwrap(); + assert!( + backend.control_calls.is_empty(), + "a reaped pane must not be filed into the tree" + ); + assert_eq!(backend.runs.len(), 1); + assert!(matches!(out, Outcome::Exit(0))); + } + + #[test] + fn run_keep_without_a_workspace_names_the_fix() { + let mut backend = mock(); + let err = execute( + cli(&["tty7", "run", "--keep", "--", "make"]), + &Context::default(), + &mut backend, + ) + .expect_err("a kept pane with no workspace would be an unlisted orphan"); + assert!(err.to_string().contains("--ws"), "{err}"); + assert!(backend.runs.is_empty(), "nothing must be spawned"); + } + + #[test] + fn a_missing_exit_code_still_exits_nonzero_via_the_note_path() { + let mut backend = mock(); + backend.run_exit = None; + let out = execute( + cli(&["tty7", "run", "--", "make"]), + &Context::default(), + &mut backend, + ) + .unwrap(); + assert!( + matches!(out, Outcome::Exit(1)), + "an unknown exit code is still a failure" + ); + assert!(EXIT_CODE_UNKNOWN.contains("could not be determined")); + } + + #[test] + fn a_machine_flag_refuses_the_local_server_verbs() { + for verb in ["start", "stop", "restart", "logs"] { + let mut backend = mock(); + let err = execute( + cli(&["tty7", "-m", "devbox", "server", verb]), + &Context::default(), + &mut backend, + ) + .expect_err("server lifecycle verbs are local-only"); + let msg = err.to_string(); + assert!(msg.contains("LOCAL"), "{msg}"); + assert!(msg.contains(verb), "{msg}"); + assert!( + backend.control_calls.is_empty(), + "the refusal must come before any dial" + ); + } + } + #[test] fn the_still_missing_verbs_say_so_without_touching_the_wire() { for (args, needle) in [ diff --git a/crates/tty7-cli/src/main.rs b/crates/tty7-cli/src/main.rs index e8e75c1a..fa9d9a5e 100644 --- a/crates/tty7-cli/src/main.rs +++ b/crates/tty7-cli/src/main.rs @@ -15,7 +15,7 @@ fn main() -> std::process::ExitCode { let json = cli.json; let quiet = cli.quiet; let ctx = address::Context::from_env(); - let mut backend = backend::RealBackend::new(cli.machine.clone()); + let mut backend = backend::RealBackend::new(cli.machine.clone(), ctx.socket.clone()); match commands::execute(cli, &ctx, &mut backend) { Ok(commands::Outcome::Exit(code)) => std::process::exit(code), Ok(commands::Outcome::Report(report)) => { diff --git a/crates/tty7-cli/src/server.rs b/crates/tty7-cli/src/server.rs index 7b5f8f68..a554f2e5 100644 --- a/crates/tty7-cli/src/server.rs +++ b/crates/tty7-cli/src/server.rs @@ -44,15 +44,30 @@ pub fn start() -> Result { .stdout(Stdio::null()) .stderr(Stdio::null()); detach(&mut cmd); - let child = cmd + let mut child = cmd .spawn() .map_err(|e| anyhow::anyhow!("could not start {}: {e}", exe.display()))?; let pid = child.id(); let deadline = Instant::now() + START_TIMEOUT; while !running() { if Instant::now() >= deadline { + let fate = match child.try_wait() { + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + "it was still running and has been killed" + } + Ok(Some(status)) => { + if status.success() { + "it had already exited cleanly" + } else { + "it had already exited with an error" + } + } + Err(_) => "its state could not be checked, so it was left alone", + }; bail!( - "{} (pid {pid}) did not open its endpoints within {START_TIMEOUT:?}", + "{} (pid {pid}) did not open its endpoints within {START_TIMEOUT:?} — {fate}", exe.display() ); } diff --git a/crates/tty7-cli/tests/cli_e2e.rs b/crates/tty7-cli/tests/cli_e2e.rs index 84d858e1..4d71a8d2 100644 --- a/crates/tty7-cli/tests/cli_e2e.rs +++ b/crates/tty7-cli/tests/cli_e2e.rs @@ -24,8 +24,10 @@ fn main() { ("ls_on_an_empty_server", ls_on_an_empty_server), ("new_builds_a_workspace_with_a_live_pane", new_builds_a_workspace_with_a_live_pane), ("run_streams_output_and_passes_the_exit_code", run_streams_output_and_passes_the_exit_code), + ("run_keep_files_the_pane_so_ls_shows_it", run_keep_files_the_pane_so_ls_shows_it), ("send_then_capture_round_trip", send_then_capture_round_trip), ("status_reports_the_live_server", status_reports_the_live_server), + ("status_answers_over_tty7_socket_alone", status_answers_over_tty7_socket_alone), ("events_stream_reports_a_workspace_creation", events_stream_reports_a_workspace_creation), ]; @@ -51,6 +53,8 @@ fn main() { struct Daemon { child: Child, dir: tempfile::TempDir, + #[cfg(windows)] + _job: job::Job, } impl Daemon { @@ -67,7 +71,14 @@ impl Daemon { .stderr(Stdio::null()) .spawn() .expect("start the in-test tty7 server"); - let daemon = Daemon { child, dir }; + #[cfg(windows)] + let _job = job::Job::kill_on_close(&child); + let daemon = Daemon { + child, + dir, + #[cfg(windows)] + _job, + }; daemon.await_ready(); daemon } @@ -153,6 +164,56 @@ impl Drop for Daemon { } } +#[cfg(windows)] +mod job { + use std::os::windows::io::AsRawHandle as _; + use std::process::Child; + + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, + }; + + pub struct Job(HANDLE); + + impl Job { + pub fn kill_on_close(child: &Child) -> Job { + unsafe { + let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + assert!(!handle.is_null(), "CreateJobObjectW failed"); + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + assert_ne!( + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + (&raw const info).cast(), + size_of::() as u32, + ), + 0, + "SetInformationJobObject failed" + ); + assert_ne!( + AssignProcessToJobObject(handle, child.as_raw_handle()), + 0, + "AssignProcessToJobObject failed" + ); + Job(handle) + } + } + } + + impl Drop for Job { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } +} + fn workdir() -> String { std::env::temp_dir().display().to_string() } @@ -211,6 +272,74 @@ fn run_streams_output_and_passes_the_exit_code(daemon: &Daemon) { ); } +fn run_keep_files_the_pane_so_ls_shows_it(daemon: &Daemon) { + let ws = daemon.run_json(&["ws", "new", "runws"]); + let ws_id = ws["id"].as_str().expect("ws new prints the id").to_string(); + + let mut args: Vec = vec![ + "run".into(), + "--keep".into(), + "--ws".into(), + ws_id.clone(), + "--".into(), + ]; + args.extend(one_shot("echo tty7_e2e_keep_marker")); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let out = daemon.run(&arg_refs); + assert!( + out.status.success(), + "run --keep failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + + let listed = daemon.run_json(&["ls"]); + let workspaces = listed["workspaces"].as_array().expect("ls --json lists workspaces"); + let ours = workspaces + .iter() + .find(|w| w["id"].as_str() == Some(ws_id.as_str())) + .unwrap_or_else(|| panic!("the target workspace is missing from ls: {listed}")); + assert_eq!( + ours["panes"].as_u64(), + Some(1), + "the kept pane must be filed where every listing sees it: {listed}" + ); + + let panes = daemon.run_json(&["pane", "ls", &ws_id]); + let filed = panes["panes"].as_array().expect("pane ls --json lists panes"); + assert_eq!(filed.len(), 1, "{panes}"); + assert!( + filed[0]["pane"].as_u64().is_some_and(|p| p >= 1), + "{panes}" + ); +} + +fn status_answers_over_tty7_socket_alone(daemon: &Daemon) { + let out = Command::new(env!("CARGO_BIN_EXE_tty7")) + .args(["status", "--json"]) + .env_remove("TTY7_CONFIG_DIR") + .env_remove("TTY7_DATA_DIR") + .env_remove("TTY7_CONTROL_SOCK") + .env_remove("TTY7_PANE") + .env_remove("TTY7_WS") + .env("TTY7_SOCKET", daemon.control_endpoint()) + .output() + .expect("run tty7 status with only TTY7_SOCKET"); + assert!( + out.status.success(), + "status over TTY7_SOCKET failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let status: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)) + .expect("status --json prints one JSON object"); + assert_eq!( + status["pid"].as_u64(), + Some(u64::from(daemon.child.id())), + "the answer must come from the isolated daemon TTY7_SOCKET points at: {status}" + ); +} + fn send_then_capture_round_trip(daemon: &Daemon) { let created = daemon.run_json(&["new", &workdir()]); let pane = created["pane"].as_u64().expect("new prints the pane id");