mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 16:02:24 +00:00
Manual testing found `tty7 run`, `send`, `capture`, `procs` and `split` broken against any normally-installed server — the CLI's entire hot path. Only the control verbs worked. Two endpoints, two rules. The pane socket came from the config dir; the control socket ignored it and sat in $XDG_RUNTIME_DIR/tty7 or ~/.local/share/tty7 — under the same basename, `daemon.sock`. So they were told apart by directory alone, and the CLI, handed one path in TTY7_SOCKET, reconstructed the other with with_file_name: on the default layout that returns the input unchanged. Pane verbs dialed the control socket and the daemon hung up on them. A --config-dir server was worse: it published the *default* control socket to the shells it spawned, so a CLI inside an isolated instance drove a different server. The e2e suite passed throughout because its harness set TTY7_CONTROL_SOCK explicitly, placing both endpoints in one directory under different names — a layout production never produces. It had removed the bug's precondition. Now: the control socket is derived from the config dir like the pane socket (control.sock beside daemon.sock, mirroring Windows' control.port/daemon.port, with -control on the hashed fallback so the two cannot collide), and panes are handed TTY7_CONFIG_DIR instead of a socket path. A CLI inherits it, so ControlClient::connect and PaneClient::local resolve the same two sockets the server opened, through the same functions. No second derivation to disagree. remote_link's remote_control_socket was a third copy of the old rule, used to locate a remote server's endpoint before connecting; it follows the config dir too, and the env probe now reads $TTY7_CONFIG_DIR. Drops the CLI's server-lifecycle guard: stop/start already follow the config dir through transport::connect and --config-dir, so there is no longer a mismatch to refuse. The e2e case that covered only `status` over a lone variable now also runs a pane verb — the asymmetry it missed is exactly what broke. Note: this moves the control socket for existing installs. A running pre-change daemon will not be found at the new path, which is the honest outcome — its control dialect is v3 against this build's v4, so reaching it only produced a version error anyway.
199 lines
5.8 KiB
Rust
199 lines
5.8 KiB
Rust
use std::path::PathBuf;
|
|
use std::process::{Command, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::{Result, bail};
|
|
use serde_json::json;
|
|
use tty7_core::client::PaneClient;
|
|
use tty7_core::core::config;
|
|
use tty7_core::daemon::spawn;
|
|
|
|
use crate::commands::{Outcome, Report};
|
|
|
|
const START_TIMEOUT: Duration = Duration::from_secs(10);
|
|
const POLL_INTERVAL: Duration = Duration::from_millis(50);
|
|
const LOG_TAIL_LINES: usize = 40;
|
|
|
|
pub const SERVER_EXE_ENV: &str = "TTY7_SERVER_EXE";
|
|
|
|
fn report(human: impl Into<String>, json: serde_json::Value) -> Result<Outcome> {
|
|
Ok(Outcome::Report(Report {
|
|
human: human.into(),
|
|
json,
|
|
}))
|
|
}
|
|
|
|
fn running() -> bool {
|
|
PaneClient::local().version().is_ok()
|
|
}
|
|
|
|
/// Every verb, lifecycle ones included, acts on the server named by
|
|
/// `$TTY7_CONFIG_DIR`: `spawn::stop` dials `transport::connect`, which derives
|
|
/// its endpoint from the config dir, and `start` passes that same dir to the
|
|
/// server it launches. There is nothing left to guard against here — the
|
|
/// endpoint these verbs reach and the one `tty7 status` reports on are one and
|
|
/// the same by construction.
|
|
pub fn start() -> Result<Outcome> {
|
|
if running() {
|
|
return report(
|
|
"the server is already running",
|
|
json!({ "started": false, "running": true }),
|
|
);
|
|
}
|
|
let exe = server_exe()?;
|
|
let mut cmd = Command::new(&exe);
|
|
cmd.arg("--daemon");
|
|
if let Some(dir) = config::config_dir_path() {
|
|
cmd.arg("--config-dir").arg(dir);
|
|
}
|
|
cmd.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
detach(&mut 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:?} — {fate}",
|
|
exe.display()
|
|
);
|
|
}
|
|
std::thread::sleep(POLL_INTERVAL);
|
|
}
|
|
report(
|
|
format!("started {} (pid {pid})", exe.display()),
|
|
json!({ "started": true, "pid": pid, "exe": exe.display().to_string() }),
|
|
)
|
|
}
|
|
|
|
pub fn stop() -> Result<Outcome> {
|
|
if !running() {
|
|
return report(
|
|
"the server is not running",
|
|
json!({ "stopped": false, "running": false }),
|
|
);
|
|
}
|
|
spawn::stop();
|
|
if running() {
|
|
bail!("the server did not shut down on request");
|
|
}
|
|
report("stopped", json!({ "stopped": true }))
|
|
}
|
|
|
|
pub fn restart() -> Result<Outcome> {
|
|
if running() {
|
|
spawn::stop();
|
|
if running() {
|
|
bail!("the server did not shut down on request");
|
|
}
|
|
}
|
|
start()
|
|
}
|
|
|
|
pub fn logs() -> Result<Outcome> {
|
|
let Some(path) = config::config_path("tty7.log") else {
|
|
bail!("no config directory, so no log file location");
|
|
};
|
|
let mut human = format!("{}\n", path.display());
|
|
let mut lines: Vec<String> = Vec::new();
|
|
match std::fs::read_to_string(&path) {
|
|
Ok(contents) => {
|
|
lines = contents
|
|
.lines()
|
|
.rev()
|
|
.take(LOG_TAIL_LINES)
|
|
.map(str::to_string)
|
|
.collect();
|
|
lines.reverse();
|
|
for line in &lines {
|
|
human.push_str(line);
|
|
human.push('\n');
|
|
}
|
|
}
|
|
Err(_) => {
|
|
human.push_str("no log file yet — set TTY7_LOG=info before starting the server\n");
|
|
}
|
|
}
|
|
report(
|
|
human,
|
|
json!({ "path": path.display().to_string(), "lines": lines }),
|
|
)
|
|
}
|
|
|
|
fn server_exe() -> Result<PathBuf> {
|
|
if let Some(explicit) = std::env::var_os(SERVER_EXE_ENV).filter(|v| !v.is_empty()) {
|
|
return Ok(PathBuf::from(explicit));
|
|
}
|
|
let name = if cfg!(windows) {
|
|
"tty7-server.exe"
|
|
} else {
|
|
"tty7-server"
|
|
};
|
|
if let Ok(own) = std::env::current_exe() {
|
|
if let Some(dir) = own.parent() {
|
|
let sibling = dir.join(name);
|
|
if sibling.exists() {
|
|
return Ok(sibling);
|
|
}
|
|
}
|
|
}
|
|
if let Some(paths) = std::env::var_os("PATH") {
|
|
for dir in std::env::split_paths(&paths) {
|
|
let candidate = dir.join(name);
|
|
if candidate.is_file() {
|
|
return Ok(candidate);
|
|
}
|
|
}
|
|
}
|
|
bail!(
|
|
"could not find {name} next to this binary or on PATH — install it, or point \
|
|
{SERVER_EXE_ENV} at it"
|
|
)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn detach(cmd: &mut Command) {
|
|
use std::os::unix::process::CommandExt as _;
|
|
unsafe {
|
|
cmd.pre_exec(|| {
|
|
if libc::setsid() == -1 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn detach(cmd: &mut Command) {
|
|
use std::os::windows::process::CommandExt as _;
|
|
|
|
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
|
|
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
|
|
}
|
|
|
|
#[cfg(not(any(unix, windows)))]
|
|
fn detach(_cmd: &mut Command) {}
|