Files
tty7/crates/tty7-server/tests/client_lib.rs
l0ng-ai fc01b3e31f fix(client): stop timeout setup from masking the daemon's refusal
On macOS, setsockopt against a socket whose peer has already closed fails with
EINVAL. The daemon answers a bad request by writing one Error frame and hanging
up at once, so PaneSession's `set_recv_timeout(...)?` would fail before the
refusal was ever read — turning "no such pane 42", already sitting in the
buffer, into "Invalid argument".

Bounding the reply wait is an optimisation, not a correctness requirement, so it
is now best effort in both attach/observe and spawn. Nothing can hang as a
result: a closed peer returns EOF immediately, and a live peer is exactly the
case where setsockopt succeeds.

This is what made client_lib's reattach test red.
2026-07-31 13:04:51 +08:00

372 lines
11 KiB
Rust

use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use tty7_core::client::{ControlClient, PaneClient};
use tty7_core::core::machine::{LayoutDelta, PaneSeed};
use tty7_core::daemon::control::{ControlEvent, ControlHello, ControlRequest, ReplyOk, feature};
use tty7_core::daemon::protocol::{DaemonMsg, PROTOCOL_VERSION, ShellSpec, WinSize};
const READY_WITHIN: Duration = Duration::from_secs(30);
const STREAM_WITHIN: Duration = Duration::from_secs(30);
struct Daemon {
child: Child,
dir: tempfile::TempDir,
}
impl Daemon {
fn start() -> Daemon {
let dir = tempfile::TempDir::new().unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
.arg("--daemon")
.arg("--config-dir")
.arg(dir.path())
.env("TTY7_DATA_DIR", dir.path())
.env("TTY7_CONTROL_SOCK", dir.path().join("control.sock"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("start tty7-server --daemon");
let daemon = Daemon { child, dir };
daemon.await_ready();
daemon
}
fn pane_endpoint(&self) -> PathBuf {
let file = if cfg!(windows) {
"daemon.port"
} else {
"daemon.sock"
};
self.dir.path().join(file)
}
fn control_endpoint(&self) -> PathBuf {
let file = if cfg!(windows) {
"control.port"
} else {
"control.sock"
};
self.dir.path().join(file)
}
fn panes(&self) -> PaneClient {
PaneClient::at(self.pane_endpoint())
}
fn control(&self, name: &str) -> ControlClient {
ControlClient::connect_at(&self.control_endpoint(), &hello(name))
.expect("control handshake with the spawned server")
}
fn await_ready(&self) {
let deadline = Instant::now() + READY_WITHIN;
loop {
let control_up =
ControlClient::connect_at(&self.control_endpoint(), &hello("probe")).is_ok();
let panes_up = self.panes().version().is_ok();
if control_up && panes_up {
return;
}
assert!(
Instant::now() < deadline,
"tty7-server did not open its endpoints within {READY_WITHIN:?}"
);
std::thread::sleep(Duration::from_millis(50));
}
}
}
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn hello(name: &str) -> ControlHello {
ControlHello::host_rpc(name, name)
}
fn size() -> WinSize {
WinSize {
cols: 100,
rows: 30,
cell_w: 8,
cell_h: 16,
}
}
fn one_shot_shell(command: &str) -> ShellSpec {
if cfg!(windows) {
ShellSpec {
program: "cmd.exe".into(),
args: vec!["/d".into(), "/c".into(), command.into()],
args_are_tty7_defaults: false,
}
} else {
ShellSpec {
program: "/bin/sh".into(),
args: vec!["-c".into(), command.into()],
args_are_tty7_defaults: false,
}
}
}
fn interactive_shell() -> ShellSpec {
if cfg!(windows) {
ShellSpec {
program: "cmd.exe".into(),
args: vec!["/d".into()],
args_are_tty7_defaults: false,
}
} else {
ShellSpec {
program: "/bin/sh".into(),
args: Vec::new(),
args_are_tty7_defaults: false,
}
}
}
fn seed(pane: u64) -> PaneSeed {
PaneSeed {
pane,
cwd: Some("/home/me/proj".into()),
ssh_spec: None,
agent: None,
}
}
fn collect_until(
session: &mut tty7_core::client::PaneSession,
marker: &[u8],
) -> (Vec<u8>, Option<Option<i32>>) {
let mut seen: Vec<u8> = Vec::new();
loop {
match session.recv() {
Ok(DaemonMsg::Output(bytes)) | Ok(DaemonMsg::Snapshot(bytes)) => {
seen.extend_from_slice(&bytes);
if windows_contain(&seen, marker) {
return (seen, None);
}
}
Ok(DaemonMsg::Exited { code }) => return (seen, Some(code)),
Ok(_) => {}
Err(e) => panic!(
"pane stream ended early: {e}; saw {:?}",
String::from_utf8_lossy(&seen)
),
}
}
}
fn drain_until_exit(session: &mut tty7_core::client::PaneSession) -> Vec<u8> {
let mut seen: Vec<u8> = Vec::new();
loop {
match session.recv() {
Ok(DaemonMsg::Output(bytes)) | Ok(DaemonMsg::Snapshot(bytes)) => {
seen.extend_from_slice(&bytes);
}
Ok(DaemonMsg::Exited { .. }) => return seen,
Ok(_) => {}
Err(e) => panic!(
"pane stream ended before Exited: {e}; saw {:?}",
String::from_utf8_lossy(&seen)
),
}
}
}
fn windows_contain(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
#[test]
fn the_pane_daemon_reports_its_dialect() {
let daemon = Daemon::start();
let version = daemon.panes().version().expect("query the version");
assert_eq!(version.protocol, PROTOCOL_VERSION);
}
#[test]
fn control_requests_build_the_tree_and_events_reach_the_other_client() {
let daemon = Daemon::start();
let writer = daemon.control("writer");
assert!(
writer.hello().has_feature(feature::MACHINE_TREE),
"features were {:?}",
writer.hello().features
);
let watcher = daemon.control("watcher");
watcher
.request(ControlRequest::Ping)
.expect("the watcher is live before the writer acts");
let ws = match writer
.request(ControlRequest::WorkspaceCreate {
name: Some("api".into()),
workspace: None,
})
.expect("create a workspace")
{
ReplyOk::WorkspaceTree(ws) => *ws,
other => panic!("expected WorkspaceTree, got {other:?}"),
};
match writer
.request(ControlRequest::TabCreate {
workspace: ws.id,
at: None,
pane: seed(1),
tab: None,
})
.expect("create a tab")
{
ReplyOk::TabTree(_) => {}
other => panic!("expected TabTree, got {other:?}"),
}
let machine = match writer
.request(ControlRequest::MachineGet)
.expect("fetch the machine tree")
{
ReplyOk::MachineTree(m) => *m,
other => panic!("expected MachineTree, got {other:?}"),
};
assert_eq!(machine.workspaces.len(), 1);
assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![1]);
let key = ws.id.to_string();
let deadline = Instant::now() + STREAM_WITHIN;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(
!remaining.is_zero(),
"the watcher never saw the WorkspaceCreated delta for {key}"
);
match watcher.next_event(remaining) {
Some(ControlEvent::Layout { workspace, delta })
if workspace == key && matches!(delta, LayoutDelta::WorkspaceCreated { .. }) =>
{
break;
}
Some(_) => {}
None => {}
}
}
}
#[test]
fn a_spawned_pane_streams_its_output_and_its_exit() {
let daemon = Daemon::start();
let mut session = daemon
.panes()
.spawn(
None,
size(),
Some(one_shot_shell("echo tty7_pane_roundtrip")),
Some("client-lib-test".into()),
None,
)
.expect("spawn a one-shot pane");
assert_ne!(session.pane_id(), 0, "the daemon must name the pane");
session
.set_recv_timeout(Some(STREAM_WITHIN))
.expect("bound the stream reads");
let seen = drain_until_exit(&mut session);
assert!(
windows_contain(&seen, b"tty7_pane_roundtrip"),
"output was {:?}",
String::from_utf8_lossy(&seen)
);
}
#[test]
fn a_one_shot_pane_reports_its_real_exit_code() {
let daemon = Daemon::start();
let mut session = daemon
.panes()
.spawn(
None,
size(),
Some(one_shot_shell("exit 5")),
Some("client-lib-test".into()),
None,
)
.expect("spawn a failing one-shot pane");
session
.set_recv_timeout(Some(STREAM_WITHIN))
.expect("bound the stream reads");
loop {
match session.recv() {
Ok(DaemonMsg::Exited { code }) => {
assert_eq!(code, Some(5), "the child's code must ride the Exited frame");
break;
}
Ok(_) => {}
Err(e) => panic!("pane stream ended before Exited: {e}"),
}
}
}
#[test]
fn input_reaches_the_shell_and_a_reattach_replays_it() {
let daemon = Daemon::start();
let panes = daemon.panes();
let mut session = panes
.spawn(None, size(), Some(interactive_shell()), None, None)
.expect("spawn an interactive pane");
let pane_id = session.pane_id();
session
.set_recv_timeout(Some(STREAM_WITHIN))
.expect("bound the stream reads");
session
.input(b"echo tty7_attach_replay\r")
.expect("type into the pane");
let (_, exit) = collect_until(&mut session, b"tty7_attach_replay");
assert!(exit.is_none(), "the shell must still be running");
session.detach().expect("detach");
let listed = panes.list().expect("list panes");
let entry = listed
.iter()
.find(|p| p.pane_id == pane_id)
.expect("the detached pane is still listed");
assert!(entry.alive, "the detached pane is still alive");
let mut reattached = panes.attach(pane_id, size()).expect("reattach");
reattached
.set_recv_timeout(Some(STREAM_WITHIN))
.expect("bound the replay reads");
let (_, exit) = collect_until(&mut reattached, b"tty7_attach_replay");
assert!(exit.is_none(), "the replayed pane is still running");
let refused = panes
.attach(u64::MAX, size())
.expect_err("attaching to a pane that never existed must fail");
assert!(
refused.to_string().contains("no such pane"),
"the refusal was {refused}"
);
reattached.kill().expect("kill the pane");
let deadline = Instant::now() + STREAM_WITHIN;
loop {
let listed = panes.list().expect("list panes after the kill");
let gone = !listed.iter().any(|p| p.pane_id == pane_id && p.alive);
if gone {
break;
}
assert!(
Instant::now() < deadline,
"pane {pane_id} was still listed alive after Kill: {listed:?}"
);
std::thread::sleep(Duration::from_millis(50));
}
}