Files
tty7/crates/tty7-server/tests/stdio_conformance.rs
T
l0ng-ai 382a46ac29 refactor(server): retire the opaque workspace record store
Clients stopped sending WorkspaceList/Get/Put/Delete when the tree
migration landed, so the coexistence scaffolding comes out:

- core::workspace_store is deleted. Attachment and the data-directory
  resolution (TTY7_DATA_DIR, XDG fallback chain) move into
  core::machine, which was already their only consumer; Attachment
  loses its vestigial serde derives (it never crosses disk or wire).
- The control dialect drops the four record verbs, the ReplyOk::Json
  payload they answered with, and the WorkspaceChanged event. Their
  serde names (and the workspace-store capability bit) are recorded as
  burned rather than reserved by any mechanism — the dialect has no
  numbered slots to hold, so a comment at each site is the guard, plus
  the handshake test asserting the bit never reappears.
- host::server loses Services.workspaces, the verb arms, the
  per-connection store subscription and its WorkspaceChanged forwarder,
  and the store half of attach/detach/teardown. Attachment data now
  lives solely in the tree: a workspace the tree does not list records
  no data half (the registry's live handles still move, so takeover
  behaviour is unchanged), and it appears the moment the workspace
  does. Services::with_workspaces/and_machine collapse into
  with_machine; control_services becomes a single match.
- The attach/takeover tests move onto MachineStore wholesale, attaching
  to workspaces created in a real tree; the record-store round-trip and
  fan-out tests go (tests/machine_tree.rs has carried the tree
  equivalents since the verbs landed), and tests/workspace_store.rs is
  deleted with the serde_json dev-dependency that existed only for it.
  machine.rs gains the two guarantees the old suite held uniquely: an
  attachment dies with its workspace structurally, and the default path
  resolution ends at the documented file.
- The GUI's dead WorkspaceChanged arm and every stale doc reference go.
2026-07-30 00:35:49 +08:00

177 lines
7.3 KiB
Rust

//! **The milestone's proof**: every `Host` conformance case, run against a real
//! `tty7-server --stdio` child process over real pipes.
//!
//! Not a mock, not an in-process socket pair, and — the part that matters — not
//! an sshd. The client is `RemoteHost`, the wire is the control dialect, the
//! server is the shipped binary answering out of its own address space, and the
//! only thing standing in for SSH is a pair of pipes. Everything between the
//! `Host` call and the syscall is the code a transcontinental workspace runs.
//!
//! That is what makes remote workspaces testable in CI at all. The alternative —
//! provisioning a machine, an sshd, a key, and a network for every pull request
//! — is expensive enough that in practice it does not get run, which means the
//! two `Host` implementations drift and nobody finds out until someone opens a
//! remote directory. Here the identical list of cases runs against `LocalHost`
//! in `tty7-core` and against this, and a divergence is a red test.
//!
//! # Shape
//!
//! One child process and one sandbox **per case**, via
//! [`host_conformance_suite!`](tty7_core::host_conformance_suite). Spawning
//! forty-six servers costs a few hundred milliseconds in total and buys complete
//! isolation: no case can be explained by another's leftover state, a hung
//! server fails exactly one case, and a crash names the behaviour that caused it.
// Unix-only: every case here is a `--stdio` child, and `--stdio` is refused on
// Windows by design — a Windows machine is reached over its own transport, not
// by shipping a server onto it.
#![cfg(unix)]
use std::io;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use tty7_core::daemon::control::{ControlHello, LinkShutdown};
use tty7_core::host::SharedHost;
use tty7_core::host::conformance::Sandbox;
use tty7_core::host::remote::RemoteHost;
/// The child, and the only way to end it.
///
/// `RemoteHost` closes its link through [`LinkShutdown`]; for a socket that is
/// `shutdown(2)`, and for a child process it is this. Without it, dropping the
/// host would leave the reader thread parked on a pipe the server has no reason
/// to write to and the server parked on a pipe the client has no reason to write
/// to — the exact standoff `LinkShutdown` exists to break, one transport over.
struct ServerProcess {
child: Mutex<Option<Child>>,
}
impl LinkShutdown for ServerProcess {
fn shutdown_link(&self) -> io::Result<()> {
let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() else {
return Ok(()); // already reaped; `close` and `Drop` both call this
};
let _ = child.kill();
// Reaped here rather than left to the OS: forty-six cases running in
// parallel would otherwise accumulate forty-six zombies for the life of
// the test binary.
let _ = child.wait();
Ok(())
}
}
/// A temp directory on the machine the server is on — which, this being the
/// stdio path, is also this one.
struct TempSandbox(tempfile::TempDir);
impl Sandbox for TempSandbox {
fn path(&self) -> &Path {
self.0.path()
}
fn symlink(&self, target: &Path, link: &Path) -> Option<io::Result<()>> {
#[cfg(unix)]
{
Some(std::os::unix::fs::symlink(target, link))
}
#[cfg(not(unix))]
{
let _ = (target, link);
None
}
}
}
/// Start a server and connect a `RemoteHost` to it.
fn stdio_host() -> (SharedHost, TempSandbox) {
let sandbox = TempSandbox(tempfile::TempDir::new().unwrap());
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
// `--serve` rather than letting the mode be probed: a developer running
// these tests may well have a real `tty7-server --daemon` up, and a
// bridge to *that* would be testing their machine's state instead of
// this build.
.args(["--stdio", "--serve"])
// The server opens its machine tree at startup. None of these cases
// touch it, but pointing it at the sandbox keeps forty-six child
// processes off the developer's real `~/.local/share/tty7`.
.env("TTY7_DATA_DIR", sandbox.path())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
// The server's diagnostics are not this test's output. A failure shows
// up as a failed request, which names the case.
.stderr(Stdio::null())
.spawn()
.expect("could not start tty7-server --stdio");
let stdout = child.stdout.take().expect("piped");
let stdin = child.stdin.take().expect("piped");
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
child: Mutex::new(Some(child)),
});
let hello = ControlHello::host_rpc("stdio-conformance", "localhost");
let host = RemoteHost::connect_with(stdout, stdin, Some(closer), "stdio:conformance", &hello)
.expect("handshake with tty7-server --stdio");
(host.into_shared(), sandbox)
}
// Every case in `tty7-core`'s shared suite, over the pipes. This is the same
// list `LocalHost` runs; the point is that it is not a *similar* list.
tty7_core::host_conformance_suite!(remote_stdio, stdio_host);
/// The suite above only proves the cases pass — it cannot prove they were the
/// whole suite. This checks the count the registry actually carries, so a case
/// silently dropped upstream shows up here as well as there.
#[test]
fn the_whole_suite_ran_against_the_server() {
let names: Vec<&str> = tty7_core::host::conformance::CASES
.iter()
.map(|(n, _)| *n)
.collect();
assert!(
names.len() >= 46,
"the conformance suite shrank to {} cases: {names:?}",
names.len()
);
}
/// The server is a *separate process* answering out of its own memory. Easy to
/// lose by accident — an in-process fallback would keep every case above green
/// while testing nothing that this milestone is about.
#[test]
fn the_server_really_is_another_process() {
let (host, sandbox) = stdio_host();
let marker = host.join(sandbox.path(), "written-over-the-wire.txt");
host.write_file(&marker, b"from the client").unwrap();
// This side reads it with plain `std::fs`: if the bytes are there, they went
// out through a pipe and came back through a syscall someone else made.
assert_eq!(std::fs::read(&marker).unwrap(), b"from the client");
// And the reverse: a change this process makes with `std::fs` is visible to
// the server, so both ends really are looking at one filesystem through two
// different code paths.
let from_here = sandbox.path().join("written-locally.txt");
std::fs::write(&from_here, b"from the test").unwrap();
assert_eq!(
host.read_file(&from_here, 1024).unwrap(),
b"from the test",
"the server read a file this process wrote"
);
assert!(host.is_connected());
}
/// Dropping the host kills the child. A test binary that leaked one server per
/// case would leave forty-six processes behind on every run.
#[test]
fn dropping_the_host_reaps_the_server() {
let (host, sandbox) = stdio_host();
assert!(host.exists(sandbox.path()));
drop(host);
// Nothing to assert beyond "this returns": the reap happens inside the drop,
// and a shutdown that did not wake the reader would hang here instead.
}