open-standards/wai/wai-quantum-service/tests/tasks.rs
Transaction Science 04d19cfd80 wai-quantum-service's tests kill and reap the service they start, however the test ends
A std::process::Child is not killed when it is dropped. Six of the service's seven test
files held their service as a bare Child and killed it on the test's last lines, so a test
that failed before them left its service running once the test binary had ended, with its
port, its store and its memory, one more each failed run. The Framework build host had 17
such services up from other runs this morning, the oldest 43 hours old.

tests/common/mod.rs holds a child in `Running`, whose drop kills it and reaps it, waiting up
to 10 s: a process stuck in the kernel would otherwise hang the test binary instead of
failing it. Every test file takes its service into one as it spawns it, so a service that
never answers its health check is killed too; contract.rs's and metrics.rs's `Service`
guards, which also remove the store, hold one and stop it before they do. `Running` reads as
the Child, so the tests that kill their service on purpose (a crash, a restart) do so as
before. verify.rs's test that starts its service again after a program ended it now kills
and reaps the old one within the bound, where it waited on it without one. The service's
workers and its writer end with it, as they did: each watches the service that started it
(PR_SET_PDEATHSIG on Linux, a 100 ms watch elsewhere). No test here spawns anything else
that outlives a call: mkfifo, unshare's probe, ps and prlimit are each waited for.

Planted fault, on the Framework: one test in each of the seven binaries made to panic
mid-way with its service up, each run alone in a session of its own (setsid), and the
session listed (pgrep -s) 3 s after cargo ended. At main, six of the seven left their
service: restart.rs's with a worker running its 25-qubit job, write.rs's with the writer
waiting on its FIFO; metrics.rs, which held its service so already, left none. Each was then
killed by its session. With the guard none of the seven left a process, in its session or
running this run's binary. On the old-prod Mac Studio (macOS) the same seven, with the
guard, left nothing in their process group or under this run's paths, where a service
started there by hand was found.

Gates: on the Framework, cargo test --locked --release passed 3 runs in 3 (141 tests each:
the service's unit tests and its eight test binaries), each leaving nothing in its session;
on the old-prod Mac Studio, 2 in 2 (142 tests), leaving nothing under its paths. cargo
clippy --locked --release --all-targets -D warnings is clean on both. The light checks pass.

Other crates' tests that spawn a long-running process and kill it on their last lines with
no guard, not changed here: wai-quantum-cli tests/providers.rs (serve_http's reference
provider) and tests/optimize_resume.rs, wai-quantum-qpu tests/providers.rs (the reference
over HTTP), wai-rs tests/cli_conform.rs (wai http origin) and tests/cli_studio.rs (wai dev),
joule-moq-room tests/cli_dev_room.rs (before its catch_unwind), jouleclaw-energy
tests/process_cpu.rs (a busy loop, macOS) and proof-cascade tests/process_energy.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 09:56:11 -05:00

113 lines
6.1 KiB
Rust

//! A run as an MCP task, against the service itself: made at once, followed
//! by tasks/get to its end, and its result from tasks/result, its receipt
//! verifying; and tasks/result asked while the task still works, answered as
//! an event stream once it ends.
use serde_json::{Value, json};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
mod common;
use common::Running;
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
/// One HTTP/1.1 exchange: the status, the headers and the body.
fn http(port: u16, method: &str, path: &str, body: Option<&Value>, accept: &str) -> (u16, String, String) {
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
s.set_read_timeout(Some(Duration::from_secs(180))).unwrap();
let text = body.map(Value::to_string).unwrap_or_default();
write!(s, "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nAccept: {accept}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}", text.len()).unwrap();
let mut out = String::new();
s.read_to_string(&mut out).unwrap();
let status = out.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0);
let (head, body) = out.split_once("\r\n\r\n").map(|(h, b)| (h.to_string(), b.to_string())).unwrap_or_default();
(status, head, body)
}
fn rpc(port: u16, method: &str, params: Value) -> Value {
let (code, _, body) = http(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params })), "application/json, text/event-stream");
assert_eq!(code, 200, "{method}: {body}");
serde_json::from_str(&body).unwrap_or_else(|e| panic!("{method}: {e}: {body}"))
}
fn start(dir: &Path, port: u16) -> Running {
let child = Running::new(Command::new(env!("CARGO_BIN_EXE_wai-quantum-service"))
.arg("serve")
.env("WAI_QSVC_ADDR", format!("127.0.0.1:{port}"))
.env("WAI_QSVC_STORE", dir.join("store"))
.env("WAI_QSVC_KEY", dir.join("signing.key"))
.env("WAI_QSVC_JOB_GB", "2")
.env("WAI_QSVC_BUDGET_GB", "4")
.env("WAI_QSVC_WORKERS", "1")
.env("WAI_QSVC_ENERGY", "0")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("the service starts"));
let t = Instant::now();
while TcpStream::connect(("127.0.0.1", port)).is_err() || http(port, "GET", "/v1/health", None, "application/json").0 != 200 {
assert!(t.elapsed() < Duration::from_secs(60), "the service did not answer");
std::thread::sleep(Duration::from_millis(50));
}
child
}
#[test]
fn a_run_as_a_task_is_followed_to_its_end_and_its_result_streams_when_asked_early() {
let dir = std::env::temp_dir().join(format!("wai-qsvc-tasks-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let port = free_port();
let mut svc = start(&dir, port);
// Made at once, followed by tasks/get to its end.
let bell = json!({ "qasm": "OPENQASM 3.0; qubit[2] q; bit[2] c; h q[0]; cx q[0], q[1]; c = measure q;", "shots": 200, "seed": 3 });
let made = rpc(port, "tools/call", json!({ "name": "run_quantum_circuit", "arguments": bell, "task": { "ttl": 60000 } }));
let id = made["result"]["task"]["taskId"].as_str().unwrap_or_else(|| panic!("{made}")).to_string();
assert_eq!(made["result"]["task"]["status"], "working", "{made}");
let t = Instant::now();
let ended = loop {
let got = rpc(port, "tasks/get", json!({ "taskId": id }));
if got["result"]["status"] != "working" {
break got;
}
assert!(t.elapsed() < Duration::from_secs(120), "never ended: {got}");
std::thread::sleep(Duration::from_millis(50));
};
assert_eq!(ended["result"]["status"], "completed", "{ended}");
let r = rpc(port, "tasks/result", json!({ "taskId": id }));
let sc = &r["result"]["structuredContent"];
assert_eq!((sc["id"].as_str(), sc["status"].as_str(), r["result"]["isError"].as_bool()), (Some(id.as_str()), Some("done"), Some(false)), "{r}");
assert_eq!(r["result"]["_meta"]["io.modelcontextprotocol/related-task"]["taskId"], id.as_str());
assert!(wai_quantum::engine::job::verify(&json!({ "request": sc["request"], "result": sc["result"] })).unwrap().0, "{sc}");
assert_eq!(sc["result"]["counts"].as_object().unwrap().values().filter_map(Value::as_u64).sum::<u64>(), 200);
// Asked for its result while it works, the answer is an event stream
// that ends with the result.
// Asked a few milliseconds after it is made, it is still queued or running.
let mut long = String::from("OPENQASM 3.0;\nqubit[16] q;\nh q;\n");
for k in 0..1000 {
long += &format!("rx(0.{}) q[{}];\ncx q[{}], q[{}];\n", k % 9 + 1, k % 16, k % 16, (k + 1) % 16);
}
let made = rpc(port, "tools/call", json!({ "name": "run_quantum_circuit", "arguments": { "qasm": long, "observables": ["Z0"], "method": "statevector" }, "task": {} }));
let id = made["result"]["task"]["taskId"].as_str().unwrap_or_else(|| panic!("{made}")).to_string();
let (code, head, body) = http(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 9, "method": "tasks/result", "params": { "taskId": id } })), "application/json, text/event-stream");
assert_eq!(code, 200, "{body}");
assert!(head.to_ascii_lowercase().contains("content-type: text/event-stream"), "{head}");
let data: String = body.lines().filter_map(|l| l.strip_prefix("data:")).map(str::trim_start).collect();
let answer: Value = serde_json::from_str(&data).unwrap_or_else(|e| panic!("{e}: {body}"));
assert_eq!((answer["id"].as_u64(), answer["result"]["structuredContent"]["status"].as_str()), (Some(9), Some("done")), "{answer}");
assert_eq!(answer["result"]["_meta"]["io.modelcontextprotocol/related-task"]["taskId"], id.as_str());
assert_eq!(rpc(port, "tasks/get", json!({ "taskId": id }))["result"]["status"], "completed");
svc.kill().unwrap();
svc.wait().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}