mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
Two things a pane lost when the background service stopped and started, both of them things the tree was the only possible place to keep. **The shell.** `PaneRecord` and `PaneSeed` carried a pane's cwd, its ssh spec and its agent, but never what it was running. A window rebuilding a dead pane from the tree therefore had nothing to pass and spawned on whatever the default shell is now — so a restart turned a bash pane into a PowerShell one, quietly and in place. The daemon resolves the override against the config at spawn time and is the only party that knows the answer, so it keeps it and reports it; the seed carries it too, for the panes a window spawned itself. A handoff carries it in the blob, because nothing on the far side of an `execve` can work out the command line of a child it never spawned. **The screen.** The startup sweep ran before the endpoint was listening, which is the one moment nothing can answer the question it asks: the registry is empty and the windows that know which screens are still wanted cannot say so yet. A tree that failed to parse made it worse — `read_machine` quarantines it and returns an empty `Machine`, so one bad file took every pane's stored screen with it. The sweep now happens only on the periodic pass, a tick later, with the registry filled in and the tree caught up; nothing is serving a request in between. Turning the setting *off* still clears the directory at once, because there the promptness is the whole promise. Two smaller ones alongside it: `restorable_pane_ids` now counts the tree's pane list and not only the panes some tab currently stands on — the two disagree while a window is between layouts, and being wrong costs a file swept a tick late in one direction and somebody's terminal in the other. And `restored_screen` drops the snapshot file *after* deciding it was not empty, so a snapshot holding nothing is no longer consumed by the request it could not answer. The restore path had no end-to-end test, which is how this shipped: the unit tests cover the file, not whether a window that reattaches is shown anything. The new one runs a real daemon, puts a marker on a real pane, stops the daemon, starts another, and reads the wire.
377 lines
11 KiB
Rust
377 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,
|
|
shell: 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}"
|
|
);
|
|
|
|
// This test covers attach replay rather than the streaming connection's
|
|
// Kill command. Close that connection explicitly, then use the one-shot
|
|
// PaneClient path for deterministic cleanup on loaded Windows runners.
|
|
drop(reattached);
|
|
panes.kill(pane_id).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));
|
|
}
|
|
}
|