Skip to main content

aion/runtime/
monitor.rs

1//! Runtime-owned process exit monitoring and cleanup orchestration.
2
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, Condvar, Mutex, MutexGuard};
5
6use beamr::process::ExitReason;
7use dashmap::mapref::entry::Entry;
8
9use crate::{EngineError, Pid, RuntimeHandle};
10
11use super::cleanup_executor::CleanupSubmitError;
12use super::outcome::{self, WorkflowProcessOutcome};
13use super::process_exit::{ObservedProcessExit, OwnedProcessExitOutcome};
14
15/// Identity retained until one committed monitor callback completes.
16pub(super) struct MonitorInstallation {
17    committed: AtomicBool,
18}
19
20impl MonitorInstallation {
21    fn uncommitted() -> Self {
22        Self {
23            committed: AtomicBool::new(false),
24        }
25    }
26
27    pub(super) fn commit(&self) {
28        self.committed.store(true, Ordering::Release);
29    }
30}
31
32/// Typed failure from synchronously requesting an unmonitored-process abort.
33#[derive(Debug, thiserror::Error)]
34pub(crate) enum UnmonitoredProcessAbortError {
35    /// Process cleanup did not complete before the caller's observation bound.
36    #[error("process {process_id} did not complete unmonitored abort within {timeout_millis}ms")]
37    TimedOut {
38        /// Process whose termination remained owned by the runtime job.
39        process_id: Pid,
40        /// Configured observation bound in milliseconds.
41        timeout_millis: u128,
42    },
43    /// The runtime cleanup executor had already closed.
44    #[error("process cleanup executor is unavailable for process {process_id}")]
45    ExecutorUnavailable {
46        /// Process retained by the terminal abort identity.
47        process_id: Pid,
48    },
49    /// The bounded cleanup executor queue had no capacity for a distinct job.
50    #[error("process cleanup executor is exhausted for process {process_id}")]
51    ExecutorExhausted {
52        /// Process retained by the terminal abort identity.
53        process_id: Pid,
54    },
55    /// The cleanup executor's ownership lock was poisoned.
56    #[error("process cleanup executor state is poisoned for process {process_id}")]
57    ExecutorPoisoned {
58        /// Process retained by the terminal abort identity.
59        process_id: Pid,
60    },
61    /// A completion monitor already owns this process generation.
62    #[error("process {process_id} already has a completion monitor owner")]
63    MonitorInstalled {
64        /// Process the abort refused to terminate.
65        process_id: Pid,
66    },
67    /// The per-process monitor/abort ownership gate was poisoned.
68    #[error("process exit ownership gate for process {process_id} was poisoned")]
69    OwnershipPoisoned {
70        /// Process whose ownership could not be serialized.
71        process_id: Pid,
72    },
73    /// An abort job's identity state was poisoned.
74    #[error("unmonitored abort state for process {process_id} was poisoned")]
75    StatePoisoned {
76        /// Process whose abort state could not be observed.
77        process_id: Pid,
78    },
79    /// Runtime cleanup failed after the job acquired execution ownership.
80    #[error("process {process_id} cleanup failed: {reason}")]
81    CleanupFailed {
82        /// Process whose shared cleanup failed.
83        process_id: Pid,
84        /// Typed engine failure rendered for repeatable fan-out reads.
85        reason: String,
86    },
87}
88
89impl UnmonitoredProcessAbortError {
90    pub(crate) fn into_engine_error(self) -> EngineError {
91        EngineError::Runtime {
92            reason: self.to_string(),
93        }
94    }
95}
96
97/// Handle returned after installing a workflow process monitor.
98#[derive(Clone)]
99pub struct ProcessMonitorHandle {
100    installed: Arc<AtomicBool>,
101}
102
103impl ProcessMonitorHandle {
104    fn installed() -> Self {
105        Self {
106            installed: Arc::new(AtomicBool::new(true)),
107        }
108    }
109
110    /// Returns whether the runtime accepted monitor installation.
111    #[must_use]
112    pub fn is_installed(&self) -> bool {
113        self.installed.load(Ordering::Acquire)
114    }
115}
116
117#[derive(Clone)]
118enum AbortJobTerminal {
119    Succeeded,
120    CleanupFailed(String),
121}
122
123enum AbortJobPhase {
124    Running,
125    Finalizing,
126    Complete(AbortJobTerminal),
127}
128
129struct AbortJobState {
130    phase: AbortJobPhase,
131    installation: Option<Arc<MonitorInstallation>>,
132    /// Callbacks to run once this job reaches [`AbortJobPhase::Complete`] — i.e.
133    /// once the process has been terminated (both terminals run
134    /// `terminate_process` first). A failed start whose synchronous abort `wait`
135    /// timed out attaches its registry retraction here instead of racing a
136    /// monitor install the in-flight job would reject, so ownership is retracted
137    /// only after the job proves termination.
138    finalizers: Vec<Box<dyn FnOnce() + Send>>,
139}
140
141/// One runtime-owned abort identity for a `pid` generation.
142pub(super) struct UnmonitoredProcessAbortJob {
143    pid: Pid,
144    state: Mutex<AbortJobState>,
145    ready: Condvar,
146}
147
148impl UnmonitoredProcessAbortJob {
149    fn new(pid: Pid, installation: Option<Arc<MonitorInstallation>>) -> Self {
150        Self {
151            pid,
152            state: Mutex::new(AbortJobState {
153                phase: AbortJobPhase::Running,
154                installation,
155                finalizers: Vec::new(),
156            }),
157            ready: Condvar::new(),
158        }
159    }
160
161    /// Attach a callback to run when this job completes (the process is
162    /// terminated). Runs it immediately if the job has already completed, so a
163    /// caller that lost the timeout-vs-completion race still gets its finalizer.
164    fn attach_finalizer(
165        &self,
166        finalizer: Box<dyn FnOnce() + Send>,
167    ) -> Result<(), UnmonitoredProcessAbortError> {
168        let run_now = {
169            let mut state = self.lock_state()?;
170            if matches!(state.phase, AbortJobPhase::Complete(_)) {
171                Some(finalizer)
172            } else {
173                state.finalizers.push(finalizer);
174                None
175            }
176        };
177        if let Some(finalizer) = run_now {
178            finalizer();
179        }
180        Ok(())
181    }
182
183    fn attach_installation(
184        &self,
185        installation: Option<Arc<MonitorInstallation>>,
186    ) -> Result<(), UnmonitoredProcessAbortError> {
187        let Some(installation) = installation else {
188            return Ok(());
189        };
190        let mut state = self.lock_state()?;
191        if state.installation.is_none() {
192            state.installation = Some(installation);
193        }
194        Ok(())
195    }
196
197    fn complete_cleanup(
198        self: &Arc<Self>,
199        runtime: &RuntimeHandle,
200        record: Option<&Arc<super::process_exit::ProcessExitRecord>>,
201        cleanup: Result<(), EngineError>,
202    ) -> Result<(), UnmonitoredProcessAbortError> {
203        let (installation, terminal) = {
204            let mut state = self.lock_state()?;
205            let terminal = match cleanup {
206                Ok(()) => AbortJobTerminal::Succeeded,
207                Err(error) => AbortJobTerminal::CleanupFailed(error.to_string()),
208            };
209            state.phase = AbortJobPhase::Finalizing;
210            (state.installation.take(), terminal)
211        };
212        let ownership = record
213            .map(|record| record.lock_ownership())
214            .transpose()
215            .map_err(|_| UnmonitoredProcessAbortError::OwnershipPoisoned {
216                process_id: self.pid,
217            })?;
218        if let Some(installation) = installation {
219            runtime.release_monitor_installation(self.pid, &installation);
220        }
221        if let Some(record) = record {
222            runtime.process_exits.retire(self.pid, record);
223        }
224        let finalizers = {
225            let mut state = self.lock_state()?;
226            state.phase = AbortJobPhase::Complete(terminal);
227            std::mem::take(&mut state.finalizers)
228        };
229        if let Entry::Occupied(entry) = runtime.abort_jobs.entry(self.pid)
230            && Arc::ptr_eq(entry.get(), self)
231        {
232            entry.remove();
233        }
234        self.ready.notify_all();
235        drop(ownership);
236        // Run attached finalizers OUTSIDE the state lock (they retract engine
237        // registry ownership now that the process is terminated).
238        for finalizer in finalizers {
239            finalizer();
240        }
241        Ok(())
242    }
243
244    fn wait(&self, timeout: std::time::Duration) -> Result<(), UnmonitoredProcessAbortError> {
245        let state = self.lock_state()?;
246        let (state, wait) = self
247            .ready
248            .wait_timeout_while(state, timeout, |state| {
249                matches!(
250                    state.phase,
251                    AbortJobPhase::Running | AbortJobPhase::Finalizing
252                )
253            })
254            .map_err(|_| UnmonitoredProcessAbortError::StatePoisoned {
255                process_id: self.pid,
256            })?;
257        match &state.phase {
258            AbortJobPhase::Running | AbortJobPhase::Finalizing if wait.timed_out() => {
259                Err(UnmonitoredProcessAbortError::TimedOut {
260                    process_id: self.pid,
261                    timeout_millis: timeout.as_millis(),
262                })
263            }
264            AbortJobPhase::Running | AbortJobPhase::Finalizing => {
265                Err(UnmonitoredProcessAbortError::StatePoisoned {
266                    process_id: self.pid,
267                })
268            }
269            AbortJobPhase::Complete(terminal) => terminal.result(self.pid),
270        }
271    }
272
273    fn lock_state(&self) -> Result<MutexGuard<'_, AbortJobState>, UnmonitoredProcessAbortError> {
274        self.state
275            .lock()
276            .map_err(|_| UnmonitoredProcessAbortError::StatePoisoned {
277                process_id: self.pid,
278            })
279    }
280}
281
282impl AbortJobTerminal {
283    fn result(&self, pid: Pid) -> Result<(), UnmonitoredProcessAbortError> {
284        match self {
285            Self::Succeeded => Ok(()),
286            Self::CleanupFailed(reason) => Err(UnmonitoredProcessAbortError::CleanupFailed {
287                process_id: pid,
288                reason: reason.clone(),
289            }),
290        }
291    }
292}
293
294impl RuntimeHandle {
295    /// Install one completion callback against the `pid`'s owned exit record.
296    ///
297    /// # Errors
298    ///
299    /// Returns a typed runtime error for unknown pids, duplicate committed
300    /// installations, or an abort already owning this `pid` generation.
301    pub fn monitor_process<F>(
302        self: &Arc<Self>,
303        pid: Pid,
304        callback: F,
305    ) -> Result<ProcessMonitorHandle, EngineError>
306    where
307        F: FnOnce(Result<WorkflowProcessOutcome, EngineError>) + Send + 'static,
308    {
309        self.ensure_monitorable_pid(pid)?;
310        let record = self.process_exits.get(pid)?;
311        let ownership = record.lock_ownership()?;
312        if !self.process_exits.is_current(pid, &record) {
313            return Err(EngineError::ProcessExitAlreadyTerminal { process_id: pid });
314        }
315        let installation = self.reserve_monitor_installation(pid)?;
316        #[cfg(test)]
317        if self.take_monitor_installation_failure_for_test() {
318            let error = EngineError::Runtime {
319                reason: format!(
320                    "failed to install workflow monitor for process {pid}: forced test failure"
321                ),
322            };
323            drop(ownership);
324            self.rollback_failed_monitor_installation(pid, &installation)?;
325            return Err(error);
326        }
327
328        let runtime = Arc::clone(self);
329        let callback_record = Arc::clone(&record);
330        let callback_installation = Arc::clone(&installation);
331        let completion = Box::new(move |owned| {
332            let process_outcome =
333                outcome::workflow_outcome_from_owned_exit(&runtime.atom_table, pid, &owned);
334            let monitored_outcome = match runtime.finish_process_monitor_cleanup(pid) {
335                Ok(()) => process_outcome,
336                Err(error) => {
337                    tracing::error!(%error, pid, "workflow activity cleanup failed");
338                    Err(error)
339                }
340            };
341            let callback_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
342                callback(monitored_outcome);
343            }));
344            match callback_record.lock_ownership() {
345                Ok(retirement) => {
346                    runtime.release_monitor_installation(pid, &callback_installation);
347                    runtime.process_exits.retire(pid, &callback_record);
348                    drop(retirement);
349                }
350                Err(error) => {
351                    tracing::error!(pid, %error, "failed to retire completed process monitor");
352                }
353            }
354            if let Err(panic) = callback_result {
355                std::panic::resume_unwind(panic);
356            }
357        });
358        if let Err(error) = self
359            .process_exits
360            .attach_callback(&record, &installation, completion)
361        {
362            drop(ownership);
363            self.rollback_failed_monitor_installation(pid, &installation)?;
364            return Err(error);
365        }
366        drop(ownership);
367        Ok(ProcessMonitorHandle::installed())
368    }
369
370    fn reserve_monitor_installation(
371        &self,
372        pid: Pid,
373    ) -> Result<Arc<MonitorInstallation>, EngineError> {
374        if self.abort_jobs.contains_key(&pid) {
375            return Err(EngineError::Runtime {
376                reason: format!("process {pid} already has an abort job"),
377            });
378        }
379        match self.nif_state().monitor_installations.entry(pid) {
380            Entry::Vacant(entry) => {
381                let installation = Arc::new(MonitorInstallation::uncommitted());
382                entry.insert(Arc::clone(&installation));
383                Ok(installation)
384            }
385            Entry::Occupied(_) => Err(EngineError::Runtime {
386                reason: format!("process {pid} already has a completion monitor installation"),
387            }),
388        }
389    }
390
391    fn release_monitor_installation(&self, pid: Pid, installation: &Arc<MonitorInstallation>) {
392        if let Entry::Occupied(entry) = self.nif_state().monitor_installations.entry(pid)
393            && Arc::ptr_eq(entry.get(), installation)
394        {
395            entry.remove();
396        }
397    }
398
399    fn rollback_failed_monitor_installation(
400        self: &Arc<Self>,
401        pid: Pid,
402        installation: &Arc<MonitorInstallation>,
403    ) -> Result<(), EngineError> {
404        if installation.committed.load(Ordering::Acquire) {
405            return Ok(());
406        }
407        self.abort_unmonitored_process_with_installation(pid, Some(Arc::clone(installation)))
408            .map_err(UnmonitoredProcessAbortError::into_engine_error)
409    }
410
411    /// Terminate and synchronously observe cleanup of an unmonitored process.
412    pub(crate) fn abort_unmonitored_process(
413        self: &Arc<Self>,
414        pid: Pid,
415    ) -> Result<(), UnmonitoredProcessAbortError> {
416        self.abort_unmonitored_process_with_installation(pid, None)
417    }
418
419    /// Attach a completion finalizer to the in-flight abort job for `pid`, if one
420    /// is still running.
421    ///
422    /// Returns `Ok(true)` when the finalizer was attached (or run inline because
423    /// the job had just completed), and `Ok(false)` when no abort job exists —
424    /// which, on a path that just observed a `TimedOut` abort for this `pid`,
425    /// means the job completed and removed itself between the timeout and this
426    /// call, so the process is already terminated and the caller may proceed with
427    /// its own cleanup. This is how a failed start whose synchronous abort timed
428    /// out defers registry retraction to the abort job's own termination instead
429    /// of racing a mutually-exclusive monitor install.
430    ///
431    /// # Errors
432    ///
433    /// Returns a typed runtime error when the abort job's state lock is poisoned.
434    pub(crate) fn attach_unmonitored_abort_finalizer<F>(
435        &self,
436        pid: Pid,
437        finalizer: F,
438    ) -> Result<bool, EngineError>
439    where
440        F: FnOnce() + Send + 'static,
441    {
442        let Some(job) = self.abort_jobs.get(&pid).map(|job| Arc::clone(job.value())) else {
443            return Ok(false);
444        };
445        job.attach_finalizer(Box::new(finalizer))
446            .map_err(UnmonitoredProcessAbortError::into_engine_error)?;
447        Ok(true)
448    }
449
450    fn abort_unmonitored_process_with_installation(
451        self: &Arc<Self>,
452        pid: Pid,
453        installation: Option<Arc<MonitorInstallation>>,
454    ) -> Result<(), UnmonitoredProcessAbortError> {
455        // Nothing owned to abort: the registry holds no record and the pid is
456        // at or below its registration watermark, so any process under this
457        // pid has already been reaped. The read is repeated after `find` so a
458        // retirement that lands between the two is not raced into an abort.
459        if self.process_exits.below_registration_watermark(pid) {
460            return Ok(());
461        }
462        let record = self.process_exits.find(pid);
463        if record.is_none() && self.process_exits.below_registration_watermark(pid) {
464            return Ok(());
465        }
466        let ownership = record
467            .as_ref()
468            .map(|record| record.lock_ownership())
469            .transpose()
470            .map_err(|_| UnmonitoredProcessAbortError::OwnershipPoisoned { process_id: pid })?;
471        if record
472            .as_ref()
473            .is_some_and(|record| !self.process_exits.is_current(pid, record))
474        {
475            return Ok(());
476        }
477        let job = match self.abort_jobs.entry(pid) {
478            Entry::Occupied(entry) => {
479                let job = Arc::clone(entry.get());
480                drop(entry);
481                job.attach_installation(installation)?;
482                job
483            }
484            Entry::Vacant(entry) => {
485                let owns_uncommitted_installation = installation.as_ref().is_some_and(|expected| {
486                    !expected.committed.load(Ordering::Acquire)
487                        && self
488                            .nif_state()
489                            .monitor_installations
490                            .get(&pid)
491                            .is_some_and(|current| Arc::ptr_eq(current.value(), expected))
492                });
493                if self.nif_state().monitor_installations.contains_key(&pid)
494                    && !owns_uncommitted_installation
495                {
496                    return Err(UnmonitoredProcessAbortError::MonitorInstalled { process_id: pid });
497                }
498                let refused_installation = installation.clone();
499                let job = Arc::new(UnmonitoredProcessAbortJob::new(pid, installation));
500                let runtime = Arc::clone(self);
501                let worker_job = Arc::clone(&job);
502                let worker_record = record.clone();
503                let submission = self.cleanup_executor.submit(Box::new(move || {
504                    if runtime.is_live(pid) {
505                        runtime.scheduler.terminate_process(pid, ExitReason::Kill);
506                    }
507                    let mut cleanup = runtime.finish_process_monitor_cleanup(pid);
508                    if let Some(record) = worker_record.as_ref() {
509                        if let Err(error) = record.wait()
510                            && cleanup.is_ok()
511                        {
512                            cleanup = Err(error);
513                        }
514                        if let Err(error) = record.close_without_monitor()
515                            && cleanup.is_ok()
516                        {
517                            cleanup = Err(error);
518                        }
519                    }
520                    if let Err(error) =
521                        worker_job.complete_cleanup(&runtime, worker_record.as_ref(), cleanup)
522                    {
523                        tracing::error!(pid, %error, "failed to publish process abort completion");
524                    }
525                }));
526                if let Err(error) = submission {
527                    if let Some(installation) = refused_installation {
528                        self.release_monitor_installation(pid, &installation);
529                    }
530                    return Err(match error {
531                        CleanupSubmitError::Unavailable => {
532                            UnmonitoredProcessAbortError::ExecutorUnavailable { process_id: pid }
533                        }
534                        CleanupSubmitError::Exhausted => {
535                            UnmonitoredProcessAbortError::ExecutorExhausted { process_id: pid }
536                        }
537                        CleanupSubmitError::Poisoned => {
538                            UnmonitoredProcessAbortError::ExecutorPoisoned { process_id: pid }
539                        }
540                    });
541                }
542                entry.insert(Arc::clone(&job));
543                job
544            }
545        };
546        drop(ownership);
547        job.wait(self.signal_delivery().ready_timeout)
548    }
549
550    #[cfg(test)]
551    pub(super) fn process_exit_outcome(
552        &self,
553        pid: Pid,
554    ) -> Result<Arc<ObservedProcessExit>, EngineError> {
555        match self.process_exits.get(pid)?.wait()? {
556            OwnedProcessExitOutcome::Observed(observed) => Ok(observed),
557            OwnedProcessExitOutcome::ObservationFailed {
558                process_id,
559                failure,
560            } => Err(failure.into_engine_error(process_id)),
561        }
562    }
563
564    pub(super) fn activity_process_exit_outcome(
565        &self,
566        pid: Pid,
567    ) -> Result<Arc<ObservedProcessExit>, EngineError> {
568        let record = self.process_exits.get(pid)?;
569        let outcome = match record.wait()? {
570            OwnedProcessExitOutcome::Observed(observed) => Ok(observed),
571            OwnedProcessExitOutcome::ObservationFailed {
572                process_id,
573                failure,
574            } => Err(failure.into_engine_error(process_id)),
575        };
576        record.close_without_monitor()?;
577        let retirement = record.lock_ownership()?;
578        self.process_exits.retire(pid, &record);
579        drop(retirement);
580        outcome
581    }
582
583    pub(super) fn finish_process_monitor_cleanup(&self, pid: Pid) -> Result<(), EngineError> {
584        self.release_spawn_heaps(pid);
585        self.nif_state().cleanup_process(pid);
586        self.kill_in_vm_children(pid);
587        let activity_cleanup = self.drain_activity_completions(pid);
588        self.finish_activity_delivery_cleanup(pid);
589        activity_cleanup
590    }
591
592    /// Test-only monitor installation status probe.
593    ///
594    /// # Errors
595    ///
596    /// Returns the same typed installation errors as [`Self::monitor_process`].
597    #[cfg(test)]
598    pub fn monitor_process_for_test<F>(
599        self: &Arc<Self>,
600        pid: Pid,
601        callback: F,
602    ) -> Result<ProcessMonitorHandle, EngineError>
603    where
604        F: FnOnce(Result<WorkflowProcessOutcome, EngineError>) + Send + 'static,
605    {
606        self.monitor_process(pid, callback)
607    }
608
609    /// Return whether Aion's shared exit cleanup has started for `pid`.
610    ///
611    /// This is the discriminator for state removed by exit cleanup. Scheduler
612    /// liveness is not: beamr publishes exit before retiring the process-table
613    /// row, so a pid may remain live after Aion cleanup has started.
614    #[must_use]
615    pub(crate) fn process_cleanup_started(&self, pid: Pid) -> bool {
616        self.nif_state().process_cleanup_started(pid)
617    }
618
619    #[cfg(test)]
620    pub(crate) fn process_cleanup_complete_for_test(&self, pid: Pid) -> bool {
621        self.process_cleanup_started(pid)
622            && !self.is_live(pid)
623            && !self.abort_jobs.contains_key(&pid)
624            && !self.nif_state().monitor_installations.contains_key(&pid)
625            && !self.process_exits.contains(pid)
626    }
627}
628
629#[cfg(test)]
630#[path = "monitor_tests.rs"]
631mod tests;