From 9d8f1ee2ed2f4b8f2b17b781097242fa1496c14d Mon Sep 17 00:00:00 2001 From: Sebastian Nagel Date: Sat, 29 Aug 2026 23:16:20 +0200 Subject: [PATCH 1/2] LeiosNotify: a past-horizon announcement is not the peer's fault 'announcementValidity' reuses ChainSync's in-future check to date an announced EB, and threw whatever it returned. A syncing node cannot place the network's current slot on a wall clock, so every announcement from a caught-up peer raised 'PastHorizon', killed the per-peer handler and shut the peer down. With no peer surviving to serve headers, ChainSync stopped, no announcements arrived via rollforward, and chain selection froze until the node was restarted -- 148 such disconnects in one 29-minute stall. The stock ChainSync client calls the same 'judgeHeaderArrival' and reads 'PastHorizon' as "cannot judge yet", blocking for a newer ledger state. Only the Leios copy turned it into a disconnect. So the verdict is now 'VerdictIgnore', the path 'StaleOCIN' already takes: accepted from the peer, neither processed nor relayed. A /far-future/ slot is a different matter -- that one is the peer lying, and 'handleHeaderArrival' still disconnects them. 'TraceLeiosAnnouncementPastHorizon' records the slot, and only the slot: a 'PastHorizonException' renders as a couple of kilobytes of era summary, and this fires once per announcement. Co-Authored-By: Claude Opus 5 (1M context) --- .../Ouroboros/Consensus/Network/NodeToNode.hs | 1 + .../src/ouroboros-consensus/LeiosDemoLogic.hs | 74 +++++++++++-------- .../src/ouroboros-consensus/LeiosDemoTypes.hs | 9 +++ 3 files changed, 54 insertions(+), 30 deletions(-) diff --git a/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Network/NodeToNode.hs b/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Network/NodeToNode.hs index 58a4b7c127..60f4cf202f 100644 --- a/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Network/NodeToNode.hs +++ b/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Network/NodeToNode.hs @@ -516,6 +516,7 @@ mkHandlers Leios.ancElId ( \ancH -> Leios.announcementValidity + kernelTracer systemTime chainSyncFutureCheck getTopLevelConfig diff --git a/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs b/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs index 45f3786a68..7376ccb6b8 100644 --- a/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs +++ b/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs @@ -127,6 +127,9 @@ import Ouroboros.Consensus.BlockchainTime.WallClock.Types , systemTimeCurrent ) import Ouroboros.Consensus.Config (TopLevelConfig, configLedger) +import Ouroboros.Consensus.HardFork.History + ( PastHorizonException (PastHorizon) + ) import Ouroboros.Consensus.Ledger.Abstract (getTipSlot) import Ouroboros.Consensus.Ledger.Basics (EmptyMK) import Ouroboros.Consensus.Ledger.Extended (ExtLedgerState, ledgerState) @@ -1629,6 +1632,11 @@ instance Exception ExnLeiosBlockAnnouncementMissing -- Chronos) — blocking the per-peer handler is acceptable, as a (near-)future -- announcement is the peer's fault. -- +-- A slot past the forecast horizon is /not/ the peer's fault, so it must not +-- disconnect them: it means our own ledger is too far behind to date the +-- announcement, which is the ordinary state of affairs while syncing. The +-- verdict is 'VerdictIgnore' and 'TraceLeiosAnnouncementPastHorizon' records it. +-- -- If the announcement is valid and 'FreshOCIN', the verdict carries its data and -- whether to relay it downstream (see 'ShouldRelay' and -- 'maxAnnouncementAgeSend'). If it is valid but 'StaleOCIN' (its opcert counter @@ -1638,6 +1646,7 @@ instance Exception ExnLeiosBlockAnnouncementMissing -- relaying it. announcementValidity :: (IOLike m, LedgerSupportsProtocol blk, ResolveLeiosBlock blk) => + Tracer m TraceLeiosKernel -> SystemTime m -> InFutureCheck.SomeHeaderInFutureCheck m blk -> TopLevelConfig blk -> @@ -1648,38 +1657,43 @@ announcementValidity :: (AnnouncementInvalidity blk) (ShouldRelay, RelativeTime, NominalDiffTime, (LeiosPoint, BytesSize)) ) -announcementValidity systemTime futureCheck cfg immLedger hdr = do - onset <- case futureCheck of +announcementValidity tracer systemTime futureCheck cfg immLedger hdr = do + mbOnset <- case futureCheck of InFutureCheck.SomeHeaderInFutureCheck hifc -> do arrival <- InFutureCheck.recordHeaderArrival hifc hdr - judgment <- - either throwIO pure $ - runExcept $ - InFutureCheck.judgeHeaderArrival - hifc - (configLedger cfg) - (ledgerState immLedger) - arrival - arrivalResult <- InFutureCheck.handleHeaderArrival hifc judgment - either throwIO pure (runExcept arrivalResult) - -- The in-future check has delayed this thread until 'onset' if the - -- slot was near-future, so 'now' is at or after 'onset' and the age - -- is non-negative. - now <- systemTimeCurrent systemTime - let age = diffRelTime now onset - pure $ - -- Only this function holds the wall clock, so it owns the too-old check. - if age > maxAnnouncementAgeRecv - then VerdictTooOld - else - let shouldRelay = - if age <= maxAnnouncementAgeSend - then DoRelay - else DoNotRelay - in case validateAnnouncementHeader cfg immLedger hdr of - Left inv -> VerdictInvalid inv - Right (StaleOCIN, _v) -> VerdictIgnore - Right (FreshOCIN, v) -> VerdictProcess (shouldRelay, onset, age, v) + case runExcept $ + InFutureCheck.judgeHeaderArrival + hifc + (configLedger cfg) + (ledgerState immLedger) + arrival of + Left PastHorizon{} -> do + traceWith tracer $ TraceLeiosAnnouncementPastHorizon (blockSlot hdr) + pure Nothing + Right judgment -> do + arrivalResult <- InFutureCheck.handleHeaderArrival hifc judgment + Just <$> either throwIO pure (runExcept arrivalResult) + case mbOnset of + Nothing -> pure VerdictIgnore + Just onset -> do + -- The in-future check has delayed this thread until 'onset' if the + -- slot was near-future, so 'now' is at or after 'onset' and the age + -- is non-negative. + now <- systemTimeCurrent systemTime + let age = diffRelTime now onset + pure $ + -- Only this function holds the wall clock, so it owns the too-old check. + if age > maxAnnouncementAgeRecv + then VerdictTooOld + else + let shouldRelay = + if age <= maxAnnouncementAgeSend + then DoRelay + else DoNotRelay + in case validateAnnouncementHeader cfg immLedger hdr of + Left inv -> VerdictInvalid inv + Right (StaleOCIN, _v) -> VerdictIgnore + Right (FreshOCIN, v) -> VerdictProcess (shouldRelay, onset, age, v) -- | Record a validated, newly-announced EB body as missing, unless its already -- pruned\/tracked\/acquired diff --git a/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoTypes.hs b/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoTypes.hs index e3e1a07e4c..15f41b5c6f 100644 --- a/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoTypes.hs +++ b/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoTypes.hs @@ -1386,6 +1386,10 @@ data TraceLeiosKernel | TraceLeiosDb TraceLeiosDb | -- | A forged RB both certifies an EB and announce a new one TraceLeiosCertifiedAndAnnounced {atSlot :: SlotNo, rbHash :: RbHash} + | -- | An EB announcement whose slot our ledger cannot place on the wall clock, + -- because it is beyond the forecast horizon. Ordinary while syncing: the + -- announcement describes the network's tip and we are far behind it. + TraceLeiosAnnouncementPastHorizon {atSlot :: SlotNo} | -- | The node accepted a new EB announcement, deduplicated across all peers -- and its own block forging (see 'AnnouncementSource'). TraceLeiosAnnouncementAccepted @@ -1681,6 +1685,11 @@ traceLeiosKernelToObject = \case , "slotNo" .= slotNo , "rbHash" .= prettyRbHash rbHash ] + TraceLeiosAnnouncementPastHorizon{atSlot} -> + mconcat + [ "kind" .= Aeson.String "LeiosAnnouncementPastHorizon" + , "slotNo" .= atSlot + ] TraceLeiosAnnouncementAccepted announcementSource equivocation acc mbAge -> mconcat $ [ "kind" .= Aeson.String "LeiosAnnouncementAccepted" From 65f9b16b514d6332ec1ec2e9ea69e2db0129f4d4 Mon Sep 17 00:00:00 2001 From: Sebastian Nagel Date: Sat, 29 Aug 2026 23:16:50 +0200 Subject: [PATCH 2/2] LeiosNotify: date announcements against the volatile tip The in-future check was judging against the immutable tip, whose forecast horizon early in a sync is tiny: the summary in one PastHorizon carried six eras collapsed at slot 0 and the last ending at slot 21600, against an announced slot of 1971193. Nearly every announcement was undatable, and the preceding commit could only ignore them. The volatile tip forecasts as far as we have any right to, so the same announcements become datable and are accepted instead. On the testnet that is the difference between ~150 announcements per run reaching a verdict of 'VerdictIgnore' and 1126 reaching 'VerdictProcess', with a residue of 4 -- all at the true chain tip, which a node 1.4M slots behind genuinely cannot place. 'validateAnnouncementHeader' keeps the immutable tip: an opcert revocation should only count once it cannot be rolled back. The two states are read in one 'atomically' so they cannot describe different chains, and the signature now names which is which. Co-Authored-By: Claude Opus 5 (1M context) --- .../Ouroboros/Consensus/Network/NodeToNode.hs | 11 ++++++++++- .../src/ouroboros-consensus/LeiosDemoLogic.hs | 12 ++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Network/NodeToNode.hs b/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Network/NodeToNode.hs index 60f4cf202f..939107cd68 100644 --- a/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Network/NodeToNode.hs +++ b/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Network/NodeToNode.hs @@ -507,7 +507,15 @@ mkHandlers anc <- case Leios.mkAnnouncingHeader hdr of Nothing -> throwIO Leios.ExnLeiosBlockAnnouncementMissing Just x -> pure x - immLedger <- atomically $ ChainDB.getImmutableLedger getChainDB + -- The volatile tip dates the announcement (longest forecast + -- horizon), the immutable tip judges OCIN revocation (only + -- a state that cannot roll back may revoke). Read together + -- so the two cannot disagree about the same chain. + (volLedger, immLedger) <- + atomically $ + (,) + <$> ChainDB.getCurrentLedger getChainDB + <*> ChainDB.getImmutableLedger getChainDB (latestPruneSlot, peerSt0) <- Prim.readMutVar peerStateVar res <- runExceptT $ @@ -520,6 +528,7 @@ mkHandlers systemTime chainSyncFutureCheck getTopLevelConfig + volLedger immLedger (Leios.ancHeader ancH) ) diff --git a/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs b/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs index 7376ccb6b8..efc79298b9 100644 --- a/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs +++ b/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs @@ -1636,6 +1636,9 @@ instance Exception ExnLeiosBlockAnnouncementMissing -- disconnect them: it means our own ledger is too far behind to date the -- announcement, which is the ordinary state of affairs while syncing. The -- verdict is 'VerdictIgnore' and 'TraceLeiosAnnouncementPastHorizon' records it. +-- The horizon comes from the volatile tip rather than the immutable one for the +-- same reason — it is the longest forecast we have, and every slot it buys is a +-- peer we do not drop. -- -- If the announcement is valid and 'FreshOCIN', the verdict carries its data and -- whether to relay it downstream (see 'ShouldRelay' and @@ -1650,6 +1653,11 @@ announcementValidity :: SystemTime m -> InFutureCheck.SomeHeaderInFutureCheck m blk -> TopLevelConfig blk -> + -- | The volatile tip's ledger state, for the in-future check: it forecasts + -- furthest, and a shorter horizon costs us peers. + ExtLedgerState blk EmptyMK -> + -- | The immutable tip's ledger state, for the OCIN revocation check: a + -- revocation only counts once it cannot be rolled back. ExtLedgerState blk EmptyMK -> Header blk -> m @@ -1657,7 +1665,7 @@ announcementValidity :: (AnnouncementInvalidity blk) (ShouldRelay, RelativeTime, NominalDiffTime, (LeiosPoint, BytesSize)) ) -announcementValidity tracer systemTime futureCheck cfg immLedger hdr = do +announcementValidity tracer systemTime futureCheck cfg volLedger immLedger hdr = do mbOnset <- case futureCheck of InFutureCheck.SomeHeaderInFutureCheck hifc -> do arrival <- InFutureCheck.recordHeaderArrival hifc hdr @@ -1665,7 +1673,7 @@ announcementValidity tracer systemTime futureCheck cfg immLedger hdr = do InFutureCheck.judgeHeaderArrival hifc (configLedger cfg) - (ledgerState immLedger) + (ledgerState volLedger) arrival of Left PastHorizon{} -> do traceWith tracer $ TraceLeiosAnnouncementPastHorizon (blockSlot hdr)