#![cfg(all(unix, any(feature = "serve", feature = "explorer", feature = "mcp")))]
#[cfg(feature = "explorer")]
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
mod common;
use common::{IsolatedHome, repo_file, scratch_dir};
const BIN: &str = env!("CARGO_BIN_EXE_roteiro");
struct ServerCase {
variant: &'static str,
args: &'static [&'static str],
runnable: bool,
}
fn cases() -> Vec<ServerCase> {
vec![
ServerCase {
variant: "Serve",
args: &["serve", "--addr", "{addr}"],
runnable: cfg!(feature = "explorer"),
},
ServerCase {
variant: "Mcp",
args: &["mcp", "--http", "{addr}"],
runnable: cfg!(feature = "mcp"),
},
ServerCase {
variant: "Explorer",
args: &["explorer", "--addr", "{addr}"],
runnable: cfg!(feature = "explorer"),
},
]
}
#[test]
fn every_long_lived_server_survives_sighup() {
let declared = server_commands_declared_in_main();
let cases = cases();
let covered: Vec<&str> = cases.iter().map(|c| c.variant).collect();
for variant in &declared {
assert!(
covered.contains(&variant.as_str()),
"`{variant}` is classified as a long-lived server in \
`is_long_lived_server`, but this test never signals it. SIGHUP's \
default disposition kills a process, so an unsignalled server is an \
unverified one — add a `ServerCase` for it."
);
}
for variant in &covered {
assert!(
declared.contains(&(*variant).to_owned()),
"this test signals `{variant}`, but `is_long_lived_server` no longer \
classifies it as a server — the two lists have drifted, which is the \
defect this guard exists for."
);
}
let base = scratch_dir("sighup-survives");
std::fs::create_dir_all(&base).expect("mkdir");
let repo = base.join("solo");
make_repo(&repo);
for case in cases.iter().filter(|c| c.runnable) {
let addr = free_addr();
let args: Vec<String> = case
.args
.iter()
.map(|a| a.replace("{addr}", &addr))
.collect();
let home = IsolatedHome::new("sighup-survives");
let mut server = Server::spawn(&args, &repo, &home);
wait_for_port(&addr, &mut server.child, case.variant);
sighup(&server.child);
std::thread::sleep(Duration::from_millis(1000));
let status = server.child.try_wait().expect("try_wait");
assert!(
status.is_none(),
"`roteiro {}` died on SIGHUP ({status:?}) — the signal's default \
disposition terminates the process, so this server registered no \
handler. Exit 129 is 128 + SIGHUP.",
args.join(" "),
);
}
std::fs::remove_dir_all(&base).ok();
}
#[test]
#[cfg(feature = "explorer")]
fn sighup_reloads_the_graph_api_and_the_flat_view_together() {
let base = scratch_dir("sighup-reload");
let root = base.join("ws");
std::fs::create_dir_all(&root).expect("mkdir");
for name in ["one", "two"] {
make_repo(&root.join(name));
}
let addr = free_addr();
let home = IsolatedHome::new("sighup-reload");
let args = [
"serve".to_owned(),
"--workspace".to_owned(),
root.to_str().expect("utf-8 root").to_owned(),
"--addr".to_owned(),
addr.clone(),
];
let mut server = Server::spawn(&args, &root.join("one"), &home);
let stderr = server.child.stderr.take().expect("piped stderr");
let (tx, rx) = std::sync::mpsc::channel::<String>();
std::thread::spawn(move || {
use std::io::BufRead;
for line in std::io::BufReader::new(stderr)
.lines()
.map_while(Result::ok)
{
let _ = tx.send(line);
}
});
wait_for_port(&addr, &mut server.child, "Serve");
assert_eq!(
projects(&addr),
vec!["one".to_owned(), "two".to_owned()],
"the graph API should host the two repos under the workspace root"
);
make_repo(&root.join("three"));
sighup(&server.child);
let reported = wait_for_reload_line(&rx, &mut server.child);
let expected = vec!["one".to_owned(), "three".to_owned(), "two".to_owned()];
assert_eq!(
reported, expected,
"the reload line should name the three hosted projects"
);
let via_graph_api = wait_for_projects(&addr, &expected);
assert_eq!(
via_graph_api, reported,
"`/v1/graph/*` (the WorkspaceSet, which also backs the explorer UI) and \
the flattened workspace the reload line reports from must host the same \
projects after a SIGHUP. Disagreeing is the defect: the server printed a \
message saying it reloaded three projects and then served two."
);
std::fs::remove_dir_all(&base).ok();
}
fn server_commands_declared_in_main() -> Vec<String> {
let Some(source) = repo_file("crates/roteiro/src/main.rs") else {
return cases().iter().map(|c| c.variant.to_owned()).collect();
};
let marker = "fn is_long_lived_server(cmd: &Command) -> bool {";
let start = source.find(marker).unwrap_or_else(|| {
panic!(
"`{marker}` not found in crates/roteiro/src/main.rs. This guard's \
coverage is defined by that function; if it was renamed, rename it \
here too rather than letting the scan go vacuous."
)
});
let body = &source[start..];
let end = body
.find("\n}\n")
.expect("unterminated `is_long_lived_server` body");
let body = &body[..end];
let mut out: Vec<String> = Vec::new();
for line in body.lines() {
let line = line.trim();
let Some(rest) = line.strip_prefix("Command::") else {
continue;
};
if !rest.ends_with("=> true,") {
continue;
}
let name: String = rest
.chars()
.take_while(char::is_ascii_alphanumeric)
.collect();
assert!(!name.is_empty(), "unparsable server arm: {line}");
out.push(name);
}
assert!(
!out.is_empty(),
"no `=> true` arms parsed out of `is_long_lived_server`. Either every \
server stopped being long-lived (it did not) or the arm shape changed \
and this scan is now vacuous."
);
out
}
struct Server {
child: Child,
}
impl Server {
fn spawn(args: &[String], cwd: &Path, home: &IsolatedHome) -> Self {
let mut command = Command::new(BIN);
command
.args(args)
.current_dir(cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
home.apply(&mut command);
let child = command
.spawn()
.unwrap_or_else(|e| panic!("spawn roteiro {args:?}: {e}"));
Self { child }
}
}
impl Drop for Server {
fn drop(&mut self) {
self.child.kill().ok();
self.child.wait().ok();
}
}
fn make_repo(dir: &Path) {
std::fs::create_dir_all(dir).expect("mkdir repo");
git(dir, &["init", "-q", "."]);
std::fs::write(dir.join("README.md"), "# fixture\n").expect("write README");
git(dir, &["add", "-A"]);
git(dir, &["commit", "-qm", "init"]);
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"-c",
"init.defaultBranch=main",
])
.args(args)
.current_dir(dir)
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed in {}", dir.display());
}
fn free_addr() -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let addr = listener.local_addr().expect("local addr");
drop(listener);
format!("127.0.0.1:{}", addr.port())
}
fn sighup(child: &Child) {
let status = Command::new("kill")
.args(["-HUP", &child.id().to_string()])
.status()
.expect("run kill");
assert!(status.success(), "kill -HUP {} failed", child.id());
}
fn wait_for_port(addr: &str, child: &mut Child, what: &str) {
let deadline = Instant::now() + Duration::from_secs(60);
while Instant::now() < deadline {
if let Some(status) = child.try_wait().expect("try_wait") {
panic!("`{what}` exited before binding {addr}: {status}");
}
if TcpStream::connect(addr).is_ok() {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
panic!("`{what}` never bound {addr}");
}
#[cfg(feature = "explorer")]
fn projects(addr: &str) -> Vec<String> {
let body = http_get(addr, "/v1/graph/projects");
let start = body
.find("\"projects\":[")
.unwrap_or_else(|| panic!("no `projects` array in {body}"))
+ "\"projects\":[".len();
let end = start
+ body[start..]
.find(']')
.unwrap_or_else(|| panic!("unterminated `projects` array in {body}"));
body[start..end]
.split(',')
.map(|s| s.trim().trim_matches('"').to_owned())
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(feature = "explorer")]
fn wait_for_projects(addr: &str, expected: &[String]) -> Vec<String> {
let deadline = Instant::now() + Duration::from_secs(20);
let mut last = Vec::new();
while Instant::now() < deadline {
last = projects(addr);
if last == expected {
return last;
}
std::thread::sleep(Duration::from_millis(100));
}
last
}
#[cfg(feature = "explorer")]
fn wait_for_reload_line(rx: &std::sync::mpsc::Receiver<String>, child: &mut Child) -> Vec<String> {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(500)) {
Ok(line) => {
if let Some(rest) = line.strip_prefix("workspace reloaded: ") {
let tail = rest
.rsplit_once(" — ")
.unwrap_or_else(|| panic!("unexpected reload line: {line}"))
.1;
return tail.split(", ").map(str::to_owned).collect();
}
assert!(
!line.starts_with("workspace reload failed"),
"the reload failed: {line}"
);
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if let Some(status) = child.try_wait().expect("try_wait") {
panic!("server died while waiting for the reload line: {status}");
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
panic!("no `workspace reloaded:` line within the deadline");
}
#[cfg(feature = "explorer")]
fn http_get(addr: &str, path: &str) -> String {
let mut stream = TcpStream::connect(addr).expect("connect");
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("read timeout");
write!(
stream,
"GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"
)
.expect("write request");
let mut raw = String::new();
stream.read_to_string(&mut raw).expect("read response");
raw.split_once("\r\n\r\n")
.unwrap_or_else(|| panic!("malformed HTTP response: {raw}"))
.1
.to_owned()
}