Files
tty7/crates/tty7-server/tests/remote_router.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

155 lines
5.7 KiB
Rust

#![cfg(unix)]
use std::io::{BufRead, BufReader};
use std::os::unix::net::{UnixListener, UnixStream};
use std::process::{Command, Stdio};
use tty7_core::daemon::control::ControlHello;
use tty7_core::daemon::remote_link::{RemoteEnv, remote_control_socket};
use tty7_core::daemon::router::{RemoteRouter, RouteAck, RouteHeader};
use tty7_core::host::Host;
use tty7_core::host::remote::RemoteHost;
const EXE: &str = env!("CARGO_BIN_EXE_tty7-server");
#[test]
fn a_routed_connection_reaches_a_real_server() {
let dir = tempfile::TempDir::new().unwrap();
let hub = dir.path().join("hub.sock");
let listener = UnixListener::bind(&hub).unwrap();
let router = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = stream.try_clone().unwrap();
let (kind, payload) = tty7_core::daemon::protocol::read_frame(&mut reader).unwrap();
assert_eq!(kind, tty7_core::daemon::router::ROUTE_KIND);
let header = RouteHeader::decode(&payload).unwrap();
RemoteRouter::route(stream, &header)
});
let mut sock = UnixStream::connect(&hub).unwrap();
let missing = dir.path().join("nobody-here.sock");
let header = RouteHeader::local_stdio(
EXE,
&[
"--stdio",
"--serve",
"--control-sock",
&missing.to_string_lossy(),
],
);
header.write(&mut sock).unwrap();
let ack = RouteAck::read(&mut sock).expect("the route should be accepted");
assert_eq!(ack.link.as_deref(), Some("local-stdio"));
let hello = ControlHello::host_rpc("router-test", "localhost");
let host = RemoteHost::over_unix(sock, "routed:local-stdio", &hello)
.expect("handshake through the router");
let sandbox = tempfile::TempDir::new().unwrap();
let file = host.join(sandbox.path(), "through-the-router.txt");
host.write_file(&file, b"two hops and a pipe").unwrap();
assert_eq!(host.read_file(&file, 1024).unwrap(), b"two hops and a pipe");
let big = host.join(sandbox.path(), "big.bin");
let body: Vec<u8> = (0..2 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect();
host.write_file(&big, &body).unwrap();
assert!(host.read_file(&big, 8 * 1024 * 1024).unwrap() == body);
assert_eq!(host.read_dir(sandbox.path(), None).unwrap().len(), 2);
drop(host);
let _ = router.join().unwrap();
}
#[test]
fn an_unreachable_target_is_reported_not_dropped() {
let dir = tempfile::TempDir::new().unwrap();
let hub = dir.path().join("hub.sock");
let listener = UnixListener::bind(&hub).unwrap();
let router = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = stream.try_clone().unwrap();
let (_, payload) = tty7_core::daemon::protocol::read_frame(&mut reader).unwrap();
let header = RouteHeader::decode(&payload).unwrap();
RemoteRouter::route(stream, &header)
});
let mut sock = UnixStream::connect(&hub).unwrap();
RouteHeader::local_stdio("tty7-server-that-does-not-exist", &[])
.write(&mut sock)
.unwrap();
let err = RouteAck::read(&mut sock).expect_err("there is nothing to route to");
assert!(
!err.to_string().is_empty(),
"the failure must say something"
);
assert!(router.join().unwrap().is_err());
}
#[test]
fn the_derived_remote_socket_is_the_one_the_server_binds() {
let with_runtime = tempfile::TempDir::new().unwrap();
let home = tempfile::TempDir::new().unwrap();
let runtime_path = with_runtime.path().to_string_lossy().to_string();
let home_path = home.path().to_string_lossy().to_string();
let bound = bound_control_socket(Some(&runtime_path), &home_path);
let derived = remote_control_socket(&RemoteEnv {
control_sock: None,
config_dir: None,
xdg_runtime_dir: Some(runtime_path.clone()),
home: Some(home_path.clone()),
tmpdir: std::env::var("TMPDIR").ok(),
});
assert_eq!(derived.as_deref(), Some(bound.as_str()));
let bound = bound_control_socket(None, &home_path);
let derived = remote_control_socket(&RemoteEnv {
control_sock: None,
config_dir: None,
xdg_runtime_dir: None,
home: Some(home_path.clone()),
tmpdir: std::env::var("TMPDIR").ok(),
});
assert_eq!(derived.as_deref(), Some(bound.as_str()));
}
fn bound_control_socket(runtime_dir: Option<&str>, home: &str) -> String {
// Deliberately no --config-dir: the control socket is derived from the
// config dir, and remote_control_socket derives the remote's from $HOME the
// same way. Overriding it here would compare two different rules. HOME is
// already a temp dir, so this stays isolated.
let mut cmd = Command::new(EXE);
cmd.arg("--daemon")
.env("HOME", home)
.env_remove("TTY7_CONFIG_DIR")
.env_remove("TTY7_CONTROL_SOCK")
.stdout(Stdio::null())
.stderr(Stdio::piped());
match runtime_dir {
Some(dir) => cmd.env("XDG_RUNTIME_DIR", dir),
None => cmd.env_remove("XDG_RUNTIME_DIR"),
};
let mut child = cmd.spawn().expect("start tty7-server --daemon");
let stderr = BufReader::new(child.stderr.take().expect("piped"));
let mut bound = None;
for line in stderr.lines().map_while(Result::ok) {
if let Some(path) = line.strip_prefix("tty7-server: control socket at ") {
bound = Some(path.to_string());
break;
}
assert!(
!line.contains("control listener unavailable"),
"the server could not bind at all: {line}"
);
}
let _ = child.kill();
let _ = child.wait();
bound.expect("the server prints the control socket it bound")
}