use crate::{
marshal::core::{Mailbox, Variant, durability::Durable as _},
types::Round,
};
use commonware_cryptography::{Digest, certificate::Scheme};
use commonware_macros::select;
use commonware_runtime::Handle;
use commonware_utils::{
channel::{fallible::OneshotExt, oneshot},
sync::Mutex,
};
use std::{collections::HashMap, future::Future, sync::Arc};
use tracing::debug;
type Staged<B> = (Arc<B>, oneshot::Sender<Handle<()>>);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GateOutcome {
Ready(bool),
Recover,
}
struct Inner<D: Digest, B> {
certifications: HashMap<(Round, D), oneshot::Receiver<GateOutcome>>,
proposals: HashMap<(Round, D), Staged<B>>,
}
#[derive(Clone)]
pub(crate) struct Gates<D: Digest, B> {
inner: Arc<Mutex<Inner<D, B>>>,
}
impl<D: Digest, B> Default for Gates<D, B> {
fn default() -> Self {
Self::new()
}
}
impl<D: Digest, B> Gates<D, B> {
pub(crate) fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(Inner {
certifications: HashMap::new(),
proposals: HashMap::new(),
})),
}
}
pub(crate) fn insert(&self, round: Round, digest: D, task: oneshot::Receiver<GateOutcome>) {
self.inner
.lock()
.certifications
.insert((round, digest), task);
}
pub(crate) fn take(&self, round: Round, digest: D) -> Option<oneshot::Receiver<GateOutcome>> {
self.inner.lock().certifications.remove(&(round, digest))
}
pub(crate) fn take_staged(&self, round: Round, digest: D) -> Option<Staged<B>> {
self.inner.lock().proposals.remove(&(round, digest))
}
pub(crate) fn flush_unrelayed<S, V>(&self, marshal: &Mailbox<S, V>, round: Round, id: D)
where
S: Scheme,
V: Variant<Block = B>,
{
if let Some((block, ack)) = self.take_staged(round, id) {
marshal.verified_deferred(round, block, ack);
}
}
pub(crate) fn retain_after(&self, finalized_round: &Round) {
let mut inner = self.inner.lock();
inner
.certifications
.retain(|(round, _), _| round > finalized_round);
inner
.proposals
.retain(|(round, _), _| round > finalized_round);
}
pub(crate) async fn stage(
&self,
round: Round,
id: D,
block: Arc<B>,
tx: oneshot::Sender<D>,
name: &'static str,
) {
let (durable_tx, durable_rx) = oneshot::channel();
let (ack, persist) = oneshot::channel();
{
let mut inner = self.inner.lock();
inner.certifications.insert((round, id), durable_rx);
inner.proposals.insert((round, id), (block, ack));
}
tx.send_lossy(id);
let Ok(handle) = persist.await else {
return;
};
if !handle.durable(round, name).await {
return;
}
durable_tx.send_lossy(GateOutcome::Ready(true));
debug!(?round, ?id, name, "block durable");
}
}
pub(crate) const fn resolve(verdict: Option<bool>, durable: bool) -> Option<bool> {
match verdict {
Some(true) if !durable => None,
other => other,
}
}
pub(crate) async fn forward<T, U>(
mut output: oneshot::Sender<T>,
input: oneshot::Receiver<U>,
map: impl FnOnce(U) -> Option<T>,
) {
let result = select! {
_ = output.closed() => return,
result = input => result,
};
if let Ok(value) = result
&& let Some(value) = map(value)
{
output.send_lossy(value);
}
}
pub(crate) async fn drive<D, F, Fut>(
mut tx: oneshot::Sender<bool>,
task: oneshot::Receiver<GateOutcome>,
round: Round,
id: D,
fallback: F,
) where
D: Digest,
F: FnOnce() -> Fut,
Fut: Future<Output = oneshot::Receiver<bool>>,
{
let result = select! {
_ = tx.closed() => {
debug!(
reason = "consensus dropped receiver",
"skipping certification"
);
return;
},
result = task => result,
};
match result {
Ok(GateOutcome::Ready(result)) => {
tx.send_lossy(result);
}
Ok(GateOutcome::Recover) | Err(_) => {
debug!(
?round,
?id,
"certification gate requires recovery, falling back to embedded context"
);
let fallback = fallback().await;
let result = select! {
_ = tx.closed() => {
debug!(
reason = "consensus dropped receiver",
"skipping certification"
);
return;
},
result = fallback => result,
};
if let Ok(result) = result {
tx.send_lossy(result);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Epoch, View};
use commonware_cryptography::{Hasher, Sha256, sha256::Digest as Sha256Digest};
use commonware_runtime::{Runner, Spawner, Supervisor, deterministic};
use std::future::ready;
type D = Sha256Digest;
type TestGates = Gates<D, u64>;
fn round(view: u64) -> Round {
Round::new(Epoch::zero(), View::new(view))
}
fn pending_task() -> oneshot::Receiver<GateOutcome> {
let (_tx, rx) = oneshot::channel();
rx
}
fn no_fallback() -> std::future::Ready<oneshot::Receiver<bool>> {
unreachable!("certification must not fall back")
}
#[test]
fn test_insert_and_take_returns_task() {
let tasks = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
tasks.insert(round(1), digest, pending_task());
assert!(tasks.take(round(1), digest).is_some());
assert!(
tasks.take(round(1), digest).is_none(),
"taking twice should yield None"
);
}
#[test]
fn test_take_absent_key_is_none() {
let tasks = TestGates::new();
assert!(tasks.take(round(1), Sha256::hash(&[b"missing"])).is_none());
}
#[test]
fn test_take_distinguishes_rounds_and_digests() {
let tasks = TestGates::new();
let digest_a = Sha256::hash(&[b"a"]);
let digest_b = Sha256::hash(&[b"b"]);
tasks.insert(round(1), digest_a, pending_task());
tasks.insert(round(2), digest_a, pending_task());
tasks.insert(round(1), digest_b, pending_task());
assert!(tasks.take(round(1), digest_a).is_some());
assert!(tasks.take(round(2), digest_a).is_some());
assert!(tasks.take(round(1), digest_b).is_some());
}
#[test]
fn test_retain_after_drops_at_and_below_boundary() {
let tasks = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
tasks.insert(round(1), digest, pending_task());
tasks.insert(round(2), digest, pending_task());
tasks.insert(round(3), digest, pending_task());
tasks.retain_after(&round(2));
assert!(
tasks.take(round(1), digest).is_none(),
"tasks strictly below boundary should be dropped"
);
assert!(
tasks.take(round(2), digest).is_none(),
"tasks at boundary should be dropped"
);
assert!(
tasks.take(round(3), digest).is_some(),
"tasks strictly above boundary should be retained"
);
}
#[test]
fn test_retain_after_spans_epochs() {
let tasks = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
let early = Round::new(Epoch::zero(), View::new(100));
let late = Round::new(Epoch::new(1), View::zero());
tasks.insert(early, digest, pending_task());
tasks.insert(late, digest, pending_task());
tasks.retain_after(&early);
assert!(
tasks.take(early, digest).is_none(),
"task at boundary must be dropped"
);
assert!(
tasks.take(late, digest).is_some(),
"task in later epoch must outlive an earlier boundary"
);
}
#[test]
fn test_retain_after_empty_map_is_noop() {
let tasks = TestGates::new();
tasks.retain_after(&round(5));
assert!(tasks.take(round(5), Sha256::hash(&[b"x"])).is_none());
}
#[test]
fn test_default_matches_new() {
let default = <TestGates as Default>::default();
let digest = Sha256::hash(&[b"block"]);
default.insert(round(1), digest, pending_task());
assert!(default.take(round(1), digest).is_some());
}
#[test]
fn test_resolve() {
assert_eq!(resolve(None, true), None);
assert_eq!(resolve(None, false), None);
assert_eq!(resolve(Some(false), false), Some(false));
assert_eq!(resolve(Some(false), true), Some(false));
assert_eq!(resolve(Some(true), true), Some(true));
assert_eq!(resolve(Some(true), false), None);
}
#[test]
fn test_forward_cancels_input_when_output_closes() {
let runner = deterministic::Runner::default();
runner.start(|_| async move {
let (input_tx, input_rx) = oneshot::channel::<bool>();
let (output_tx, output_rx) = oneshot::channel::<bool>();
drop(output_rx);
forward(output_tx, input_rx, Some).await;
assert!(input_tx.is_closed());
});
}
#[test]
fn test_forward_cancels_in_flight_input_when_output_closes() {
let runner = deterministic::Runner::default();
runner.start(|context| async move {
let (input_tx, input_rx) = oneshot::channel::<bool>();
let (output_tx, output_rx) = oneshot::channel::<bool>();
let (started_tx, started_rx) = oneshot::channel();
let forwarder = context.child("forwarder").spawn(|_| async move {
started_tx.send_lossy(());
forward(output_tx, input_rx, Some).await;
});
started_rx.await.expect("forwarder should start");
assert!(!input_tx.is_closed());
drop(output_rx);
forwarder.await.expect("forwarder should stop");
assert!(input_tx.is_closed());
});
}
#[test]
fn test_drive_adopts_ready_verdict_without_fallback() {
let runner = deterministic::Runner::default();
runner.start(|_| async move {
for verdict in [true, false] {
let digest = Sha256::hash(&[b"block"]);
let (task_tx, task_rx) = oneshot::channel();
let (tx, rx) = oneshot::channel();
task_tx.send_lossy(GateOutcome::Ready(verdict));
drive(tx, task_rx, round(1), digest, no_fallback).await;
assert_eq!(rx.await.expect("verdict published"), verdict);
}
});
}
#[test]
fn test_drive_recover_publishes_fallback_verdict() {
let runner = deterministic::Runner::default();
runner.start(|_| async move {
let digest = Sha256::hash(&[b"block"]);
let (task_tx, task_rx) = oneshot::channel();
let (tx, rx) = oneshot::channel();
task_tx.send_lossy(GateOutcome::Recover);
let (fallback_tx, fallback_rx) = oneshot::channel();
fallback_tx.send_lossy(true);
drive(tx, task_rx, round(1), digest, || ready(fallback_rx)).await;
assert!(rx.await.expect("fallback verdict published"));
});
}
#[test]
fn test_drive_dropped_sender_publishes_fallback_verdict() {
let runner = deterministic::Runner::default();
runner.start(|_| async move {
let digest = Sha256::hash(&[b"block"]);
let (task_tx, task_rx) = oneshot::channel();
let (tx, rx) = oneshot::channel();
drop(task_tx);
let (fallback_tx, fallback_rx) = oneshot::channel();
fallback_tx.send_lossy(false);
drive(tx, task_rx, round(1), digest, || ready(fallback_rx)).await;
assert!(!rx.await.expect("fallback verdict published"));
});
}
#[test]
fn test_drive_abandons_when_consensus_receiver_dropped() {
let runner = deterministic::Runner::default();
runner.start(|_| async move {
let digest = Sha256::hash(&[b"block"]);
let (_task_tx, task_rx) = oneshot::channel();
let (tx, rx) = oneshot::channel();
drop(rx);
drive(tx, task_rx, round(1), digest, no_fallback).await;
});
}
#[test]
fn test_stage_handshake() {
let runner = deterministic::Runner::default();
runner.start(|context| async move {
let gates = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
let (tx, rx) = oneshot::channel();
context.spawn({
let gates = gates.clone();
move |_| async move {
gates.stage(round(1), digest, Arc::new(7), tx, "test").await;
}
});
assert_eq!(rx.await.expect("id published"), digest);
let gate = gates.take(round(1), digest).expect("gate registered");
let (block, ack) = gates.take_staged(round(1), digest).expect("block staged");
assert_eq!(*block, 7);
assert!(
gates.take_staged(round(1), digest).is_none(),
"taking twice should yield None"
);
ack.send_lossy(Handle::ready(Ok(())));
assert_eq!(gate.await.expect("gate resolved"), GateOutcome::Ready(true));
});
}
#[test]
fn test_retain_after_drops_staged_and_abandons_handshake() {
let runner = deterministic::Runner::default();
runner.start(|context| async move {
let gates = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
let (tx, rx) = oneshot::channel();
context.spawn({
let gates = gates.clone();
move |_| async move {
gates.stage(round(1), digest, Arc::new(7), tx, "test").await;
}
});
assert_eq!(rx.await.expect("id published"), digest);
let gate = gates.take(round(1), digest).expect("gate registered");
gates.retain_after(&round(1));
assert!(gates.take_staged(round(1), digest).is_none());
assert!(gate.await.is_err(), "gate must be abandoned, not resolved");
});
}
}