Files
tty7/crates/tty7-cli/src/server.rs
T
l0ng-ai 8000461706 fix(core): derive both endpoints from the config dir, publish the dir itself
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.
2026-07-31 14:41:29 +08:00

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) {}