use std::collections::{HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use frame_core::component::ComponentId;
use frame_core::event::{
EventReceiveError, EventTryReceiveError, LifecycleEvent, LifecycleEventKind, LifecycleState,
LifecycleSubscription,
};
use liminal_sdk::remote::PushClient;
use crate::error::HostError;
use crate::truth::AppTruth;
const FACT_QUEUE_BOUND: usize = 256;
const PUMP_WAIT_QUANTUM: Duration = Duration::from_secs(3600);
struct PendingFacts {
queue: VecDeque<serde_json::Value>,
dropped: usize,
live: bool,
}
struct DeathSlot {
dead: AtomicBool,
detail: Mutex<Option<String>>,
}
struct Shared {
client: Mutex<PushClient>,
channel: String,
pending: Mutex<PendingFacts>,
death: DeathSlot,
}
pub struct Announcer {
shared: Arc<Shared>,
subscription: Mutex<Option<LifecycleSubscription>>,
pump: Mutex<Option<JoinHandle<()>>>,
intake_closed: AtomicBool,
truth: Mutex<Option<Arc<AppTruth>>>,
}
impl Announcer {
pub fn connect(
address: &str,
auth_token: Option<&[u8]>,
channel: String,
subscription: LifecycleSubscription,
) -> Result<Self, HostError> {
let connected = match auth_token {
Some(token) => PushClient::connect_with_auth(address, token),
None => PushClient::connect(address),
};
let client = connected.map_err(|error| HostError::AnnouncerConnect {
address: address.to_owned(),
detail: error.to_string(),
})?;
Ok(Self {
shared: Arc::new(Shared {
client: Mutex::new(client),
channel,
pending: Mutex::new(PendingFacts {
queue: VecDeque::new(),
dropped: 0,
live: false,
}),
death: DeathSlot {
dead: AtomicBool::new(false),
detail: Mutex::new(None),
},
}),
subscription: Mutex::new(Some(subscription)),
pump: Mutex::new(None),
intake_closed: AtomicBool::new(false),
truth: Mutex::new(None),
})
}
pub fn attach_truth(&self, truth: Arc<AppTruth>) {
match self.truth.lock() {
Ok(mut slot) => *slot = Some(truth),
Err(_) => {
tracing::error!(
"announcer synchronization poisoned while attaching the app-truth recorder; \
facts will still publish on the bus but will not appear in the \
/frame/app/status.json snapshot"
);
}
}
}
pub fn start(&self, expected: HashSet<ComponentId>) -> Result<(), HostError> {
if expected.is_empty() {
return Err(HostError::ConfigContract {
detail: "the application-event announcer needs at least one expected component; \
an empty set would leave its pump with no ordered exit"
.to_owned(),
});
}
let subscription = self
.subscription
.lock()
.map_err(|_| HostError::SynchronizationPoisoned)?
.take()
.ok_or(HostError::AnnouncerAlreadyStarted)?;
let shared = Arc::clone(&self.shared);
let join = std::thread::Builder::new()
.name("frame-host-announcer".to_owned())
.spawn(move || pump(&shared, &subscription, &expected))
.map_err(|source| HostError::AnnouncerSpawn { source })?;
*self
.pump
.lock()
.map_err(|_| HostError::SynchronizationPoisoned)? = Some(join);
Ok(())
}
pub fn announce_fact(&self, fact: serde_json::Value) -> Result<(), HostError> {
if self.intake_closed.load(Ordering::Acquire) {
return Err(HostError::AnnouncerIntakeClosed);
}
match self.truth.lock() {
Ok(slot) => {
if let Some(truth) = slot.as_ref() {
truth.record_fact(fact.clone());
}
}
Err(_) => {
tracing::error!(
"announcer synchronization poisoned while checking for an attached app-truth \
recorder; the fact is still announced on the bus"
);
}
}
let live = {
let mut pending = self
.shared
.pending
.lock()
.map_err(|_| HostError::SynchronizationPoisoned)?;
if !pending.live {
if pending.queue.len() == FACT_QUEUE_BOUND {
let _discarded = pending.queue.pop_front();
pending.dropped += 1;
tracing::warn!(
total_dropped = pending.dropped,
"announcer fact queue overflowed its bound before the pump went live; \
the oldest fact was dropped"
);
}
pending.queue.push_back(fact);
return Ok(());
}
pending.live
};
debug_assert!(live);
publish_json(&self.shared, &fact_json(&fact))
}
pub fn close_intake(&self) {
self.intake_closed.store(true, Ordering::Release);
}
pub fn stop(self) -> Result<Option<String>, HostError> {
let join = self
.pump
.lock()
.map_err(|_| HostError::SynchronizationPoisoned)?
.take();
if let Some(join) = join {
join.join().map_err(|_| HostError::AnnouncerPanicked)?;
}
let detail = self
.shared
.death
.detail
.lock()
.map_err(|_| HostError::SynchronizationPoisoned)?
.clone();
Ok(detail)
}
}
fn publish_json(shared: &Shared, value: &serde_json::Value) -> Result<(), HostError> {
let payload = value.to_string().into_bytes();
let outcome = {
let client = shared
.client
.lock()
.map_err(|_| HostError::SynchronizationPoisoned)?;
client.publish(&shared.channel, payload)
};
outcome.map_err(|error| {
let detail = error.to_string();
record_death(shared, &detail);
HostError::AnnouncerPublish {
channel: shared.channel.clone(),
detail,
}
})
}
fn record_death(shared: &Shared, detail: &str) {
if !shared.death.dead.swap(true, Ordering::AcqRel) {
tracing::error!(
channel = %shared.channel,
detail,
"application-event announcer DIED at runtime: its bus connection failed; the served \
page will observe the death as an absence of events"
);
match shared.death.detail.lock() {
Ok(mut slot) => *slot = Some(detail.to_owned()),
Err(_) => {
tracing::error!("announcer death-detail slot poisoned while recording the death");
}
}
}
}
fn pump(shared: &Shared, subscription: &LifecycleSubscription, expected: &HashSet<ComponentId>) {
let mut removed: HashSet<ComponentId> = HashSet::new();
let mut reported_lag = 0;
loop {
match subscription.try_recv() {
Ok(event) => {
if !announce_event(shared, subscription, &mut reported_lag, &event) {
return;
}
track_removed(&event, &mut removed);
}
Err(EventTryReceiveError::Empty) => break,
Err(EventTryReceiveError::Closed) => {
tracing::info!("lifecycle event stream closed; announcer pump exiting");
return;
}
Err(EventTryReceiveError::Poisoned) => {
record_death(shared, "lifecycle event stream synchronization poisoned");
return;
}
}
}
let Ok(mut pending) = shared.pending.lock() else {
record_death(shared, "announcer fact queue synchronization poisoned");
return;
};
pending.live = true;
if pending.dropped > 0 {
tracing::warn!(
dropped = pending.dropped,
"announcer fact queue dropped facts before the pump went live"
);
}
let queued = std::mem::take(&mut pending.queue);
drop(pending);
for fact in queued {
if publish_json(shared, &fact_json(&fact)).is_err() {
return;
}
}
if removed.is_superset(expected) {
tracing::info!("every expected component is removed; announcer pump exiting");
return;
}
loop {
let event = match subscription.recv_timeout(PUMP_WAIT_QUANTUM) {
Ok(event) => event,
Err(EventReceiveError::Timeout) => continue,
Err(EventReceiveError::Closed) => {
tracing::info!("lifecycle event stream closed; announcer pump exiting");
return;
}
Err(EventReceiveError::Poisoned) => {
record_death(shared, "lifecycle event stream synchronization poisoned");
return;
}
};
if !announce_event(shared, subscription, &mut reported_lag, &event) {
return;
}
track_removed(&event, &mut removed);
if removed.is_superset(expected) {
tracing::info!("every expected component is removed; announcer pump exiting");
return;
}
}
}
fn announce_event(
shared: &Shared,
subscription: &LifecycleSubscription,
reported_lag: &mut usize,
event: &LifecycleEvent,
) -> bool {
let lagged = subscription.lagged_events();
if lagged > *reported_lag {
tracing::warn!(
dropped = lagged - *reported_lag,
total_dropped = lagged,
"announcer overflowed its bounded lifecycle buffer; oldest events were dropped"
);
*reported_lag = lagged;
}
publish_json(shared, &event_json(event)).is_ok()
}
fn track_removed(event: &LifecycleEvent, removed: &mut HashSet<ComponentId>) {
if let LifecycleEventKind::Transition {
to: LifecycleState::Removed,
..
} = event.kind
{
removed.insert(event.component_id);
}
}
fn event_json(event: &LifecycleEvent) -> serde_json::Value {
match &event.kind {
LifecycleEventKind::Transition { from, to } => serde_json::json!({
"kind": "lifecycle",
"sequence": event.sequence,
"componentId": event.component_id.to_string(),
"from": from,
"to": to,
}),
LifecycleEventKind::CapabilityDenied(denial) => serde_json::json!({
"kind": "capability-denied",
"sequence": event.sequence,
"componentId": event.component_id.to_string(),
"denial": denial,
}),
}
}
fn fact_json(body: &serde_json::Value) -> serde_json::Value {
serde_json::json!({ "kind": "fact", "body": body })
}
#[cfg(test)]
mod tests {
use super::{event_json, fact_json};
use frame_core::capability::{CapabilityDenied, CapabilityKind, CapabilityScope};
use frame_core::component::ComponentId;
use frame_core::event::{LifecycleEvent, LifecycleEventKind, LifecycleState};
fn id() -> ComponentId {
ComponentId::derive("frame.host", "announcer-wire-proof")
}
#[test]
fn lifecycle_transition_encodes_states_and_component() {
let value = event_json(&LifecycleEvent {
sequence: 7,
component_id: id(),
kind: LifecycleEventKind::Transition {
from: Some(LifecycleState::Registered),
to: LifecycleState::Starting,
},
});
assert_eq!(value["kind"], "lifecycle");
assert_eq!(value["sequence"], 7);
assert_eq!(value["componentId"], id().to_string());
assert_eq!(value["from"], "Registered");
assert_eq!(value["to"], "Starting");
}
#[test]
fn initial_registration_encodes_null_from() {
let value = event_json(&LifecycleEvent {
sequence: 0,
component_id: id(),
kind: LifecycleEventKind::Transition {
from: None,
to: LifecycleState::Registered,
},
});
assert!(value["from"].is_null());
assert_eq!(value["to"], "Registered");
}
#[test]
fn capability_denial_encodes_the_full_denial() {
let value = event_json(&LifecycleEvent {
sequence: 3,
component_id: id(),
kind: LifecycleEventKind::CapabilityDenied(CapabilityDenied {
component_id: id(),
kind: CapabilityKind::Network,
scope: CapabilityScope::Host("bus.example".to_owned()),
declared: false,
}),
});
assert_eq!(value["kind"], "capability-denied");
assert_eq!(value["sequence"], 3);
assert_eq!(value["componentId"], id().to_string());
assert_eq!(value["denial"]["declared"], false);
}
#[test]
fn facts_are_wrapped_verbatim() {
let value = fact_json(&serde_json::json!({ "entity": "e-1" }));
assert_eq!(value["kind"], "fact");
assert_eq!(value["body"]["entity"], "e-1");
}
}