Skip to main content

frame_core/
event.rs

1//! Typed, bounded lifecycle event delivery.
2
3use std::collections::VecDeque;
4use std::num::NonZeroUsize;
5use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
6use std::sync::{Arc, Condvar, Mutex, Weak};
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12use crate::capability::CapabilityDenied;
13use crate::component::ComponentId;
14
15/// An externally observable component lifecycle state.
16#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
17pub enum LifecycleState {
18    /// Definition is registered but has not started.
19    Registered,
20    /// Module loading and child liveness checks are in progress.
21    Starting,
22    /// Every declared child completed a mailbox round-trip.
23    Running,
24    /// Ordered child and supervisor drain is in progress.
25    Stopping,
26    /// All component processes have normal tombstones.
27    Stopped,
28    /// Start or supervision failed; the associated status carries the reason.
29    Failed,
30    /// Modules and registration were removed without residue.
31    Removed,
32}
33
34/// One ordered item on the component lifecycle and security-news stream.
35#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
36pub struct LifecycleEvent {
37    /// Registry-wide monotonic sequence number.
38    pub sequence: u64,
39    /// Component associated with this news item.
40    pub component_id: ComponentId,
41    /// Typed transition or capability denial payload.
42    pub kind: LifecycleEventKind,
43}
44
45/// The typed payload carried by one lifecycle stream item.
46#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
47pub enum LifecycleEventKind {
48    /// One component lifecycle transition.
49    Transition {
50        /// State before this transition, absent for initial registration.
51        from: Option<LifecycleState>,
52        /// State after this transition.
53        to: LifecycleState,
54    },
55    /// One consuming act was denied by the fresh capability check.
56    CapabilityDenied(CapabilityDenied),
57    /// One committed live fragment content update (F-5a R2/R3). The stream
58    /// carries the news, never the bytes: the registry's fragment snapshot
59    /// stays the authority, and a consumer that misses this event converges
60    /// through its next lag-forced resync.
61    FragmentContentUpdated {
62        /// The updated fragment.
63        key: crate::fragment::FragmentKey,
64    },
65}
66
67/// A bounded subscription that reports every event it had to discard.
68pub struct LifecycleSubscription {
69    queue: Arc<SubscriberQueue>,
70}
71
72impl LifecycleSubscription {
73    /// Waits up to `timeout` for the next retained event.
74    ///
75    /// # Errors
76    ///
77    /// Returns a typed timeout, closure, or synchronization failure.
78    pub fn recv_timeout(&self, timeout: Duration) -> Result<LifecycleEvent, EventReceiveError> {
79        let guard = self
80            .queue
81            .state
82            .lock()
83            .map_err(|_| EventReceiveError::Poisoned)?;
84        let (mut guard, _wait) = self
85            .queue
86            .ready
87            .wait_timeout_while(guard, timeout, |state| {
88                state.events.is_empty() && !state.closed
89            })
90            .map_err(|_| EventReceiveError::Poisoned)?;
91        if let Some(event) = guard.events.pop_front() {
92            return Ok(event);
93        }
94        if guard.closed {
95            Err(EventReceiveError::Closed)
96        } else {
97            Err(EventReceiveError::Timeout)
98        }
99    }
100
101    /// Returns the next retained event without waiting.
102    ///
103    /// # Errors
104    ///
105    /// Returns closure, emptiness, or synchronization failures distinctly.
106    pub fn try_recv(&self) -> Result<LifecycleEvent, EventTryReceiveError> {
107        let mut state = self
108            .queue
109            .state
110            .lock()
111            .map_err(|_| EventTryReceiveError::Poisoned)?;
112        if let Some(event) = state.events.pop_front() {
113            Ok(event)
114        } else if state.closed {
115            Err(EventTryReceiveError::Closed)
116        } else {
117            Err(EventTryReceiveError::Empty)
118        }
119    }
120
121    /// Blocks until the next retained event arrives or the subscription is
122    /// closed — the timeout-less sibling of [`Self::recv_timeout`], same
123    /// condvar, no timer anywhere (Ruling B's sanctioned blocking shape:
124    /// close is the only external wake, delivered by
125    /// [`SubscriptionCloseHandle::close`] or this subscription's drop).
126    ///
127    /// # Errors
128    ///
129    /// Returns a typed closure or synchronization failure; never a timeout.
130    pub fn recv(&self) -> Result<LifecycleEvent, EventReceiveError> {
131        let guard = self
132            .queue
133            .state
134            .lock()
135            .map_err(|_| EventReceiveError::Poisoned)?;
136        let mut guard = self
137            .queue
138            .ready
139            .wait_while(guard, |state| state.events.is_empty() && !state.closed)
140            .map_err(|_| EventReceiveError::Poisoned)?;
141        if let Some(event) = guard.events.pop_front() {
142            return Ok(event);
143        }
144        Err(EventReceiveError::Closed)
145    }
146
147    /// Returns the cumulative number of events dropped from this subscriber.
148    #[must_use]
149    pub fn lagged_events(&self) -> usize {
150        self.queue.lagged.load(Ordering::Acquire)
151    }
152
153    /// Returns a handle that closes this subscription from another thread,
154    /// waking every blocked receiver (the demand-driven teardown wake — the
155    /// F-6a frame-core sanction amendment).
156    ///
157    /// The handle holds only a weak reference: retaining it never keeps the
158    /// subscription's queue alive after the subscription drops.
159    #[must_use]
160    pub fn close_handle(&self) -> SubscriptionCloseHandle {
161        SubscriptionCloseHandle {
162            queue: Arc::downgrade(&self.queue),
163        }
164    }
165}
166
167/// Externally-holdable close switch for one [`LifecycleSubscription`].
168pub struct SubscriptionCloseHandle {
169    queue: Weak<SubscriberQueue>,
170}
171
172impl SubscriptionCloseHandle {
173    /// Closes the subscription and wakes every blocked receiver. Idempotent;
174    /// a no-op once the subscription itself has dropped.
175    ///
176    /// A poisoned subscriber lock cannot mark the queue closed, but blocked
177    /// receivers are still woken and then observe their own typed
178    /// [`EventReceiveError::Poisoned`] — the failure is propagated at the
179    /// receive surface, never swallowed silently.
180    pub fn close(&self) {
181        if let Some(queue) = self.queue.upgrade() {
182            if let Ok(mut state) = queue.state.lock() {
183                state.closed = true;
184            }
185            queue.ready.notify_all();
186        }
187    }
188}
189
190impl Drop for LifecycleSubscription {
191    fn drop(&mut self) {
192        if let Ok(mut state) = self.queue.state.lock() {
193            state.closed = true;
194            self.queue.ready.notify_all();
195        }
196    }
197}
198
199/// Failure while waiting for an event.
200#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
201pub enum EventReceiveError {
202    /// No event arrived before the caller's deadline.
203    #[error("lifecycle event receive timed out")]
204    Timeout,
205    /// The subscription was closed.
206    #[error("lifecycle event subscription is closed")]
207    Closed,
208    /// Subscriber synchronization was poisoned by a panic.
209    #[error("lifecycle event subscription synchronization is poisoned")]
210    Poisoned,
211}
212
213/// Failure while polling for an event.
214#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
215pub enum EventTryReceiveError {
216    /// No retained event is currently available.
217    #[error("lifecycle event subscription is empty")]
218    Empty,
219    /// The subscription was closed.
220    #[error("lifecycle event subscription is closed")]
221    Closed,
222    /// Subscriber synchronization was poisoned by a panic.
223    #[error("lifecycle event subscription synchronization is poisoned")]
224    Poisoned,
225}
226
227#[derive(Default)]
228pub(crate) struct EventHub {
229    next_sequence: AtomicU64,
230    subscribers: Mutex<Vec<Weak<SubscriberQueue>>>,
231    /// Deterministic count of constructed events — the F-5a R6 zero-cost
232    /// choke point: with no live subscriber, publication must return before
233    /// ANY event is constructed, and this counter proves it stayed zero.
234    #[cfg(test)]
235    pub(crate) constructed: AtomicUsize,
236}
237
238impl EventHub {
239    pub(crate) fn subscribe(
240        &self,
241        capacity: NonZeroUsize,
242    ) -> Result<LifecycleSubscription, EventPublishError> {
243        let queue = Arc::new(SubscriberQueue {
244            capacity: capacity.get(),
245            state: Mutex::new(QueueState::default()),
246            ready: Condvar::new(),
247            lagged: AtomicUsize::new(0),
248        });
249        self.subscribers
250            .lock()
251            .map_err(|_| EventPublishError::Poisoned)?
252            .push(Arc::downgrade(&queue));
253        Ok(LifecycleSubscription { queue })
254    }
255
256    pub(crate) fn publish_transition(
257        &self,
258        component_id: ComponentId,
259        from: Option<LifecycleState>,
260        to: LifecycleState,
261    ) -> Result<(), EventPublishError> {
262        self.publish(component_id, LifecycleEventKind::Transition { from, to })
263    }
264
265    pub(crate) fn publish_denial(&self, denial: CapabilityDenied) -> Result<(), EventPublishError> {
266        self.publish(
267            denial.component_id,
268            LifecycleEventKind::CapabilityDenied(denial),
269        )
270    }
271
272    pub(crate) fn publish_fragment_update(
273        &self,
274        key: crate::fragment::FragmentKey,
275    ) -> Result<(), EventPublishError> {
276        self.publish(
277            key.component_id,
278            LifecycleEventKind::FragmentContentUpdated { key },
279        )
280    }
281
282    fn publish(
283        &self,
284        component_id: ComponentId,
285        kind: LifecycleEventKind,
286    ) -> Result<(), EventPublishError> {
287        let mut subscribers = self
288            .subscribers
289            .lock()
290            .map_err(|_| EventPublishError::Poisoned)?;
291        subscribers.retain(|subscriber| subscriber.strong_count() > 0);
292        if subscribers.is_empty() {
293            return Ok(());
294        }
295        #[cfg(test)]
296        self.constructed.fetch_add(1, Ordering::AcqRel);
297        let event = LifecycleEvent {
298            sequence: self.next_sequence.fetch_add(1, Ordering::AcqRel),
299            component_id,
300            kind,
301        };
302        for subscriber in subscribers.iter().filter_map(Weak::upgrade) {
303            let mut state = subscriber
304                .state
305                .lock()
306                .map_err(|_| EventPublishError::Poisoned)?;
307            if state.events.len() == subscriber.capacity {
308                let _discarded = state.events.pop_front();
309                subscriber.lagged.fetch_add(1, Ordering::AcqRel);
310            }
311            state.events.push_back(event.clone());
312            subscriber.ready.notify_one();
313        }
314        Ok(())
315    }
316}
317
318#[derive(Debug, Error)]
319pub(crate) enum EventPublishError {
320    #[error("lifecycle event stream synchronization is poisoned")]
321    Poisoned,
322}
323
324struct SubscriberQueue {
325    capacity: usize,
326    state: Mutex<QueueState>,
327    ready: Condvar,
328    lagged: AtomicUsize,
329}
330
331#[derive(Default)]
332struct QueueState {
333    events: VecDeque<LifecycleEvent>,
334    closed: bool,
335}
336
337#[cfg(test)]
338mod tests {
339    #![allow(clippy::panic)]
340
341    use std::num::NonZeroUsize;
342    use std::sync::mpsc;
343    use std::thread;
344    use std::time::Duration;
345
346    use super::{EventHub, EventReceiveError, LifecycleState};
347    use crate::component::ComponentId;
348
349    /// Generous wall for the TEST harness only (the code under test carries
350    /// no timeout — that is the point).
351    const HARNESS_WALL: Duration = Duration::from_secs(10);
352
353    fn capacity(value: usize) -> NonZeroUsize {
354        NonZeroUsize::new(value).unwrap_or(NonZeroUsize::MIN)
355    }
356
357    #[test]
358    fn blocked_recv_wakes_promptly_on_external_close() {
359        let hub = EventHub::default();
360        let subscription = hub
361            .subscribe(capacity(4))
362            .unwrap_or_else(|_| unreachable!("subscribing to a fresh hub cannot be poisoned"));
363        let close = subscription.close_handle();
364        let (done, observed) = mpsc::channel();
365        let (entering, entered) = mpsc::channel();
366        let worker = thread::spawn(move || {
367            entering.send(()).ok();
368            let outcome = subscription.recv();
369            done.send(outcome).ok();
370        });
371        entered
372            .recv_timeout(HARNESS_WALL)
373            .unwrap_or_else(|_| panic!("receiver thread never started"));
374        // Settle so the worker is INSIDE the condvar wait before close fires;
375        // without this the close-before-block path returns via the wait
376        // predicate alone and the notify wake is never exercised.
377        thread::sleep(Duration::from_millis(100));
378        close.close();
379        let outcome = observed
380            .recv_timeout(HARNESS_WALL)
381            .unwrap_or_else(|_| panic!("blocked recv() never woke on external close"));
382        assert!(
383            matches!(outcome, Err(EventReceiveError::Closed)),
384            "external close must surface the typed Closed error, got: {outcome:?}"
385        );
386        worker
387            .join()
388            .unwrap_or_else(|_| panic!("receiver thread panicked"));
389    }
390
391    #[test]
392    fn no_subscriber_churn_constructs_zero_events() {
393        use std::sync::atomic::Ordering;
394        let hub = EventHub::default();
395        let id = ComponentId::derive("frame.test", "zero-cost");
396        for _ in 0..64 {
397            hub.publish_transition(id, None, LifecycleState::Registered)
398                .unwrap_or_else(|_| unreachable!("publish with no subscribers cannot be poisoned"));
399        }
400        // The F-5a R6 zero-cost choke point: with no live subscriber,
401        // publication returns before ANY event is constructed — the
402        // deterministic counter at the construction site stays zero across
403        // churn.
404        assert_eq!(
405            hub.constructed.load(Ordering::Acquire),
406            0,
407            "zero live subscribers must cost zero event constructions"
408        );
409        // Companion isolation: the counter genuinely counts — the same
410        // publish with one live subscriber constructs exactly one event.
411        let subscription = hub
412            .subscribe(capacity(4))
413            .unwrap_or_else(|_| unreachable!("subscribing to a fresh hub cannot be poisoned"));
414        hub.publish_transition(id, None, LifecycleState::Registered)
415            .unwrap_or_else(|_| unreachable!("publish to a live subscriber cannot be poisoned"));
416        assert_eq!(hub.constructed.load(Ordering::Acquire), 1);
417        drop(subscription);
418    }
419
420    #[test]
421    fn recv_delivers_a_published_event_without_any_close() {
422        let hub = EventHub::default();
423        let subscription = hub
424            .subscribe(capacity(4))
425            .unwrap_or_else(|_| unreachable!("subscribing to a fresh hub cannot be poisoned"));
426        let id = ComponentId::derive("frame.test", "event-close");
427        hub.publish_transition(id, None, LifecycleState::Registered)
428            .unwrap_or_else(|_| unreachable!("publish to a live subscriber cannot be poisoned"));
429        let event = subscription
430            .recv()
431            .unwrap_or_else(|error| panic!("recv with a queued event must succeed: {error}"));
432        assert_eq!(event.component_id, id);
433    }
434
435    #[test]
436    fn close_is_idempotent_and_still_typed_after_drop() {
437        let hub = EventHub::default();
438        let subscription = hub
439            .subscribe(capacity(4))
440            .unwrap_or_else(|_| unreachable!("subscribing to a fresh hub cannot be poisoned"));
441        let close = subscription.close_handle();
442        close.close();
443        close.close();
444        assert!(matches!(
445            subscription.recv(),
446            Err(EventReceiveError::Closed)
447        ));
448        drop(subscription);
449        close.close();
450    }
451
452    #[test]
453    fn close_handle_does_not_keep_the_queue_alive() {
454        let hub = EventHub::default();
455        let subscription = hub
456            .subscribe(capacity(1))
457            .unwrap_or_else(|_| unreachable!("subscribing to a fresh hub cannot be poisoned"));
458        let close = subscription.close_handle();
459        drop(subscription);
460        // Publication after the subscription dropped must construct no event
461        // for it; the weak handle upgrading to nothing proves the queue died.
462        let id = ComponentId::derive("frame.test", "event-close-weak");
463        hub.publish_transition(id, None, LifecycleState::Registered)
464            .unwrap_or_else(|_| unreachable!("publish with no live subscriber is a no-op"));
465        assert!(close.queue.upgrade().is_none());
466    }
467}