diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 217d635..c27eec7 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -511,6 +511,11 @@ pub fn spawn_registry_bridge( let mut attached: HashSet = HashSet::new(); let mut ticker = tokio::time::interval(REGISTRY_POLL_INTERVAL); loop { + // Prune ids that left the registry: their per-event tasks exit on their own (they + // watch for the event's disappearance), and dropping them here keeps this set from + // growing forever across create/delete cycles. + let live: HashSet = registry.list().into_iter().map(|m| m.id).collect(); + attached.retain(|id| live.contains(id)); for meta in registry.list() { if attached.contains(&meta.id) { continue; @@ -601,6 +606,12 @@ pub(crate) async fn run_bridge( loop { ticker.tick().await; + // A DELETED event ends its bridge: the AppState Arc outlives the registry entry, so + // without this check the orphaned task would poll a dead log forever. + if registry.resolve(&event_id).is_none() { + return; + } + // Reap a finished/cancelled source task so a heat that ran to the end clears the // slot (without it, a re-Start of the same heat would be ignored). A heat with an armed // RotorHazard connection is NOT reaped on the Mock tasks finishing — the live connection @@ -699,6 +710,8 @@ pub fn spawn_presence_reconciler(registry: EventRegistry) -> JoinHandle<()> { let mut attached: HashSet = HashSet::new(); let mut ticker = tokio::time::interval(REGISTRY_POLL_INTERVAL); loop { + let live: HashSet = registry.list().into_iter().map(|m| m.id).collect(); + attached.retain(|id| live.contains(id)); for meta in registry.list() { if attached.contains(&meta.id) { continue; @@ -734,6 +747,10 @@ pub(crate) async fn run_presence_reconciler( let mut ticker = tokio::time::interval(POLL_INTERVAL); loop { ticker.tick().await; + // A DELETED event ends its reconciler (the log Arc alone would keep it alive forever). + if registry.resolve(&event_id).is_none() { + return; + } let new_events = match read_tail(&state, cursor) { Ok(batch) => batch, // A poisoned lock (or a dropped log at shutdown) ends the reconciler cleanly. @@ -748,6 +765,10 @@ pub(crate) async fn run_presence_reconciler( Ok(events) => registrations(events.iter()), Err(_) => continue, }; + // Dedup WITHIN the batch: `bindings` was folded once before the loop, so two + // CompetitorSeen for the same key in one poll batch both read "unbound" and appended + // twice (benign — same key/value, last-wins fold — but noise in the log). + let mut seen_this_batch: HashSet<(AdapterId, CompetitorRef)> = HashSet::new(); for (offset, event) in new_events { cursor = offset + 1; if let Event::CompetitorSeen { @@ -755,6 +776,9 @@ pub(crate) async fn run_presence_reconciler( competitor, } = event { + if !seen_this_batch.insert((adapter.clone(), competitor.clone())) { + continue; + } reconcile_seen( &state, ®istry, &pilots, &event_id, &bindings, adapter, competitor, ); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 8f25454..b2ab139 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1710,9 +1710,13 @@ pub(crate) fn class_window_offsets(events: &[Event], class: &ClassId) -> Vec<(u6 } } // A tagged pass belongs to its stamped heat — in or out by class membership, - // independent of the positional cursor (same rule as `heat_window_offsets`). + // independent of the positional cursor (same rule as `heat_window_offsets`), + // and frozen out once that heat's run is official (the Final freeze). Event::Pass(p) if p.heat.is_some() => { - if p.heat.as_ref().is_some_and(|h| class_heats.contains(h)) { + if p.heat.as_ref().is_some_and(|h| { + class_heats.contains(h) + && offset < crate::live_state::current_run_pass_ceiling(events, h) as u64 + }) { window.push((offset, event.clone())); } } @@ -1921,6 +1925,7 @@ fn now_micros() -> i64 { /// they carry the lineup and the FSM lineage the folds need. pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, Event)> { let run_start = crate::live_state::current_run_start(events, heat) as u64; + let pass_ceiling = crate::live_state::current_run_pass_ceiling(events, heat) as u64; let mut window = Vec::new(); // The offsets already claimed by this window — a target-carrying ruling joins iff its // target is one of them (targets always point backwards, so one forward scan suffices). @@ -1952,11 +1957,17 @@ pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, } // Passes: by their bridge-stamped heat TAG when present (robust against a // heat-span event closing the positional span mid-race — the scheduling-eats-laps - // bug); an untagged (legacy) pass keeps the positional rule. - Event::Pass(p) => match &p.heat { - Some(h) => h == heat && offset >= run_start, - None => active && offset >= run_start, - }, + // bug); an untagged (legacy) pass keeps the positional rule. Either way, a pass + // landing at/after the run's `Finalized` is FROZEN OUT: once a result is official + // it cannot shift under a delayed catch-up pass with no command and no audit + // entry (rulings are unaffected — a Revert re-opens marshaling, not the record). + Event::Pass(p) => { + let before_official = offset < pass_ceiling; + match &p.heat { + Some(h) => h == heat && offset >= run_start && before_official, + None => active && offset >= run_start && before_official, + } + } // Untagged (legacy insertions, registrations, …): positional. _ => active && offset >= run_start, }; @@ -2096,6 +2107,58 @@ mod tests { ] } + /// The FINAL FREEZE: a pass landing AFTER a heat's run went official never joins its + /// window — a delayed RotorHazard catch-up pass (tagged or untagged) used to silently + /// change a Final result with no command, no ruling, and no audit entry. + #[test] + fn a_pass_landing_after_finalized_never_joins_the_official_record() { + let heat = HeatId("q-1".into()); + let mut events = recorded_heat(); + let window_before = heat_window_offsets(&events, &heat); + let passes_before = window_before + .iter() + .filter(|(_, e)| matches!(e, Event::Pass(_))) + .count(); + assert_eq!(passes_before, 5, "the run's real passes all count"); + + // A late catch-up pass TAGGED with the (now Final) heat… + let mut late = Pass { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(9_000_000), + sequence: Some(9), + gate: GateIndex::LAP, + signal: None, + heat: Some(heat.clone()), + }; + events.push(Event::Pass(late.clone())); + // …and an UNTAGGED one (legacy positional attribution would claim it too). + late.heat = None; + late.sequence = Some(10); + events.push(Event::Pass(late)); + + let window_after = heat_window_offsets(&events, &heat); + let passes_after = window_after + .iter() + .filter(|(_, e)| matches!(e, Event::Pass(_))) + .count(); + assert_eq!( + passes_after, passes_before, + "the official record is frozen — late passes stay out" + ); + + // Rulings are NOT frozen (Revert re-opens marshaling): a void targeting a real run + // pass still joins the window. + events.push(Event::DetectionVoided { target: LogRef(6) }); + let with_ruling = heat_window_offsets(&events, &heat); + assert!( + with_ruling + .iter() + .any(|(_, e)| matches!(e, Event::DetectionVoided { .. })), + "rulings keep flowing into the window" + ); + } + /// A registry whose Practice event carries a round with `win_condition` and a heat `q-1` /// tagged with that round, driven Scheduled → Final over the given lap-gate `passes`. Used to /// prove the result projection scores under the heat's round win condition (#45). diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index c1759f2..a4263c7 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -265,6 +265,7 @@ fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState // marshaling adjudication still addresses the right pass. A heat that never reset has no // boundary, so everything counts (a normally-finalized heat is unaffected). let run_start = current_run_start(events, ¤t_heat); + let pass_ceiling = current_run_pass_ceiling(events, ¤t_heat); let laps = lap_list_marshaled( window .iter() @@ -276,9 +277,12 @@ fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState // Tag-aware pass attribution: a pass stamped for ANOTHER heat never counts // toward this one — selecting an older heat as current used to absorb every // later heat's passes (they all sit after its run_start). An untagged - // (legacy) pass keeps the positional rule. + // (legacy) pass keeps the positional rule. Either way a pass landing AFTER + // the run went official is frozen out (the Final freeze). match e { - Event::Pass(p) => p.heat.as_ref().is_none_or(|h| h == ¤t_heat), + Event::Pass(p) => { + *i < pass_ceiling && p.heat.as_ref().is_none_or(|h| h == ¤t_heat) + } _ => true, } }) @@ -604,6 +608,29 @@ pub(crate) fn current_run_start(events: &[Event], heat: &HeatId) -> usize { start } +/// The log index where the current run's **official record closes**: the first `Finalized` +/// transition for `heat` at/after [`current_run_start`], or `usize::MAX` while the run is not +/// (yet) official. Passes at/after this offset NEVER join the heat's window — once a result is +/// official it is frozen against late arrivals (a delayed RotorHazard catch-up pass tagged with +/// the heat used to silently change a Final result with no command, no ruling, and no audit +/// entry). A `Revert` re-opens marshaling (rulings are not passes and stay unaffected), but the +/// late passes stay out: the race's observation record ended with its run. +pub(crate) fn current_run_pass_ceiling(events: &[Event], heat: &HeatId) -> usize { + let run_start = current_run_start(events, heat); + events + .iter() + .enumerate() + .skip(run_start) + .find_map(|(i, e)| match e { + Event::HeatStateChanged { + heat: h, + transition: HeatTransition::Finalized, + } if h == heat => Some(i), + _ => None, + }) + .unwrap_or(usize::MAX) +} + /// The lineup of a heat: the competitors from its most recent `HeatScheduled`. fn lineup_of(events: &[Event], heat: &HeatId) -> Vec { let mut lineup = Vec::new(); diff --git a/docs/release-audit-2026-07.html b/docs/release-audit-2026-07.html index de2da9d..9a44400 100644 --- a/docs/release-audit-2026-07.html +++ b/docs/release-audit-2026-07.html @@ -213,6 +213,14 @@

4.4 Fuzz verdict

5. Deferred — recorded, deliberately not built

+

+ Closed by the release-polish batch (2026-07-04, late): the Final freeze + (a delayed catch-up pass can no longer shift an official result), + version-skew rendering (unknown timer kinds/metrics render labeled instead of a crash / + a DNF-reading dash), the start-tone round-trip (editing any round silently erased + it), orphan bridge/presence tasks (they exit when their event is deleted), and the + presence-batch duplicate bindings. What follows is what genuinely remains. +

  • Failover completeness (dual-timer): crossings that land between the primary's real death and the gate flip are dropped (a standby's passes are discarded, never @@ -220,17 +228,10 @@

    5. Deferred — recorded, deliberately not built

    Real but rare (multi-timer setups), and buffering needs a design of its own — replay semantics, dedup against the primary's already-appended passes. The cheap status-stomp fix (§2) did land.
  • -
  • Orphan bridge pruning: a deleted event's bridge task holds a strong log - handle for the process lifetime — a resource leak, no correctness impact.
  • -
  • Presence-reconciler duplicate bindings within one poll batch — benign - (same key/value, last-wins fold).
  • -
  • Version-skew rendering: an additive server enum variant renders as a - blank metric cell / mislabeled timer kind on a stale console build. Mitigated by the - both-halves deploy rule and the new contract pins; a graceful-unknown rendering pass is - future polish.
  • -
  • StartProcedure form round-trip: the round form always writes - randomized-delay — lossless today (one mode exists), lossy the day a second - mode ships. The wire shape is now contract-pinned so that day is loud, not silent.
  • + + + +