use super::*;
use crate::runtime::encoding::default_runtime_text_encoding;
use crate::runtime::test_support::process_env_test_guard;
use std::fs;
use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::AtomicUsize;
use std::thread;
use std::time::{Duration, Instant};
#[cfg(windows)]
use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER;
#[cfg(windows)]
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
#[cfg(windows)]
const WINDOWS_SYNCHRONIZE_ACCESS: u32 = 0x0010_0000;
const DESCENDANT_PROBE_START_TIMEOUT: Duration = Duration::from_secs(60);
static DESCENDANT_FIXTURE_SEQUENCE: AtomicUsize = AtomicUsize::new(1);
#[test]
fn direct_child_reaping_is_bounded_by_absolute_deadline() {
let mut command = if cfg!(windows) {
let mut command = Command::new("powershell");
command.args(["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]);
command
} else {
let mut command = Command::new("sleep");
command.arg("30");
command
};
let mut child = command.spawn().expect("spawn bounded reap test child");
let started = Instant::now();
let error = wait_for_child_exit_until(
&mut child,
Instant::now() + Duration::from_millis(20),
"bounded reap test child",
)
.expect_err("live child should outlast short reap deadline");
assert!(error.contains("forced-reap deadline"));
assert!(started.elapsed() < Duration::from_secs(1));
child.kill().expect("kill bounded reap test child");
wait_for_child_exit_until(
&mut child,
Instant::now() + Duration::from_secs(2),
"killed bounded reap test child",
)
.expect("reap killed bounded reap test child");
}
fn make_drop_cleanup_request() -> ProcessSessionOpenRequest {
let encoding = default_runtime_text_encoding();
if cfg!(windows) {
ProcessSessionOpenRequest {
program: "powershell".to_string(),
args: vec![
"-NoProfile".to_string(),
"-Command".to_string(),
"Start-Sleep -Seconds 30".to_string(),
],
cwd: None,
stdout_encoding: encoding,
stderr_encoding: encoding,
stdin_encoding: encoding,
buffer_limit_bytes: DEFAULT_SESSION_BUFFER_LIMIT_BYTES,
}
} else {
ProcessSessionOpenRequest {
program: "sleep".to_string(),
args: vec!["30".to_string()],
cwd: None,
stdout_encoding: encoding,
stderr_encoding: encoding,
stdin_encoding: encoding,
buffer_limit_bytes: DEFAULT_SESSION_BUFFER_LIMIT_BYTES,
}
}
}
fn make_descendant_cleanup_request() -> (ProcessSessionOpenRequest, Option<PathBuf>) {
let encoding = default_runtime_text_encoding();
if cfg!(windows) {
(ProcessSessionOpenRequest {
program: "python".to_string(),
args: vec![
"-c".to_string(),
"import subprocess, sys, time; child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); print(child.pid, flush=True); time.sleep(0.3)".to_string(),
],
cwd: None,
stdout_encoding: encoding,
stderr_encoding: encoding,
stdin_encoding: encoding,
buffer_limit_bytes: DEFAULT_SESSION_BUFFER_LIMIT_BYTES,
}, None)
} else {
let fixture_sequence = DESCENDANT_FIXTURE_SEQUENCE.fetch_add(1, Ordering::AcqRel);
let pid_path = std::env::temp_dir().join(format!(
"luaskills-process-descendant-{}-{fixture_sequence}.pid",
std::process::id()
));
let _ = fs::remove_file(&pid_path);
(ProcessSessionOpenRequest {
program: "/bin/sh".to_string(),
args: vec![
"-c".to_string(),
"/bin/sleep 30 </dev/null >/dev/null 2>&1 & echo $!; echo $! > \"$1\"; /bin/sleep 0.3; exit 0".to_string(),
"managed-descendant-fixture".to_string(),
pid_path.to_string_lossy().into_owned(),
],
cwd: None,
stdout_encoding: encoding,
stderr_encoding: encoding,
stdin_encoding: encoding,
buffer_limit_bytes: DEFAULT_SESSION_BUFFER_LIMIT_BYTES,
}, Some(pid_path))
}
}
fn make_immediate_exit_request() -> ProcessSessionOpenRequest {
let encoding = default_runtime_text_encoding();
if cfg!(windows) {
ProcessSessionOpenRequest {
program: "cmd".to_string(),
args: vec!["/c".to_string(), "exit 0".to_string()],
cwd: None,
stdout_encoding: encoding,
stderr_encoding: encoding,
stdin_encoding: encoding,
buffer_limit_bytes: DEFAULT_SESSION_BUFFER_LIMIT_BYTES,
}
} else {
ProcessSessionOpenRequest {
program: "sh".to_string(),
args: vec!["-c".to_string(), "exit 0".to_string()],
cwd: None,
stdout_encoding: encoding,
stderr_encoding: encoding,
stdin_encoding: encoding,
buffer_limit_bytes: DEFAULT_SESSION_BUFFER_LIMIT_BYTES,
}
}
}
fn command_from_open_request(request: &ProcessSessionOpenRequest) -> Command {
let mut command = Command::new(&request.program);
command.args(&request.args);
if let Some(cwd) = request.cwd.as_deref() {
command.current_dir(cwd);
}
command
}
fn make_core_launch_options(buffer_limit_bytes: usize) -> ManagedProcessSessionLaunchOptions {
let encoding = default_runtime_text_encoding();
ManagedProcessSessionLaunchOptions {
stdout_encoding: encoding,
stderr_encoding: encoding,
stdin_encoding: encoding,
buffer_limit_bytes,
}
}
fn make_output_then_sleep_command(output: &str) -> Command {
if cfg!(windows) {
let script = format!(
"[Console]::Out.Write('{}'); [Console]::Out.Flush(); Start-Sleep -Seconds 30",
output.replace('\'', "''")
);
let mut command = Command::new("powershell");
command.args(["-NoProfile", "-Command", &script]);
command
} else {
let script = format!("printf '%s' '{}'; sleep 30", output.replace('\'', "'\\''"));
let mut command = Command::new("sh");
command.args(["-c", &script]);
command
}
}
fn make_inherited_pipe_descendant_command() -> Command {
if cfg!(windows) {
let mut command = Command::new("python");
command.args([
"-c",
"import subprocess, sys; child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'], stdin=subprocess.DEVNULL); print(child.pid, flush=True)",
]);
command
} else {
let mut command = Command::new("sh");
command.args(["-c", "sleep 30 </dev/null & echo $!; exit 0"]);
command
}
}
fn wait_for_stdout_total(
core: &ManagedProcessSessionCore,
minimum_total_bytes: u64,
timeout: Duration,
) {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let status = core.status().expect("read managed core status");
if status.stdout.total_bytes >= minimum_total_bytes {
return;
}
thread::sleep(Duration::from_millis(10));
}
panic!("stdout should receive at least {minimum_total_bytes} bytes before timeout");
}
#[derive(Default)]
struct CountingProcessSessionObserver {
stdout_readable: AtomicUsize,
stderr_readable: AtomicUsize,
exited: AtomicUsize,
failed: AtomicUsize,
}
impl ManagedProcessSessionObserver for CountingProcessSessionObserver {
fn stdout_readable(&self) {
self.stdout_readable.fetch_add(1, Ordering::SeqCst);
}
fn stderr_readable(&self) {
self.stderr_readable.fetch_add(1, Ordering::SeqCst);
}
fn exited(&self) {
self.exited.fetch_add(1, Ordering::SeqCst);
}
fn failed(&self) {
self.failed.fetch_add(1, Ordering::SeqCst);
}
}
fn wait_for_observer_count(counter: &AtomicUsize, minimum: usize, timeout: Duration) {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if counter.load(Ordering::SeqCst) >= minimum {
return;
}
thread::sleep(Duration::from_millis(10));
}
panic!("observer counter should reach {minimum} before timeout");
}
fn spawn_background_thread_except(
role: ManagedProcessBackgroundThread,
task: ManagedProcessBackgroundTask,
rejected: ManagedProcessBackgroundThread,
) -> Result<thread::JoinHandle<()>, std::io::Error> {
if role == rejected {
return Err(std::io::Error::other(format!(
"forced {} creation failure",
role.name()
)));
}
spawn_managed_process_background_thread(role, task)
}
fn fail_stdout_reader_spawn(
role: ManagedProcessBackgroundThread,
task: ManagedProcessBackgroundTask,
) -> Result<thread::JoinHandle<()>, std::io::Error> {
spawn_background_thread_except(role, task, ManagedProcessBackgroundThread::StdoutReader)
}
fn fail_stderr_reader_spawn(
role: ManagedProcessBackgroundThread,
task: ManagedProcessBackgroundTask,
) -> Result<thread::JoinHandle<()>, std::io::Error> {
spawn_background_thread_except(role, task, ManagedProcessBackgroundThread::StderrReader)
}
fn fail_exit_watcher_spawn(
role: ManagedProcessBackgroundThread,
task: ManagedProcessBackgroundTask,
) -> Result<thread::JoinHandle<()>, std::io::Error> {
spawn_background_thread_except(role, task, ManagedProcessBackgroundThread::ExitWatcher)
}
fn process_exists(pid: u32) -> Result<bool, String> {
#[cfg(windows)]
{
let handle = unsafe {
OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION | WINDOWS_SYNCHRONIZE_ACCESS,
0,
pid,
)
};
if handle.is_null() {
let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) {
return Ok(false);
}
return Err(format!("OpenProcess({pid}): {error}"));
}
let handle_owner = unsafe { OwnedHandle::from_raw_handle(handle as _) };
let wait_result = unsafe { WaitForSingleObject(handle_owner.as_raw_handle() as HANDLE, 0) };
match wait_result {
WAIT_TIMEOUT => Ok(true),
WAIT_OBJECT_0 => Ok(false),
WAIT_FAILED => Err(format!(
"WaitForSingleObject({pid}): {}",
std::io::Error::last_os_error()
)),
other => Err(format!(
"WaitForSingleObject({pid}) returned unexpected status {other}"
)),
}
}
#[cfg(unix)]
{
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
if result == 0 {
return Ok(true);
}
let error = std::io::Error::last_os_error();
match error.raw_os_error() {
Some(libc::ESRCH) => Ok(false),
Some(libc::EPERM) => Ok(true),
_ => Err(format!("kill({pid}, 0): {error}")),
}
}
#[cfg(not(any(unix, windows)))]
{
Err(format!(
"process existence probing is unsupported on {}",
std::env::consts::OS
))
}
}
fn assert_process_exits(pid: u32, timeout: Duration) {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if !process_exists(pid).expect("probe process existence") {
return;
}
thread::sleep(Duration::from_millis(50));
}
panic!("process {pid} should have exited after session drop");
}
fn wait_for_descendant_pid(
session: &ManagedProcessSession,
pid_path: Option<&Path>,
timeout: Duration,
) -> u32 {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let stdout = session
.core
.state
.stdout_buffer
.lock()
.expect("lock stdout buffer");
if !stdout.bytes.is_empty() {
let pid_lines = stdout
.bytes
.iter()
.filter_map(|byte| match byte {
b'0'..=b'9' => Some(char::from(*byte)),
b'\r' | b'\n' => Some('\n'),
_ => None,
})
.collect::<String>();
drop(stdout);
for pid_text in pid_lines
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
if let Ok(pid) = pid_text.parse::<u32>() {
return pid;
}
}
}
if let Some(pid_path) = pid_path
&& let Ok(pid_text) = fs::read_to_string(pid_path)
&& let Ok(pid) = pid_text.trim().parse::<u32>()
{
return pid;
}
thread::sleep(Duration::from_millis(25));
}
panic!("descendant pid should be published before cleanup");
}
fn wait_for_root_exit(session: &ManagedProcessSession, timeout: Duration) {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if session
.core
.state
.peek_status_snapshot()
.expect("peek root process status")
.exited
{
return;
}
thread::sleep(Duration::from_millis(10));
}
panic!("root process should exit before the lifecycle deadline");
}
#[test]
fn dropping_process_session_kills_child_process() {
let _env_guard = process_env_test_guard();
let session = ManagedProcessSession::open(make_drop_cleanup_request())
.expect("open drop cleanup session");
let pid = session
.core
.state
.child
.lock()
.expect("lock child process")
.as_ref()
.expect("drop cleanup child ownership")
.id();
assert!(
process_exists(pid).expect("probe child before drop"),
"child process should be running before drop"
);
drop(session);
assert_process_exits(pid, Duration::from_secs(5));
}
#[test]
fn killing_process_session_terminates_descendants_and_releases_readers() {
let _env_guard = process_env_test_guard();
let (request, pid_path) = make_descendant_cleanup_request();
let session = ManagedProcessSession::open(request).expect("open descendant cleanup session");
let descendant_pid = wait_for_descendant_pid(
&session,
pid_path.as_deref(),
DESCENDANT_PROBE_START_TIMEOUT,
);
assert!(
process_exists(descendant_pid).expect("probe descendant before cleanup"),
"descendant process should be running before cleanup"
);
session
.mark_closed("process.session.test")
.expect("mark process session closed");
let start = Instant::now();
session
.kill_child()
.expect("kill descendant process tree cleanly");
session
.join_reader_threads("process.session.test")
.expect("join process session readers");
assert!(
start.elapsed() < Duration::from_secs(5),
"process session cleanup should not block after tree termination"
);
assert_process_exits(descendant_pid, Duration::from_secs(5));
if let Some(pid_path) = pid_path {
let _ = fs::remove_file(pid_path);
}
}
#[cfg(windows)]
#[test]
fn windows_suspended_root_and_descendant_share_managed_job() {
let _env_guard = process_env_test_guard();
let (request, pid_path) = make_descendant_cleanup_request();
let session =
ManagedProcessSession::open(request).expect("open Windows Job containment session");
let descendant_pid = wait_for_descendant_pid(
&session,
pid_path.as_deref(),
DESCENDANT_PROBE_START_TIMEOUT,
);
let child_guard = session
.core
.state
.child
.lock()
.expect("lock Windows Job root child");
let child = child_guard
.as_ref()
.expect("Windows Job root child ownership");
session
.core
.state
.process_tree
.job
.require_contains(child.as_raw_handle() as HANDLE, child.id())
.expect("root process should belong to managed Job");
let descendant_handle = unsafe {
OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION | WINDOWS_SYNCHRONIZE_ACCESS,
0,
descendant_pid,
)
};
assert!(
!descendant_handle.is_null(),
"open managed descendant {descendant_pid}: {}",
std::io::Error::last_os_error()
);
let descendant_handle = unsafe { OwnedHandle::from_raw_handle(descendant_handle as _) };
session
.core
.state
.process_tree
.job
.require_contains(descendant_handle.as_raw_handle() as HANDLE, descendant_pid)
.expect("descendant process should inherit managed Job");
drop(child_guard);
session.kill().expect("terminate contained Windows Job");
assert_process_exits(descendant_pid, Duration::from_secs(5));
}
#[test]
fn process_session_tree_teardown_is_idempotent_after_explicit_kill() {
let _env_guard = process_env_test_guard();
let session =
ManagedProcessSession::open(make_drop_cleanup_request()).expect("open idempotent session");
session
.mark_closed("process.session.test")
.expect("mark idempotent session closed");
let first = session
.kill_child()
.expect("first process tree teardown should succeed");
let second = session
.kill_child()
.expect("second process tree teardown should reuse cached final status");
assert_eq!(first, second);
}
#[test]
fn cloned_managed_process_session_core_retains_strong_lifetime() {
let _env_guard = process_env_test_guard();
let request = make_drop_cleanup_request();
let core = ManagedProcessSessionCore::launch(
command_from_open_request(&request),
make_core_launch_options(DEFAULT_SESSION_BUFFER_LIMIT_BYTES),
)
.expect("launch cloned managed core");
let retained_core = core.clone();
drop(core);
assert!(
retained_core
.status()
.expect("read retained managed core status")
.process
.running
);
retained_core.kill().expect("kill retained managed core");
}
#[test]
fn managed_process_session_observer_is_lifecycle_bounded() {
let _env_guard = process_env_test_guard();
let observer = Arc::new(CountingProcessSessionObserver::default());
let core = ManagedProcessSessionCore::launch_with_observer(
make_inherited_pipe_descendant_command(),
make_core_launch_options(DEFAULT_SESSION_BUFFER_LIMIT_BYTES),
observer.clone(),
)
.expect("launch observed managed core");
wait_for_observer_count(&observer.stdout_readable, 1, Duration::from_secs(5));
wait_for_observer_count(&observer.exited, 1, Duration::from_secs(5));
assert!(
!core
.state
.output_readers_drained()
.expect("inspect inherited output pipes"),
"direct-child exit notification must not depend on output EOF"
);
core.close(0).expect("close observed managed core");
let stdout_count = observer.stdout_readable.load(Ordering::SeqCst);
notify_managed_process_observer(
core.state.observer.as_ref(),
&core.state.observer_notifications_open,
ManagedProcessSessionObserver::stdout_readable,
);
assert_eq!(
observer.stdout_readable.load(Ordering::SeqCst),
stdout_count
);
assert_eq!(observer.exited.load(Ordering::SeqCst), 1);
assert_eq!(observer.failed.load(Ordering::SeqCst), 0);
}
#[test]
fn process_session_core_zero_timeout_is_truly_non_blocking() {
let _env_guard = process_env_test_guard();
let empty_request = make_drop_cleanup_request();
let empty_core = ManagedProcessSessionCore::launch(
command_from_open_request(&empty_request),
make_core_launch_options(DEFAULT_SESSION_BUFFER_LIMIT_BYTES),
)
.expect("launch empty-output managed core");
let non_blocking_read = ManagedProcessSessionReadRequest {
timeout_ms: 0,
max_bytes: DEFAULT_SESSION_MAX_READ_BYTES,
until_text: None,
};
let empty_start = Instant::now();
let empty_result = empty_core
.read(&non_blocking_read)
.expect("read empty core without waiting");
assert!(empty_start.elapsed() < Duration::from_millis(100));
assert!(empty_result.timed_out);
assert!(empty_result.stdout.is_empty());
empty_core.kill().expect("kill empty-output managed core");
let ready_core = ManagedProcessSessionCore::launch(
make_output_then_sleep_command("ready"),
make_core_launch_options(DEFAULT_SESSION_BUFFER_LIMIT_BYTES),
)
.expect("launch ready-output managed core");
wait_for_stdout_total(&ready_core, 5, Duration::from_secs(5));
let ready_start = Instant::now();
let ready_result = ready_core
.read(&non_blocking_read)
.expect("read ready core without waiting");
assert!(ready_start.elapsed() < Duration::from_millis(100));
assert!(!ready_result.timed_out);
assert_eq!(ready_result.stdout, "ready");
ready_core.kill().expect("kill ready-output managed core");
}
#[test]
fn process_session_lua_timeout_parsers_accept_zero() {
let lua = Lua::new();
let read_options = lua.create_table().expect("create read timeout options");
read_options
.set("timeout_ms", 0)
.expect("set zero read timeout");
let mut read_args = MultiValue::new();
read_args.push_back(LuaValue::Table(read_options));
let read_request = parse_session_read_request(read_args).expect("parse zero read timeout");
assert_eq!(read_request.timeout_ms, 0);
let close_options = lua.create_table().expect("create close timeout options");
close_options
.set("timeout_ms", 0)
.expect("set zero close timeout");
let mut close_args = MultiValue::new();
close_args.push_back(LuaValue::Table(close_options));
let close_request = parse_session_close_request(close_args).expect("parse zero close timeout");
assert_eq!(close_request.timeout_ms, 0);
}
#[test]
fn process_session_core_rejects_unrepresentable_timeout_deadlines() {
let _env_guard = process_env_test_guard();
let request = make_drop_cleanup_request();
let core = ManagedProcessSessionCore::launch(
command_from_open_request(&request),
make_core_launch_options(DEFAULT_SESSION_BUFFER_LIMIT_BYTES),
)
.expect("launch timeout validation core");
let read_request = ManagedProcessSessionReadRequest {
timeout_ms: u64::MAX,
max_bytes: 1,
until_text: None,
};
let read_error = core
.read(&read_request)
.expect_err("oversized read timeout should be rejected");
assert!(read_error.contains("read timeout_ms is too large"));
let close_error = core
.close(u64::MAX)
.expect_err("oversized close timeout should be rejected");
assert!(close_error.contains("close timeout_ms is too large"));
assert!(
core.status()
.expect("status after timeout errors")
.process
.running
);
core.kill().expect("kill timeout validation core");
}
#[test]
fn process_session_core_reports_bounded_output_drops() {
let _env_guard = process_env_test_guard();
let core = ManagedProcessSessionCore::launch(
make_output_then_sleep_command("abcdefghijklmnop"),
make_core_launch_options(8),
)
.expect("launch bounded-output managed core");
wait_for_stdout_total(&core, 16, Duration::from_secs(5));
let status = core.status().expect("read bounded-output status");
assert_eq!(status.stdout.buffered_bytes, 8);
assert_eq!(status.stdout.total_bytes, 16);
assert_eq!(status.stdout.dropped_bytes, 8);
let read = core
.read(&ManagedProcessSessionReadRequest {
timeout_ms: 0,
max_bytes: 8,
until_text: None,
})
.expect("read bounded-output tail");
assert_eq!(read.stdout, "ijklmnop");
assert_eq!(read.stdout_stats.buffered_bytes, 0);
assert_eq!(read.stdout_stats.total_bytes, 16);
assert_eq!(read.stdout_stats.dropped_bytes, 8);
core.kill().expect("kill bounded-output managed core");
}
#[test]
fn process_session_core_attach_failure_cleans_spawned_child() {
let _env_guard = process_env_test_guard();
let request = make_drop_cleanup_request();
let spawned_pid = Arc::new(Mutex::new(None));
let spawned_pid_for_attach = spawned_pid.clone();
let launch_result = ManagedProcessSessionCore::launch_with_attacher(
command_from_open_request(&request),
make_core_launch_options(DEFAULT_SESSION_BUFFER_LIMIT_BYTES),
move |child| {
*spawned_pid_for_attach
.lock()
.expect("store spawned child pid") = Some(child.id());
Err("forced process-tree attach failure".to_string())
},
);
let error = match launch_result {
Ok(unexpected_core) => {
drop(unexpected_core.kill());
panic!("forced attach failure should reject launch");
}
Err(error) => error,
};
assert!(error.contains("forced process-tree attach failure"));
let pid = spawned_pid
.lock()
.expect("read spawned child pid")
.expect("attacher should observe spawned child");
assert_process_exits(pid, Duration::from_secs(5));
}
#[test]
fn process_session_background_thread_spawn_failures_are_transactional() {
let _env_guard = process_env_test_guard();
let cases: [(
ManagedProcessBackgroundThread,
ManagedProcessBackgroundThreadSpawner,
); 3] = [
(
ManagedProcessBackgroundThread::StdoutReader,
fail_stdout_reader_spawn,
),
(
ManagedProcessBackgroundThread::StderrReader,
fail_stderr_reader_spawn,
),
(
ManagedProcessBackgroundThread::ExitWatcher,
fail_exit_watcher_spawn,
),
];
for (failed_role, spawn_thread) in cases {
let spawned_pid = Arc::new(Mutex::new(None));
let spawned_pid_for_attach = spawned_pid.clone();
let observer = Arc::new(CountingProcessSessionObserver::default());
let request = make_drop_cleanup_request();
let started_at = Instant::now();
let launch_result = ManagedProcessSessionCore::launch_with_attacher_and_observer(
command_from_open_request(&request),
make_core_launch_options(DEFAULT_SESSION_BUFFER_LIMIT_BYTES),
Some(observer.clone()),
None,
move |child| {
*spawned_pid_for_attach
.lock()
.expect("store background-failure child pid") = Some(child.id());
ProcessTreeController::attach(child)
},
spawn_thread,
);
let error = match launch_result {
Ok(unexpected_core) => {
drop(unexpected_core.kill());
panic!(
"{} failure injection should reject launch",
failed_role.name()
);
}
Err(error) => error,
};
assert!(
error.contains(failed_role.name()),
"unexpected background-thread error: {error}"
);
assert!(
started_at.elapsed() < Duration::from_secs(5),
"{} rollback should join prior readers promptly",
failed_role.name()
);
let pid = spawned_pid
.lock()
.expect("read background-failure child pid")
.expect("tree attacher should observe spawned child");
assert_process_exits(pid, Duration::from_secs(5));
assert_eq!(observer.failed.load(Ordering::SeqCst), 1);
}
}
#[test]
fn process_session_userdata_cleanup_runs_after_core_teardown() {
let _env_guard = process_env_test_guard();
let request = make_drop_cleanup_request();
let core = ManagedProcessSessionCore::launch(
command_from_open_request(&request),
make_core_launch_options(DEFAULT_SESSION_BUFFER_LIMIT_BYTES),
)
.expect("launch userdata cleanup core");
let pid = lock_session_child(&core.state.child)
.as_ref()
.expect("userdata cleanup child ownership")
.id();
let (cleanup_tx, cleanup_rx) = mpsc::channel();
let lua = Lua::new();
let userdata = create_managed_process_session_userdata(
&lua,
core,
Some(ManagedProcessSessionCleanupHandle::new(Box::new(
move || {
let probe = process_exists(pid).map(|exists| !exists);
drop(cleanup_tx.send(probe));
},
))),
None,
)
.expect("create managed process userdata");
drop(userdata);
lua.gc_collect().expect("collect managed process userdata");
let exited_before_cleanup = cleanup_rx
.recv_timeout(Duration::from_secs(5))
.expect("receive userdata cleanup result")
.expect("probe process during userdata cleanup");
assert!(
exited_before_cleanup,
"cleanup callback must observe a terminated process"
);
}
#[test]
fn process_session_userdata_drop_preserves_cleanup_after_teardown_failure() {
let _env_guard = process_env_test_guard();
let request = make_drop_cleanup_request();
let core = ManagedProcessSessionCore::launch(
command_from_open_request(&request),
make_core_launch_options(DEFAULT_SESSION_BUFFER_LIMIT_BYTES),
)
.expect("launch cleanup-retention managed core");
core.kill()
.expect("complete real process teardown before injecting reader failure");
let retained_core = core.clone();
let (done_tx, done_rx) = mpsc::channel();
let done = Arc::new(AtomicBool::new(false));
let done_for_thread = done.clone();
let reader_handle = thread::Builder::new()
.name("managed-process-test-panicking-reader".to_string())
.spawn(move || {
done_for_thread.store(true, Ordering::Release);
let _ = done_tx.send(());
panic!("forced reader join failure");
})
.expect("spawn synthetic panicking reader");
*lock_session_reader_slot(&core.state.stdout_reader) = Some(SessionPipeReader {
handle: reader_handle,
done_rx,
done,
});
let cleanup_count = Arc::new(AtomicUsize::new(0));
let cleanup_count_for_callback = cleanup_count.clone();
let retry_count = Arc::new(AtomicUsize::new(0));
let retry_count_for_callback = Arc::clone(&retry_count);
let cleanup = ManagedProcessSessionCleanupHandle::new_with_retry(
Box::new(move || {
cleanup_count_for_callback.fetch_add(1, Ordering::SeqCst);
}),
Box::new(move || {
retry_count_for_callback.fetch_add(1, Ordering::SeqCst);
}),
);
let session = ManagedProcessSession::from_core(core, Some(cleanup.clone()), None);
drop(session);
assert_eq!(cleanup_count.load(Ordering::SeqCst), 0);
assert_eq!(retry_count.load(Ordering::SeqCst), 1);
retained_core
.teardown_before_userdata_cleanup()
.expect("retry teardown after synthetic reader failure");
cleanup.run_once();
assert_eq!(cleanup_count.load(Ordering::SeqCst), 1);
}
#[test]
fn process_session_explicit_close_runs_userdata_cleanup_once() {
let _env_guard = process_env_test_guard();
let request = make_drop_cleanup_request();
let core = ManagedProcessSessionCore::launch(
command_from_open_request(&request),
make_core_launch_options(DEFAULT_SESSION_BUFFER_LIMIT_BYTES),
)
.expect("launch explicit cleanup core");
let pid = lock_session_child(&core.state.child)
.as_ref()
.expect("explicit cleanup child ownership")
.id();
let cleanup_count = Arc::new(AtomicUsize::new(0));
let cleanup_count_for_callback = Arc::clone(&cleanup_count);
let (cleanup_tx, cleanup_rx) = mpsc::channel();
let lua = Lua::new();
let userdata = create_managed_process_session_userdata(
&lua,
core,
Some(ManagedProcessSessionCleanupHandle::new(Box::new(
move || {
cleanup_count_for_callback.fetch_add(1, Ordering::SeqCst);
drop(cleanup_tx.send(process_exists(pid).map(|exists| !exists)));
},
))),
Some(42),
)
.expect("create explicit cleanup userdata");
lua.globals()
.set("session", userdata.clone())
.expect("store explicit cleanup userdata");
let managed_session_id = lua
.load("return session:status().managed_session_id")
.eval::<u64>()
.expect("read managed session correlation id");
assert_eq!(managed_session_id, 42);
lua.load("return session:close({ timeout_ms = 0 })")
.eval::<LuaValue>()
.expect("close managed process userdata");
assert!(
cleanup_rx
.recv_timeout(Duration::from_secs(5))
.expect("receive explicit cleanup result")
.expect("probe process during explicit cleanup")
);
assert_eq!(cleanup_count.load(Ordering::SeqCst), 1);
lua.globals()
.set("session", LuaValue::Nil)
.expect("clear explicit cleanup userdata");
drop(userdata);
lua.gc_collect()
.expect("collect explicitly closed userdata");
assert_eq!(cleanup_count.load(Ordering::SeqCst), 1);
}
#[test]
fn join_one_reader_detaches_current_reader_thread() {
let reader_slot = Arc::new(Mutex::new(None));
let (start_tx, start_rx) = mpsc::channel();
let (result_tx, result_rx) = mpsc::channel();
let thread_reader_slot = reader_slot.clone();
let handle = thread::spawn(move || {
start_rx.recv().expect("release self-detach reader");
let result = ManagedProcessSessionState::join_one_reader(&thread_reader_slot, "self");
let _ = result_tx.send(result);
});
let (_done_tx, done_rx) = mpsc::channel();
*reader_slot.lock().expect("install self reader handle") = Some(SessionPipeReader {
handle,
done_rx,
done: Arc::new(AtomicBool::new(false)),
});
start_tx.send(()).expect("start self-detach reader");
result_rx
.recv_timeout(Duration::from_secs(1))
.expect("receive self-detach result")
.expect("detach current reader thread");
assert!(
reader_slot
.lock()
.expect("inspect self reader slot")
.is_none()
);
}
#[test]
fn join_one_reader_timeout_preserves_reader_handle() {
let (release_tx, release_rx) = mpsc::channel();
let (done_tx, done_rx) = mpsc::channel();
let done = Arc::new(AtomicBool::new(false));
let done_flag = done.clone();
let handle = thread::spawn(move || {
release_rx.recv().expect("release test reader");
done_flag.store(true, Ordering::Release);
let _ = done_tx.send(());
});
let reader_slot = Mutex::new(Some(SessionPipeReader {
handle,
done_rx,
done,
}));
let error = ManagedProcessSessionState::join_one_reader(&reader_slot, "test")
.expect_err("reader join should time out before release");
assert!(
error.contains("timed out"),
"timeout error should mention shutdown timeout, got: {error}"
);
assert!(
reader_slot
.lock()
.expect("lock reader slot after timeout")
.is_some(),
"reader handle should stay available after timeout"
);
release_tx.send(()).expect("release test reader thread");
ManagedProcessSessionState::join_one_reader(&reader_slot, "test")
.expect("reader join should succeed after release");
assert!(
reader_slot
.lock()
.expect("lock reader slot after join")
.is_none(),
"reader handle should be removed after successful join"
);
}
#[test]
fn process_session_output_buffer_recovers_after_poisoned_lock() {
let buffer = Arc::new(Mutex::new(ManagedProcessOutputBuffer {
bytes: VecDeque::from(Vec::from(&b"ready"[..])),
total_bytes: 5,
dropped_bytes: 0,
}));
let poison_result = panic::catch_unwind(AssertUnwindSafe(|| {
let _guard = buffer
.lock()
.expect("initial process session output buffer lock");
panic!("poison process session output buffer for recovery test");
}));
assert!(poison_result.is_err());
let (drained, stats) = drain_output_buffer(&buffer, 3);
assert_eq!(drained, b"rea");
assert_eq!(stats.buffered_bytes, 2);
assert_eq!(stats.total_bytes, 5);
assert_eq!(stats.dropped_bytes, 0);
}
#[test]
fn process_session_reader_slot_recovers_after_poisoned_lock() {
let reader_slot: Mutex<Option<SessionPipeReader>> = Mutex::new(None);
let poison_result = panic::catch_unwind(AssertUnwindSafe(|| {
let _guard = reader_slot
.lock()
.expect("initial process session reader slot lock");
panic!("poison process session reader slot for recovery test");
}));
assert!(poison_result.is_err());
assert!(ManagedProcessSessionState::reader_completed(&reader_slot));
}
#[test]
fn process_session_lifecycle_state_locks_recover_after_poisoned_lock() {
let _env_guard = process_env_test_guard();
let session = ManagedProcessSession::open(make_drop_cleanup_request())
.expect("open lifecycle poison recovery session");
let stdin_poison = panic::catch_unwind(AssertUnwindSafe(|| {
let _guard = session
.core
.state
.stdin
.lock()
.expect("initial process session stdin lock");
panic!("poison process session stdin for recovery test");
}));
assert!(stdin_poison.is_err());
assert!(
session
.write_values(MultiValue::new())
.expect("write through poisoned stdin lock")
);
session
.close_stdin("process.session.test")
.expect("close poisoned stdin lock");
let closed_poison = panic::catch_unwind(AssertUnwindSafe(|| {
let _guard = session
.core
.state
.closed
.lock()
.expect("initial process session closed lock");
panic!("poison process session closed flag for recovery test");
}));
assert!(closed_poison.is_err());
session
.mark_closed("process.session.test")
.expect("mark closed through poisoned closed flag");
let final_status_poison = panic::catch_unwind(AssertUnwindSafe(|| {
let _guard = session
.core
.state
.final_status
.lock()
.expect("initial process session final status lock");
panic!("poison process session final status for recovery test");
}));
assert!(final_status_poison.is_err());
let tree_terminated_poison = panic::catch_unwind(AssertUnwindSafe(|| {
let _guard = session
.core
.state
.process_tree_terminated
.lock()
.expect("initial process tree terminated lock");
panic!("poison process tree terminated flag for recovery test");
}));
assert!(tree_terminated_poison.is_err());
let killed_status = session
.kill_child()
.expect("kill child through poisoned final status cache");
let cached_status = session
.core
.state
.cached_final_status()
.expect("read poisoned final status cache");
assert_eq!(cached_status, Some(killed_status));
session
.join_reader_threads("process.session.test")
.expect("join readers after lifecycle poison recovery");
}
#[test]
fn process_session_child_lock_recovers_after_poisoned_lock() {
let _env_guard = process_env_test_guard();
let session = ManagedProcessSession::open(make_drop_cleanup_request())
.expect("open child poison session");
let child_poison = panic::catch_unwind(AssertUnwindSafe(|| {
let _guard = session
.core
.state
.child
.lock()
.expect("initial process session child lock");
panic!("poison process session child for recovery test");
}));
assert!(child_poison.is_err());
let status = session
.core
.state
.peek_status_snapshot()
.expect("peek status through poisoned child lock");
assert!(status.running || !status.exited);
let killed_status = session
.kill_child()
.expect("kill through poisoned child lock");
assert!(killed_status.exited);
session
.join_reader_threads("process.session.test")
.expect("join readers after child poison recovery");
}
#[test]
fn closing_process_session_after_child_exit_still_cleans_descendants() {
let _env_guard = process_env_test_guard();
let lua = Lua::new();
let (request, pid_path) = make_descendant_cleanup_request();
let session =
ManagedProcessSession::open(request).expect("open close descendant cleanup session");
let descendant_pid = wait_for_descendant_pid(
&session,
pid_path.as_deref(),
DESCENDANT_PROBE_START_TIMEOUT,
);
wait_for_root_exit(&session, Duration::from_secs(5));
assert!(
process_exists(descendant_pid).expect("probe descendant before close cleanup"),
"descendant process should be running before close cleanup"
);
let reaped_status = {
let mut child_guard = lock_session_child(&session.core.state.child);
child_guard
.as_mut()
.expect("exited root child ownership")
.wait()
.map(Some)
.map(process_status_snapshot_from_exit_status)
.expect("reap exited root process")
};
session
.core
.state
.store_final_status(reaped_status)
.expect("cache reaped root process status");
let start = Instant::now();
let status = session
.close(&lua, MultiValue::new())
.expect("close descendant cleanup session");
assert!(
start.elapsed() < Duration::from_secs(5),
"process.session.close should not block after descendant cleanup"
);
let exited: bool = status.get("exited").expect("read close exited flag");
assert!(exited, "close should report one exited process status");
assert_process_exits(descendant_pid, Duration::from_secs(5));
if let Some(pid_path) = pid_path {
let _ = fs::remove_file(pid_path);
}
}
#[test]
fn read_waits_for_descendant_output_after_root_exit() {
let _env_guard = process_env_test_guard();
let lua = Lua::new();
let session = ManagedProcessSession::open(make_immediate_exit_request())
.expect("open immediate exit session");
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if session
.core
.state
.peek_status_snapshot()
.expect("peek immediate exit status")
.exited
{
break;
}
thread::sleep(Duration::from_millis(10));
}
assert!(
session
.core
.state
.peek_status_snapshot()
.expect("recheck immediate exit status")
.exited,
"immediate exit process should finish before read regression check"
);
session
.core
.state
.join_reader_threads()
.expect("join real readers before installing test readers");
let install_test_reader = || -> (SessionPipeReader, mpsc::Sender<()>, Arc<AtomicBool>) {
let (release_tx, release_rx) = mpsc::channel();
let (done_tx, done_rx) = mpsc::channel();
let done = Arc::new(AtomicBool::new(false));
let done_flag = done.clone();
let handle = thread::spawn(move || {
release_rx.recv().expect("release synthetic session reader");
done_flag.store(true, Ordering::Release);
let _ = done_tx.send(());
});
(
SessionPipeReader {
handle,
done_rx,
done: done.clone(),
},
release_tx,
done,
)
};
let (stdout_reader, stdout_release_tx, _) = install_test_reader();
let (stderr_reader, stderr_release_tx, _) = install_test_reader();
*session
.core
.state
.stdout_reader
.lock()
.expect("lock stdout reader slot for synthetic install") = Some(stdout_reader);
*session
.core
.state
.stderr_reader
.lock()
.expect("lock stderr reader slot for synthetic install") = Some(stderr_reader);
let stdout_buffer = session.core.state.stdout_buffer.clone();
let release_producer = thread::spawn(move || {
thread::sleep(Duration::from_millis(250));
let mut buffer = stdout_buffer
.lock()
.expect("lock stdout buffer for synthetic descendant output");
append_bounded(
&mut buffer,
b"child-ready\n",
DEFAULT_SESSION_BUFFER_LIMIT_BYTES,
);
drop(buffer);
stdout_release_tx
.send(())
.expect("release synthetic stdout reader");
stderr_release_tx
.send(())
.expect("release synthetic stderr reader");
});
let options = lua.create_table().expect("create read options");
options.set("timeout_ms", 3_000).expect("set read timeout");
options
.set("until_text", "child-ready")
.expect("set read marker");
let mut args = MultiValue::new();
args.push_back(LuaValue::Table(options));
let result = session.read(&lua, args).expect("read descendant output");
let stdout: String = result.get("stdout").expect("read stdout text");
let timed_out: bool = result.get("timed_out").expect("read timed_out flag");
assert!(
!timed_out,
"read should finish from descendant output instead of timing out"
);
assert!(
stdout.contains("child-ready"),
"read should capture descendant output after root exit, got: {stdout:?}"
);
release_producer
.join()
.expect("join synthetic descendant output producer");
session
.core
.state
.join_reader_threads()
.expect("join synthetic session readers");
}