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            if Arc::ptr_eq(entry.get(), self) {
231                entry.remove();
232            }
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            if Arc::ptr_eq(entry.get(), installation) {
394                entry.remove();
395            }
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        if self.process_exits.is_retired(pid) {
456            return Ok(());
457        }
458        let record = self.process_exits.find(pid);
459        if record.is_none() && self.process_exits.is_retired(pid) {
460            return Ok(());
461        }
462        let ownership = record
463            .as_ref()
464            .map(|record| record.lock_ownership())
465            .transpose()
466            .map_err(|_| UnmonitoredProcessAbortError::OwnershipPoisoned { process_id: pid })?;
467        if record
468            .as_ref()
469            .is_some_and(|record| !self.process_exits.is_current(pid, record))
470        {
471            return Ok(());
472        }
473        let job = match self.abort_jobs.entry(pid) {
474            Entry::Occupied(entry) => {
475                let job = Arc::clone(entry.get());
476                drop(entry);
477                job.attach_installation(installation)?;
478                job
479            }
480            Entry::Vacant(entry) => {
481                let owns_uncommitted_installation = installation.as_ref().is_some_and(|expected| {
482                    !expected.committed.load(Ordering::Acquire)
483                        && self
484                            .nif_state()
485                            .monitor_installations
486                            .get(&pid)
487                            .is_some_and(|current| Arc::ptr_eq(current.value(), expected))
488                });
489                if self.nif_state().monitor_installations.contains_key(&pid)
490                    && !owns_uncommitted_installation
491                {
492                    return Err(UnmonitoredProcessAbortError::MonitorInstalled { process_id: pid });
493                }
494                let refused_installation = installation.clone();
495                let job = Arc::new(UnmonitoredProcessAbortJob::new(pid, installation));
496                let runtime = Arc::clone(self);
497                let worker_job = Arc::clone(&job);
498                let worker_record = record.clone();
499                let submission = self.cleanup_executor.submit(Box::new(move || {
500                    if runtime.is_live(pid) {
501                        runtime.scheduler.terminate_process(pid, ExitReason::Kill);
502                    }
503                    let mut cleanup = runtime.finish_process_monitor_cleanup(pid);
504                    if let Some(record) = worker_record.as_ref() {
505                        if let Err(error) = record.wait() {
506                            if cleanup.is_ok() {
507                                cleanup = Err(error);
508                            }
509                        }
510                        if let Err(error) = record.close_without_monitor() {
511                            if cleanup.is_ok() {
512                                cleanup = Err(error);
513                            }
514                        }
515                    }
516                    if let Err(error) =
517                        worker_job.complete_cleanup(&runtime, worker_record.as_ref(), cleanup)
518                    {
519                        tracing::error!(pid, %error, "failed to publish process abort completion");
520                    }
521                }));
522                if let Err(error) = submission {
523                    if let Some(installation) = refused_installation {
524                        self.release_monitor_installation(pid, &installation);
525                    }
526                    return Err(match error {
527                        CleanupSubmitError::Unavailable => {
528                            UnmonitoredProcessAbortError::ExecutorUnavailable { process_id: pid }
529                        }
530                        CleanupSubmitError::Exhausted => {
531                            UnmonitoredProcessAbortError::ExecutorExhausted { process_id: pid }
532                        }
533                        CleanupSubmitError::Poisoned => {
534                            UnmonitoredProcessAbortError::ExecutorPoisoned { process_id: pid }
535                        }
536                    });
537                }
538                entry.insert(Arc::clone(&job));
539                job
540            }
541        };
542        drop(ownership);
543        job.wait(self.signal_delivery().ready_timeout)
544    }
545
546    #[cfg(test)]
547    pub(super) fn process_exit_outcome(
548        &self,
549        pid: Pid,
550    ) -> Result<Arc<ObservedProcessExit>, EngineError> {
551        match self.process_exits.get(pid)?.wait()? {
552            OwnedProcessExitOutcome::Observed(observed) => Ok(observed),
553            OwnedProcessExitOutcome::ObservationFailed {
554                process_id,
555                failure,
556            } => Err(failure.into_engine_error(process_id)),
557        }
558    }
559
560    pub(super) fn activity_process_exit_outcome(
561        &self,
562        pid: Pid,
563    ) -> Result<Arc<ObservedProcessExit>, EngineError> {
564        let record = self.process_exits.get(pid)?;
565        let outcome = match record.wait()? {
566            OwnedProcessExitOutcome::Observed(observed) => Ok(observed),
567            OwnedProcessExitOutcome::ObservationFailed {
568                process_id,
569                failure,
570            } => Err(failure.into_engine_error(process_id)),
571        };
572        record.close_without_monitor()?;
573        let retirement = record.lock_ownership()?;
574        self.process_exits.retire(pid, &record);
575        drop(retirement);
576        outcome
577    }
578
579    pub(super) fn finish_process_monitor_cleanup(&self, pid: Pid) -> Result<(), EngineError> {
580        self.release_spawn_heaps(pid);
581        self.nif_state().cleanup_process(pid);
582        self.kill_in_vm_children(pid);
583        let activity_cleanup = self.drain_activity_completions(pid);
584        self.finish_activity_delivery_cleanup(pid);
585        activity_cleanup
586    }
587
588    /// Test-only monitor installation status probe.
589    ///
590    /// # Errors
591    ///
592    /// Returns the same typed installation errors as [`Self::monitor_process`].
593    #[cfg(test)]
594    pub fn monitor_process_for_test<F>(
595        self: &Arc<Self>,
596        pid: Pid,
597        callback: F,
598    ) -> Result<ProcessMonitorHandle, EngineError>
599    where
600        F: FnOnce(Result<WorkflowProcessOutcome, EngineError>) + Send + 'static,
601    {
602        self.monitor_process(pid, callback)
603    }
604
605    #[cfg(test)]
606    pub(crate) fn process_cleanup_started_for_test(&self, pid: Pid) -> bool {
607        self.nif_state().process_cleanup_started(pid)
608    }
609
610    #[cfg(test)]
611    pub(crate) fn process_cleanup_complete_for_test(&self, pid: Pid) -> bool {
612        self.process_cleanup_started_for_test(pid)
613            && !self.is_live(pid)
614            && !self.abort_jobs.contains_key(&pid)
615            && !self.nif_state().monitor_installations.contains_key(&pid)
616            && !self.process_exits.contains(pid)
617    }
618}
619
620#[cfg(test)]
621#[path = "monitor_tests.rs"]
622mod tests;