use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::time::Duration;
struct Client {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
}
impl Client {
fn spawn(ledger: &str) -> Self {
let exe = env!("CARGO_BIN_EXE_effectfence");
let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/slow_child.py");
let mut child = Command::new(exe)
.args(["wrap", "--", "python3", fixture])
.env("EFFECTFENCE_LEASE_SECS", "1") .env("EFFECTFENCE_LEDGER", "memory") .env("SLOW_CHILD_LEDGER", ledger)
.env("SLOW_CHILD_DELAY", "3")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn wrap");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let mut c = Self {
child,
stdin,
stdout,
};
c.send(
&serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2026-07-28","capabilities":{},
"clientInfo":{"name":"slow-test","version":"0"}}}),
);
c.read_id(1);
c.send(&serde_json::json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
c
}
fn send(&mut self, v: &serde_json::Value) {
writeln!(self.stdin, "{v}").unwrap();
self.stdin.flush().unwrap();
}
fn call(&mut self, id: i64, order: &str) {
self.send(
&serde_json::json!({"jsonrpc":"2.0","id":id,"method":"tools/call",
"params":{"name":"slow_charge","arguments":{"order":order}}}),
);
}
fn read_id(&mut self, id: i64) -> serde_json::Value {
loop {
let mut line = String::new();
let n = self.stdout.read_line(&mut line).expect("read");
assert!(n > 0, "wrap closed stdout while awaiting id {id}");
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line)
&& v.get("id").and_then(|x| x.as_i64()) == Some(id)
{
return v;
}
}
}
}
impl Drop for Client {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn a_child_slower_than_its_lease_is_still_executed_exactly_once() {
let ledger = std::env::temp_dir().join(format!("ef-slow-{}.ndjson", std::process::id()));
let ledger_s = ledger.to_string_lossy().to_string();
let _ = std::fs::remove_file(&ledger);
let mut c = Client::spawn(&ledger_s);
c.call(10, "order-777");
std::thread::sleep(Duration::from_millis(1500));
c.call(11, "order-777");
let first = c.read_id(10);
let second = c.read_id(11);
let runs = std::fs::read_to_string(&ledger).unwrap_or_default();
let executions = runs.lines().filter(|l| !l.trim().is_empty()).count();
let _ = std::fs::remove_file(&ledger);
assert_eq!(
executions, 1,
"the child must charge order-777 exactly once; ledger:\n{runs}\n\
first={first}\nsecond={second}"
);
assert!(
first["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default()
.contains("order-777"),
"the original call must still get its real result: {first}"
);
}