Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions crates/app/src/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,11 @@ pub fn spawn_registry_bridge(
let mut attached: HashSet<EventId> = 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<EventId> = 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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -699,6 +710,8 @@ pub fn spawn_presence_reconciler(registry: EventRegistry) -> JoinHandle<()> {
let mut attached: HashSet<EventId> = HashSet::new();
let mut ticker = tokio::time::interval(REGISTRY_POLL_INTERVAL);
loop {
let live: HashSet<EventId> = 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;
Expand Down Expand Up @@ -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.
Expand All @@ -748,13 +765,20 @@ 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 {
adapter,
competitor,
} = event
{
if !seen_this_batch.insert((adapter.clone(), competitor.clone())) {
continue;
}
reconcile_seen(
&state, &registry, &pilots, &event_id, &bindings, adapter, competitor,
);
Expand Down
77 changes: 70 additions & 7 deletions crates/server/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}
}
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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).
Expand Down
31 changes: 29 additions & 2 deletions crates/server/src/live_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &current_heat);
let pass_ceiling = current_run_pass_ceiling(events, &current_heat);
let laps = lap_list_marshaled(
window
.iter()
Expand All @@ -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 == &current_heat),
Event::Pass(p) => {
*i < pass_ceiling && p.heat.as_ref().is_none_or(|h| h == &current_heat)
}
_ => true,
}
})
Expand Down Expand Up @@ -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<CompetitorRef> {
let mut lineup = Vec::new();
Expand Down
23 changes: 12 additions & 11 deletions docs/release-audit-2026-07.html
Original file line number Diff line number Diff line change
Expand Up @@ -213,24 +213,25 @@ <h3>4.4 Fuzz verdict</h3>
</p>

<h2>5. Deferred — recorded, deliberately not built</h2>
<p>
<strong>Closed by the release-polish batch (2026-07-04, late):</strong> 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-<em>tone</em> 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.
</p>
<ul>
<li><strong>Failover completeness (dual-timer)</strong>: crossings that land between the
primary's real death and the gate flip are dropped (a standby's passes are discarded, never
buffered/replayed), and a hung-but-open socket defers detection until the ~5s idle probe.
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.</li>
<li><strong>Orphan bridge pruning</strong>: a deleted event's bridge task holds a strong log
handle for the process lifetime — a resource leak, no correctness impact.</li>
<li><strong>Presence-reconciler duplicate bindings</strong> within one poll batch — benign
(same key/value, last-wins fold).</li>
<li><strong>Version-skew rendering</strong>: 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.</li>
<li><strong>StartProcedure form round-trip</strong>: the round form always writes
<code>randomized-delay</code> — 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.</li>




</ul>

<footer class="doc-footer">
Expand Down
5 changes: 4 additions & 1 deletion frontend/apps/rd-console/src/lib/RssiGraph.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -409,14 +409,17 @@

function startPan(e: PointerEvent, ref: CompetitorRef): void {
if (!zoom || zoom.ref !== ref) return;
panning = { ref, lastX: pointerX(e, e.currentTarget as SVGSVGElement), engaged: false };
const x = pointerX(e, e.currentTarget as SVGSVGElement);
if (!Number.isFinite(x)) return; // ditto — never seed a drag from a coordinate-less event
panning = { ref, lastX: x, engaged: false };
}

function movePan(e: PointerEvent, full: { from: number; to: number }): void {
if (!panning || !zoom || zoom.ref !== panning.ref) return;
const svg = e.currentTarget as SVGSVGElement;
const x = pointerX(e, svg);
const dx = x - panning.lastX;
if (!Number.isFinite(dx)) return; // a coordinate-less synthetic event must not corrupt the view
if (!panning.engaged) {
if (Math.abs(dx) < PAN_START_UNITS) return; // a click in progress, not a pan
// A real drag: NOW capture (safe — any click this gesture could produce is a drag end).
Expand Down
25 changes: 18 additions & 7 deletions frontend/apps/rd-console/src/lib/timers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,36 @@
*/
import type { Timer, TimerKind } from '@gridfpv/types';

/** The two selectable kinds in the add/edit dialog (the discriminant tag). */
export type TimerKindTag = 'Mock' | 'Rotorhazard';
/** The two selectable kinds in the add/edit dialog (the discriminant tag). `'Unknown'` is the
* version-skew fallback: a NEWER Director may send a kind this console build doesn't model
* yet, and it must render labeled (not mislabeled as RotorHazard, and never crash on a field
* access) — the timer is still real and still selectable. */
export type TimerKindTag = 'Mock' | 'Rotorhazard' | 'Unknown';

/** Sensible defaults for a fresh **Mock** timer: a handful of laps at a one-minute-ish pace. */
export const DEFAULT_MOCK_LAPS = 3;
export const DEFAULT_MOCK_LAP_MS = 30_000;

/** The discriminant tag of a kind (`'Mock'` | `'Rotorhazard'`). */
export function kindTag(kind: TimerKind): TimerKindTag {
return 'Mock' in kind ? 'Mock' : 'Rotorhazard';
if ('Mock' in kind) return 'Mock';
if ('Rotorhazard' in kind) return 'Rotorhazard';
return 'Unknown';
}

/** The short display label for the kind **badge** (RotorHazard is the brand spelling). */
export function kindLabel(kind: TimerKind): string {
return 'Mock' in kind ? 'Mock' : 'RotorHazard';
if ('Mock' in kind) return 'Mock';
if ('Rotorhazard' in kind) return 'RotorHazard';
// A newer Director's kind: show its discriminant verbatim rather than a wrong brand.
return Object.keys(kind)[0] ?? 'Unknown';
}

/** The Badge `tone` for a kind: Mock is the brand accent; RotorHazard reads as informational. */
export function kindTone(kind: TimerKind): 'accent' | 'info' {
return 'Mock' in kind ? 'accent' : 'info';
export function kindTone(kind: TimerKind): 'accent' | 'info' | 'neutral' {
if ('Mock' in kind) return 'accent';
if ('Rotorhazard' in kind) return 'info';
return 'neutral';
}

/** A one-line summary of a kind's config for the timer row (the sim pace, or the RH url). */
Expand All @@ -37,7 +47,8 @@ export function kindSummary(kind: TimerKind): string {
const lapName = laps === 1 ? 'lap' : 'laps';
return `${laps} ${lapName} · ${(lap_ms / 1000).toFixed(1)}s pace`;
}
return kind.Rotorhazard.url || 'No URL set';
if ('Rotorhazard' in kind) return kind.Rotorhazard.url || 'No URL set';
return 'Unsupported by this console build — update the console';
}

/** Whether a timer is the undeletable built-in Mock (its reserved id). */
Expand Down
25 changes: 24 additions & 1 deletion frontend/apps/rd-console/src/screens/EventRounds.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,12 @@
// bracket level's "winners of Semifinal" seeding to FromRoster — a grace-window tweak destroyed
// the bracket chain. When set, the seeding controls lock and submit round-trips it unchanged.
let editPreservedSeeding = $state<SeedingRule | undefined>(undefined);
// The edited round's stored start procedure, verbatim — same wholesale-replace trap as the
// seeding above: the form models only min/max delay, so rebuilding the procedure from the
// two inputs silently ERASED a configured start `tone` (and would flatten any future
// non-randomized mode back to randomized-delay). Submit spreads this under the form's
// fields, so everything the form doesn't model survives the round trip.
let editPreservedStart = $state<StartProcedure | undefined>(undefined);
// The chosen format's params, as a `key → value` map (Rounds form redesign item 4): every param
// the format declares is shown inline as a proper labeled field, seeded from its schema default
// (or the edited round's stored value). On a format switch the map is re-seeded to the new
Expand Down Expand Up @@ -698,6 +704,7 @@
seedSources = new Set();
seedTopN = 8;
editPreservedSeeding = undefined;
editPreservedStart = undefined;
selectedNodes = new Set();
paramValues = {};
pointsTable = [...DEFAULT_POINTS_TABLE];
Expand Down Expand Up @@ -783,6 +790,7 @@
const stagingTotal = round.staging_timer_secs ?? 300;
stagingMinutes = Math.floor(stagingTotal / 60);
stagingSeconds = stagingTotal % 60;
editPreservedStart = round.start_procedure ?? undefined;
startMinSeconds = msToSeconds(round.start_procedure?.min_delay_ms ?? 2000);
startMaxSeconds = msToSeconds(round.start_procedure?.max_delay_ms ?? 5000);
const grace = round.grace_window;
Expand Down Expand Up @@ -926,9 +934,24 @@
* applies).
*/
function buildStartProcedure(): StartProcedure {
// A mode this form can't model (a future fixed-countdown / external trigger): round-trip
// it VERBATIM — the delay inputs simply don't apply to it.
if (
editPreservedStart &&
(editPreservedStart as { mode: string }).mode !== 'randomized-delay'
) {
return editPreservedStart;
}
const min = secondsToMs(startMinSeconds);
const max = Math.max(min, secondsToMs(startMaxSeconds));
return { mode: 'randomized-delay', min_delay_ms: min, max_delay_ms: max };
// Spread the stored procedure UNDER the form's fields: the `tone` (and any additive future
// field) survives; only what the form actually edits is rewritten.
return {
...editPreservedStart,
mode: 'randomized-delay',
min_delay_ms: min,
max_delay_ms: max
};
}

/** The completion grace window as a bounded `Duration` (seconds → micros). */
Expand Down
Loading