Skip to main content

beamr/scheduler/
exit_events.rs

1//! Bounded, single-subscriber process-exit event delivery.
2
3use std::sync::Arc;
4#[cfg(test)]
5use std::sync::Mutex;
6use std::sync::OnceLock;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::time::Duration;
9
10use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, TrySendError};
11
12use crate::process::ExitReason;
13
14/// Maximum number of exit notifications buffered for the subscriber.
15///
16/// When the subscriber falls behind this bound, the queue remains bounded and
17/// [`ExitEvent::Lagged`] reports that one or more notifications were not queued.
18pub const EXIT_EVENT_CAPACITY: usize = 1_024;
19
20/// A notification delivered by an [`ExitEventSubscription`].
21#[derive(Copy, Clone, Debug, Eq, PartialEq)]
22pub enum ExitEvent {
23    /// A process exited. Its outcome was published before this event, so an
24    /// immediate `Scheduler::take_exit_outcome(pid)` can consume it.
25    Exited {
26        /// Exited process identifier.
27        pid: u64,
28        /// Process exit reason.
29        reason: ExitReason,
30    },
31    /// At least one exit notification could not fit in the bounded queue.
32    ///
33    /// No outcome is discarded: pending notifications are reset when this is
34    /// observed, and consumers can recover by calling
35    /// `Scheduler::take_exit_outcome` for the process identifiers they track.
36    /// Multiple overflows may be coalesced into one marker.
37    Lagged,
38}
39
40/// Failure while waiting for the next exit event.
41#[derive(Copy, Clone, Debug, Eq, PartialEq)]
42pub enum ExitEventRecvError {
43    /// The scheduler and its event publisher were dropped.
44    Disconnected,
45    /// No event arrived before the requested timeout.
46    Timeout,
47}
48
49impl std::fmt::Display for ExitEventRecvError {
50    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        formatter.write_str(match self {
52            Self::Disconnected => "exit-event publisher disconnected",
53            Self::Timeout => "timed out waiting for an exit event",
54        })
55    }
56}
57
58impl std::error::Error for ExitEventRecvError {}
59
60/// The receiving handle for a scheduler's bounded exit-event stream.
61///
62/// A scheduler permits one subscription for its lifetime. The handle blocks on
63/// the channel rather than polling, and can be shared between threads if the
64/// consumer wants to move the single draining responsibility.
65pub struct ExitEventSubscription {
66    receiver: Receiver<ExitEvent>,
67    overflowed: Arc<AtomicBool>,
68}
69
70impl ExitEventSubscription {
71    /// Block until an exit event, overflow marker, or disconnection is observed.
72    pub fn recv(&self) -> Result<ExitEvent, ExitEventRecvError> {
73        if self.take_lag_marker() {
74            return Ok(ExitEvent::Lagged);
75        }
76        self.receiver
77            .recv()
78            .map_err(|_| ExitEventRecvError::Disconnected)
79    }
80
81    /// Wait up to `timeout` for an exit event or overflow marker.
82    pub fn recv_timeout(&self, timeout: Duration) -> Result<ExitEvent, ExitEventRecvError> {
83        if self.take_lag_marker() {
84            return Ok(ExitEvent::Lagged);
85        }
86        self.receiver
87            .recv_timeout(timeout)
88            .map_err(|error| match error {
89                RecvTimeoutError::Timeout => ExitEventRecvError::Timeout,
90                RecvTimeoutError::Disconnected => ExitEventRecvError::Disconnected,
91            })
92    }
93
94    fn take_lag_marker(&self) -> bool {
95        if !self.overflowed.swap(false, Ordering::AcqRel) {
96            return false;
97        }
98        // Events already in the queue belong to the lagged batch. Discard only
99        // the bounded snapshot currently present; a concurrent later publish is
100        // left for the next receive and never turns this into a polling loop.
101        for _ in 0..self.receiver.len() {
102            let _ = self.receiver.try_recv();
103        }
104        true
105    }
106}
107
108#[cfg(test)]
109#[derive(Clone)]
110struct ExitEventPublicationGate {
111    published: Sender<()>,
112    observed: Receiver<()>,
113}
114
115#[cfg(test)]
116pub(super) struct ExitEventPublicationObserver {
117    published: Receiver<()>,
118    observed: Sender<()>,
119}
120
121pub(super) struct ExitEventPublisher {
122    sender: OnceLock<Sender<ExitEvent>>,
123    overflowed: Arc<AtomicBool>,
124    capacity: usize,
125    #[cfg(test)]
126    publication_gate: Mutex<Option<ExitEventPublicationGate>>,
127}
128
129impl ExitEventPublisher {
130    pub(super) fn new() -> Self {
131        Self::with_capacity(EXIT_EVENT_CAPACITY)
132    }
133
134    fn with_capacity(capacity: usize) -> Self {
135        Self {
136            sender: OnceLock::new(),
137            overflowed: Arc::new(AtomicBool::new(false)),
138            capacity: capacity.max(1),
139            #[cfg(test)]
140            publication_gate: Mutex::new(None),
141        }
142    }
143
144    pub(super) fn subscribe(&self) -> Option<ExitEventSubscription> {
145        let (sender, receiver) = crossbeam_channel::bounded(self.capacity);
146        self.sender.set(sender).ok()?;
147        Some(ExitEventSubscription {
148            receiver,
149            overflowed: Arc::clone(&self.overflowed),
150        })
151    }
152
153    pub(super) fn publish(&self, event: ExitEvent) {
154        let Some(sender) = self.sender.get() else {
155            return;
156        };
157        match sender.try_send(event) {
158            Ok(()) => {
159                #[cfg(test)]
160                self.wait_at_publication_gate();
161            }
162            Err(TrySendError::Disconnected(_)) => {}
163            Err(TrySendError::Full(_)) => self.overflowed.store(true, Ordering::Release),
164        }
165    }
166
167    /// Install a zero-capacity, post-send rendezvous for one test phase.
168    ///
169    /// A successful publisher cannot return from [`Self::publish`] until the
170    /// observer confirms that it received the event. This is deliberately
171    /// after `try_send`: outcome installation and event publication retain
172    /// their production order, while the observer can prove it contested the
173    /// actual publication call rather than merely running before it.
174    #[cfg(test)]
175    pub(super) fn install_publication_gate(&self) -> ExitEventPublicationObserver {
176        let (published, observe_publication) = crossbeam_channel::bounded(0);
177        let (observation_complete, observed) = crossbeam_channel::bounded(0);
178        let mut publication_gate = match self.publication_gate.lock() {
179            Ok(guard) => guard,
180            Err(poisoned) => poisoned.into_inner(),
181        };
182        *publication_gate = Some(ExitEventPublicationGate {
183            published,
184            observed,
185        });
186        ExitEventPublicationObserver {
187            published: observe_publication,
188            observed: observation_complete,
189        }
190    }
191
192    #[cfg(test)]
193    pub(super) fn clear_publication_gate(&self) {
194        let mut publication_gate = match self.publication_gate.lock() {
195            Ok(guard) => guard,
196            Err(poisoned) => poisoned.into_inner(),
197        };
198        *publication_gate = None;
199    }
200
201    #[cfg(test)]
202    fn wait_at_publication_gate(&self) {
203        let gate = match self.publication_gate.lock() {
204            Ok(guard) => guard.clone(),
205            Err(poisoned) => poisoned.into_inner().clone(),
206        };
207        let Some(gate) = gate else {
208            return;
209        };
210        if gate.published.send(()).is_ok() {
211            // Disconnection means the observer failed and is unwinding; do not
212            // turn its finite receive timeout into a stuck publisher thread.
213            let _ = gate.observed.recv();
214        }
215    }
216}
217
218#[cfg(test)]
219impl ExitEventPublicationObserver {
220    pub(super) fn acknowledge_observed(&self, timeout: Duration) {
221        self.published
222            .recv_timeout(timeout)
223            .expect("event publisher must reach the post-send gate");
224        self.observed
225            .send_timeout((), timeout)
226            .expect("event publisher must remain at the post-send gate");
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn overflow_is_typed_and_queue_stays_bounded() {
236        let publisher = ExitEventPublisher::with_capacity(2);
237        let subscription = publisher.subscribe().expect("first subscriber");
238        for pid in 1..=3 {
239            publisher.publish(ExitEvent::Exited {
240                pid,
241                reason: ExitReason::Normal,
242            });
243        }
244
245        assert_eq!(subscription.recv(), Ok(ExitEvent::Lagged));
246        assert!(
247            publisher.subscribe().is_none(),
248            "subscription is single-use"
249        );
250        assert_eq!(
251            subscription.recv_timeout(Duration::ZERO),
252            Err(ExitEventRecvError::Timeout),
253            "lag resets the queued batch before recovery"
254        );
255        publisher.publish(ExitEvent::Exited {
256            pid: 4,
257            reason: ExitReason::Normal,
258        });
259        assert_eq!(
260            subscription.recv(),
261            Ok(ExitEvent::Exited {
262                pid: 4,
263                reason: ExitReason::Normal,
264            })
265        );
266    }
267}