Implement closed typed effect path

This commit is contained in:
tmk241
2026-09-01 00:58:29 +02:00
parent 353174604e
commit 31a0f02211
58 changed files with 4247 additions and 5933 deletions
+228 -79
View File
@@ -1,116 +1,265 @@
use hemx_test::TestProcess;
use hemx_test::{ProcessError, TestProcess};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::process::Command;
use std::time::{Duration, Instant};
#[test]
fn process_harness_reports_early_exit_with_context() {
// req: test/019
fn current_test_command(helper: &str) -> Command {
let mut command = Command::new(std::env::current_exe().expect("current test executable"));
command.arg("--exact").arg(helper).arg("--nocapture");
command
.arg("--exact")
.arg("helper_process_exits_successfully")
.arg("--nocapture");
let error = match TestProcess::start(
command,
"short-lived helper",
"127.0.0.1:9",
Duration::from_secs(2),
) {
Ok(_) => panic!("a process that exits before readiness must fail startup"),
Err(error) => error,
};
let message = error.to_string();
assert!(message.contains("short-lived helper"), "{message}");
assert!(message.contains("127.0.0.1:9"), "{message}");
assert!(message.contains("exited with"), "{message}");
}
#[test]
fn process_harness_waits_for_readiness_and_reaps_on_drop() {
// test req: test/019
fn unused_loopback_addr() -> String {
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = reservation.local_addr().unwrap().to_string();
let address = reservation.local_addr().unwrap().to_string();
drop(reservation);
address
}
let mut command = Command::new(std::env::current_exe().unwrap());
command
.arg("--exact")
.arg("helper_process_listens")
.arg("--nocapture")
.env("HEMX_TEST_PROCESS_ADDR", &addr);
let process = TestProcess::start(command, "listening helper", &addr, Duration::from_secs(2))
.expect("readiness must observe the helper listener");
assert!(TcpStream::connect(&addr).is_ok());
drop(process);
fn wait_until_closed(address: &str) {
let deadline = Instant::now() + Duration::from_secs(2);
while TcpStream::connect(&addr).is_ok() && Instant::now() < deadline {
while TcpStream::connect(address).is_ok() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
TcpStream::connect(&addr).is_err(),
"drop must reap the helper"
TcpStream::connect(address).is_err(),
"child listener {address} must be closed"
);
}
#[test]
fn process_harness_reports_spawn_and_readiness_timeout_errors() {
// test req: test/019
let spawn_error = match TestProcess::start(
Command::new("/definitely/not/a/hemx/executable"),
"missing helper",
"127.0.0.1:9",
Duration::from_millis(10),
) {
Ok(_) => panic!("spawn failure must be returned, not panic"),
Err(error) => error,
};
assert!(spawn_error
.to_string()
.contains("failed to spawn missing helper"));
fn builder_waits_for_delayed_tcp_readiness_and_captures_output() {
let address = unused_loopback_addr();
let process = TestProcess::builder(current_test_command("helper_process_listens"))
.label("delayed TCP helper")
.env("HEMX_TEST_PROCESS_ADDR", &address)
.env("HEMX_TEST_PROCESS_DELAY_MS", "75")
.tcp(&address)
.timeout(Duration::from_secs(2))
.poll_interval(Duration::from_millis(10))
.start()
.expect("readiness must observe the helper listener");
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
let unused_addr = reservation.local_addr().unwrap().to_string();
drop(reservation);
let mut command = Command::new(std::env::current_exe().unwrap());
command
.arg("--exact")
.arg("helper_process_sleeps")
.arg("--nocapture")
.env("HEMX_TEST_PROCESS_SLEEP", "1");
let timeout = match TestProcess::start(
command,
"non-listening helper",
&unused_addr,
Duration::from_millis(100),
) {
Ok(_) => panic!("non-listening process must time out"),
Err(error) => error,
};
let message = timeout.to_string();
assert!(message.contains("timed out"), "{message}");
assert!(message.contains("non-listening helper"), "{message}");
assert!(process.id().is_some());
assert!(TcpStream::connect(&address).is_ok());
assert!(process.stdout().contains("tcp helper ready"));
drop(process);
wait_until_closed(&address);
}
#[test]
fn helper_process_exits_successfully() {}
fn builder_waits_for_successful_http_readiness() {
let address = unused_loopback_addr();
let process = TestProcess::builder(current_test_command("helper_process_serves_http"))
.label("HTTP helper")
.env("HEMX_TEST_PROCESS_ADDR", &address)
.http(&address, "/health")
.timeout(Duration::from_secs(2))
.poll_interval(Duration::from_millis(10))
.start()
.expect("the second health response is successful");
assert!(process.stdout().contains("http helper ready"));
drop(process);
wait_until_closed(&address);
}
#[test]
fn early_exit_reports_bounded_stdout_and_stderr() {
let error = TestProcess::builder(current_test_command("helper_process_is_noisy"))
.label("noisy helper")
.env("HEMX_TEST_PROCESS_NOISY", "1")
.tcp("127.0.0.1:9")
.output_limit(256)
.timeout(Duration::from_secs(2))
.start()
.unwrap_err();
let message = error.to_string();
assert!(matches!(error, ProcessError::EarlyExit { .. }));
assert!(message.contains("noisy helper"), "{message}");
assert!(message.contains("exited with"), "{message}");
assert!(message.contains("earlier bytes omitted"), "{message}");
assert!(message.contains("stdout marker"), "{message}");
assert!(message.contains("stderr marker"), "{message}");
assert!(
message.len() < 1_500,
"diagnostic was not bounded: {message}"
);
}
#[test]
fn timeout_reports_readiness_attempts_output_and_cleanup() {
let address = unused_loopback_addr();
let error = TestProcess::builder(current_test_command("helper_process_stays_unready"))
.label("unready HTTP helper")
.env("HEMX_TEST_PROCESS_ADDR", &address)
.http(&address, "/health")
.timeout(Duration::from_millis(100))
.poll_interval(Duration::from_millis(10))
.start()
.unwrap_err();
let message = error.to_string();
assert!(matches!(error, ProcessError::TimedOut { .. }));
assert!(message.contains("timed out"), "{message}");
assert!(message.contains("unready HTTP helper"), "{message}");
assert!(message.contains("readiness attempts"), "{message}");
assert!(message.contains("unready helper started"), "{message}");
}
#[test]
fn configuration_spawn_and_occupied_http_fail_honestly() {
let missing_readiness = TestProcess::builder(current_test_command("helper_process_sleeps"))
.label("unconfigured helper")
.start()
.unwrap_err();
assert!(matches!(
missing_readiness,
ProcessError::Configuration { .. }
));
let invalid_http = TestProcess::builder(current_test_command("helper_process_sleeps"))
.label("invalid HTTP helper")
.http("127.0.0.1:9", "health")
.start()
.unwrap_err();
assert!(invalid_http
.to_string()
.contains("path must start with '/'"));
let spawn = TestProcess::builder(Command::new("/definitely/not/a/hemx/executable"))
.label("missing helper")
.tcp("127.0.0.1:9")
.start()
.unwrap_err();
assert!(matches!(spawn, ProcessError::Spawn { .. }));
assert!(spawn.to_string().contains("failed to spawn missing helper"));
let occupied = TcpListener::bind("127.0.0.1:0").unwrap();
let address = occupied.local_addr().unwrap().to_string();
let occupied_error = TestProcess::builder(current_test_command("helper_process_sleeps"))
.label("HTTP ownership helper")
.env("HEMX_TEST_PROCESS_SLEEP", "1")
.http(&address, "/health")
.timeout(Duration::from_millis(100))
.poll_interval(Duration::from_millis(10))
.start()
.unwrap_err();
assert!(matches!(occupied_error, ProcessError::TimedOut { .. }));
drop(occupied);
}
#[test]
fn explicit_shutdown_and_drop_after_panic_reap_the_child() {
let address = unused_loopback_addr();
let mut process = TestProcess::builder(current_test_command("helper_process_listens"))
.env("HEMX_TEST_PROCESS_ADDR", &address)
.tcp(&address)
.start()
.unwrap();
process.shutdown().unwrap();
process.shutdown().unwrap();
assert!(process.id().is_none());
assert!(process.exit_status().is_some());
wait_until_closed(&address);
let panic_address = unused_loopback_addr();
let result = std::panic::catch_unwind(|| {
let _process = TestProcess::builder(current_test_command("helper_process_listens"))
.env("HEMX_TEST_PROCESS_ADDR", &panic_address)
.tcp(&panic_address)
.start()
.unwrap();
panic!("exercise panic cleanup");
});
assert!(result.is_err());
wait_until_closed(&panic_address);
}
#[test]
fn compatibility_start_still_waits_for_tcp_and_reaps() {
let address = unused_loopback_addr();
let mut command = current_test_command("helper_process_listens");
command.env("HEMX_TEST_PROCESS_ADDR", &address);
let process = TestProcess::start(
command,
"compatibility helper",
&address,
Duration::from_secs(2),
)
.unwrap();
drop(process);
wait_until_closed(&address);
}
#[test]
fn helper_process_listens() {
let Ok(addr) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
let Ok(address) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
return;
};
let _listener = TcpListener::bind(addr).expect("bind helper listener");
if let Ok(delay) = std::env::var("HEMX_TEST_PROCESS_DELAY_MS") {
std::thread::sleep(Duration::from_millis(delay.parse().unwrap()));
}
let _listener = TcpListener::bind(address).expect("bind helper listener");
println!("tcp helper ready");
std::thread::sleep(Duration::from_secs(10));
}
#[test]
fn helper_process_serves_http() {
let Ok(address) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
return;
};
let listener = TcpListener::bind(address).expect("bind HTTP helper listener");
println!("http helper ready");
for status in ["503 Service Unavailable", "204 No Content"] {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 512];
let read = stream.read(&mut request).unwrap();
assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET /health HTTP/1.1"));
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.unwrap();
}
std::thread::sleep(Duration::from_secs(10));
}
#[test]
fn helper_process_stays_unready() {
let Ok(address) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
return;
};
let listener = TcpListener::bind(address).expect("bind unready helper listener");
println!("unready helper started");
loop {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 512];
let _ = stream.read(&mut request);
write!(
stream,
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.unwrap();
}
}
#[test]
fn helper_process_is_noisy() {
if std::env::var_os("HEMX_TEST_PROCESS_NOISY").is_none() {
return;
}
println!("{}\nstdout marker", "o".repeat(4_096));
eprintln!("{}\nstderr marker", "e".repeat(4_096));
}
#[test]
fn helper_process_sleeps() {
if std::env::var_os("HEMX_TEST_PROCESS_SLEEP").is_some() {
println!("sleeping helper started");
std::thread::sleep(Duration::from_secs(10));
}
}