From a3c3db6994b047d2ec5059e9504da62514a891d1 Mon Sep 17 00:00:00 2001 From: cds-amal Date: Mon, 24 Aug 2026 00:28:44 -0400 Subject: [PATCH 1/9] feat(core): derive geyser slot statuses from a per-slot lifecycle - Every slot-status emission now comes from one transition relation: - `announce` emits `CreatedBank` - `produce` emits `Processed` - `confirm` emits `Confirmed` - `root` emits `Rooted` - `warp` emits `Dead` for the slot the clock abandons and `CreatedBank` for the one it lands on Warp announcement is guarded so a slot is announced at most once. Every transition requires its exact predecessor stage, so no status can be skipped or repeated. - Block production follows the lifecycle order: - emit the slot's block data - produce and confirm the slot - root whatever is due - announce the next slot last A consumer therefore sees every slot announced before its data, and its data before its confirmation. - Startup announces the open slot and sends `EndOfStartup` before the RPC listeners bind. Nothing external can therefore emit block data for a slot a plugin is not tracking yet. - Both clock-warp handlers resolve the lifecycle after moving the clock. A network reset forgets every slot and announces its new genesis. - `UpdateSlotStatus` now carries Agave's `SlotStatus`, since `CreatedBank` and `Dead` exist only there. - The registry lives in `surfnet/slot_lifecycle.rs`, with the slot table as its test oracle: one test per deciding cell. SVM-level tests read the geyser stream as a consumer sees it and check: - announce before data - data before confirmation - statuses in order and exactly once - a warp yielding exactly `Dead`, then `CreatedBank` Co-authored-by: Snehendu Roy Co-authored-by: Micaiah Reid --- crates/core/src/runloops/mod.rs | 23 ++- crates/core/src/surfnet/mod.rs | 16 +- crates/core/src/surfnet/slot_lifecycle.rs | 229 ++++++++++++++++++++++ crates/core/src/surfnet/svm.rs | 166 ++++++++++++++-- 4 files changed, 393 insertions(+), 41 deletions(-) create mode 100644 crates/core/src/surfnet/slot_lifecycle.rs diff --git a/crates/core/src/runloops/mod.rs b/crates/core/src/runloops/mod.rs index 496afaba..cc4fcfc6 100644 --- a/crates/core/src/runloops/mod.rs +++ b/crates/core/src/runloops/mod.rs @@ -238,6 +238,12 @@ pub async fn start_local_surfnet_runloop( let (plugin_commands_tx, plugin_commands_rx) = unbounded::(); + // Startup before traffic: plugins observe startup completion and the open slot + // before the RPC listeners bind, so external traffic can't emit block data for + // a slot that a plugin is not tracking yet. + let _ = svm_locker.with_svm_reader(|svm| svm.geyser_events_tx.send(GeyserEvent::EndOfStartup)); + svm_locker.with_svm_writer(|svm_writer| svm_writer.announce_open_slot()); + let (_rpc_handle, _ws_handle, shutdown_rpc_servers) = start_rpc_servers_runloop( &config, &simnet_commands_tx, @@ -331,9 +337,6 @@ pub async fn start_local_surfnet_runloop( } simnet_events_tx_cc.core_started(initial_transaction_count); - // Notify geyser plugins that startup is complete - let _ = svm_locker.with_svm_reader(|svm| svm.geyser_events_tx.send(GeyserEvent::EndOfStartup)); - start_block_production_runloop( clock_event_rx, clock_command_tx, @@ -466,6 +469,7 @@ pub async fn start_block_production_runloop( } svm_locker.with_svm_writer(|svm_writer| { + let open_slot = svm_writer.get_latest_absolute_slot(); svm_writer.inner.set_sysvar(&clock); svm_writer.updated_at = clock.unix_timestamp as u64 * 1_000; svm_writer.latest_epoch_info.absolute_slot = clock.slot; @@ -474,6 +478,9 @@ pub async fn start_block_production_runloop( svm_writer.latest_epoch_info.epoch = clock.epoch; svm_writer.latest_epoch_info.absolute_slot = clock.slot + clock.epoch * svm_writer.latest_epoch_info.slots_in_epoch; svm_writer.simnet_events_tx.system_clock_updated(clock); + // The slot that was open dies unless the warp landed on it, and the + // destination is announced. + svm_writer.warp_slot_lifecycle(open_slot); }); } SimnetCommand::UpdateInternalClockWithConfirmation(_, clock, response_tx) => { @@ -485,6 +492,7 @@ pub async fn start_block_production_runloop( } let epoch_info = svm_locker.with_svm_writer(|svm_writer| { + let open_slot = svm_writer.get_latest_absolute_slot(); svm_writer.inner.set_sysvar(&clock); svm_writer.updated_at = clock.unix_timestamp as u64 * 1_000; svm_writer.latest_epoch_info.absolute_slot = clock.slot; @@ -493,6 +501,7 @@ pub async fn start_block_production_runloop( svm_writer.latest_epoch_info.epoch = clock.epoch; svm_writer.latest_epoch_info.absolute_slot = clock.slot + clock.epoch * svm_writer.latest_epoch_info.slots_in_epoch; svm_writer.simnet_events_tx.system_clock_updated(clock); + svm_writer.warp_slot_lifecycle(open_slot); svm_writer.latest_epoch_info.clone() }); @@ -824,14 +833,8 @@ fn start_geyser_runloop( } } Ok(GeyserEvent::UpdateSlotStatus { slot, parent, status }) => { - let slot_status = match status { - crate::surfnet::GeyserSlotStatus::Processed => SlotStatus::Processed, - crate::surfnet::GeyserSlotStatus::Confirmed => SlotStatus::Confirmed, - crate::surfnet::GeyserSlotStatus::Rooted => SlotStatus::Rooted, - }; - for plugin in managed_plugins.iter().map(|p| &*p.plugin) { - if let Err(e) = plugin.update_slot_status(slot, parent, &slot_status) { + if let Err(e) = plugin.update_slot_status(slot, parent, &status) { simnet_events_tx.error(format!("Failed to update slot status in Geyser plugin: {:?}", e)); } } diff --git a/crates/core/src/surfnet/mod.rs b/crates/core/src/surfnet/mod.rs index 0e1e5bb8..94b3529f 100644 --- a/crates/core/src/surfnet/mod.rs +++ b/crates/core/src/surfnet/mod.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, fmt::Display, sync::Arc}; +use agave_geyser_plugin_interface::geyser_plugin_interface::SlotStatus; use crossbeam_channel::{Receiver, Sender}; use jsonrpc_core::Result as RpcError; use locker::SurfnetSvmLocker; @@ -32,6 +33,7 @@ use crate::{ pub mod locker; pub mod noop_program; pub mod remote; +pub mod slot_lifecycle; pub mod surfnet_lite_svm; pub mod svm; @@ -40,18 +42,6 @@ pub const SLOTS_PER_EPOCH: u64 = 432000; pub type AccountFactory = Box GetAccountResult + Send + Sync>; -/// Slot status for geyser plugin notifications. -/// Mirrors `agave_geyser_plugin_interface::geyser_plugin_interface::SlotStatus`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GeyserSlotStatus { - /// Slot is being processed - Processed, - /// Slot has been rooted (finalized) - Rooted, - /// Slot has been confirmed - Confirmed, -} - /// Block metadata for geyser plugin notifications. #[derive(Debug, Clone)] pub struct GeyserBlockMetadata { @@ -90,7 +80,7 @@ pub enum GeyserEvent { UpdateSlotStatus { slot: Slot, parent: Option, - status: GeyserSlotStatus, + status: SlotStatus, }, /// Notify plugins of block metadata. NotifyBlockMetadata(GeyserBlockMetadata), diff --git a/crates/core/src/surfnet/slot_lifecycle.rs b/crates/core/src/surfnet/slot_lifecycle.rs new file mode 100644 index 00000000..39201c19 --- /dev/null +++ b/crates/core/src/surfnet/slot_lifecycle.rs @@ -0,0 +1,229 @@ +//! The per-slot lifecycle every geyser slot-status emission derives from. +//! +//! A slot is announced (`CreatedBank`) before any of its block data is +//! emitted, advances through `Processed`, `Confirmed`, and `Rooted` in +//! that order and at most once each, or dies (`Dead`) when a clock warp +//! abandons it before it is produced. Block production, the startup +//! task, the warp handlers, and a network reset all drive the same +//! transition relation instead of each emitting statuses by hand, so +//! the ordering and existence obligations of the lifecycle contract +//! (NOTES/747/lifecycle-contract.md) are properties of one table rather +//! than of the discipline at every site. + +use std::collections::HashMap; + +use agave_geyser_plugin_interface::geyser_plugin_interface::SlotStatus; +use solana_clock::Slot; + +/// A slot's recorded stage. `Rooted` and `Dead` are terminal and are +/// forgotten once emitted, so the registry holds only live slots. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SlotStage { + Announced, + Processed, + Confirmed, +} + +/// One slot-status emission a transition produced, in emission order. +#[derive(Debug, Clone, PartialEq)] +pub struct SlotEmission { + pub slot: Slot, + pub parent: Option, + pub status: SlotStatus, +} + +fn emission(slot: Slot, status: SlotStatus) -> SlotEmission { + SlotEmission { + slot, + parent: slot.checked_sub(1), + status, + } +} + +/// The registry of live slots and their stages. +#[derive(Debug, Clone, Default)] +pub struct SlotLifecycle { + stages: HashMap, +} + +impl SlotLifecycle { + /// Announces a slot. A slot already on record is left alone, so the + /// two announcers (startup for genesis, block production for N+1) + /// and a warp landing on an announced slot cannot double-announce. + pub fn announce(&mut self, slot: Slot) -> Vec { + if self.stages.contains_key(&slot) { + return vec![]; + } + self.stages.insert(slot, SlotStage::Announced); + vec![emission(slot, SlotStatus::CreatedBank)] + } + + /// The slot's block was produced. Called after the slot's block data + /// has been emitted, which is the data-before-seal order the contract + /// asks for. An unannounced slot is announced first, so no data- + /// carrying slot can go unannounced even from a path that forgot. + pub fn produce(&mut self, slot: Slot) -> Vec { + let mut out = self.announce(slot); + if self.advance(slot, SlotStage::Announced, SlotStage::Processed) { + out.push(emission(slot, SlotStatus::Processed)); + } + out + } + + /// The slot's block was confirmed (the seal). + pub fn confirm(&mut self, slot: Slot) -> Vec { + if self.advance(slot, SlotStage::Processed, SlotStage::Confirmed) { + vec![emission(slot, SlotStatus::Confirmed)] + } else { + vec![] + } + } + + /// The slot was rooted; it leaves the registry. + pub fn root(&mut self, slot: Slot) -> Vec { + match self.stages.remove(&slot) { + Some(SlotStage::Confirmed) => vec![emission(slot, SlotStatus::Rooted)], + Some(stage) => { + // Rooting a slot that never confirmed would skip a status; + // keep the record and emit nothing (O4). + self.stages.insert(slot, stage); + vec![] + } + None => vec![], + } + } + + /// A clock warp from the open slot `from` to `to`. The open slot was + /// announced and never produced, so it dies unless the warp lands on + /// it; slots at or past `to` that the old timeline had produced are + /// forgotten, since the new timeline rewrites them; the destination + /// is announced if it is not already. + pub fn warp(&mut self, from: Slot, to: Slot) -> Vec { + let mut out = vec![]; + if from != to && self.stages.get(&from) == Some(&SlotStage::Announced) { + self.stages.remove(&from); + out.push(emission( + from, + SlotStatus::Dead(format!("abandoned by a clock warp to slot {to}")), + )); + } + if to < from { + self.stages.retain(|slot, _| *slot < to); + } + out.extend(self.announce(to)); + out + } + + /// Forgets every slot (a network reset). + pub fn clear(&mut self) { + self.stages.clear(); + } + + /// Moves a slot from exactly `from` to `to`. Any other recorded stage + /// leaves the slot alone: a status cannot be skipped and cannot repeat. + fn advance(&mut self, slot: Slot, from: SlotStage, to: SlotStage) -> bool { + match self.stages.get_mut(&slot) { + Some(stage) if *stage == from => { + *stage = to; + true + } + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + //! The slot table as the oracle: one test per deciding cell. + + use super::*; + + fn statuses(emissions: &[SlotEmission]) -> Vec<(Slot, String)> { + emissions + .iter() + .map(|e| (e.slot, format!("{:?}", e.status))) + .collect() + } + + #[test] + fn a_slot_advances_in_order_and_each_status_emits_once() { + let mut life = SlotLifecycle::default(); + assert_eq!(statuses(&life.announce(7)), vec![(7, "CreatedBank".into())]); + assert!(life.announce(7).is_empty(), "announced at most once"); + assert_eq!(statuses(&life.produce(7)), vec![(7, "Processed".into())]); + assert!(life.produce(7).is_empty(), "processed at most once"); + assert_eq!(statuses(&life.confirm(7)), vec![(7, "Confirmed".into())]); + assert_eq!(statuses(&life.root(7)), vec![(7, "Rooted".into())]); + assert!(life.root(7).is_empty(), "rooted slots are forgotten"); + } + + #[test] + fn statuses_cannot_be_skipped() { + let mut life = SlotLifecycle::default(); + life.announce(3); + assert!( + life.confirm(3).is_empty(), + "confirm before produce is ignored" + ); + assert!(life.root(3).is_empty(), "root before confirm is ignored"); + assert_eq!(statuses(&life.produce(3)), vec![(3, "Processed".into())]); + } + + #[test] + fn producing_an_unannounced_slot_announces_it_first() { + let mut life = SlotLifecycle::default(); + assert_eq!( + statuses(&life.produce(4)), + vec![(4, "CreatedBank".into()), (4, "Processed".into())] + ); + } + + #[test] + fn a_forward_warp_kills_the_orphan_and_announces_the_destination() { + // E1 and L1 as one cell: the open slot 3 was announced by + // closing slot 2 and never produced; the warp lands on 9. + let mut life = SlotLifecycle::default(); + life.announce(3); + let out = life.warp(3, 9); + assert_eq!(out.len(), 2); + assert_eq!(out[0].slot, 3); + assert!(matches!(out[0].status, SlotStatus::Dead(_))); + assert_eq!(statuses(&out[1..]), vec![(9, "CreatedBank".into())]); + assert!(life.announce(9).is_empty(), "the destination is on record"); + } + + #[test] + fn a_warp_landing_on_the_open_slot_changes_nothing() { + // The O4 trap: an unconditional announce here would double-announce. + let mut life = SlotLifecycle::default(); + life.announce(3); + assert!(life.warp(3, 3).is_empty()); + } + + #[test] + fn a_backward_warp_forgets_the_rewritten_slots() { + let mut life = SlotLifecycle::default(); + for slot in 5..8 { + life.announce(slot); + life.produce(slot); + life.confirm(slot); + } + life.announce(8); + let out = life.warp(8, 6); + assert!(matches!(out[0].status, SlotStatus::Dead(_))); + assert_eq!(statuses(&out[1..]), vec![(6, "CreatedBank".into())]); + assert!( + life.announce(7).len() == 1, + "slot 7 was forgotten with the old timeline" + ); + assert!(life.announce(5).is_empty(), "slot 5 stays on record"); + } + + #[test] + fn a_reset_forgets_everything() { + let mut life = SlotLifecycle::default(); + life.announce(1); + life.clear(); + assert_eq!(life.announce(1).len(), 1); + } +} diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index e7b7a6d2..b0021d56 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -81,9 +81,10 @@ use uuid::Uuid; use super::{ AccountSource, AccountSubscriptionData, BlockHeader, BlockIdentifier, CoupledAccount, FINALIZATION_SLOT_THRESHOLD, GetAccountResult, GeyserBlockMetadata, GeyserEntryInfo, - GeyserEvent, GeyserSlotStatus, LocalSignatureStatus, LocalSignatureStatusOrSubscription, - ProgramSubscriptionData, SignatureSubscriptionData, SignatureSubscriptionType, - SlotsUpdatesSubscriptionData, remote::SurfnetRemoteClient, + GeyserEvent, LocalSignatureStatus, LocalSignatureStatusOrSubscription, ProgramSubscriptionData, + SignatureSubscriptionData, SignatureSubscriptionType, SlotsUpdatesSubscriptionData, + remote::SurfnetRemoteClient, + slot_lifecycle::{SlotEmission, SlotLifecycle}, }; use crate::{ error::{AirdropError, SurfpoolError, SurfpoolResult}, @@ -361,6 +362,8 @@ pub struct SurfnetSvm { pub streamed_accounts: Box>, pub recent_blockhashes: VecDeque<(SyntheticBlockhash, i64)>, pub scheduled_overrides: Box>>, + /// The per-slot lifecycle every geyser slot-status emission derives from. + pub slot_lifecycle: SlotLifecycle, /// Tracks accounts that should not be downloaded from the remote RPC. /// This includes accounts explicitly closed locally and accounts marked offline via cheatcodes. /// The key is the account pubkey as a string. If `include_owned_accounts` is true, @@ -574,6 +577,7 @@ impl SurfnetSvm { registered_idls: OverlayStorage::wrap(self.registered_idls.clone_box()), streamed_accounts: OverlayStorage::wrap(self.streamed_accounts.clone_box()), scheduled_overrides: OverlayStorage::wrap(self.scheduled_overrides.clone_box()), + slot_lifecycle: self.slot_lifecycle.clone(), // Clone non-storage fields normally transactions_queued_for_confirmation: self.transactions_queued_for_confirmation.clone(), @@ -1079,6 +1083,7 @@ impl SurfnetSvm { streamed_accounts: streamed_accounts_db, recent_blockhashes: VecDeque::new(), scheduled_overrides: scheduled_overrides_db, + slot_lifecycle: SlotLifecycle::default(), offline_accounts: offline_accounts_db, genesis_slot: default_genesis_slot, genesis_updated_at: updated_at, @@ -1902,6 +1907,8 @@ impl SurfnetSvm { self.latest_epoch_info = epoch_info.clone(); // Set genesis_slot to the current slot when resetting (similar to initialize) self.genesis_slot = epoch_info.absolute_slot; + self.slot_lifecycle.clear(); + self.announce_open_slot(); let chain_tip_hash = SyntheticBlockhash::new(epoch_info.block_height).to_string(); self.chain_tip = BlockIdentifier::new(epoch_info.block_height, chain_tip_hash.as_str()); self.inner.set_sysvar(&epoch_schedule); @@ -2531,14 +2538,6 @@ impl SurfnetSvm { let geyser_parent_slot = slot.saturating_sub(1); - // Emit confirmation for the same slot used by processed account/transaction updates. - self.geyser_events_tx - .send(GeyserEvent::UpdateSlotStatus { - slot, - parent: slot.checked_sub(1), - status: GeyserSlotStatus::Confirmed, - }) - .ok(); // Mirror the Confirmed Geyser event as an `OptimisticConfirmation` // notification for `slotsUpdatesSubscribe` clients. self.notify_slots_updates_subscribers(SlotUpdate::OptimisticConfirmation { @@ -2577,6 +2576,13 @@ impl SurfnetSvm { .send(GeyserEvent::NotifyEntry(entry_info)) .ok(); + // Slot statuses derive from the lifecycle after the slot's block data (data + // before confirmation); the next slot is announced last, so its data + // can never precede its announcement. + let mut emissions = self.slot_lifecycle.produce(slot); + emissions.extend(self.slot_lifecycle.confirm(slot)); + self.emit_slot_statuses(emissions); + let clock: Clock = Clock { slot: self.latest_epoch_info.absolute_slot, epoch: self.latest_epoch_info.epoch, @@ -2593,13 +2599,8 @@ impl SurfnetSvm { // Notify geyser plugins of newly rooted (finalized) slot // Only emit if root is a valid slot (greater than genesis) if root >= self.genesis_slot { - self.geyser_events_tx - .send(GeyserEvent::UpdateSlotStatus { - slot: root, - parent: root.checked_sub(1), - status: GeyserSlotStatus::Rooted, - }) - .ok(); + let emissions = self.slot_lifecycle.root(root); + self.emit_slot_statuses(emissions); // Mirror the Rooted Geyser event as a `Root` notification for // `slotsUpdatesSubscribe` clients. self.notify_slots_updates_subscribers(SlotUpdate::Root { @@ -2607,6 +2608,8 @@ impl SurfnetSvm { timestamp: slots_update_ts, }); } + let emissions = self.slot_lifecycle.announce(new_slot); + self.emit_slot_statuses(emissions); // Evict the accounts marked as streamed from cache to enforce them to be fetched again let accounts_to_reset: Vec<_> = self.streamed_accounts.into_iter()?.collect(); @@ -3060,6 +3063,41 @@ impl SurfnetSvm { )) } + /// Sends the slot statuses a lifecycle transition produced, in order. + pub fn emit_slot_statuses(&self, emissions: Vec) { + for SlotEmission { + slot, + parent, + status, + } in emissions + { + self.geyser_events_tx + .send(GeyserEvent::UpdateSlotStatus { + slot, + parent, + status, + }) + .ok(); + } + } + + /// Announces the slot currently open for block production. Startup calls this for + /// genesis and a reset for its new genesis; block production announces N+1 itself. + pub fn announce_open_slot(&mut self) { + let slot = self.get_latest_absolute_slot(); + let emissions = self.slot_lifecycle.announce(slot); + self.emit_slot_statuses(emissions); + } + + /// Resolves the slot lifecycle across a clock warp: the slot that was open before + /// (`from`) dies unless the warp landed on it, and the new open slot is announced if + /// it is not already. + pub fn warp_slot_lifecycle(&mut self, from: Slot) { + let to = self.get_latest_absolute_slot(); + let emissions = self.slot_lifecycle.warp(from, to); + self.emit_slot_statuses(emissions); + } + pub fn subscribe_for_account_updates( &mut self, account_pubkey: &Pubkey, @@ -7070,4 +7108,96 @@ mod tests { .expect("Valid account should be restored"); assert_eq!(restored_account.lamports, 1_000_000); } + + /// Drains the geyser stream into (slot, tag) pairs, in emission order. + fn geyser_slot_events(rx: &crossbeam_channel::Receiver) -> Vec<(u64, String)> { + use agave_geyser_plugin_interface::geyser_plugin_interface::SlotStatus; + let mut out = vec![]; + while let Ok(event) = rx.try_recv() { + match event { + GeyserEvent::UpdateSlotStatus { slot, status, .. } => { + let tag = match status { + SlotStatus::CreatedBank => "created", + SlotStatus::Processed => "processed", + SlotStatus::Confirmed => "confirmed", + SlotStatus::Rooted => "rooted", + SlotStatus::Dead(_) => "dead", + _ => "other", + }; + out.push((slot, tag.to_string())); + } + GeyserEvent::NotifyBlockMetadata(m) => out.push((m.slot, "block_meta".to_string())), + GeyserEvent::NotifyEntry(e) => out.push((e.slot, "entry".to_string())), + _ => {} + } + } + out + } + + #[test] + fn every_slot_is_announced_once_before_its_data_and_confirmed_after_it() { + let (mut svm, _events_rx, geyser_rx) = SurfnetSvm::default(); + svm.announce_open_slot(); + for _ in 0..5 { + svm.confirm_current_block().unwrap(); + } + let events = geyser_slot_events(&geyser_rx); + let mut announced = std::collections::HashSet::new(); + let mut confirmed = std::collections::HashSet::new(); + let mut processed = std::collections::HashSet::new(); + for (slot, tag) in &events { + match tag.as_str() { + "created" => assert!(announced.insert(*slot), "slot {slot} announced twice"), + "block_meta" | "entry" => { + assert!(announced.contains(slot), "data for unannounced slot {slot}"); + assert!( + !confirmed.contains(slot), + "data for slot {slot} after its confirmation" + ); + } + "processed" => { + assert!( + announced.contains(slot), + "processed before announced: {slot}" + ); + assert!(processed.insert(*slot), "slot {slot} processed twice"); + } + "confirmed" => { + assert!( + processed.contains(slot), + "confirmed before processed: {slot}" + ); + assert!(confirmed.insert(*slot), "slot {slot} confirmed twice"); + } + _ => {} + } + } + assert_eq!(confirmed.len(), 5, "five slots confirmed"); + } + + #[test] + fn a_warp_kills_the_open_slot_and_announces_the_destination() { + // Existence and liveness against the real SVM: after two blocks the open slot is + // genesis + 2, announced and unproduced; the clock jumps to +40. + let (mut svm, _events_rx, geyser_rx) = SurfnetSvm::default(); + svm.announce_open_slot(); + svm.confirm_current_block().unwrap(); + svm.confirm_current_block().unwrap(); + let open_slot = svm.get_latest_absolute_slot(); + let _ = geyser_slot_events(&geyser_rx); + + svm.latest_epoch_info.absolute_slot = open_slot + 40; + svm.warp_slot_lifecycle(open_slot); + + assert_eq!( + geyser_slot_events(&geyser_rx), + vec![ + (open_slot, "dead".to_string()), + (open_slot + 40, "created".to_string()) + ] + ); + // Landing on the slot already open announces nothing. + svm.warp_slot_lifecycle(open_slot + 40); + assert!(geyser_slot_events(&geyser_rx).is_empty()); + } } From e0ac35f37da08298e472decfd61d5ddca9dae31d Mon Sep 17 00:00:00 2001 From: cds-amal Date: Mon, 24 Aug 2026 11:27:46 -0400 Subject: [PATCH 2/9] test(core): hold the slot registry to its spec by exhaustive sweep - Add the spec as a second encoding of the slot table, following the startup state machine's precedence: - expected emissions per `(state, event)` cell - expected successor registry per cell - reads of the machine only through its public accessors The maintenance procedure stays the same: state the cell in the spec first, change the machine second, and let the sweep fail while they disagree. - Sweep the full alphabet over a bounded slot domain: - visit every reachable registry state times every event - compare the machine's emissions exactly against the spec - compare the successor registry exactly against the spec - assert that the whole bounded state space was reached This checks the transition systems cell for cell, discharging implementation-refines-model for the bounded sequential core rather than leaving it as an argument. - Sweep production's call grammar separately, mirroring the operations that drive the registry: - close a block, as in `confirm_current_block` - warp, as in `warp_clock` - reset, as in `reset_network` At every reachable point, assert the invariant underlying E1 and L1: exactly one slot is `Announced`, and it is the open slot. - Keep interleaving properties in the Promela models; these sweeps cover the sequential transition core and its production grammar. --- .../mod.rs} | 32 ++- .../slot_lifecycle/reachability_tests.rs | 233 ++++++++++++++++++ .../core/src/surfnet/slot_lifecycle/spec.rs | 133 ++++++++++ 3 files changed, 388 insertions(+), 10 deletions(-) rename crates/core/src/surfnet/{slot_lifecycle.rs => slot_lifecycle/mod.rs} (89%) create mode 100644 crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs create mode 100644 crates/core/src/surfnet/slot_lifecycle/spec.rs diff --git a/crates/core/src/surfnet/slot_lifecycle.rs b/crates/core/src/surfnet/slot_lifecycle/mod.rs similarity index 89% rename from crates/core/src/surfnet/slot_lifecycle.rs rename to crates/core/src/surfnet/slot_lifecycle/mod.rs index 39201c19..c79b51c9 100644 --- a/crates/core/src/surfnet/slot_lifecycle.rs +++ b/crates/core/src/surfnet/slot_lifecycle/mod.rs @@ -15,10 +15,15 @@ use std::collections::HashMap; use agave_geyser_plugin_interface::geyser_plugin_interface::SlotStatus; use solana_clock::Slot; +#[cfg(test)] +mod reachability_tests; +#[cfg(test)] +pub(crate) mod spec; + /// A slot's recorded stage. `Rooted` and `Dead` are terminal and are /// forgotten once emitted, so the registry holds only live slots. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SlotStage { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) enum SlotStage { Announced, Processed, Confirmed, @@ -59,9 +64,9 @@ impl SlotLifecycle { } /// The slot's block was produced. Called after the slot's block data - /// has been emitted, which is the data-before-seal order the contract - /// asks for. An unannounced slot is announced first, so no data- - /// carrying slot can go unannounced even from a path that forgot. + /// has been emitted, which is the data-before-confirmation order the + /// contract asks for. An unannounced slot is announced first, so no + /// data-carrying slot can go unannounced even from a path that forgot. pub fn produce(&mut self, slot: Slot) -> Vec { let mut out = self.announce(slot); if self.advance(slot, SlotStage::Announced, SlotStage::Processed) { @@ -70,7 +75,7 @@ impl SlotLifecycle { out } - /// The slot's block was confirmed (the seal). + /// The slot's block was confirmed. pub fn confirm(&mut self, slot: Slot) -> Vec { if self.advance(slot, SlotStage::Processed, SlotStage::Confirmed) { vec![emission(slot, SlotStatus::Confirmed)] @@ -85,7 +90,7 @@ impl SlotLifecycle { Some(SlotStage::Confirmed) => vec![emission(slot, SlotStatus::Rooted)], Some(stage) => { // Rooting a slot that never confirmed would skip a status; - // keep the record and emit nothing (O4). + // keep the record and emit nothing. self.stages.insert(slot, stage); vec![] } @@ -114,6 +119,13 @@ impl SlotLifecycle { out } + /// The recorded stage of a slot, if it is live. The spec and the + /// reachability sweep read the registry only through this accessor. + #[cfg(test)] + pub(crate) fn stage(&self, slot: Slot) -> Option { + self.stages.get(&slot).copied() + } + /// Forgets every slot (a network reset). pub fn clear(&mut self) { self.stages.clear(); @@ -180,8 +192,8 @@ mod tests { #[test] fn a_forward_warp_kills_the_orphan_and_announces_the_destination() { - // E1 and L1 as one cell: the open slot 3 was announced by - // closing slot 2 and never produced; the warp lands on 9. + // The open slot 3 was announced by closing slot 2 and never + // produced; the warp lands on 9. let mut life = SlotLifecycle::default(); life.announce(3); let out = life.warp(3, 9); @@ -194,7 +206,7 @@ mod tests { #[test] fn a_warp_landing_on_the_open_slot_changes_nothing() { - // The O4 trap: an unconditional announce here would double-announce. + // An unconditional announce here would double-announce. let mut life = SlotLifecycle::default(); life.announce(3); assert!(life.warp(3, 3).is_empty()); diff --git a/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs b/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs new file mode 100644 index 00000000..b4d52197 --- /dev/null +++ b/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs @@ -0,0 +1,233 @@ +//! Complete check of the slot registry against its spec, in the startup state +//! machine's style (`types/src/startup`): the reachable state space over a +//! bounded slot domain is small, so a depth-first sweep can hold the machine +//! to the spec at every reachable state and across every event, with no +//! sampling involved. +//! +//! Two sweeps, two claims: +//! +//! 1. State-machine equivalence over the full alphabet: from every reachable +//! registry state, every event produces exactly the emissions and successor +//! registry the spec's table names. This discharges +//! implementation-refines-model outright for the sequential core: the model +//! is the spec module, and the machine is equal to it, not merely contained +//! in it. Per-slot status ordering follows: the spec's cells emit statuses +//! only from their legal predecessors, and every machine emission matches a +//! spec cell. +//! +//! 2. Production's grammar: a driver that calls the registry as the SVM does +//! (close a block, warp, reset) establishes the state invariant on which +//! the contract's Existence and Liveness rest: at every reachable point, +//! exactly one slot is Announced, and it is the open slot. A second +//! Announced slot would be an orphan nobody will resolve; an unannounced +//! open slot would emit untracked data. + +use std::collections::{HashSet, VecDeque}; + +use agave_geyser_plugin_interface::geyser_plugin_interface::SlotStatus; + +use super::{ + SlotEmission, SlotLifecycle, + spec::{self, Event, Status, View}, +}; + +/// The bounded slot domain for the full-alphabet sweep. +const SLOTS: u64 = 5; + +fn view_of(life: &SlotLifecycle, domain: u64) -> View { + (0..domain) + .filter_map(|slot| life.stage(slot).map(|stage| (slot, stage))) + .collect() +} + +fn as_spec(emissions: &[SlotEmission]) -> Vec<(u64, Status)> { + emissions + .iter() + .map(|emission| { + let status = match &emission.status { + SlotStatus::CreatedBank => Status::Created, + SlotStatus::Processed => Status::Processed, + SlotStatus::Confirmed => Status::Confirmed, + SlotStatus::Rooted => Status::Rooted, + SlotStatus::Dead(_) => Status::Dead, + other => panic!("the registry never emits {other:?}"), + }; + (emission.slot, status) + }) + .collect() +} + +fn drive(life: &mut SlotLifecycle, event: &Event) -> Vec { + match event { + Event::Announce(slot) => life.announce(*slot), + Event::Produce(slot) => life.produce(*slot), + Event::Confirm(slot) => life.confirm(*slot), + Event::Root(slot) => life.root(*slot), + Event::Warp { from, to } => life.warp(*from, *to), + Event::Clear => { + life.clear(); + vec![] + } + } +} + +fn alphabet() -> Vec { + let mut events = vec![Event::Clear]; + for slot in 0..SLOTS { + events.push(Event::Announce(slot)); + events.push(Event::Produce(slot)); + events.push(Event::Confirm(slot)); + events.push(Event::Root(slot)); + } + for from in 0..SLOTS { + for to in 0..SLOTS { + events.push(Event::Warp { from, to }); + } + } + events +} + +/// Rebuilds a registry whose live slots match `view`. The machine has +/// no bulk constructor, so the sweep replays each slot's stage through +/// the public transitions. +fn registry_of(view: &View) -> SlotLifecycle { + let mut life = SlotLifecycle::default(); + for (slot, stage) in view { + life.announce(*slot); + if *stage >= super::SlotStage::Processed { + life.produce(*slot); + } + if *stage >= super::SlotStage::Confirmed { + life.confirm(*slot); + } + } + life +} + +#[test] +fn the_machine_and_the_spec_are_the_same_table() { + let events = alphabet(); + let mut seen: HashSet>> = HashSet::new(); + let mut queue: VecDeque = VecDeque::from([View::new()]); + let key = |view: &View| (0..SLOTS).map(|slot| view.get(&slot).copied()).collect(); + seen.insert(key(&View::new())); + + let mut states = 0u64; + let mut transitions = 0u64; + while let Some(view) = queue.pop_front() { + states += 1; + for event in &events { + let mut life = registry_of(&view); + assert_eq!(view_of(&life, SLOTS), view, "the rebuild is faithful"); + let emissions = drive(&mut life, event); + assert_eq!( + as_spec(&emissions), + spec::expected_emissions(&view, event), + "emissions for {event:?} from {view:?}" + ); + let next = view_of(&life, SLOTS); + assert_eq!( + next, + spec::expected_view(&view, event), + "successor for {event:?} from {view:?}" + ); + transitions += 1; + if seen.insert(key(&next)) { + queue.push_back(next); + } + } + } + // 4 stages (absent included) over the domain: the sweep must have + // visited every combination, or the alphabet cannot express some + // state and the claim above quietly shrank. + assert_eq!(states, 4u64.pow(SLOTS as u32), "all states reachable"); + assert!(transitions == states * events.len() as u64); +} + +/// The SVM's call grammar. `close_block` mirrors `confirm_current_block` +/// (produce and confirm the open slot, root what is due, announce the +/// next slot); `warp` mirrors `warp_clock` plus `warp_slot_lifecycle`; +/// `reset` mirrors `reset_network` (clear, then announce the open +/// slot). The rooting depth is 2 rather than production's 31 so roots +/// occur inside a small sweep; the invariant does not mention the +/// constant. +const ROOT_DEPTH: u64 = 2; +const MAX_SLOT: u64 = 7; + +#[derive(Clone, PartialEq, Eq, Hash)] +struct Driver { + open: u64, + registry: Vec>, +} + +#[test] +fn production_grammar_keeps_exactly_the_open_slot_announced() { + #[derive(Clone, Copy)] + enum Op { + CloseBlock, + Warp(u64), + Reset, + } + let ops: Vec = { + let mut ops = vec![Op::CloseBlock, Op::Reset]; + ops.extend((0..=MAX_SLOT).map(Op::Warp)); + ops + }; + + let snapshot = |open: u64, life: &SlotLifecycle| Driver { + open, + registry: (0..=MAX_SLOT).map(|slot| life.stage(slot)).collect(), + }; + let check = |open: u64, life: &SlotLifecycle, what: &str| { + let announced: Vec = (0..=MAX_SLOT) + .filter(|slot| life.stage(*slot) == Some(super::SlotStage::Announced)) + .collect(); + assert_eq!( + announced, + vec![open], + "after {what}: the one Announced slot is the open one" + ); + }; + + let mut initial = SlotLifecycle::default(); + initial.announce(0); + check(0, &initial, "startup"); + + let mut seen: HashSet = HashSet::new(); + let mut queue: VecDeque<(u64, SlotLifecycle)> = VecDeque::from([(0, initial.clone())]); + seen.insert(snapshot(0, &initial)); + + while let Some((open, life)) = queue.pop_front() { + for op in &ops { + let mut life = life.clone(); + let open = match op { + Op::CloseBlock => { + if open >= MAX_SLOT { + continue; + } + life.produce(open); + life.confirm(open); + if let Some(root) = open.checked_sub(ROOT_DEPTH) { + life.root(root); + } + life.announce(open + 1); + open + 1 + } + Op::Warp(to) => { + life.warp(open, *to); + *to + } + Op::Reset => { + life.clear(); + life.announce(open); + open + } + }; + check(open, &life, "an op"); + if seen.insert(snapshot(open, &life)) { + queue.push_back((open, life)); + } + } + } + assert!(seen.len() > 100, "the grammar explored a real state space"); +} diff --git a/crates/core/src/surfnet/slot_lifecycle/spec.rs b/crates/core/src/surfnet/slot_lifecycle/spec.rs new file mode 100644 index 00000000..3adda653 --- /dev/null +++ b/crates/core/src/surfnet/slot_lifecycle/spec.rs @@ -0,0 +1,133 @@ +//! The spec: the slot table's rules stated on their own, as a second +//! encoding the reachability sweep holds the machine to. +//! +//! The principle, from the startup state machine (types/src/startup/ +//! spec.rs): spec and implementation must be different encodings of the +//! same rules, because the sweep proves the machine agrees with this +//! module, and that proof is empty the moment the two share code. +//! Everything here is written from the slot table (one arm per cell) +//! and reads the machine only through its public accessors. +//! +//! Maintenance procedure for changing a state, event, or transition: +//! +//! 1. State the new cell here first, in the table's vocabulary. +//! 2. Change the machine to satisfy it. +//! 3. `cargo test -p surfpool-core --lib slot_lifecycle` fails while +//! the two disagree, naming the first state and event where they +//! part. +//! 4. Update the table in the notes (and the Promela model, whose +//! process bodies are this table transcribed) as the observable +//! change. + +use std::collections::BTreeMap; + +use solana_clock::Slot; + +use super::SlotStage; + +/// The events the registry reacts to; the sweep drives every one from +/// every reachable state. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Event { + Announce(Slot), + Produce(Slot), + Confirm(Slot), + Root(Slot), + Warp { from: Slot, to: Slot }, + Clear, +} + +/// A status in spec vocabulary, so comparisons read as the table does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Status { + Created, + Processed, + Confirmed, + Rooted, + Dead, +} + +pub(crate) type View = BTreeMap; + +/// The emissions the table's cell for (view, event) requires, in order. +pub(crate) fn expected_emissions(view: &View, event: &Event) -> Vec<(Slot, Status)> { + match event { + Event::Announce(slot) => match view.get(slot) { + None => vec![(*slot, Status::Created)], + Some(_) => vec![], + }, + Event::Produce(slot) => match view.get(slot) { + None => vec![(*slot, Status::Created), (*slot, Status::Processed)], + Some(SlotStage::Announced) => vec![(*slot, Status::Processed)], + Some(_) => vec![], + }, + Event::Confirm(slot) => match view.get(slot) { + Some(SlotStage::Processed) => vec![(*slot, Status::Confirmed)], + _ => vec![], + }, + Event::Root(slot) => match view.get(slot) { + Some(SlotStage::Confirmed) => vec![(*slot, Status::Rooted)], + _ => vec![], + }, + Event::Warp { from, to } => { + let mut out = vec![]; + let killed = from != to && view.get(from) == Some(&SlotStage::Announced); + if killed { + out.push((*from, Status::Dead)); + } + // The destination is announced exactly when it is not on + // record once the kill and, for a backward warp, the + // forgetting of every slot at or past the destination have + // taken effect. + let mut interim = view.clone(); + if killed { + interim.remove(from); + } + if to < from { + interim.retain(|slot, _| slot < to); + } + if !interim.contains_key(to) { + out.push((*to, Status::Created)); + } + out + } + Event::Clear => vec![], + } +} + +/// The registry the table's cell for (view, event) leaves behind. +pub(crate) fn expected_view(view: &View, event: &Event) -> View { + let mut next = view.clone(); + match event { + Event::Announce(slot) => { + next.entry(*slot).or_insert(SlotStage::Announced); + } + Event::Produce(slot) => match next.get(slot) { + None | Some(SlotStage::Announced) => { + next.insert(*slot, SlotStage::Processed); + } + Some(_) => {} + }, + Event::Confirm(slot) => { + if next.get(slot) == Some(&SlotStage::Processed) { + next.insert(*slot, SlotStage::Confirmed); + } + } + Event::Root(slot) => { + if next.get(slot) == Some(&SlotStage::Confirmed) { + next.remove(slot); + } + } + Event::Warp { from, to } => { + if from != to && next.get(from) == Some(&SlotStage::Announced) { + next.remove(from); + } + if to < from { + next.retain(|slot, _| slot < to); + } + next.entry(*to).or_insert(SlotStage::Announced); + } + Event::Clear => next.clear(), + } + next +} From 3d8f651a0055f7f7b9a1c2f7f76c38dd4c1bc42e Mon Sep 17 00:00:00 2001 From: cds-amal Date: Mon, 24 Aug 2026 11:58:24 -0400 Subject: [PATCH 3/9] docs(core): the slot table as spec data, rendered into cargo doc - Make the spec's encoding the table itself in `slot_lifecycle/spec.rs`: - `PER_SLOT` holds one grid-aligned row per `(state, event)` cell - the sweeps interpret those rows directly - a totality test keeps the table complete and names any missing cell This makes a missing cell explicit, which the old match's exhaustiveness check did not surface as clearly. - Keep warp and clear as set-level rules: - warp routes its announce step through the table's `announce` cell - clear remains outside the per-slot transition table This prevents the two encodings from drifting on what announcing means. The machine itself never reads the table; spec and implementation remain independent encodings, or the sweeps would prove nothing. - Render the documentation from the same spec rows: - `slot-lifecycle.md` carries authored prose around generated table and edge-diagram blocks - the module includes it directly into rustdoc - a test detects stale generated blocks and directs the developer to `cargo surfpool-update-slot-spec` - that alias regenerates the documentation One generic renderer iterates the rows, keeping the apparatus to a fraction of the startup spec's per-table render functions. - Give this machine the full treatment because geyser plugins consume its emission sequences and the table has outgrown eyeball totality. Smaller registries keep a named test per cell and hand-written rustdoc. --- .cargo/config.toml | 2 + crates/core/src/surfnet/slot-lifecycle.md | 75 ++++ crates/core/src/surfnet/slot_lifecycle/mod.rs | 17 +- .../slot_lifecycle/reachability_tests.rs | 57 +++ .../core/src/surfnet/slot_lifecycle/spec.rs | 356 +++++++++++++++--- 5 files changed, 439 insertions(+), 68 deletions(-) create mode 100644 crates/core/src/surfnet/slot-lifecycle.md diff --git a/.cargo/config.toml b/.cargo/config.toml index 6d767f57..3c649cab 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -6,5 +6,7 @@ surfpool-install-minimal = "install --path crates/cli --locked --force" # regenerate every table in crates/types/src/startup-lifecycle.md surfpool-update-startup-spec = "test -p surfpool-types regenerate_the_startup_spec_tables -- --ignored --nocapture" +# regenerate the tables in crates/core/src/surfnet/slot-lifecycle.md +surfpool-update-slot-spec = "test -p surfpool-core --lib regenerate_the_slot_spec_tables -- --ignored --nocapture" # re-render the spec's mermaid diagrams to crates/types/src/diagrams/ (needs mmdc) surfpool-render-startup-diagrams = "test -p surfpool-types render_the_startup_diagrams -- --ignored --nocapture" diff --git a/crates/core/src/surfnet/slot-lifecycle.md b/crates/core/src/surfnet/slot-lifecycle.md new file mode 100644 index 00000000..b4d6e29e --- /dev/null +++ b/crates/core/src/surfnet/slot-lifecycle.md @@ -0,0 +1,75 @@ +A slot is announced (`CreatedBank`) before any of its block data is +emitted, advances through `Processed`, `Confirmed`, and `Rooted` in that +order and at most once each, or dies (`Dead`) when a clock warp abandons +it before it is produced. Block production, the startup task, the warp +handlers, and a network reset all drive this one transition relation +instead of each emitting statuses by hand. + +## The per-slot table + +Rows are the recorded stage of one slot, columns the per-slot events; a +cell says what is emitted and where the slot goes. This table is +generated from the spec's `PER_SLOT` constant +(`slot_lifecycle/spec.rs`), which the exhaustive sweeps hold the +machine to, so what you read here is what runs. + + +| State | announce | produce | confirm | root | +|---|---|---|---|---| +| `(absent)` | emits CreatedBank; -> Announced | emits CreatedBank, Processed; -> Processed | ignored | ignored | +| `Announced` | ignored | emits Processed; -> Processed | ignored | ignored | +| `Processed` | ignored | ignored | emits Confirmed; -> Confirmed | ignored | +| `Confirmed` | ignored | ignored | ignored | emits Rooted; -> forgotten | + + +## The machine, as edges + + +```text +(absent) --announce--> Announced emits CreatedBank +(absent) --produce--> Processed emits CreatedBank, Processed +Announced --produce--> Processed emits Processed +Processed --confirm--> Confirmed emits Confirmed +Confirmed --root--> (forgotten) emits Rooted +Announced --warp away--> (forgotten) emits Dead +(any slot the new timeline rewrites)--> (forgotten), backward warps only +``` + + +## Warp and clear + +A warp and a reset are set-level operations, deliberately kept out of +the table: + +- A warp from the open slot `f` to `t` kills the abandoned slot (`f` + was announced and never produced, so it is emitted `Dead` and + forgotten), forgets every slot at or past `t` when the warp is + backward (the new timeline rewrites them), and then announces `t` + through the table's own announce cell, so a warp landing on a slot + already on record announces nothing. +- A reset forgets every slot; the caller announces the new open slot. + +## What the table cannot hold + +Two obligations order this machine's emissions against other streams, +and live in code order rather than in cells: + +- Data before confirmation: `confirm_current_block` emits a slot's block + data (`BlockMeta`, `Entry`) before driving `produce` and `confirm`, + so a consumer that flushes on `Confirmed` never loses data. +- Startup before traffic: the startup task announces the open slot and + sends `EndOfStartup` before the RPC listeners bind, so nothing + external can emit block data for a slot a plugin is not tracking. + +Interleavings (who runs between which writer sections) are checked in +the Promela models kept with the review notes, not here; the sweeps in +`slot_lifecycle/reachability_tests.rs` cover every reachable state and +event of the sequential machine. + +## Maintenance + +State a rule change in `PER_SLOT` (or the warp/clear arms) first, then +change the machine; the sweep names the first disagreement. Then run +`cargo surfpool-update-slot-spec` to regenerate the blocks above, and +review that diff as the observable change. The prose here is authored: +revise it when a rule changes meaning. diff --git a/crates/core/src/surfnet/slot_lifecycle/mod.rs b/crates/core/src/surfnet/slot_lifecycle/mod.rs index c79b51c9..9bffc843 100644 --- a/crates/core/src/surfnet/slot_lifecycle/mod.rs +++ b/crates/core/src/surfnet/slot_lifecycle/mod.rs @@ -1,14 +1,6 @@ //! The per-slot lifecycle every geyser slot-status emission derives from. //! -//! A slot is announced (`CreatedBank`) before any of its block data is -//! emitted, advances through `Processed`, `Confirmed`, and `Rooted` in -//! that order and at most once each, or dies (`Dead`) when a clock warp -//! abandons it before it is produced. Block production, the startup -//! task, the warp handlers, and a network reset all drive the same -//! transition relation instead of each emitting statuses by hand, so -//! the ordering and existence obligations of the lifecycle contract -//! (NOTES/747/lifecycle-contract.md) are properties of one table rather -//! than of the discipline at every site. +#![doc = include_str!("../slot-lifecycle.md")] use std::collections::HashMap; @@ -24,8 +16,11 @@ pub(crate) mod spec; /// forgotten once emitted, so the registry holds only live slots. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub(crate) enum SlotStage { + /// `CreatedBank` has been emitted; the slot awaits its block. Announced, + /// The slot's block was produced (`Processed` emitted). Processed, + /// The slot's block was confirmed (`Confirmed` emitted). Confirmed, } @@ -45,7 +40,9 @@ fn emission(slot: Slot, status: SlotStatus) -> SlotEmission { } } -/// The registry of live slots and their stages. +/// The registry of live slots and their stages. The [module +/// documentation](self) carries the full state table and the +/// transition diagram. #[derive(Debug, Clone, Default)] pub struct SlotLifecycle { stages: HashMap, diff --git a/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs b/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs index b4d52197..c6d0286e 100644 --- a/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs +++ b/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs @@ -231,3 +231,60 @@ fn production_grammar_keeps_exactly_the_open_slot_announced() { } assert!(seen.len() > 100, "the grammar explored a real state space"); } + +/// The generated blocks of `slot-lifecycle.md`, named by their markers. +fn generated_blocks() -> Vec<(&'static str, String)> { + vec![ + ("per-slot-table", spec::render_per_slot_table()), + ("diagram", spec::render_diagram()), + ] +} + +const SPEC_DOC_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/surfnet/slot-lifecycle.md"); + +fn read_spec_doc() -> String { + std::fs::read_to_string(SPEC_DOC_PATH) + .unwrap_or_else(|error| panic!("could not read {SPEC_DOC_PATH}: {error}")) +} + +/// The character range between a block's markers, exclusive of both. +fn region(text: &str, name: &str) -> (usize, usize) { + let begin = format!("\n"); + let end = format!(""); + let start = text + .find(&begin) + .unwrap_or_else(|| panic!("{SPEC_DOC_PATH} has no {begin:?} marker")) + + begin.len(); + let stop = text[start..] + .find(&end) + .unwrap_or_else(|| panic!("{SPEC_DOC_PATH} has no {end:?} marker")) + + start; + (start, stop) +} + +#[test] +fn the_spec_document_is_current() { + let text = read_spec_doc(); + for (name, content) in generated_blocks() { + let (start, stop) = region(&text, name); + assert_eq!( + &text[start..stop], + content, + "the {name} block in slot-lifecycle.md disagrees with the spec; \ + run `cargo surfpool-update-slot-spec` and review the diff" + ); + } +} + +#[test] +#[ignore = "writes slot-lifecycle.md; run via cargo surfpool-update-slot-spec"] +fn regenerate_the_slot_spec_tables() { + let mut text = read_spec_doc(); + for (name, content) in generated_blocks() { + let (start, stop) = region(&text, name); + text.replace_range(start..stop, &content); + } + std::fs::write(SPEC_DOC_PATH, text) + .unwrap_or_else(|error| panic!("could not write {SPEC_DOC_PATH}: {error}")); + eprintln!("regenerated the spec tables in {SPEC_DOC_PATH}"); +} diff --git a/crates/core/src/surfnet/slot_lifecycle/spec.rs b/crates/core/src/surfnet/slot_lifecycle/spec.rs index 3adda653..dbdbaad5 100644 --- a/crates/core/src/surfnet/slot_lifecycle/spec.rs +++ b/crates/core/src/surfnet/slot_lifecycle/spec.rs @@ -1,23 +1,39 @@ -//! The spec: the slot table's rules stated on their own, as a second -//! encoding the reachability sweep holds the machine to. +//! The spec: the slot table itself, as data the sweeps hold the +//! machine to and the module documentation renders. //! //! The principle, from the startup state machine (types/src/startup/ //! spec.rs): spec and implementation must be different encodings of the //! same rules, because the sweep proves the machine agrees with this -//! module, and that proof is empty the moment the two share code. -//! Everything here is written from the slot table (one arm per cell) -//! and reads the machine only through its public accessors. +//! module, and that proof is empty the moment the two share code. Here +//! the spec's encoding is [`PER_SLOT`], one row per (state, event) +//! cell, and the machine must never interpret it: the table belongs to +//! the spec side only. +//! +//! This machine carries the full treatment (table-as-data, exhaustive +//! sweeps, a generated document) because an external protocol depends +//! on it: geyser plugins consume the emission sequences, and the table +//! has enough cells, plus two set-level events, that eyeballing +//! totality stopped being credible. Smaller registries make do with a +//! named test per cell and hand-written rustdoc. +//! +//! Warp and clear are not cells: they are set-level operations over the +//! whole registry, stated in [`expected_emissions`] and +//! [`expected_view`] directly, with the warp's announce step routed +//! through the table's announce row so the two encodings cannot drift +//! on what announcing means. //! //! Maintenance procedure for changing a state, event, or transition: //! -//! 1. State the new cell here first, in the table's vocabulary. +//! 1. State the new cell in [`PER_SLOT`] first (or the new rule in the +//! warp/clear arms, in their vocabulary). //! 2. Change the machine to satisfy it. //! 3. `cargo test -p surfpool-core --lib slot_lifecycle` fails while //! the two disagree, naming the first state and event where they //! part. -//! 4. Update the table in the notes (and the Promela model, whose -//! process bodies are this table transcribed) as the observable -//! change. +//! 4. `cargo surfpool-update-slot-spec` regenerates the tables in +//! `slot-lifecycle.md`; review that diff as the observable change. +//! The prose around the tables is authored: revise it by hand when +//! a rule changes meaning, and leave it alone otherwise. use std::collections::BTreeMap; @@ -29,105 +45,329 @@ use super::SlotStage; /// every reachable state. #[derive(Debug, Clone, PartialEq)] pub(crate) enum Event { + /// The slot is announced (block production for N+1, startup and + /// resets for the open slot, a warp for its destination). Announce(Slot), + /// The slot's block was produced. Produce(Slot), + /// The slot's block was confirmed. Confirm(Slot), + /// The slot was rooted. Root(Slot), + /// The clock jumped from the open slot `from` to `to`. Warp { from: Slot, to: Slot }, + /// A network reset forgot every slot. Clear, } +/// The per-slot events: the table's columns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EventKind { + /// The announce column. + Announce, + /// The produce column. + Produce, + /// The confirm column. + Confirm, + /// The root column. + Root, +} + /// A status in spec vocabulary, so comparisons read as the table does. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Status { + /// `SlotStatus::CreatedBank`: the slot is announced. Created, + /// `SlotStatus::Processed`: the slot's block was produced. Processed, + /// `SlotStatus::Confirmed`: the slot's block was confirmed. Confirmed, + /// `SlotStatus::Rooted`: finalized; the slot leaves the registry. Rooted, + /// `SlotStatus::Dead`: abandoned by a warp before it was produced. Dead, } +/// A cell's successor: the slot keeps its stage, moves to another, or +/// leaves the registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Next { + /// The slot keeps its stage (an ignored event). + Stay, + /// The slot moves to this stage. + To(SlotStage), + /// The slot leaves the registry (a terminal status was emitted). + Forgotten, +} + +/// One cell of the per-slot table. +pub(crate) struct Row { + /// The stage the slot is in; `None` is a slot not on record. + pub(crate) state: Option, + /// The column: which per-slot event arrives. + pub(crate) event: EventKind, + /// The statuses the cell emits, in order. + pub(crate) emits: &'static [Status], + /// Where the slot goes. + pub(crate) next: Next, +} + +use EventKind as E; +use Next::{Forgotten, Stay, To}; +use SlotStage::{Announced, Confirmed as SConfirmed, Processed as SProcessed}; +use Status as S; + +const fn row( + state: Option, + event: EventKind, + emits: &'static [Status], + next: Next, +) -> Row { + Row { + state, + event, + emits, + next, + } +} + +/// The per-slot table. The source is the readable artifact; the same +/// rows render into `slot-lifecycle.md` for cargo doc. +#[rustfmt::skip] +pub(crate) const PER_SLOT: &[Row] = &[ + // state event emits next + row( None, E::Announce, &[S::Created], To(Announced) ), + row( None, E::Produce, &[S::Created, S::Processed], To(SProcessed) ), + row( None, E::Confirm, &[], Stay ), + row( None, E::Root, &[], Stay ), + row( Some(Announced), E::Announce, &[], Stay ), // announced at most once + row( Some(Announced), E::Produce, &[S::Processed], To(SProcessed) ), + row( Some(Announced), E::Confirm, &[], Stay ), // no skipping + row( Some(Announced), E::Root, &[], Stay ), + row( Some(SProcessed), E::Announce, &[], Stay ), + row( Some(SProcessed), E::Produce, &[], Stay ), // processed at most once + row( Some(SProcessed), E::Confirm, &[S::Confirmed], To(SConfirmed) ), + row( Some(SProcessed), E::Root, &[], Stay ), + row( Some(SConfirmed), E::Announce, &[], Stay ), + row( Some(SConfirmed), E::Produce, &[], Stay ), + row( Some(SConfirmed), E::Confirm, &[], Stay ), // confirmed at most once + row( Some(SConfirmed), E::Root, &[S::Rooted], Forgotten ), +]; + +/// The table's cell for a stage and event. Totality is a test, so the +/// lookup cannot miss. +fn cell(state: Option, event: EventKind) -> &'static Row { + PER_SLOT + .iter() + .find(|row| row.state == state && row.event == event) + .expect("the_table_is_total guarantees every cell exists") +} + pub(crate) type View = BTreeMap; -/// The emissions the table's cell for (view, event) requires, in order. +fn apply_cell(view: &mut View, slot: Slot, event: EventKind) -> Vec<(Slot, Status)> { + let row = cell(view.get(&slot).copied(), event); + match row.next { + Stay => {} + To(stage) => { + view.insert(slot, stage); + } + Forgotten => { + view.remove(&slot); + } + } + row.emits.iter().map(|status| (slot, *status)).collect() +} + +/// The emissions the spec requires for (view, event), in order. pub(crate) fn expected_emissions(view: &View, event: &Event) -> Vec<(Slot, Status)> { + let mut view = view.clone(); match event { - Event::Announce(slot) => match view.get(slot) { - None => vec![(*slot, Status::Created)], - Some(_) => vec![], - }, - Event::Produce(slot) => match view.get(slot) { - None => vec![(*slot, Status::Created), (*slot, Status::Processed)], - Some(SlotStage::Announced) => vec![(*slot, Status::Processed)], - Some(_) => vec![], - }, - Event::Confirm(slot) => match view.get(slot) { - Some(SlotStage::Processed) => vec![(*slot, Status::Confirmed)], - _ => vec![], - }, - Event::Root(slot) => match view.get(slot) { - Some(SlotStage::Confirmed) => vec![(*slot, Status::Rooted)], - _ => vec![], - }, + Event::Announce(slot) => apply_cell(&mut view, *slot, E::Announce), + Event::Produce(slot) => apply_cell(&mut view, *slot, E::Produce), + Event::Confirm(slot) => apply_cell(&mut view, *slot, E::Confirm), + Event::Root(slot) => apply_cell(&mut view, *slot, E::Root), Event::Warp { from, to } => { + // Set-level rules: the abandoned open slot dies, a backward + // warp forgets every slot the new timeline rewrites, and + // the destination is announced through the table's own + // announce cell. let mut out = vec![]; - let killed = from != to && view.get(from) == Some(&SlotStage::Announced); - if killed { - out.push((*from, Status::Dead)); - } - // The destination is announced exactly when it is not on - // record once the kill and, for a backward warp, the - // forgetting of every slot at or past the destination have - // taken effect. - let mut interim = view.clone(); - if killed { - interim.remove(from); + if from != to && view.get(from) == Some(&Announced) { + view.remove(from); + out.push((*from, S::Dead)); } if to < from { - interim.retain(|slot, _| slot < to); - } - if !interim.contains_key(to) { - out.push((*to, Status::Created)); + view.retain(|slot, _| slot < to); } + out.extend(apply_cell(&mut view, *to, E::Announce)); out } Event::Clear => vec![], } } -/// The registry the table's cell for (view, event) leaves behind. +/// The registry the spec requires (view, event) to leave behind. pub(crate) fn expected_view(view: &View, event: &Event) -> View { let mut next = view.clone(); match event { Event::Announce(slot) => { - next.entry(*slot).or_insert(SlotStage::Announced); + apply_cell(&mut next, *slot, E::Announce); + } + Event::Produce(slot) => { + apply_cell(&mut next, *slot, E::Produce); } - Event::Produce(slot) => match next.get(slot) { - None | Some(SlotStage::Announced) => { - next.insert(*slot, SlotStage::Processed); - } - Some(_) => {} - }, Event::Confirm(slot) => { - if next.get(slot) == Some(&SlotStage::Processed) { - next.insert(*slot, SlotStage::Confirmed); - } + apply_cell(&mut next, *slot, E::Confirm); } Event::Root(slot) => { - if next.get(slot) == Some(&SlotStage::Confirmed) { - next.remove(slot); - } + apply_cell(&mut next, *slot, E::Root); } Event::Warp { from, to } => { - if from != to && next.get(from) == Some(&SlotStage::Announced) { + if from != to && next.get(from) == Some(&Announced) { next.remove(from); } if to < from { next.retain(|slot, _| slot < to); } - next.entry(*to).or_insert(SlotStage::Announced); + apply_cell(&mut next, *to, E::Announce); } Event::Clear => next.clear(), } next } + +fn stage_name(state: Option) -> &'static str { + match state { + None => "(absent)", + Some(Announced) => "Announced", + Some(SProcessed) => "Processed", + Some(SConfirmed) => "Confirmed", + } +} + +fn status_name(status: Status) -> &'static str { + match status { + S::Created => "CreatedBank", + S::Processed => "Processed", + S::Confirmed => "Confirmed", + S::Rooted => "Rooted", + S::Dead => "Dead", + } +} + +fn event_name(event: EventKind) -> &'static str { + match event { + E::Announce => "announce", + E::Produce => "produce", + E::Confirm => "confirm", + E::Root => "root", + } +} + +fn cell_text(row: &Row) -> String { + let emits = row + .emits + .iter() + .map(|status| status_name(*status)) + .collect::>() + .join(", "); + let next = match row.next { + Stay => "no change".to_string(), + To(stage) => format!("-> {}", stage_name(Some(stage))), + Forgotten => "-> forgotten".to_string(), + }; + if emits.is_empty() { + "ignored".to_string() + } else { + format!("emits {emits}; {next}") + } +} + +/// The per-slot table rendered as markdown, one state per row and one +/// event per column, for `slot-lifecycle.md`. +pub(crate) fn render_per_slot_table() -> String { + let states = [None, Some(Announced), Some(SProcessed), Some(SConfirmed)]; + let events = [E::Announce, E::Produce, E::Confirm, E::Root]; + let mut out = String::from("| State |"); + for event in events { + out.push_str(&format!(" {} |", event_name(event))); + } + out.push_str("\n|---|---|---|---|---|\n"); + for state in states { + out.push_str(&format!("| `{}` |", stage_name(state))); + for event in events { + out.push_str(&format!(" {} |", cell_text(cell(state, event)))); + } + out.push('\n'); + } + out +} + +/// The machine's advancing edges rendered as a preformatted block, for +/// `slot-lifecycle.md`. The two warp edges are the set-level rules, +/// spelled beside the table-driven ones. +pub(crate) fn render_diagram() -> String { + let mut out = String::from("```text\n"); + for row in PER_SLOT { + if let To(stage) = row.next + && row.state != Some(stage) + { + out.push_str(&format!( + "{:<11} --{}--> {:<10} emits {}\n", + stage_name(row.state), + event_name(row.event), + stage_name(Some(stage)), + row.emits + .iter() + .map(|status| status_name(*status)) + .collect::>() + .join(", "), + )); + } + if row.next == Forgotten { + out.push_str(&format!( + "{:<11} --{}--> {:<10} emits {}\n", + stage_name(row.state), + event_name(row.event), + "(forgotten)", + row.emits + .iter() + .map(|status| status_name(*status)) + .collect::>() + .join(", "), + )); + } + } + out.push_str("Announced --warp away--> (forgotten) emits Dead\n"); + out.push_str("(any slot the new timeline rewrites)--> (forgotten), backward warps only\n"); + out.push_str("```\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_table_is_total() { + let states = [None, Some(Announced), Some(SProcessed), Some(SConfirmed)]; + let events = [E::Announce, E::Produce, E::Confirm, E::Root]; + for state in states { + for event in events { + let count = PER_SLOT + .iter() + .filter(|row| row.state == state && row.event == event) + .count(); + assert_eq!( + count, + 1, + "the cell ({}, {}) must appear exactly once", + stage_name(state), + event_name(event) + ); + } + } + assert_eq!(PER_SLOT.len(), states.len() * events.len()); + } +} From 58bca65c301779dc142a716f97e955b73ad637b1 Mon Sep 17 00:00:00 2001 From: cds-amal Date: Mon, 24 Aug 2026 20:35:46 -0400 Subject: [PATCH 4/9] feat(core): reorg warps, threshold rooting, and a ws mirror for slot statuses Three changes to how the slot lifecycle meets its callers, from reviewing the warp paths: - A backward clock warp is a reorg: every slot the new timeline rewrites dies (Dead, in slot order) instead of vanishing silently, and the destination is re-announced. At-most-once holds per bank; a reorg makes a new bank, so time travel to the current slot reads as Dead then CreatedBank rather than as a repeated status. - Rooting is a threshold, not arithmetic: root_through(r) roots every confirmed slot at or below r from the registry's own record, so a history with gaps (a clock warp) roots exactly what it confirmed and strands nothing. The genesis guard in confirm_current_block goes away: a threshold below everything on record roots nothing. - emit_slot_statuses mirrors CreatedBank, Confirmed, Rooted, and Dead to slotsUpdatesSubscribe, so the geyser and ws streams cannot diverge on lifecycle events; warp emissions previously reached geyser only. Block production keeps sending Frozen itself, since only it knows the block's transaction stats. The bespoke ws sends in confirm_current_block are gone. - CreatedBank names its parent from the registry (the highest slot on record below), so a warp destination links to the chain tip instead of a slot that never existed. Other statuses carry no parent, as agave's notifier does. - Both clock command handlers call one warp_clock writer method, which owns the capture-write-resolve order the two arms previously duplicated, one of them with a dead first write of absolute_slot. The spec table gains a set-level RootThrough event routed through the table's root cell, and the reachability sweeps hold the machine to the new rules; the production grammar's state space is now linear in its slot bound, since threshold rooting keeps the steady-state registry at two slots. --- crates/core/src/runloops/mod.rs | 30 +-- crates/core/src/surfnet/slot_lifecycle/mod.rs | 105 +++++++++-- .../slot_lifecycle/reachability_tests.rs | 25 ++- .../core/src/surfnet/slot_lifecycle/spec.rs | 61 ++++-- crates/core/src/surfnet/svm.rs | 176 ++++++++++++++---- 5 files changed, 295 insertions(+), 102 deletions(-) diff --git a/crates/core/src/runloops/mod.rs b/crates/core/src/runloops/mod.rs index cc4fcfc6..2be10314 100644 --- a/crates/core/src/runloops/mod.rs +++ b/crates/core/src/runloops/mod.rs @@ -239,7 +239,7 @@ pub async fn start_local_surfnet_runloop( let (plugin_commands_tx, plugin_commands_rx) = unbounded::(); // Startup before traffic: plugins observe startup completion and the open slot - // before the RPC listeners bind, so external traffic can't emit block data for + // before the RPC listeners bind, so external traffic can't emit block data for // a slot that a plugin is not tracking yet. let _ = svm_locker.with_svm_reader(|svm| svm.geyser_events_tx.send(GeyserEvent::EndOfStartup)); svm_locker.with_svm_writer(|svm_writer| svm_writer.announce_open_slot()); @@ -469,18 +469,7 @@ pub async fn start_block_production_runloop( } svm_locker.with_svm_writer(|svm_writer| { - let open_slot = svm_writer.get_latest_absolute_slot(); - svm_writer.inner.set_sysvar(&clock); - svm_writer.updated_at = clock.unix_timestamp as u64 * 1_000; - svm_writer.latest_epoch_info.absolute_slot = clock.slot; - svm_writer.latest_epoch_info.epoch = clock.epoch; - svm_writer.latest_epoch_info.slot_index = clock.slot; - svm_writer.latest_epoch_info.epoch = clock.epoch; - svm_writer.latest_epoch_info.absolute_slot = clock.slot + clock.epoch * svm_writer.latest_epoch_info.slots_in_epoch; - svm_writer.simnet_events_tx.system_clock_updated(clock); - // The slot that was open dies unless the warp landed on it, and the - // destination is announced. - svm_writer.warp_slot_lifecycle(open_slot); + svm_writer.warp_clock(clock); }); } SimnetCommand::UpdateInternalClockWithConfirmation(_, clock, response_tx) => { @@ -491,19 +480,8 @@ pub async fn start_block_production_runloop( )); } - let epoch_info = svm_locker.with_svm_writer(|svm_writer| { - let open_slot = svm_writer.get_latest_absolute_slot(); - svm_writer.inner.set_sysvar(&clock); - svm_writer.updated_at = clock.unix_timestamp as u64 * 1_000; - svm_writer.latest_epoch_info.absolute_slot = clock.slot; - svm_writer.latest_epoch_info.epoch = clock.epoch; - svm_writer.latest_epoch_info.slot_index = clock.slot; - svm_writer.latest_epoch_info.epoch = clock.epoch; - svm_writer.latest_epoch_info.absolute_slot = clock.slot + clock.epoch * svm_writer.latest_epoch_info.slots_in_epoch; - svm_writer.simnet_events_tx.system_clock_updated(clock); - svm_writer.warp_slot_lifecycle(open_slot); - svm_writer.latest_epoch_info.clone() - }); + let epoch_info = svm_locker + .with_svm_writer(|svm_writer| svm_writer.warp_clock(clock)); // Send confirmation back let _ = response_tx.send(epoch_info); diff --git a/crates/core/src/surfnet/slot_lifecycle/mod.rs b/crates/core/src/surfnet/slot_lifecycle/mod.rs index 9bffc843..cbe3061a 100644 --- a/crates/core/src/surfnet/slot_lifecycle/mod.rs +++ b/crates/core/src/surfnet/slot_lifecycle/mod.rs @@ -32,10 +32,13 @@ pub struct SlotEmission { pub status: SlotStatus, } +/// A `CreatedBank` emission carries the parent computed by +/// [`SlotLifecycle::announce`]; every other status carries none, as +/// agave's own notifier does. fn emission(slot: Slot, status: SlotStatus) -> SlotEmission { SlotEmission { slot, - parent: slot.checked_sub(1), + parent: None, status, } } @@ -56,13 +59,21 @@ impl SlotLifecycle { if self.stages.contains_key(&slot) { return vec![]; } + // The parent is the highest slot on record below this one: the + // chain tip as this registry knows it. `slot - 1` would invent + // a parent that never existed whenever a warp leaves a gap. + let parent = self.stages.keys().copied().filter(|s| *s < slot).max(); self.stages.insert(slot, SlotStage::Announced); - vec![emission(slot, SlotStatus::CreatedBank)] + vec![SlotEmission { + slot, + parent, + status: SlotStatus::CreatedBank, + }] } /// The slot's block was produced. Called after the slot's block data - /// has been emitted, which is the data-before-confirmation order the - /// contract asks for. An unannounced slot is announced first, so no + /// has been emitted, keeping a slot's data ahead of its + /// confirmation. An unannounced slot is announced first, so no /// data-carrying slot can go unannounced even from a path that forgot. pub fn produce(&mut self, slot: Slot) -> Vec { let mut out = self.announce(slot); @@ -81,6 +92,22 @@ impl SlotLifecycle { } } + /// Finality reached `threshold`: roots every confirmed slot at or + /// below it, in slot order. The registry decides which slots are + /// due from its own record, so a slot history with gaps (a clock + /// warp, a restart) roots exactly what it confirmed and nothing it + /// never held. + pub fn root_through(&mut self, threshold: Slot) -> Vec { + let mut due: Vec = self + .stages + .iter() + .filter(|(slot, stage)| **slot <= threshold && **stage == SlotStage::Confirmed) + .map(|(slot, _)| *slot) + .collect(); + due.sort_unstable(); + due.into_iter().flat_map(|slot| self.root(slot)).collect() + } + /// The slot was rooted; it leaves the registry. pub fn root(&mut self, slot: Slot) -> Vec { match self.stages.remove(&slot) { @@ -95,23 +122,36 @@ impl SlotLifecycle { } } - /// A clock warp from the open slot `from` to `to`. The open slot was - /// announced and never produced, so it dies unless the warp lands on - /// it; slots at or past `to` that the old timeline had produced are - /// forgotten, since the new timeline rewrites them; the destination - /// is announced if it is not already. + /// A clock warp from the open slot `from` to `to`. A backward warp + /// is a reorg: every slot at or past `to` dies (`Dead`, in slot + /// order), since the new timeline rewrites them. A forward warp + /// kills only the open slot, which was announced and never + /// produced. The destination is announced if it is not already; a + /// backward warp therefore re-announces the slot it lands on. pub fn warp(&mut self, from: Slot, to: Slot) -> Vec { let mut out = vec![]; - if from != to && self.stages.get(&from) == Some(&SlotStage::Announced) { + if to < from { + let mut rewritten: Vec = self + .stages + .keys() + .copied() + .filter(|slot| *slot >= to) + .collect(); + rewritten.sort_unstable(); + for slot in rewritten { + self.stages.remove(&slot); + out.push(emission( + slot, + SlotStatus::Dead(format!("rewritten by a clock warp to slot {to}")), + )); + } + } else if from != to && self.stages.get(&from) == Some(&SlotStage::Announced) { self.stages.remove(&from); out.push(emission( from, SlotStatus::Dead(format!("abandoned by a clock warp to slot {to}")), )); } - if to < from { - self.stages.retain(|slot, _| *slot < to); - } out.extend(self.announce(to)); out } @@ -210,7 +250,7 @@ mod tests { } #[test] - fn a_backward_warp_forgets_the_rewritten_slots() { + fn a_backward_warp_kills_the_rewritten_slots() { let mut life = SlotLifecycle::default(); for slot in 5..8 { life.announce(slot); @@ -219,15 +259,44 @@ mod tests { } life.announce(8); let out = life.warp(8, 6); - assert!(matches!(out[0].status, SlotStatus::Dead(_))); - assert_eq!(statuses(&out[1..]), vec![(6, "CreatedBank".into())]); assert!( - life.announce(7).len() == 1, - "slot 7 was forgotten with the old timeline" + out[..3] + .iter() + .all(|e| matches!(e.status, SlotStatus::Dead(_))), + "every slot the new timeline rewrites dies" + ); + assert_eq!( + out[..3].iter().map(|e| e.slot).collect::>(), + vec![6, 7, 8], + "deaths are emitted in slot order" ); + assert_eq!(statuses(&out[3..]), vec![(6, "CreatedBank".into())]); assert!(life.announce(5).is_empty(), "slot 5 stays on record"); } + #[test] + fn a_created_bank_names_the_chain_tip_as_parent() { + let mut life = SlotLifecycle::default(); + assert_eq!( + life.announce(3)[0].parent, + None, + "the first announced slot has no recorded parent" + ); + life.produce(3); + life.confirm(3); + assert_eq!( + life.announce(4)[0].parent, + Some(3), + "block production's announce links to the slot just closed" + ); + let out = life.warp(4, 43); + assert_eq!( + out[1].parent, + Some(3), + "a warp destination links to the chain tip, not the phantom slot 42" + ); + } + #[test] fn a_reset_forgets_everything() { let mut life = SlotLifecycle::default(); diff --git a/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs b/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs index c6d0286e..f8cb10d9 100644 --- a/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs +++ b/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs @@ -16,11 +16,11 @@ //! spec cell. //! //! 2. Production's grammar: a driver that calls the registry as the SVM does -//! (close a block, warp, reset) establishes the state invariant on which -//! the contract's Existence and Liveness rest: at every reachable point, -//! exactly one slot is Announced, and it is the open slot. A second -//! Announced slot would be an orphan nobody will resolve; an unannounced -//! open slot would emit untracked data. +//! (close a block, warp, reset) establishes the state invariant the +//! emission guarantees rest on: at every reachable point, exactly one +//! slot is Announced, and it is the open slot. A second Announced slot +//! would be an orphan nobody will resolve; an unannounced open slot +//! would emit untracked data. use std::collections::{HashSet, VecDeque}; @@ -63,6 +63,7 @@ fn drive(life: &mut SlotLifecycle, event: &Event) -> Vec { Event::Produce(slot) => life.produce(*slot), Event::Confirm(slot) => life.confirm(*slot), Event::Root(slot) => life.root(*slot), + Event::RootThrough(threshold) => life.root_through(*threshold), Event::Warp { from, to } => life.warp(*from, *to), Event::Clear => { life.clear(); @@ -78,6 +79,7 @@ fn alphabet() -> Vec { events.push(Event::Produce(slot)); events.push(Event::Confirm(slot)); events.push(Event::Root(slot)); + events.push(Event::RootThrough(slot)); } for from in 0..SLOTS { for to in 0..SLOTS { @@ -207,9 +209,7 @@ fn production_grammar_keeps_exactly_the_open_slot_announced() { } life.produce(open); life.confirm(open); - if let Some(root) = open.checked_sub(ROOT_DEPTH) { - life.root(root); - } + life.root_through((open + 1).saturating_sub(ROOT_DEPTH)); life.announce(open + 1); open + 1 } @@ -229,7 +229,14 @@ fn production_grammar_keeps_exactly_the_open_slot_announced() { } } } - assert!(seen.len() > 100, "the grammar explored a real state space"); + // `root_through` keeps the steady-state registry at two slots (the + // confirmed tip and the open slot), so the space is linear in + // MAX_SLOT rather than combinatorial; the bound guards against the + // grammar collapsing, not against tight rooting. + assert!( + seen.len() > 2 * MAX_SLOT as usize, + "the grammar explored a real state space" + ); } /// The generated blocks of `slot-lifecycle.md`, named by their markers. diff --git a/crates/core/src/surfnet/slot_lifecycle/spec.rs b/crates/core/src/surfnet/slot_lifecycle/spec.rs index dbdbaad5..966c891a 100644 --- a/crates/core/src/surfnet/slot_lifecycle/spec.rs +++ b/crates/core/src/surfnet/slot_lifecycle/spec.rs @@ -16,11 +16,12 @@ //! totality stopped being credible. Smaller registries make do with a //! named test per cell and hand-written rustdoc. //! -//! Warp and clear are not cells: they are set-level operations over the -//! whole registry, stated in [`expected_emissions`] and -//! [`expected_view`] directly, with the warp's announce step routed -//! through the table's announce row so the two encodings cannot drift -//! on what announcing means. +//! Warp, root-through, and clear are not cells: they are set-level +//! operations over the whole registry, stated in +//! [`expected_emissions`] and [`expected_view`] directly, with the +//! warp's announce step and each root-through root routed through the +//! table's own rows so the two encodings cannot drift on what +//! announcing or rooting means. //! //! Maintenance procedure for changing a state, event, or transition: //! @@ -54,6 +55,9 @@ pub(crate) enum Event { Confirm(Slot), /// The slot was rooted. Root(Slot), + /// Finality reached `threshold`: every confirmed slot at or below + /// it roots, in slot order, each through the table's root cell. + RootThrough(Slot), /// The clock jumped from the open slot `from` to `to`. Warp { from: Slot, to: Slot }, /// A network reset forgot every slot. @@ -187,19 +191,33 @@ pub(crate) fn expected_emissions(view: &View, event: &Event) -> Vec<(Slot, Statu Event::Produce(slot) => apply_cell(&mut view, *slot, E::Produce), Event::Confirm(slot) => apply_cell(&mut view, *slot, E::Confirm), Event::Root(slot) => apply_cell(&mut view, *slot, E::Root), + Event::RootThrough(threshold) => { + let due: Vec = view + .iter() + .filter(|(slot, stage)| **slot <= *threshold && **stage == SConfirmed) + .map(|(slot, _)| *slot) + .collect(); + due.into_iter() + .flat_map(|slot| apply_cell(&mut view, slot, E::Root)) + .collect() + } Event::Warp { from, to } => { - // Set-level rules: the abandoned open slot dies, a backward - // warp forgets every slot the new timeline rewrites, and - // the destination is announced through the table's own - // announce cell. + // Set-level rules: a backward warp kills every slot the new + // timeline rewrites (`Dead`, ascending; the abandoned open + // slot is among them), a forward warp kills only the + // abandoned open slot, and the destination is announced + // through the table's own announce cell. let mut out = vec![]; - if from != to && view.get(from) == Some(&Announced) { + if to < from { + let rewritten: Vec = view.keys().copied().filter(|slot| slot >= to).collect(); + for slot in rewritten { + view.remove(&slot); + out.push((slot, S::Dead)); + } + } else if from != to && view.get(from) == Some(&Announced) { view.remove(from); out.push((*from, S::Dead)); } - if to < from { - view.retain(|slot, _| slot < to); - } out.extend(apply_cell(&mut view, *to, E::Announce)); out } @@ -223,12 +241,21 @@ pub(crate) fn expected_view(view: &View, event: &Event) -> View { Event::Root(slot) => { apply_cell(&mut next, *slot, E::Root); } - Event::Warp { from, to } => { - if from != to && next.get(from) == Some(&Announced) { - next.remove(from); + Event::RootThrough(threshold) => { + let due: Vec = next + .iter() + .filter(|(slot, stage)| **slot <= *threshold && **stage == SConfirmed) + .map(|(slot, _)| *slot) + .collect(); + for slot in due { + apply_cell(&mut next, slot, E::Root); } + } + Event::Warp { from, to } => { if to < from { next.retain(|slot, _| slot < to); + } else if from != to && next.get(from) == Some(&Announced) { + next.remove(from); } apply_cell(&mut next, *to, E::Announce); } @@ -340,7 +367,7 @@ pub(crate) fn render_diagram() -> String { } } out.push_str("Announced --warp away--> (forgotten) emits Dead\n"); - out.push_str("(any slot the new timeline rewrites)--> (forgotten), backward warps only\n"); + out.push_str("(any stage) --warp back--> (forgotten) emits Dead, every rewritten slot\n"); out.push_str("```\n"); out } diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index b0021d56..67efaf27 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -7,6 +7,7 @@ use std::{ }; use agave_feature_set::FeatureSet; +use agave_geyser_plugin_interface::geyser_plugin_interface::SlotStatus; use base64::{Engine, prelude::BASE64_STANDARD}; use chrono::Utc; use convert_case::Casing; @@ -1907,6 +1908,10 @@ impl SurfnetSvm { self.latest_epoch_info = epoch_info.clone(); // Set genesis_slot to the current slot when resetting (similar to initialize) self.genesis_slot = epoch_info.absolute_slot; + // A reset erases the world rather than reorging it: live slots + // are forgotten with no terminal status, and only the new + // genesis is announced. A warp, by contrast, kills what it + // abandons with `Dead`. self.slot_lifecycle.clear(); self.announce_open_slot(); let chain_tip_hash = SyntheticBlockhash::new(epoch_info.block_height).to_string(); @@ -2530,21 +2535,8 @@ impl SurfnetSvm { max_transactions_per_entry: num_transactions, }, }); - self.notify_slots_updates_subscribers(SlotUpdate::CreatedBank { - slot: new_slot, - parent: parent_slot, - timestamp: slots_update_ts, - }); - let geyser_parent_slot = slot.saturating_sub(1); - // Mirror the Confirmed Geyser event as an `OptimisticConfirmation` - // notification for `slotsUpdatesSubscribe` clients. - self.notify_slots_updates_subscribers(SlotUpdate::OptimisticConfirmation { - slot, - timestamp: slots_update_ts, - }); - // Notify geyser plugins of block metadata let block_metadata = GeyserBlockMetadata { slot, @@ -2581,7 +2573,7 @@ impl SurfnetSvm { // can never precede its announcement. let mut emissions = self.slot_lifecycle.produce(slot); emissions.extend(self.slot_lifecycle.confirm(slot)); - self.emit_slot_statuses(emissions); + self.emit_slot_statuses(emissions, slots_update_ts); let clock: Clock = Clock { slot: self.latest_epoch_info.absolute_slot, @@ -2596,20 +2588,14 @@ impl SurfnetSvm { self.finalize_transactions()?; - // Notify geyser plugins of newly rooted (finalized) slot - // Only emit if root is a valid slot (greater than genesis) - if root >= self.genesis_slot { - let emissions = self.slot_lifecycle.root(root); - self.emit_slot_statuses(emissions); - // Mirror the Rooted Geyser event as a `Root` notification for - // `slotsUpdatesSubscribe` clients. - self.notify_slots_updates_subscribers(SlotUpdate::Root { - slot: root, - timestamp: slots_update_ts, - }); - } + // The registry decides which slots are due from its own record, + // so a history with gaps (a clock warp) roots exactly what it + // confirmed, and a slot below what the registry holds (an early + // block, a restart) roots nothing. + let emissions = self.slot_lifecycle.root_through(root); + self.emit_slot_statuses(emissions, slots_update_ts); let emissions = self.slot_lifecycle.announce(new_slot); - self.emit_slot_statuses(emissions); + self.emit_slot_statuses(emissions, slots_update_ts); // Evict the accounts marked as streamed from cache to enforce them to be fetched again let accounts_to_reset: Vec<_> = self.streamed_accounts.into_iter()?.collect(); @@ -3063,14 +3049,44 @@ impl SurfnetSvm { )) } - /// Sends the slot statuses a lifecycle transition produced, in order. - pub fn emit_slot_statuses(&self, emissions: Vec) { + /// Sends the slot statuses a lifecycle transition produced, in + /// order: each status to geyser plugins, and a mirror of + /// `CreatedBank`, `Confirmed`, `Rooted`, and `Dead` to + /// `slotsUpdatesSubscribe` clients, so the two streams cannot + /// diverge on lifecycle events. `Frozen` has no lifecycle emission + /// to mirror: block production sends it directly, since only it + /// knows the block's transaction stats. `ws_timestamp` is the + /// millisecond wall-clock sample the ws variants carry; a caller + /// emitting several batches for one block passes the same sample to + /// all of them. + pub fn emit_slot_statuses(&mut self, emissions: Vec, ws_timestamp: u64) { for SlotEmission { slot, parent, status, } in emissions { + let mirror = match &status { + SlotStatus::CreatedBank => Some(SlotUpdate::CreatedBank { + slot, + parent: parent.unwrap_or_else(|| slot.saturating_sub(1)), + timestamp: ws_timestamp, + }), + SlotStatus::Confirmed => Some(SlotUpdate::OptimisticConfirmation { + slot, + timestamp: ws_timestamp, + }), + SlotStatus::Rooted => Some(SlotUpdate::Root { + slot, + timestamp: ws_timestamp, + }), + SlotStatus::Dead(reason) => Some(SlotUpdate::Dead { + slot, + timestamp: ws_timestamp, + err: reason.clone(), + }), + _ => None, + }; self.geyser_events_tx .send(GeyserEvent::UpdateSlotStatus { slot, @@ -3078,6 +3094,9 @@ impl SurfnetSvm { status, }) .ok(); + if let Some(update) = mirror { + self.notify_slots_updates_subscribers(update); + } } } @@ -3086,7 +3105,40 @@ impl SurfnetSvm { pub fn announce_open_slot(&mut self) { let slot = self.get_latest_absolute_slot(); let emissions = self.slot_lifecycle.announce(slot); - self.emit_slot_statuses(emissions); + let ws_timestamp = Utc::now().timestamp_millis().max(0) as u64; + self.emit_slot_statuses(emissions, ws_timestamp); + } + + /// Applies a time-travel clock: writes the sysvar and epoch info, + /// reports the jump on the simnet events channel, and resolves the + /// slot lifecycle across it. Returns the resulting epoch info. + /// + /// The open slot is captured before any clock write, and the + /// lifecycle resolves after all of them, since + /// [`Self::warp_slot_lifecycle`] reads the destination from the + /// epoch info this method just wrote; keeping both sides here is + /// what stops a caller from getting that order wrong. + /// + /// `clock.slot` is the epoch-relative slot index, as the + /// `helpers::time_travel` calculators produce it; the absolute slot + /// is reconstructed from it and `clock.epoch`. + /// + /// A destination at or below the just-confirmed slot is a reorg: + /// the rewritten slots die (`Dead`) and the destination is + /// re-announced, to be replayed by the new timeline. Time travel to + /// the current slot lands there, because the command handlers + /// confirm a block before applying the clock. + pub fn warp_clock(&mut self, clock: Clock) -> EpochInfo { + let open_slot = self.get_latest_absolute_slot(); + self.inner.set_sysvar(&clock); + self.updated_at = clock.unix_timestamp as u64 * 1_000; + self.latest_epoch_info.slot_index = clock.slot; + self.latest_epoch_info.epoch = clock.epoch; + self.latest_epoch_info.absolute_slot = + clock.slot + clock.epoch * self.latest_epoch_info.slots_in_epoch; + let _ = self.simnet_events_tx.system_clock_updated(clock); + self.warp_slot_lifecycle(open_slot); + self.latest_epoch_info.clone() } /// Resolves the slot lifecycle across a clock warp: the slot that was open before @@ -3095,7 +3147,8 @@ impl SurfnetSvm { pub fn warp_slot_lifecycle(&mut self, from: Slot) { let to = self.get_latest_absolute_slot(); let emissions = self.slot_lifecycle.warp(from, to); - self.emit_slot_statuses(emissions); + let ws_timestamp = Utc::now().timestamp_millis().max(0) as u64; + self.emit_slot_statuses(emissions, ws_timestamp); } pub fn subscribe_for_account_updates( @@ -7134,6 +7187,65 @@ mod tests { out } + #[test] + fn warp_clock_moves_the_clock_and_resolves_the_lifecycle() { + let (mut svm, _events_rx, geyser_rx) = SurfnetSvm::default(); + svm.announce_open_slot(); + svm.confirm_current_block().unwrap(); + let open_slot = svm.get_latest_absolute_slot(); + let _ = geyser_slot_events(&geyser_rx); + + let slots_in_epoch = svm.latest_epoch_info.slots_in_epoch; + let target = open_slot + 40; + let clock = Clock { + slot: target % slots_in_epoch, + epoch: target / slots_in_epoch, + unix_timestamp: 1_700_000_000, + epoch_start_timestamp: 1_700_000_000, + leader_schedule_epoch: 0, + }; + let epoch_info = svm.warp_clock(clock); + + assert_eq!(epoch_info.absolute_slot, target); + assert_eq!(svm.latest_epoch_info.slot_index, target % slots_in_epoch); + assert_eq!(svm.updated_at, 1_700_000_000_000); + assert_eq!( + geyser_slot_events(&geyser_rx), + vec![ + (open_slot, "dead".to_string()), + (target, "created".to_string()) + ], + "the abandoned slot dies and the destination is announced" + ); + } + + #[test] + fn warp_emissions_reach_slots_updates_subscribers_too() { + let (mut svm, _events_rx, geyser_rx) = SurfnetSvm::default(); + svm.announce_open_slot(); + svm.confirm_current_block().unwrap(); + svm.confirm_current_block().unwrap(); + let ws_rx = svm.subscribe_for_slots_updates(); + let open_slot = svm.get_latest_absolute_slot(); + let chain_tip = open_slot - 1; + let _ = geyser_slot_events(&geyser_rx); + + svm.latest_epoch_info.absolute_slot = open_slot + 40; + svm.warp_slot_lifecycle(open_slot); + + let updates: Vec<_> = ws_rx.try_iter().collect(); + assert!( + matches!(*updates[0], SlotUpdate::Dead { slot, .. } if slot == open_slot), + "the abandoned slot's death reaches ws clients" + ); + assert!( + matches!(*updates[1], SlotUpdate::CreatedBank { slot, parent, .. } + if slot == open_slot + 40 && parent == chain_tip), + "the destination's bank reaches ws clients with the chain-tip parent" + ); + assert_eq!(updates.len(), 2, "the warp mirrors exactly its emissions"); + } + #[test] fn every_slot_is_announced_once_before_its_data_and_confirmed_after_it() { let (mut svm, _events_rx, geyser_rx) = SurfnetSvm::default(); @@ -7177,7 +7289,7 @@ mod tests { #[test] fn a_warp_kills_the_open_slot_and_announces_the_destination() { - // Existence and liveness against the real SVM: after two blocks the open slot is + // Against the real SVM: after two blocks the open slot is // genesis + 2, announced and unproduced; the clock jumps to +40. let (mut svm, _events_rx, geyser_rx) = SurfnetSvm::default(); svm.announce_open_slot(); From 92874c58c1f36bf0f0bd0e23de84a68b6e9be3a4 Mon Sep 17 00:00:00 2001 From: cds-amal Date: Mon, 24 Aug 2026 20:35:53 -0400 Subject: [PATCH 5/9] docs(core): state reorg warps, threshold rooting, and limits in the slot spec - define backward warps as reorgs - describe rooting as a threshold drained from the registry's record - document histories the registry cannot see: persistent-mode restarts start empty, and network resets forget slots without terminal status - regenerate the slot lifecycle diagram - drop the review-notes model pointer; the exhaustive sweeps are the checkable artifact that ships --- crates/core/src/surfnet/slot-lifecycle.md | 49 ++++++++++++++++------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/crates/core/src/surfnet/slot-lifecycle.md b/crates/core/src/surfnet/slot-lifecycle.md index b4d6e29e..2829d5b2 100644 --- a/crates/core/src/surfnet/slot-lifecycle.md +++ b/crates/core/src/surfnet/slot-lifecycle.md @@ -1,7 +1,8 @@ A slot is announced (`CreatedBank`) before any of its block data is emitted, advances through `Processed`, `Confirmed`, and `Rooted` in that -order and at most once each, or dies (`Dead`) when a clock warp abandons -it before it is produced. Block production, the startup task, the warp +order and at most once each, or dies (`Dead`): a clock warp abandons the +open slot it leaves behind, and a backward warp kills every slot the new +timeline rewrites. Block production, the startup task, the warp handlers, and a network reset all drive this one transition relation instead of each emitting statuses by hand. @@ -32,21 +33,29 @@ Announced --produce--> Processed emits Processed Processed --confirm--> Confirmed emits Confirmed Confirmed --root--> (forgotten) emits Rooted Announced --warp away--> (forgotten) emits Dead -(any slot the new timeline rewrites)--> (forgotten), backward warps only +(any stage) --warp back--> (forgotten) emits Dead, every rewritten slot ``` -## Warp and clear +## Warp, rooting, and clear -A warp and a reset are set-level operations, deliberately kept out of -the table: +Warps, rooting, and a reset are set-level operations, deliberately +kept out of the table: -- A warp from the open slot `f` to `t` kills the abandoned slot (`f` - was announced and never produced, so it is emitted `Dead` and - forgotten), forgets every slot at or past `t` when the warp is - backward (the new timeline rewrites them), and then announces `t` - through the table's own announce cell, so a warp landing on a slot - already on record announces nothing. +- A forward warp from the open slot `f` to `t` kills the abandoned + slot (`f` was announced and never produced, so it is emitted `Dead` + and forgotten) and announces `t` through the table's own announce + cell, so a warp landing on a slot already on record announces + nothing. +- A backward warp is a reorg: every slot at or past `t` dies (`Dead`, + in slot order), whatever its stage, and `t` is then re-announced. + The new timeline replays the killed slots, so their statuses appear + again; at-most-once holds per bank, and a reorg makes a new bank. +- Rooting is a threshold, not a single slot: when finality reaches + slot `r`, every confirmed slot at or below `r` roots, in slot order, + each through the table's root cell. The registry decides which slots + are due from its own record, so a history with gaps (a warp) roots + exactly what it confirmed. - A reset forgets every slot; the caller announces the new open slot. ## What the table cannot hold @@ -61,11 +70,23 @@ and live in code order rather than in cells: sends `EndOfStartup` before the RPC listeners bind, so nothing external can emit block data for a slot a plugin is not tracking. -Interleavings (who runs between which writer sections) are checked in -the Promela models kept with the review notes, not here; the sweeps in +Interleavings (who runs between which writer sections) are out of +scope for this document; the sweeps in `slot_lifecycle/reachability_tests.rs` cover every reachable state and event of the sequential machine. +## Limits + +Two histories fall outside the registry's record: + +- A restart in persistent mode starts an empty registry. Slots + confirmed by an earlier process get no further statuses in either + stream: nothing roots them, and nothing replays them. +- A network reset erases the world: every live slot is forgotten with + no terminal status, and the new genesis is announced. This is the + one path that drops a slot without a `Rooted` or a `Dead`; a warp, + by contrast, kills what it abandons. + ## Maintenance State a rule change in `PER_SLOT` (or the warp/clear arms) first, then From acb6bbab9fe7e3fa1a6f7b51adec3126bbed3bc2 Mon Sep 17 00:00:00 2001 From: cds-amal Date: Mon, 24 Aug 2026 21:03:08 -0400 Subject: [PATCH 6/9] docs(core): define deep warps as operator-accepted discontinuities - define warps at or below root as legal discontinuities, not reconciliation - let rooted history disappear without Dead; CreatedBank at the landing signals the new timeline - require consumers that see CreatedBank for a rooted slot to drop state at or above it and resync - split unconditional per-bank guarantees from cross-timeline guarantees that hold only until an operator warps across them - document the contract in the slot spec, warp_clock, and SlotLifecycle::warp - leave the machine unchanged; the registry already implements this contract --- crates/core/src/surfnet/slot-lifecycle.md | 20 +++++++++++++++++-- crates/core/src/surfnet/slot_lifecycle/mod.rs | 5 +++++ crates/core/src/surfnet/svm.rs | 7 +++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/core/src/surfnet/slot-lifecycle.md b/crates/core/src/surfnet/slot-lifecycle.md index 2829d5b2..7ef0f543 100644 --- a/crates/core/src/surfnet/slot-lifecycle.md +++ b/crates/core/src/surfnet/slot-lifecycle.md @@ -51,6 +51,14 @@ kept out of the table: in slot order), whatever its stage, and `t` is then re-announced. The new timeline replays the killed slots, so their statuses appear again; at-most-once holds per bank, and a reorg makes a new bank. +- A backward warp may land at or below the root line (time travel to + the current epoch does exactly this). Rooted slots left the registry + when they rooted, so they die without a `Dead`; the landing emits a + `CreatedBank` at or below anything a consumer saw `Rooted`, with no + recorded parent when the registry holds nothing below it. That + announce is the discontinuity signal: a consumer treats a + `CreatedBank` for a slot it saw rooted as a timeline replacement, + dropping its state for every slot at or above it. - Rooting is a threshold, not a single slot: when finality reaches slot `r`, every confirmed slot at or below `r` roots, in slot order, each through the table's root cell. The registry decides which slots @@ -84,8 +92,16 @@ Two histories fall outside the registry's record: stream: nothing roots them, and nothing replays them. - A network reset erases the world: every live slot is forgotten with no terminal status, and the new genesis is announced. This is the - one path that drops a slot without a `Rooted` or a `Dead`; a warp, - by contrast, kills what it abandons. + one path that drops a live slot without a `Rooted` or a `Dead`; a + warp, by contrast, kills what it abandons. + +Warps split the guarantees in two. Within a bank, the per-slot +guarantees are unconditional: announced before data, data before +confirmation, statuses in order and at most once. Across timelines, +rooted-is-final and slot monotonicity hold only until the operator +warps across them: time travel is a cheatcode, and the operator who +calls it suspends exactly those two guarantees, at one announced +boundary. ## Maintenance diff --git a/crates/core/src/surfnet/slot_lifecycle/mod.rs b/crates/core/src/surfnet/slot_lifecycle/mod.rs index cbe3061a..68162455 100644 --- a/crates/core/src/surfnet/slot_lifecycle/mod.rs +++ b/crates/core/src/surfnet/slot_lifecycle/mod.rs @@ -128,6 +128,11 @@ impl SlotLifecycle { /// kills only the open slot, which was announced and never /// produced. The destination is announced if it is not already; a /// backward warp therefore re-announces the slot it lands on. + /// + /// Slots already rooted are no longer on record, so a warp at or + /// below the root line kills them without a `Dead`; the + /// destination's announce is the consumer's replacement signal + /// (see the [module documentation](self)). pub fn warp(&mut self, from: Slot, to: Slot) -> Vec { let mut out = vec![]; if to < from { diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 67efaf27..0fbe60d9 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -3128,6 +3128,13 @@ impl SurfnetSvm { /// re-announced, to be replayed by the new timeline. Time travel to /// the current slot lands there, because the command handlers /// confirm a block before applying the clock. + /// + /// A destination at or below the root line goes further: rooted + /// slots are no longer on record, so they die without a `Dead`, and + /// the landing `CreatedBank` (parentless when nothing below it is + /// on record) is the consumer's signal to drop everything at or + /// above it and resync. The module documentation for + /// [`super::slot_lifecycle`] states that contract. pub fn warp_clock(&mut self, clock: Clock) -> EpochInfo { let open_slot = self.get_latest_absolute_slot(); self.inner.set_sysvar(&clock); From 2a1e73a5031e169949035687c2cbac0adfa7fdea Mon Sep 17 00:00:00 2001 From: cds-amal Date: Mon, 24 Aug 2026 23:00:35 -0400 Subject: [PATCH 7/9] docs(core): the slot diagram as mermaid, pre-rendered into cargo doc - Emit the machine's edges as a mermaid state diagram instead of a preformatted text block: - `render_diagram` maps `(absent)` and forgotten onto the start and end pseudo-states and generates one edge per advancing cell - the two warp rules stay appended beside the table-driven edges, with a floating "any stage" node carrying the set-level warp back - Adopt the startup spec's render pipeline, with one twist: the fence is itself spec-generated, so its `BEGIN MERMAID` markers nest inside the `GENERATED: diagram` region and regenerate with it: - `cargo surfpool-render-slot-diagrams` renders the fence to `src/surfnet/diagrams/machine-edges.svg`, pinned to the fnv1a hash of its source - `the_diagrams_match_their_renderings` fails on a stale render, so CI needs no mermaid toolchain - a new `crates/core/build.rs` splices the SVG in place of the fence into `$OUT_DIR/slot-lifecycle.rustdoc.md`, which the module now includes; the source file keeps the fence GitHub renders natively - Render labels as SVG text with a quote-free font stack: rustdoc applies smart punctuation to text inside the inlined style block, so a quoted font name arrives curly-quoted and the browser falls back to a wider face, clipping every foreignObject label at its measured edge. SVG text overflows visibly instead, and an unquoted stack survives the pipeline; the mechanism is spelled beside the config in the render test. - Duplicate the splice script and the diagram test helpers from the startup crate rather than exporting its test-only module; the copies point at their originals, and a third spec doc is the trigger to extract a shared crate. --- .cargo/config.toml | 2 + crates/core/build.rs | 45 ++++ .../src/surfnet/diagrams/machine-edges.svg | 2 + crates/core/src/surfnet/slot-lifecycle.md | 27 ++- crates/core/src/surfnet/slot_lifecycle/mod.rs | 2 +- .../slot_lifecycle/reachability_tests.rs | 194 ++++++++++++++++++ .../core/src/surfnet/slot_lifecycle/spec.rs | 71 +++---- 7 files changed, 297 insertions(+), 46 deletions(-) create mode 100644 crates/core/build.rs create mode 100644 crates/core/src/surfnet/diagrams/machine-edges.svg diff --git a/.cargo/config.toml b/.cargo/config.toml index 3c649cab..f392a81e 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -10,3 +10,5 @@ surfpool-update-startup-spec = "test -p surfpool-types regenerate_the_startup_sp surfpool-update-slot-spec = "test -p surfpool-core --lib regenerate_the_slot_spec_tables -- --ignored --nocapture" # re-render the spec's mermaid diagrams to crates/types/src/diagrams/ (needs mmdc) surfpool-render-startup-diagrams = "test -p surfpool-types render_the_startup_diagrams -- --ignored --nocapture" +# re-render the slot spec's mermaid diagrams to crates/core/src/surfnet/diagrams/ (needs mmdc) +surfpool-render-slot-diagrams = "test -p surfpool-core --lib render_the_slot_diagrams -- --ignored --nocapture" diff --git a/crates/core/build.rs b/crates/core/build.rs new file mode 100644 index 00000000..a5c89183 --- /dev/null +++ b/crates/core/build.rs @@ -0,0 +1,45 @@ +use std::{env, fs, path::Path}; + +/// Produces the rustdoc variant of `slot-lifecycle.md`: each mermaid +/// region is replaced by its pre-rendered SVG from +/// `src/surfnet/diagrams/`, so rustdoc shows the drawing while the +/// source file keeps the editable fence, which GitHub and editors +/// render natively. The test `the_diagrams_match_their_renderings` +/// holds the SVGs to their sources; this script only splices. +fn main() { + println!("cargo:rerun-if-changed=src/surfnet/slot-lifecycle.md"); + println!("cargo:rerun-if-changed=src/surfnet/diagrams"); + + let source = fs::read_to_string("src/surfnet/slot-lifecycle.md") + .expect("slot-lifecycle.md should exist"); + + let mut output = String::new(); + let mut rest = source.as_str(); + loop { + let Some(start) = rest.find("") + .expect("a mermaid marker name") + + name_start; + let name = &rest[name_start..name_end]; + let end_marker = format!(""); + let end = rest + .find(&end_marker) + .unwrap_or_else(|| panic!("no closing marker for mermaid region {name}")) + + end_marker.len(); + + output.push_str(&rest[..start]); + let svg_path = format!("src/surfnet/diagrams/{name}.svg"); + let svg = fs::read_to_string(&svg_path) + .unwrap_or_else(|error| panic!("could not read {svg_path}: {error}")); + output.push_str(&svg); + rest = &rest[end..]; + } + + let out = Path::new(&env::var("OUT_DIR").expect("OUT_DIR")).join("slot-lifecycle.rustdoc.md"); + fs::write(out, output).expect("write the rustdoc variant"); +} diff --git a/crates/core/src/surfnet/diagrams/machine-edges.svg b/crates/core/src/surfnet/diagrams/machine-edges.svg new file mode 100644 index 00000000..07c7d514 --- /dev/null +++ b/crates/core/src/surfnet/diagrams/machine-edges.svg @@ -0,0 +1,2 @@ + +announce(CreatedBank)produce (CreatedBank,Processed)produce (Processed)confirm (Confirmed)root (Rooted)warp away (Dead)warp back (Dead, everyrewritten slot)AnnouncedProcessedConfirmedany stage \ No newline at end of file diff --git a/crates/core/src/surfnet/slot-lifecycle.md b/crates/core/src/surfnet/slot-lifecycle.md index 7ef0f543..d3ce1efe 100644 --- a/crates/core/src/surfnet/slot-lifecycle.md +++ b/crates/core/src/surfnet/slot-lifecycle.md @@ -26,15 +26,19 @@ machine to, so what you read here is what runs. ## The machine, as edges -```text -(absent) --announce--> Announced emits CreatedBank -(absent) --produce--> Processed emits CreatedBank, Processed -Announced --produce--> Processed emits Processed -Processed --confirm--> Confirmed emits Confirmed -Confirmed --root--> (forgotten) emits Rooted -Announced --warp away--> (forgotten) emits Dead -(any stage) --warp back--> (forgotten) emits Dead, every rewritten slot + +```mermaid +stateDiagram-v2 + [*] --> Announced : announce (CreatedBank) + [*] --> Processed : produce (CreatedBank, Processed) + Announced --> Processed : produce (Processed) + Processed --> Confirmed : confirm (Confirmed) + Confirmed --> [*] : root (Rooted) + Announced --> [*] : warp away (Dead) + state "any stage" as any_stage + any_stage --> [*] : warp back (Dead, every rewritten slot) ``` + ## Warp, rooting, and clear @@ -108,5 +112,8 @@ boundary. State a rule change in `PER_SLOT` (or the warp/clear arms) first, then change the machine; the sweep names the first disagreement. Then run `cargo surfpool-update-slot-spec` to regenerate the blocks above, and -review that diff as the observable change. The prose here is authored: -revise it when a rule changes meaning. +review that diff as the observable change. When the diagram changed, +also run `cargo surfpool-render-slot-diagrams` (needs `mmdc`) to +re-render the SVG that cargo doc splices in place of the fence; a +stale render fails `the_diagrams_match_their_renderings`. The prose +here is authored: revise it when a rule changes meaning. diff --git a/crates/core/src/surfnet/slot_lifecycle/mod.rs b/crates/core/src/surfnet/slot_lifecycle/mod.rs index 68162455..6da7c5f7 100644 --- a/crates/core/src/surfnet/slot_lifecycle/mod.rs +++ b/crates/core/src/surfnet/slot_lifecycle/mod.rs @@ -1,6 +1,6 @@ //! The per-slot lifecycle every geyser slot-status emission derives from. //! -#![doc = include_str!("../slot-lifecycle.md")] +#![doc = include_str!(concat!(env!("OUT_DIR"), "/slot-lifecycle.rustdoc.md"))] use std::collections::HashMap; diff --git a/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs b/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs index f8cb10d9..a6471982 100644 --- a/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs +++ b/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs @@ -295,3 +295,197 @@ fn regenerate_the_slot_spec_tables() { .unwrap_or_else(|error| panic!("could not write {SPEC_DOC_PATH}: {error}")); eprintln!("regenerated the spec tables in {SPEC_DOC_PATH}"); } + +// The diagram pipeline below mirrors the startup spec's +// (`types/src/startup/surfnet_startup_reachability_tests.rs`); the +// helpers are duplicated because they live in that crate's test-only +// module and exporting them would put test scaffolding in the library. + +/// Every mermaid region of `slot-lifecycle.md`, as (name, fenced source). +fn diagram_sources() -> Vec<(String, String)> { + let text = read_spec_doc(); + let mut sources = vec![]; + let mut rest = text.as_str(); + while let Some(start) = rest.find("") + .expect("a mermaid marker name") + + name_start; + let name = rest[name_start..name_end].to_string(); + let body_start = name_end + " -->".len(); + let end_marker = format!(""); + let end = rest.find(&end_marker).expect("a closing mermaid marker"); + sources.push((name, rest[body_start..end].trim().to_string())); + rest = &rest[end + end_marker.len()..]; + } + sources +} + +/// FNV-1a, implemented locally: the pin must be stable across Rust +/// releases, which std's `DefaultHasher` does not promise. +fn fnv1a(text: &str) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in text.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +fn diagram_svg_path(name: &str) -> String { + format!( + "{}/src/surfnet/diagrams/{name}.svg", + env!("CARGO_MANIFEST_DIR") + ) +} + +/// Replaces each distinct random token after `state-id-` with its +/// first-occurrence index, rewriting every reference to it the same +/// way, so internal id links inside the SVG stay consistent. +fn normalize_state_ids(svg: &str) -> String { + const MARKER: &str = "state-id-"; + let mut tokens: Vec = vec![]; + let mut output = String::with_capacity(svg.len()); + let mut rest = svg; + while let Some(found) = rest.find(MARKER) { + let after = found + MARKER.len(); + let token_len = rest[after..] + .bytes() + .take_while(u8::is_ascii_alphanumeric) + .count(); + let token = rest[after..after + token_len].to_string(); + let index = match tokens.iter().position(|seen| *seen == token) { + Some(index) => index, + None => { + tokens.push(token); + tokens.len() - 1 + } + }; + output.push_str(&rest[..after]); + output.push_str(&format!("d{index}")); + rest = &rest[after + token_len..]; + } + output.push_str(rest); + output +} + +/// Each rendered SVG pins the hash of the mermaid source it was +/// rendered from, so editing a diagram without re-rendering fails +/// here, and CI needs no mermaid toolchain to detect the drift. +#[test] +fn the_diagrams_match_their_renderings() { + for (name, source) in diagram_sources() { + let path = diagram_svg_path(&name); + let svg = std::fs::read_to_string(&path).unwrap_or_else(|error| { + panic!( + "could not read {path}: {error}; run `cargo \ + surfpool-render-slot-diagrams`" + ) + }); + let expected = format!("", fnv1a(&source)); + assert!( + svg.starts_with(&expected), + "{name}: the rendered SVG is stale; run `cargo \ + surfpool-render-slot-diagrams`" + ); + } +} + +/// Renders each mermaid region to `src/surfnet/diagrams/.svg` +/// and pins the source hash. Ignored so a plain test run never needs +/// the mermaid CLI; `cargo surfpool-render-slot-diagrams` runs it. +/// +/// Determinism boundary: with the id fixes below, re-rendering an +/// unchanged source is byte-stable on one machine, and CI never +/// compares SVG bytes at all (the check test pins the source hash), +/// so environments can differ without breaking anything. Across +/// machines, Chromium versions and font fallbacks still move text +/// metrics, so a re-render on different hardware may churn measured +/// coordinates; that churn is confined to commits that edit a +/// diagram. Byte-stability across machines would need a pinned +/// render container, which this mechanism deliberately omits. +#[test] +#[ignore = "runs mmdc; invoke via cargo surfpool-render-slot-diagrams"] +fn render_the_slot_diagrams() { + for (name, source) in diagram_sources() { + let body: String = source + .lines() + .filter(|line| !line.trim_start().starts_with("```")) + .collect::>() + .join("\n"); + let input = std::env::temp_dir().join(format!("{name}.mmd")); + let rendered = std::env::temp_dir().join(format!("{name}.svg")); + let config = std::env::temp_dir().join(format!("{name}.mermaid.json")); + std::fs::write(&input, &body).expect("write the mermaid source"); + // Deterministic ids, seeded by the diagram name: mermaid + // otherwise embeds a random token in every render, and a + // re-render with an unchanged source would dirty the tree. + // + // htmlLabels off, everywhere: HTML labels sit in foreignObject + // boxes that clip at their measured edge, and SVG text + // overflows visibly instead. State diagrams read the flowchart + // key for edge labels, so all three keys are needed. + // + // The font stack must carry no quotes: rustdoc's markdown + // pipeline applies smart punctuation to the text inside the + // inlined SVG's style block, so a quoted "trebuchet ms" + // arrives as a curly-quoted unknown font and the browser falls + // back to a wider face than the one mmdc measured, clipping + // every label. Quote-free names survive, and mmdc then + // measures the same face the browser renders. + std::fs::write( + &config, + format!( + r#"{{"deterministicIds": true, "deterministicIDSeed": "{name}", + "htmlLabels": false, "state": {{"htmlLabels": false}}, + "flowchart": {{"htmlLabels": false}}, + "themeVariables": {{"fontFamily": "verdana, arial, sans-serif"}}}}"# + ), + ) + .expect("write the mermaid config"); + + let status = std::process::Command::new("mmdc") + .arg("-i") + .arg(&input) + .arg("-o") + .arg(&rendered) + .arg("-c") + .arg(&config) + .status() + .expect("mmdc should be installed: npm i -g @mermaid-js/mermaid-cli"); + assert!(status.success(), "mmdc failed for {name}"); + + let svg = std::fs::read_to_string(&rendered).expect("read the rendered svg"); + // mmdc can prepend an XML declaration; rustdoc wants raw . + let svg = svg.trim_start_matches(|c| c != '<'); + let svg = if svg.starts_with("").map(|i| i + 2).unwrap_or(0)..] + } else { + svg + }; + // The deterministicIds config misses the internal ids of + // composite states, which carry a fresh random token on every + // render; normalize them so an unchanged source re-renders + // byte-identically and never dirties the tree. + let svg = normalize_state_ids(svg); + let svg = svg.as_str(); + let path = diagram_svg_path(&name); + std::fs::create_dir_all( + std::path::Path::new(&path) + .parent() + .expect("the diagrams directory"), + ) + .expect("create the diagrams directory"); + std::fs::write( + &path, + format!( + "\n{}", + fnv1a(&source), + svg.trim_start() + ), + ) + .expect("write the pinned svg"); + eprintln!("rendered {path}"); + } +} diff --git a/crates/core/src/surfnet/slot_lifecycle/spec.rs b/crates/core/src/surfnet/slot_lifecycle/spec.rs index 966c891a..2a8789e9 100644 --- a/crates/core/src/surfnet/slot_lifecycle/spec.rs +++ b/crates/core/src/surfnet/slot_lifecycle/spec.rs @@ -331,44 +331,45 @@ pub(crate) fn render_per_slot_table() -> String { out } -/// The machine's advancing edges rendered as a preformatted block, for -/// `slot-lifecycle.md`. The two warp edges are the set-level rules, -/// spelled beside the table-driven ones. +/// A stage as a mermaid state reference: `(absent)` is the start +/// pseudo-state, since a slot's record begins at its first event. +fn stage_ref(state: Option) -> &'static str { + match state { + None => "[*]", + Some(stage) => stage_name(Some(stage)), + } +} + +/// The machine's advancing edges rendered as a mermaid state diagram, +/// for `slot-lifecycle.md`. The two warp edges are the set-level +/// rules, spelled beside the table-driven ones; `(forgotten)` is the +/// end pseudo-state. The fence sits inside `BEGIN MERMAID` markers so +/// the render pipeline can pre-draw it for cargo doc. pub(crate) fn render_diagram() -> String { - let mut out = String::from("```text\n"); + let mut out = + String::from("\n```mermaid\nstateDiagram-v2\n"); for row in PER_SLOT { - if let To(stage) = row.next - && row.state != Some(stage) - { - out.push_str(&format!( - "{:<11} --{}--> {:<10} emits {}\n", - stage_name(row.state), - event_name(row.event), - stage_name(Some(stage)), - row.emits - .iter() - .map(|status| status_name(*status)) - .collect::>() - .join(", "), - )); - } - if row.next == Forgotten { - out.push_str(&format!( - "{:<11} --{}--> {:<10} emits {}\n", - stage_name(row.state), - event_name(row.event), - "(forgotten)", - row.emits - .iter() - .map(|status| status_name(*status)) - .collect::>() - .join(", "), - )); - } + let target = match row.next { + To(stage) if row.state != Some(stage) => stage_name(Some(stage)), + Forgotten => "[*]", + _ => continue, + }; + out.push_str(&format!( + " {} --> {} : {} ({})\n", + stage_ref(row.state), + target, + event_name(row.event), + row.emits + .iter() + .map(|status| status_name(*status)) + .collect::>() + .join(", "), + )); } - out.push_str("Announced --warp away--> (forgotten) emits Dead\n"); - out.push_str("(any stage) --warp back--> (forgotten) emits Dead, every rewritten slot\n"); - out.push_str("```\n"); + out.push_str(" Announced --> [*] : warp away (Dead)\n"); + out.push_str(" state \"any stage\" as any_stage\n"); + out.push_str(" any_stage --> [*] : warp back (Dead, every rewritten slot)\n"); + out.push_str("```\n\n"); out } From ceb3235fbd74cd18715439a2e6275073c6789094 Mon Sep 17 00:00:00 2001 From: cds-amal Date: Thu, 27 Aug 2026 20:15:47 -0400 Subject: [PATCH 8/9] refactor(core): keep slot-status emission private to the SVM - make `emit_slot_statuses` and `warp_slot_lifecycle` private to `svm.rs`. - prevent callers from emitting statuses the lifecycle never produced. - keep announce, warp, and confirmation as the only lifecycle entry points. --- crates/core/src/surfnet/svm.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 0fbe60d9..98de4128 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -3059,7 +3059,7 @@ impl SurfnetSvm { /// millisecond wall-clock sample the ws variants carry; a caller /// emitting several batches for one block passes the same sample to /// all of them. - pub fn emit_slot_statuses(&mut self, emissions: Vec, ws_timestamp: u64) { + fn emit_slot_statuses(&mut self, emissions: Vec, ws_timestamp: u64) { for SlotEmission { slot, parent, @@ -3151,7 +3151,7 @@ impl SurfnetSvm { /// Resolves the slot lifecycle across a clock warp: the slot that was open before /// (`from`) dies unless the warp landed on it, and the new open slot is announced if /// it is not already. - pub fn warp_slot_lifecycle(&mut self, from: Slot) { + fn warp_slot_lifecycle(&mut self, from: Slot) { let to = self.get_latest_absolute_slot(); let emissions = self.slot_lifecycle.warp(from, to); let ws_timestamp = Utc::now().timestamp_millis().max(0) as u64; From 8e8c2e7a3dcc47c70e82f3d8cd4153bf3efe925e Mon Sep 17 00:00:00 2001 From: cds-amal Date: Thu, 27 Aug 2026 20:15:48 -0400 Subject: [PATCH 9/9] test(core): name the failing op in the production-grammar check --- .../core/src/surfnet/slot_lifecycle/reachability_tests.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs b/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs index a6471982..db813dc1 100644 --- a/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs +++ b/crates/core/src/surfnet/slot_lifecycle/reachability_tests.rs @@ -223,7 +223,12 @@ fn production_grammar_keeps_exactly_the_open_slot_announced() { open } }; - check(open, &life, "an op"); + let what = match op { + Op::CloseBlock => "close_block", + Op::Warp(_) => "a warp", + Op::Reset => "a reset", + }; + check(open, &life, what); if seen.insert(snapshot(open, &life)) { queue.push_back((open, life)); }