mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-23 00:02:27 +00:00
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.
372 lines
11 KiB
Rust
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));
|
|
}
|
|
}
|