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]>
113 lines
6.1 KiB
Rust
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);
|
|
}
|