mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
merge: CLI review fixes — CI coverage, kept-pane filing, endpoint honesty
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
This commit is contained in:
Generated
+1
@@ -9698,6 +9698,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tty7-core",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+3
-3
@@ -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"
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -30,7 +30,9 @@ pub trait Backend {
|
||||
|
||||
fn attach_pane(&mut self, pane: u64) -> Result<()>;
|
||||
|
||||
fn run(&mut self, spec: RunSpec) -> Result<i32>;
|
||||
fn run_spawn(&mut self, spec: RunSpec) -> Result<u64>;
|
||||
|
||||
fn run_wait(&mut self) -> Result<Option<i32>>;
|
||||
|
||||
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()>;
|
||||
}
|
||||
@@ -61,6 +63,7 @@ pub mod mock {
|
||||
pub procs_calls: Vec<u64>,
|
||||
pub procs_reply: PaneProcs,
|
||||
pub runs: Vec<RunSpec>,
|
||||
pub run_exit: Option<i32>,
|
||||
pub events: Vec<ControlEvent>,
|
||||
}
|
||||
|
||||
@@ -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<i32> {
|
||||
fn run_spawn(&mut self, spec: RunSpec) -> Result<u64> {
|
||||
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<Option<i32>> {
|
||||
Ok(self.run_exit)
|
||||
}
|
||||
|
||||
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> {
|
||||
|
||||
@@ -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<String>,
|
||||
socket: Option<String>,
|
||||
route: Option<RouteTarget>,
|
||||
control: Option<ControlClient>,
|
||||
panes: Option<PaneClient>,
|
||||
running: Option<RunningCommand>,
|
||||
}
|
||||
|
||||
struct RunningCommand {
|
||||
session: PaneSession,
|
||||
keep: bool,
|
||||
}
|
||||
|
||||
impl RealBackend {
|
||||
pub fn new(machine: Option<String>) -> RealBackend {
|
||||
pub fn new(machine: Option<String>, socket: Option<String>) -> 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<ControlClient> {
|
||||
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<Option<RouteTarget>> {
|
||||
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<ReplyOk> {
|
||||
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<i32> {
|
||||
fn run_spawn(&mut self, spec: RunSpec) -> Result<u64> {
|
||||
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<Option<i32>> {
|
||||
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<RouteTarget> {
|
||||
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)];
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>, json: Value) -> Result<Outcome> {
|
||||
Ok(Outcome::Report(Report {
|
||||
human: human.into(),
|
||||
@@ -37,6 +41,7 @@ fn report(human: impl Into<String>, json: Value) -> Result<Outcome> {
|
||||
|
||||
pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
|
||||
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<Out
|
||||
"managing machine links from the CLI is not implemented yet — \
|
||||
use the GUI's connection manager for now"
|
||||
),
|
||||
Some(Command::Server(ServerCmd::Start)) => 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<Outcome>,
|
||||
) -> Result<Outcome> {
|
||||
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<String>) -> Result<Outcome> {
|
||||
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<Outcome> {
|
||||
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<Outcom
|
||||
}
|
||||
None => ctx.ws.as_deref().and_then(|v| v.parse::<WorkspaceId>().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<Outcome> {
|
||||
@@ -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 [
|
||||
|
||||
@@ -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)) => {
|
||||
|
||||
@@ -44,15 +44,30 @@ pub fn start() -> Result<Outcome> {
|
||||
.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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() 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<String> = 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");
|
||||
|
||||
Reference in New Issue
Block a user