use super::exit_events::{ExitEvent, ExitEventPublisher, ExitEventSubscription};
use crate::ets::copy::OwnedTerm;
use crate::process::ExitReason;
use dashmap::DashMap;
use std::collections::VecDeque;
use std::sync::Mutex;
pub(super) const TOMBSTONE_CAPACITY: usize = 65_536;
struct FinalizedOutcome {
reason: ExitReason,
outcome: Option<OwnedTerm>,
}
pub(super) struct BoundedTombstones {
reasons: DashMap<u64, ExitReason>,
outcomes: DashMap<u64, FinalizedOutcome>,
order: Mutex<VecDeque<u64>>,
capacity: usize,
events: ExitEventPublisher,
}
impl BoundedTombstones {
pub(super) fn new() -> Self {
Self::with_capacity(TOMBSTONE_CAPACITY)
}
pub(super) fn with_capacity(capacity: usize) -> Self {
Self {
reasons: DashMap::new(),
outcomes: DashMap::new(),
order: Mutex::new(VecDeque::new()),
capacity: capacity.max(1),
events: ExitEventPublisher::new(),
}
}
pub(super) fn get(&self, pid: &u64) -> Option<ExitReason> {
self.reasons.get(pid).map(|entry| *entry)
}
pub(super) fn contains_key(&self, pid: &u64) -> bool {
self.reasons.contains_key(pid)
}
pub(super) fn take_outcome(&self, pid: &u64) -> Option<(ExitReason, OwnedTerm)> {
let mut finalized = self.outcomes.get_mut(pid)?;
let outcome = finalized.outcome.take()?;
Some((finalized.reason, outcome))
}
pub(super) fn subscribe(&self) -> Option<ExitEventSubscription> {
self.events.subscribe()
}
#[cfg(test)]
pub(super) fn install_event_publication_gate(
&self,
) -> super::exit_events::ExitEventPublicationObserver {
self.events.install_publication_gate()
}
#[cfg(test)]
pub(super) fn clear_event_publication_gate(&self) {
self.events.clear_publication_gate();
}
#[cfg(test)]
pub(super) fn insert(&self, pid: u64, reason: ExitReason) -> Option<u64> {
self.insert_inner(pid, reason, None)
}
pub(super) fn insert_outcome(
&self,
pid: u64,
reason: ExitReason,
outcome: OwnedTerm,
) -> Option<u64> {
self.insert_inner(pid, reason, Some(outcome))
}
fn insert_inner(
&self,
pid: u64,
reason: ExitReason,
outcome: Option<OwnedTerm>,
) -> Option<u64> {
let mut order = match self.order.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if self.reasons.contains_key(&pid) {
self.reasons.insert(pid, reason);
return None;
}
let publish_event = if let Some(outcome) = outcome {
if self.outcomes.contains_key(&pid) {
false
} else {
self.outcomes.insert(
pid,
FinalizedOutcome {
reason,
outcome: Some(outcome),
},
);
true
}
} else {
false
};
self.reasons.insert(pid, reason);
order.push_back(pid);
let mut evicted = None;
if order.len() > self.capacity {
while let Some(oldest) = order.pop_front() {
if let Some((evicted_pid, _)) = self.reasons.remove(&oldest) {
evicted = Some(evicted_pid);
break;
}
}
}
drop(order);
if publish_event {
self.events.publish(ExitEvent::Exited { pid, reason });
}
evicted
}
#[cfg(test)]
pub(super) fn len(&self) -> usize {
self.reasons.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::term::Term;
use std::sync::Barrier;
use std::time::Duration;
const EVENT_TIMEOUT: Duration = Duration::from_secs(10);
fn insert(store: &BoundedTombstones, pid: u64, reason: ExitReason) -> Option<u64> {
store.insert_outcome(pid, reason, OwnedTerm::immediate(Term::NIL))
}
#[test]
fn finalized_outcome_residue_is_size_bounded_and_payload_free_after_take() {
const PINNED_RETAINED_VALUE_BYTES: usize = 40;
assert!(
core::mem::size_of::<FinalizedOutcome>() <= PINNED_RETAINED_VALUE_BYTES,
"retained value grew beyond {PINNED_RETAINED_VALUE_BYTES} bytes"
);
let store = BoundedTombstones::with_capacity(1);
let pid = 1;
let payload = OwnedTerm::from_allocations(Term::NIL, vec![vec![0_u64].into_boxed_slice()]);
assert_eq!(payload.allocation_count(), 1, "test payload must allocate");
store.insert_outcome(pid, ExitReason::Normal, payload);
let (reason, payload) = store.take_outcome(&pid).expect("outcome is takeable");
assert_eq!(reason, ExitReason::Normal);
assert_eq!(payload.allocation_count(), 1, "take returns the allocation");
drop(payload);
let retained = store.outcomes.get(&pid).expect("token remains durable");
assert!(
retained.outcome.is_none(),
"take_exit_outcome must leave no OwnedTerm or payload allocation in the ledger"
);
}
#[test]
fn insert_over_cap_stays_bounded() {
let cap = 8;
let store = BoundedTombstones::with_capacity(cap);
for pid in 0..1_000u64 {
insert(&store, pid, ExitReason::Normal);
assert!(
store.len() <= cap,
"len {} exceeded cap {} after inserting pid {}",
store.len(),
cap,
pid
);
}
assert_eq!(store.len(), cap, "store settles exactly at the cap");
}
#[test]
fn most_recent_survive_and_are_readable() {
let cap = 8;
let store = BoundedTombstones::with_capacity(cap);
for pid in 0..100u64 {
let reason = if pid % 2 == 0 {
ExitReason::Normal
} else {
ExitReason::Kill
};
insert(&store, pid, reason);
}
for pid in 92..100u64 {
let expected = if pid % 2 == 0 {
ExitReason::Normal
} else {
ExitReason::Kill
};
assert_eq!(
store.get(&pid),
Some(expected),
"recent pid {pid} must survive with its reason"
);
assert!(store.contains_key(&pid));
}
}
#[test]
fn oldest_are_evicted_recent_retained() {
let cap = 4;
let store = BoundedTombstones::with_capacity(cap);
for pid in 0..10u64 {
insert(&store, pid, ExitReason::Normal);
}
for pid in 0..6u64 {
assert_eq!(store.get(&pid), None, "old pid {pid} must be evicted");
assert!(!store.contains_key(&pid));
}
for pid in 6..10u64 {
assert_eq!(
store.get(&pid),
Some(ExitReason::Normal),
"recent pid {pid} must be retained"
);
}
}
#[test]
fn overwrite_does_not_duplicate_or_misevict() {
let cap = 3;
let store = BoundedTombstones::with_capacity(cap);
insert(&store, 1, ExitReason::Normal);
insert(&store, 2, ExitReason::Normal);
insert(&store, 3, ExitReason::Normal);
insert(&store, 1, ExitReason::Kill);
assert_eq!(store.get(&1), Some(ExitReason::Kill));
assert_eq!(store.len(), cap);
let insertion = insert(&store, 4, ExitReason::Normal);
assert_eq!(insertion, Some(1));
assert_eq!(store.get(&1), None, "first-inserted pid is the one evicted");
assert_eq!(store.get(&2), Some(ExitReason::Normal));
assert_eq!(store.get(&3), Some(ExitReason::Normal));
assert_eq!(store.get(&4), Some(ExitReason::Normal));
assert_eq!(store.len(), cap);
let (reason, _term) = store
.take_outcome(&1)
.expect("legacy overwrite and eviction leave outcome retained");
assert_eq!(reason, ExitReason::Normal);
assert!(store.take_outcome(&1).is_none(), "outcome is take-once");
}
#[test]
fn duplicate_after_eviction_preserves_original_untaken_outcome_and_emits_no_event() {
let store = BoundedTombstones::with_capacity(2);
let subscription = store.subscribe().expect("first subscriber");
store.insert_outcome(
1,
ExitReason::Normal,
OwnedTerm::immediate(Term::small_int(11)),
);
assert_eq!(
subscription.recv_timeout(EVENT_TIMEOUT),
Ok(ExitEvent::Exited {
pid: 1,
reason: ExitReason::Normal,
})
);
for pid in 2..=3 {
store.insert_outcome(
pid,
ExitReason::Normal,
OwnedTerm::immediate(Term::small_int(pid as i64)),
);
match subscription.recv_timeout(EVENT_TIMEOUT) {
Ok(ExitEvent::Exited { pid: event_pid, .. }) if event_pid == pid => {}
other => {
panic!("expected exit event for pid {pid}, got {other:?}")
}
}
assert!(store.take_outcome(&pid).is_some());
}
assert_eq!(store.get(&1), None, "pid 1 tombstone was evicted");
store.insert_outcome(
1,
ExitReason::Kill,
OwnedTerm::immediate(Term::small_int(99)),
);
let (reason, outcome) = store
.take_outcome(&1)
.expect("first terminal transition remains takeable");
assert_eq!(reason, ExitReason::Normal);
assert_eq!(outcome.root().as_small_int(), Some(11));
assert_eq!(
subscription.recv_timeout(Duration::ZERO),
Err(super::super::ExitEventRecvError::Timeout),
"duplicate finalization cannot emit another event"
);
}
#[test]
fn concurrent_terminal_callers_publish_one_authoritative_outcome_and_event() {
let store = BoundedTombstones::with_capacity(4);
let subscription = store.subscribe().expect("first subscriber");
let start = Barrier::new(3);
std::thread::scope(|scope| {
let normal = scope.spawn(|| {
start.wait();
store.insert_outcome(
1,
ExitReason::Normal,
OwnedTerm::immediate(Term::small_int(10)),
);
});
let killed = scope.spawn(|| {
start.wait();
store.insert_outcome(
1,
ExitReason::Kill,
OwnedTerm::immediate(Term::small_int(20)),
);
});
start.wait();
normal.join().expect("normal finalizer completes");
killed.join().expect("kill finalizer completes");
});
let (event_reason, expected_value) = match subscription.recv_timeout(EVENT_TIMEOUT) {
Ok(ExitEvent::Exited { pid: 1, reason }) => match reason {
ExitReason::Normal => (reason, 10),
ExitReason::Kill => (reason, 20),
other => panic!("unexpected authoritative reason {other:?}"),
},
other => panic!("expected one exit event, got {other:?}"),
};
let (outcome_reason, outcome) = store
.take_outcome(&1)
.expect("authoritative outcome is installed once");
assert_eq!(outcome_reason, event_reason);
assert_eq!(outcome.root().as_small_int(), Some(expected_value));
assert!(
store.take_outcome(&1).is_none(),
"the losing finalizer cannot install another outcome"
);
assert_eq!(
subscription.recv_timeout(Duration::ZERO),
Err(super::super::ExitEventRecvError::Timeout),
"the losing finalizer cannot emit an event"
);
let later_legacy_reason = match event_reason {
ExitReason::Normal => ExitReason::Kill,
ExitReason::Kill => ExitReason::Normal,
_ => unreachable!("event reason was restricted above"),
};
assert_eq!(
store.get(&1),
Some(later_legacy_reason),
"legacy overwrite is compatibility-only; the additive reason is authoritative"
);
}
}