use std::collections::VecDeque;
use std::process::{ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use aion_store::{DesiredState, WorkerDeploymentStore};
use aion_worker::ContainedChild;
use tokio::io::AsyncBufReadExt;
use tokio::process::Command;
use tokio::sync::watch;
use tokio::time::Instant;
use super::error::SupervisionError;
use super::executable::ManagedExecutable;
use super::policy::SupervisionPolicy;
use super::status::{ManagedWorkerExit, ManagedWorkerState, SpawnedBinary};
pub(super) struct InstanceConfig {
pub(super) name: String,
pub(super) verb: Vec<String>,
pub(super) executable: ManagedExecutable,
pub(super) policy: SupervisionPolicy,
pub(super) store: Arc<dyn WorkerDeploymentStore>,
}
#[derive(Clone, Debug, Default)]
pub(super) struct InstanceSnapshot {
pub(super) state: Option<ManagedWorkerState>,
pub(super) pid: Option<u32>,
pub(super) process_group: Option<i32>,
pub(super) restarts: u32,
pub(super) last_exit: Option<ManagedWorkerExit>,
pub(super) last_error: Option<String>,
pub(super) spawn_binary: Option<SpawnedBinary>,
pub(super) containment_unconfirmed: bool,
}
#[derive(Debug, Default)]
pub(super) struct InstanceShared {
snapshot: Mutex<InstanceSnapshot>,
}
impl InstanceShared {
fn snapshot(&self) -> Result<InstanceSnapshot, SupervisionError> {
self.snapshot
.lock()
.map(|guard| guard.clone())
.map_err(|poison| SupervisionError::StatePoisoned {
detail: poison.to_string(),
})
}
fn mutate<F>(&self, mutation: F)
where
F: FnOnce(&mut InstanceSnapshot),
{
match self.snapshot.lock() {
Ok(mut guard) => mutation(&mut guard),
Err(poison) => {
mutation(&mut poison.into_inner());
}
}
}
fn set_terminal(&self, state: ManagedWorkerState, detail: Option<String>) {
self.mutate(|snapshot| {
snapshot.state = Some(state);
snapshot.pid = None;
snapshot.process_group = None;
if detail.is_some() {
snapshot.last_error = detail;
}
});
}
}
enum Cycle {
Exited,
StopHandled,
}
pub(super) async fn run(
config: InstanceConfig,
shared: Arc<InstanceShared>,
mut stop: watch::Receiver<bool>,
) {
let mut window: VecDeque<Instant> = VecDeque::new();
let mut restarts = 0_u32;
loop {
shared.mutate(|snapshot| {
snapshot.state = Some(ManagedWorkerState::Starting);
snapshot.pid = None;
snapshot.process_group = None;
});
match spawn(&config) {
Ok(spawned) => match observe(&config, &shared, spawned, &mut stop).await {
Cycle::StopHandled => return,
Cycle::Exited => {}
},
Err(error) => {
tracing::error!(
worker = config.name.as_str(),
%error,
"managed worker could not be spawned"
);
shared.mutate(|snapshot| snapshot.last_error = Some(error.to_string()));
}
}
if !restart_permitted(&config, &shared, &mut window, &mut restarts, &mut stop).await {
return;
}
}
}
fn spawn(config: &InstanceConfig) -> Result<Spawned, SupervisionError> {
let path = config.executable.resolve()?;
let binary = config.executable.identify(&path)?;
let mut command = Command::new(&path);
command.args(&config.verb);
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = ContainedChild::spawn(command).map_err(|source| SupervisionError::Process {
name: config.name.clone(),
source,
})?;
tracing::info!(
worker = config.name.as_str(),
executable = binary.path.as_str(),
content_hash = binary.content_hash.as_str(),
pid = child.id(),
"managed worker spawned"
);
let output = Output {
stdout: child
.take_stdout()
.map(|pipe| forward(config.name.clone(), "stdout", pipe)),
stderr: child
.take_stderr()
.map(|pipe| forward(config.name.clone(), "stderr", pipe)),
};
Ok(Spawned {
child,
binary,
output,
})
}
struct Spawned {
child: ContainedChild,
binary: SpawnedBinary,
output: Output,
}
struct Output {
stdout: Option<tokio::task::JoinHandle<()>>,
stderr: Option<tokio::task::JoinHandle<()>>,
}
impl Output {
async fn drained(self, within: Duration) -> bool {
let joined = async {
for handle in [self.stdout, self.stderr].into_iter().flatten() {
if handle.await.is_err() {
return false;
}
}
true
};
(tokio::time::timeout(within, joined).await).unwrap_or(false)
}
}
fn forward(
worker: String,
stream: &'static str,
pipe: impl tokio::io::AsyncRead + Unpin + Send + 'static,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut lines = tokio::io::BufReader::new(pipe).lines();
loop {
match lines.next_line().await {
Ok(Some(line)) => {
tracing::info!(worker = worker.as_str(), stream, "{line}");
}
Ok(None) => return,
Err(error) => {
tracing::warn!(
worker = worker.as_str(),
stream,
%error,
"managed worker output could not be read"
);
return;
}
}
}
})
}
async fn observe(
config: &InstanceConfig,
shared: &Arc<InstanceShared>,
spawned: Spawned,
stop: &mut watch::Receiver<bool>,
) -> Cycle {
let Spawned {
mut child,
binary,
output,
} = spawned;
let pid = child.id();
let group = child.process_group_id();
shared.mutate(|snapshot| {
snapshot.state = Some(ManagedWorkerState::Running);
snapshot.pid = pid;
snapshot.process_group = group;
snapshot.spawn_binary = Some(binary);
snapshot.containment_unconfirmed = true;
});
let requested = tokio::select! {
result = child.wait() => {
if let Err(source) = result {
let error = SupervisionError::Process {
name: config.name.clone(),
source,
};
tracing::error!(
worker = config.name.as_str(),
%error,
"managed worker could not be waited for"
);
shared.mutate(|snapshot| snapshot.last_error = Some(error.to_string()));
}
false
}
() = wait_for_stop(stop) => true,
};
let signalled = child.terminate(config.policy.stop_grace).await;
let drained = output.drained(config.policy.stop_grace).await;
let exit = child.exit_status().map(exit_record);
record_teardown(config, shared, requested, exit, &signalled, drained);
if requested {
Cycle::StopHandled
} else {
Cycle::Exited
}
}
fn record_teardown(
config: &InstanceConfig,
shared: &Arc<InstanceShared>,
requested: bool,
exit: Option<ManagedWorkerExit>,
signalled: &Result<(), aion_worker::ProcessGroupError>,
drained: bool,
) {
let failure = match signalled {
Err(source) => Some(SupervisionError::StopIncomplete {
name: config.name.clone(),
detail: source.to_string(),
}),
Ok(()) if !drained => Some(SupervisionError::StopIncomplete {
name: config.name.clone(),
detail: format!(
"the worker's output streams did not reach end-of-file within {:?}, so a \
descendant is still holding them open and the process tree cannot be \
declared gone",
config.policy.stop_grace
),
}),
Ok(()) => None,
};
if let Some(error) = failure {
tracing::error!(
worker = config.name.as_str(),
%error,
"managed worker process tree could not be confirmed gone"
);
shared.set_terminal(ManagedWorkerState::Failed, Some(error.to_string()));
return;
}
let ending = exit.map(|mut record| {
record.requested = requested;
record
});
shared.mutate(|snapshot| {
snapshot.pid = None;
snapshot.process_group = None;
snapshot.containment_unconfirmed = false;
if ending.is_some() {
snapshot.last_exit = ending;
}
snapshot.state = Some(if requested {
ManagedWorkerState::Stopped
} else {
ManagedWorkerState::Backoff
});
});
if requested {
tracing::info!(
worker = config.name.as_str(),
"managed worker stopped; its process tree reached end-of-file"
);
} else {
tracing::warn!(worker = config.name.as_str(), "managed worker exited");
}
}
async fn restart_permitted(
config: &InstanceConfig,
shared: &Arc<InstanceShared>,
window: &mut VecDeque<Instant>,
restarts: &mut u32,
stop: &mut watch::Receiver<bool>,
) -> bool {
if *stop.borrow_and_update() {
shared.set_terminal(ManagedWorkerState::Stopped, None);
return false;
}
if !desired_still_running(config, shared).await {
return false;
}
let now = Instant::now();
let restart_window = config.policy.restart_window;
window.retain(|at| now.duration_since(*at) < restart_window);
let already = u32::try_from(window.len()).unwrap_or(u32::MAX);
if already >= config.policy.max_restarts_per_window.get() {
let detail = format!(
"managed worker `{}` restarted {already} times within {restart_window:?} and has \
spent its `max_restarts_per_window` budget; it will not be restarted again until an \
operator starts it",
config.name
);
tracing::error!(
worker = config.name.as_str(),
%detail,
"managed worker is in a crash loop"
);
shared.set_terminal(ManagedWorkerState::Failed, Some(detail));
return false;
}
let delay = config.policy.backoff_after(already);
window.push_back(now);
shared.mutate(|snapshot| {
snapshot.state = Some(ManagedWorkerState::Backoff);
snapshot.pid = None;
snapshot.process_group = None;
});
let elapsed = tokio::select! {
() = tokio::time::sleep(delay) => true,
() = wait_for_stop(stop) => false,
};
if !elapsed {
shared.set_terminal(ManagedWorkerState::Stopped, None);
return false;
}
*restarts = restarts.saturating_add(1);
let total = *restarts;
shared.mutate(|snapshot| snapshot.restarts = total);
true
}
async fn desired_still_running(config: &InstanceConfig, shared: &Arc<InstanceShared>) -> bool {
match config.store.get_worker_deployment(&config.name).await {
Ok(Some(record)) => {
if record.desired == DesiredState::Running {
return true;
}
shared.set_terminal(ManagedWorkerState::Stopped, None);
false
}
Ok(None) => {
let detail = format!(
"worker deployment `{}` was deleted while it was running; supervision stopped",
config.name
);
tracing::warn!(
worker = config.name.as_str(),
%detail,
"managed worker record removed"
);
shared.set_terminal(ManagedWorkerState::Stopped, Some(detail));
false
}
Err(source) => {
let error = SupervisionError::Store { source };
tracing::error!(
worker = config.name.as_str(),
%error,
"managed worker desired state could not be re-read; keeping the last known intent"
);
shared.mutate(|snapshot| snapshot.last_error = Some(error.to_string()));
true
}
}
}
async fn wait_for_stop(stop: &mut watch::Receiver<bool>) {
loop {
if *stop.borrow_and_update() {
return;
}
if stop.changed().await.is_err() {
return;
}
}
}
fn exit_record(status: ExitStatus) -> ManagedWorkerExit {
#[cfg(unix)]
let signal = {
use std::os::unix::process::ExitStatusExt;
status.signal()
};
#[cfg(not(unix))]
let signal = None;
ManagedWorkerExit {
at: chrono::Utc::now(),
code: status.code(),
signal,
requested: false,
}
}
#[derive(Debug)]
pub(super) struct InstanceHandle {
shared: Arc<InstanceShared>,
stop: watch::Sender<bool>,
task: tokio::task::JoinHandle<()>,
}
impl InstanceHandle {
pub(super) fn start(config: InstanceConfig) -> Self {
let shared = Arc::new(InstanceShared::default());
shared.mutate(|snapshot| snapshot.state = Some(ManagedWorkerState::Starting));
let (stop, receiver) = watch::channel(false);
let task = tokio::spawn(run(config, Arc::clone(&shared), receiver));
Self { shared, stop, task }
}
pub(super) fn snapshot(&self) -> Result<InstanceSnapshot, SupervisionError> {
self.shared.snapshot()
}
pub(super) fn is_finished(&self) -> bool {
self.task.is_finished()
}
pub(super) async fn stop(self, name: &str) -> Result<(), SupervisionError> {
let signalled = self.stop.send(true).is_ok();
tracing::debug!(worker = name, signalled, "managed worker stop requested");
if let Err(join) = self.task.await {
return Err(SupervisionError::TaskLost {
name: name.to_owned(),
detail: join.to_string(),
});
}
let snapshot = self.shared.snapshot()?;
if snapshot.containment_unconfirmed {
return Err(SupervisionError::StopIncomplete {
name: name.to_owned(),
detail: format!(
"the process group was not confirmed empty; supervision ended in state {} ({})",
snapshot.state.map_or("unknown", ManagedWorkerState::token),
snapshot
.last_error
.as_deref()
.unwrap_or("no failure recorded")
),
});
}
Ok(())
}
}