#![cfg(unix)]
use std::io::BufRead;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use aion::{ActivityDispatch, ActivityDispatcher as _};
use aion_core::{ActivityId, RunId, WorkflowId};
use aion_server::ServerState;
use aion_server::config::{
AuthConfig, AuthoringConfig, AutoCreate, DEFAULT_MAX_IN_FLIGHT_ACTIVITIES, DeployConfig,
DevConfig, ListenConfig, MetricsConfig, NamespaceConfig, NamespaceMode, ObservabilityConfig,
OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig, ResolvedMcpConfig, RuntimeConfig,
WebSocketConfig, WorkerConfig,
};
use aion_server::shutdown::{self, ShutdownOutcome};
use aion_server::worker::WorkerActivityDispatcher;
use aion_store::InMemoryStore;
type TestError = Box<dyn std::error::Error>;
const CHILD_ENV: &str = "AION_SHUTDOWN_EXIT_CHILD";
const CHILD_TEST: &str = "parked_dispatch_child";
const ARMED_MARKER: &str = "AION-72-PARKED-DISPATCH-ARMED";
const UNSERVED_QUEUE: &str = "nobody-serves-this";
const NAMESPACE: &str = "default";
const ACTIVITY_TYPE: &str = "greet";
const ARM_DEADLINE: Duration = Duration::from_secs(90);
const EXIT_DEADLINE: Duration = Duration::from_secs(30);
const POLL_INTERVAL: Duration = Duration::from_millis(50);
#[test]
fn server_process_exits_after_sigterm_with_a_dispatch_parked_on_an_unserved_queue()
-> Result<(), TestError> {
if std::env::var_os(CHILD_ENV).is_some() {
return Err("the parent pin must never run in the child role".into());
}
let mut child = Command::new(std::env::current_exe()?)
.arg(CHILD_TEST)
.args(["--exact", "--nocapture", "--test-threads=1"])
.env(CHILD_ENV, "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child
.stdout
.take()
.ok_or("piped child stdout was not captured")?;
let stderr = child
.stderr
.take()
.ok_or("piped child stderr was not captured")?;
let (lines_tx, lines) = channel();
let readers = [
spawn_reader(stdout, "stdout", lines_tx.clone()),
spawn_reader(stderr, "stderr", lines_tx),
];
let armed = await_armed(&lines, &mut child);
if let Err(error) = armed {
kill_and_reap(&mut child);
let transcript = drain_transcript(&lines, readers);
return Err(format!("{error}\nchild output so far:\n{transcript}").into());
}
let pid = rustix::process::Pid::from_raw(i32::try_from(child.id())?)
.ok_or("child reported pid 0, which cannot be signalled")?;
rustix::process::kill_process(pid, rustix::process::Signal::TERM)?;
let signalled_at = Instant::now();
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if signalled_at.elapsed() >= EXIT_DEADLINE {
kill_and_reap(&mut child);
let transcript = drain_transcript(&lines, readers);
return Err(format!(
"#72: the server did not exit within {EXIT_DEADLINE:?} of SIGTERM while one \
activity dispatch was parked on the unserved queue `{UNSERVED_QUEUE}`. The drain \
can report success and the process still never be reaped — that is the whole \
defect.\nchild output:\n{transcript}"
)
.into());
}
std::thread::sleep(POLL_INTERVAL);
};
let transcript = drain_transcript(&lines, readers);
if !status.success() {
return Err(format!(
"the child exited with {status} rather than success; its own assertions are the \
report.\nchild output:\n{transcript}"
)
.into());
}
Ok(())
}
fn await_armed(lines: &Receiver<String>, child: &mut std::process::Child) -> Result<(), TestError> {
let started = Instant::now();
loop {
let remaining =
ARM_DEADLINE
.checked_sub(started.elapsed())
.ok_or_else(|| -> TestError {
format!("the child never armed the parked dispatch within {ARM_DEADLINE:?}")
.into()
})?;
match lines.recv_timeout(remaining.min(POLL_INTERVAL)) {
Ok(line) if line.contains(ARMED_MARKER) => return Ok(()),
Ok(_) => {}
Err(RecvTimeoutError::Timeout) => {
if let Some(status) = child.try_wait()? {
return Err(format!(
"the child exited with {status} before arming the parked dispatch"
)
.into());
}
}
Err(RecvTimeoutError::Disconnected) => {
return Err("the child's stdout closed before it armed the parked dispatch".into());
}
}
}
}
fn spawn_reader<R>(stream: R, label: &'static str, lines: Sender<String>) -> JoinHandle<()>
where
R: std::io::Read + Send + 'static,
{
std::thread::spawn(move || {
for line in std::io::BufReader::new(stream).lines() {
let Ok(line) = line else {
break;
};
if lines.send(format!("{label}: {line}")).is_err() {
break;
}
}
})
}
fn drain_transcript(lines: &Receiver<String>, readers: [JoinHandle<()>; 2]) -> String {
for reader in readers {
drop(reader.join());
}
let mut transcript = String::new();
while let Ok(line) = lines.try_recv() {
transcript.push_str(&line);
transcript.push('\n');
}
transcript
}
fn kill_and_reap(child: &mut std::process::Child) {
drop(child.kill());
drop(child.wait());
}
#[test]
fn parked_dispatch_child() -> Result<(), TestError> {
if std::env::var_os(CHILD_ENV).is_none() {
println!(
"{CHILD_TEST}: parent role — the wedge is armed by the child this binary re-execs"
);
return Ok(());
}
child_main()
}
fn child_main() -> Result<(), TestError> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let state = runtime.block_on(ServerState::build_with_store(
InMemoryStore::default(),
runtime_config(),
))?;
let dispatcher = Arc::new(
WorkerActivityDispatcher::new(
state.worker_registry().clone(),
NAMESPACE,
state.heartbeat_tracker().clone(),
)
.with_pending(state.pending_activities().clone())
.with_drain_state(state.drain_state().clone())
.with_tokio_handle(runtime.handle().clone())
.with_queue_service(state.runtime_config().worker.queue_service.clone())
.with_queue_declarations(state.queue_declarations().clone())
.with_queue_state(state.queue_service_state().clone()),
);
let dispatch = runtime
.handle()
.spawn_blocking(move || dispatcher.dispatch(unserved_dispatch()));
let mut terminate = runtime.block_on(async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
})?;
runtime.block_on(await_parked(&state))?;
println!("{ARMED_MARKER}");
runtime
.block_on(async { terminate.recv().await })
.ok_or("the SIGTERM stream closed without delivering a signal")?;
let report = runtime.block_on(shutdown::drain_after_first_signal(
state.clone(),
std::future::pending::<()>(),
))?;
let outcome = report.outcome;
println!("child: drain outcome {outcome:?}");
if !matches!(outcome, ShutdownOutcome::Clean) {
return Err(format!("expected a clean drain, got {outcome:?}").into());
}
drop(runtime);
if dispatch.is_finished() {
Ok(())
} else {
Err("the parked dispatch survived the blocking pool's shutdown".into())
}
}
async fn await_parked(state: &ServerState) -> Result<(), TestError> {
let started = Instant::now();
loop {
if state
.queue_service_state()
.parked_on_queue(UNSERVED_QUEUE)?
> 0
{
return Ok(());
}
if started.elapsed() >= ARM_DEADLINE {
return Err(format!(
"no dispatch parked on `{UNSERVED_QUEUE}` within {ARM_DEADLINE:?}"
)
.into());
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
fn unserved_dispatch() -> ActivityDispatch {
ActivityDispatch {
namespace: NAMESPACE.to_owned(),
task_queue: UNSERVED_QUEUE.to_owned(),
node: None,
workflow_id: WorkflowId::new_v4(),
run_id: RunId::new_v4(),
activity_id: ActivityId::from_sequence_position(0),
name: ACTIVITY_TYPE.to_owned(),
input: "{}".to_owned(),
config: "{}".to_owned(),
attempt: 1,
advisory: false,
labels: std::collections::BTreeMap::new(),
}
}
fn runtime_config() -> RuntimeConfig {
RuntimeConfig {
listen: ListenConfig {
grpc: std::net::SocketAddr::from(([127, 0, 0, 1], 0)),
http: std::net::SocketAddr::from(([127, 0, 0, 1], 0)),
},
tls: None,
auth: AuthConfig {
enabled: false,
jwks_url: None,
jwks_refresh_seconds: 300,
},
ops_console: OpsConsoleConfig {
source: OpsConsoleAssetSource::Embedded,
},
namespace: NamespaceConfig {
mode: NamespaceMode::SharedEngine,
},
worker: WorkerConfig {
heartbeat_window: Duration::from_secs(30),
..WorkerConfig::default()
},
websocket: WebSocketConfig {
outbound_buffer_bound: 32,
event_broadcast_capacity: Some(64),
cluster_broadcast_capacity: Some(64),
},
workflow_packages: Vec::new(),
deploy: DeployConfig::default(),
authoring: AuthoringConfig::default(),
dev: DevConfig::default(),
outbox: OutboxConfig::default(),
observability: ObservabilityConfig::with_flush_policy(64, 0),
mcp: ResolvedMcpConfig::default(),
assistant: aion_server::config::ResolvedAssistantConfig::default(),
scheduler_threads: 1,
stop_drain_timeout: Some(std::time::Duration::from_secs(5)),
jit_threshold: None,
query_timeout: Some(Duration::from_secs(10)),
workloop_sweep_interval: Some(std::time::Duration::from_millis(50)),
default_namespace: NAMESPACE.to_owned(),
auto_create: AutoCreate::Open,
max_in_flight_activities: DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
drain_timeout: Duration::from_secs(30),
metrics: MetricsConfig { enabled: false },
owned_shards: Vec::new(),
cors_allowed_origins: Vec::new(),
}
}