use std::sync::{Arc, Mutex};
use std::time::Duration;
use aion_package::ActionBodyContract;
use aion::ActivityDispatcher as _;
use super::tests::{TestResult, dispatcher_with_attempts, reached_names, request};
use super::{DeclaredBodyLookup, DeclaredCommandDispatcher};
use crate::worker::declared_body_cancel::DeclaredCommandAttempts;
use crate::worker::heartbeat::HeartbeatTracker;
use crate::worker::registry::ConnectedWorkerRegistry;
use crate::worker::workspace_root::WorkspaceRoot;
use crate::worker::{InFlightCancellation, cancel_in_flight_activities};
const STOP_DEADLINE: Duration = Duration::from_secs(60);
const POLL: Duration = Duration::from_millis(20);
const AUTHORED_BOUND: Duration = Duration::from_secs(1);
fn blocking_command(marker: &std::path::Path) -> String {
format!(
"sh -c 'touch {}; sleep 600 & sleep 600'",
marker.to_string_lossy()
)
}
fn tree_alive(token: &str) -> Result<bool, Box<dyn std::error::Error>> {
let listing = std::process::Command::new("/bin/ps")
.args(["-Ao", "args="])
.output()?;
if !listing.status.success() {
return Err(format!(
"could not read the process table: ps exited {:?}",
listing.status.code()
)
.into());
}
Ok(String::from_utf8_lossy(&listing.stdout).contains(token))
}
fn reap_leftovers(token: &str) {
match std::process::Command::new("/usr/bin/pkill")
.args(["-f", token])
.status()
{
Ok(_) => {}
Err(error) => tracing::warn!(
%error,
token,
"could not reap a failing containment test's leftover processes"
),
}
}
async fn await_started(marker: &std::path::Path) -> TestResult {
let deadline = tokio::time::Instant::now() + STOP_DEADLINE;
while !marker.exists() {
if tokio::time::Instant::now() >= deadline {
return Err(format!(
"the declared command never started: {} was never created",
marker.display()
)
.into());
}
tokio::time::sleep(POLL).await;
}
Ok(())
}
const HEARTBEAT_WINDOW: Duration = Duration::from_secs(5);
fn containment_dispatcher(
marker: &std::path::Path,
) -> (
DeclaredCommandDispatcher,
Arc<Mutex<Vec<String>>>,
DeclaredCommandAttempts,
) {
let attempts = DeclaredCommandAttempts::new(crate::shutdown::DrainState::default());
let (decorated, reached, _transcript) = dispatcher_with_attempts(
DeclaredBodyLookup::Declared(ActionBodyContract::Run {
command: blocking_command(marker),
}),
Err("terminal:the worker path must never be reached".to_owned()),
WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
attempts.clone(),
);
(decorated, reached, attempts)
}
fn stop_in_flight_work(
attempts: &DeclaredCommandAttempts,
workflow_id: &aion_core::WorkflowId,
) -> Result<InFlightCancellation, crate::error::ServerError> {
let tracker = HeartbeatTracker::new(HEARTBEAT_WINDOW);
let registry = ConnectedWorkerRegistry::default();
cancel_in_flight_activities(&tracker, ®istry, attempts, workflow_id)
}
fn bounded_config(bound: Duration) -> String {
format!(
r#"{{"retry":null,"timeout_ms":{},"heartbeat_ms":null,"labels":{{}}}}"#,
bound.as_millis()
)
}
#[tokio::test(flavor = "multi_thread")]
async fn an_authored_bound_ends_the_server_executed_command() -> TestResult {
let scratch = tempfile::tempdir()?;
let marker = scratch.path().join("started");
let token = marker.to_string_lossy().into_owned();
let (decorated, reached, _attempts) = containment_dispatcher(&marker);
let mut dispatch = request("blocker", "{}");
dispatch.config = bounded_config(AUTHORED_BOUND);
let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
await_started(&marker).await?;
let Ok(joined) = tokio::time::timeout(STOP_DEADLINE, handle).await else {
reap_leftovers(&token);
return Err(
"the attempt outlived its authored per-attempt bound: the bound reaches the \
dispatch future but not the process the server started"
.into(),
);
};
let Err(error) = joined? else {
reap_leftovers(&token);
return Err("a command stopped on its bound must fail the dispatch".into());
};
assert!(
error.starts_with("timeout:"),
"an attempt ended on its authored bound must report the engine's own timeout \
vocabulary, not an anonymous failure: {error}"
);
assert!(
!tree_alive(&token)?,
"the bound ended the wait but left the command running"
);
assert!(
reached_names(&reached).is_empty(),
"a bodied action must not fall through to the worker path"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_the_run_stops_the_server_executed_command() -> TestResult {
let scratch = tempfile::tempdir()?;
let marker = scratch.path().join("started");
let token = marker.to_string_lossy().into_owned();
let (decorated, reached, attempts) = containment_dispatcher(&marker);
let dispatch = request("blocker", "{}");
let workflow_id = dispatch.workflow_id.clone();
let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
await_started(&marker).await?;
let stopped = stop_in_flight_work(&attempts, &workflow_id)?;
let Ok(joined) = tokio::time::timeout(STOP_DEADLINE, handle).await else {
reap_leftovers(&token);
return Err(
"cancelling the run left its server-executed command running: the executing \
attempt is invisible to the cancel path"
.into(),
);
};
let Err(error) = joined? else {
reap_leftovers(&token);
return Err("a cancelled command must fail the dispatch".into());
};
assert!(
error.starts_with("terminal:"),
"a cancelled attempt must not be retried: {error}"
);
assert!(
error.contains("cancelled") && error.contains("process group"),
"the failure must be the containment core's own, so it cannot be produced \
without the group having been proven gone: {error}"
);
assert!(
!tree_alive(&token)?,
"the dispatch reported a stop the process table does not agree with"
);
assert_eq!(
stopped.declared_attempts.len(),
1,
"the cancel must NAME the server-executed attempt it stopped, not stop it \
silently: {stopped:?}"
);
assert_eq!(
stopped.declared_attempts[0].workflow_id, workflow_id,
"the named attempt must be the run's own"
);
assert!(
stopped.worker_requests.is_empty(),
"no worker held this activity, so no worker may be asked about it"
);
assert!(
reached_names(&reached).is_empty(),
"a bodied action must not fall through to the worker path"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_one_run_leaves_another_runs_body_alone() -> TestResult {
let scratch = tempfile::tempdir()?;
let marker = scratch.path().join("started");
let token = marker.to_string_lossy().into_owned();
let (decorated, _reached, attempts) = containment_dispatcher(&marker);
let mut bystander = request("blocker", "{}");
bystander.config = bounded_config(AUTHORED_BOUND);
let cancelled_run = aion_core::WorkflowId::new_v4();
let handle = tokio::task::spawn_blocking(move || decorated.dispatch(bystander));
await_started(&marker).await?;
let stopped = stop_in_flight_work(&attempts, &cancelled_run)?;
assert!(
stopped.declared_attempts.is_empty(),
"another run's cancel must signal nothing here: {stopped:?}"
);
let Ok(joined) = tokio::time::timeout(STOP_DEADLINE, handle).await else {
reap_leftovers(&token);
return Err("the bystander's command never ended on its own bound".into());
};
let Err(error) = joined? else {
reap_leftovers(&token);
return Err("the blocking subject cannot complete on its own".into());
};
assert!(
error.starts_with("timeout:"),
"the bystander must end on its own bound, not on another run's cancel: {error}"
);
assert!(
!tree_alive(&token)?,
"the bystander's own bound must still have stopped its process"
);
Ok(())
}