From 8031008eb78a92aea9a2f4ae4080cadee17a2b56 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sun, 5 Jul 2026 00:08:09 +0000 Subject: [PATCH] =?UTF-8?q?fix(rh):=20races=20start=20ON=20Grid's=20go=20?= =?UTF-8?q?=E2=80=94=20effective-format=20zeroing,=20stage=20retry,=20stal?= =?UTF-8?q?e-replay=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field report ('first lap reads 7s but arrived 25s into the race') unwound into three RH-adapter defects, diagnosed by probing the console's actual websocket path against a live rig: - prepare_instant_start zeroed the staging delays of the format that was current AT STAGE TIME — but seating the heat switches RH's effective format to the heat's CLASS format (RHRace's class_format_id override), so the race staged with STOCK multi-second staging tones + delays on top of Grid's own start procedure. Every race began ~5-7s after Grid's go (a DB-bloated RH stretched it past 15s); lap stamps stayed race-relative so results were self-consistent and the skew was invisible everywhere except live: laps, tones, and callouts all arrived that late. The prepare now re-runs right before stage_race, when the effective format is current and RH is READY. Measured on the rig: the websocket-path lap latency fell from a constant ~7.1s to 0.28-0.37s (median 0.32s). - A busy RH ('Attempted to stage race while status is not ready') silently DROPS a stage — the race never starts and no passes record. The driver now re-emits stage_race every 5s inside the settle window until RH confirms RACING (paced above RH's stock staging so a retry can never restart a countdown in progress). - Lap records drained BEFORE this arming's RACING confirmation are a stale replay — a busy RH re-broadcasts the PREVIOUS race's current_laps snapshot, and remapping it contaminated the fresh heat with the last race's laps (observed live: a new heat opening on lap 6+). ArmedHeat now carries a started flag (set on SessionStarted, shared across reconnects so mid-race snapshot replays still dedup) and pre-start passes are dropped. Operational note: a long-lived test rig accumulates RH heats (one per race, 'Heat 166' by tonight) and slows staging/saves — reset the container's DB periodically; the retry + gate make the failure mode safe rather than corrupt. Co-Authored-By: Claude Fable 5 --- crates/app/src/source/rotorhazard.rs | 60 ++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/app/src/source/rotorhazard.rs b/crates/app/src/source/rotorhazard.rs index 507d5e1..69090f0 100644 --- a/crates/app/src/source/rotorhazard.rs +++ b/crates/app/src/source/rotorhazard.rs @@ -65,6 +65,14 @@ const DRAIN_INTERVAL: Duration = Duration::from_millis(100); /// on the wait (the drain loop still runs regardless — this only bounds the staging settle). const STAGE_SETTLE: Duration = Duration::from_secs(15); +/// How often to RE-EMIT `stage_race` while staged-but-not-RACING inside the settle window — a +/// busy RH silently drops a stage ("status is not 'ready'"); without the retry the race never +/// starts on the RH side and no passes are ever recorded. Paced ABOVE RH's stock staging +/// sequence (~3-4s with unzeroed delays) so a retry can never land mid-staging and restart the +/// countdown it is waiting on; with the instant-start prepare applied, staging is sub-second +/// and the retry only ever fires on a genuinely dropped stage. +const STAGE_RETRY_INTERVAL: Duration = Duration::from_secs(5); + /// How long the driver keeps routing drained events into a **finishing** heat's sink after it /// stopped the RH race (marshaling path-2). Stopping the race drives RH to `DONE`, which /// auto-triggers the dense `current_marshal_data` / `save_laps` → `race_list` → `get_pilotrace` @@ -131,6 +139,14 @@ struct ArmedHeat { sink: PassSink, /// Set by the driver once it has staged the RH race for this arming. staged: bool, + /// Set once RH confirmed **RACING for this arming** (its READY/DONE → RACING transition — + /// the adapter's `SessionStarted`). Lap records drained BEFORE this are a stale replay: + /// a busy RH ("stage while status is not 'ready'") re-broadcasts the PREVIOUS race's + /// `current_laps` snapshot, and remapping those into the new heat recorded the last + /// race's laps as this race's passes (observed live: a fresh heat opening with lap 6+). + /// Lives in the shared slot so a mid-race reconnect (where RH re-sends the LIVE race's + /// snapshot for the carried adapter to dedup) keeps flowing. + started: bool, /// Set by [`disarm`](RhConnection::disarm) when the heat left `Running`: the driver finishes the /// heat by **stopping the RH race** (driving it to `DONE`, which auto-triggers the dense /// `current_marshal_data`/`get_pilotrace` marshal pull — marshaling path-2), keeps routing the @@ -297,6 +313,7 @@ impl RhConnection { lineup, sink, staged: false, + started: false, finishing: false, done: false, }); @@ -569,6 +586,9 @@ fn maintain( let mut last_activity = Instant::now(); let mut probed_since_activity = false; let mut stage_deadline: Option = None; + // Paces the busy-RH stage retry (see STAGE_RETRY_INTERVAL); seeded in the past so the + // first retry fires as soon as it is needed. + let mut last_stage_retry = Instant::now() - STAGE_RETRY_INTERVAL; // The settle window for a **finishing** heat (disarmed): the deadline by which the heat's sink // stays armed after the RH race is stopped, so the DONE-triggered dense marshal pull lands in // the right heat's log before the slot clears. `None` ⇒ no heat is finishing. @@ -668,6 +688,19 @@ fn maintain( matches!(slot.as_ref(), Some(heat) if !heat.staged) }; if do_stage { + // Re-zero the staging delays of the format RH will ACTUALLY race, right before the + // stage. The Stage-time prepare targeted the format that was current THEN — but + // seating the heat can switch RH's effective format (a heat with a class races the + // CLASS's format, RHRace's class_format_id override), whose stock multi-second + // staging sequence then ran on top of Grid's start procedure: every race began + // ~5-7s after Grid's go (lap stamps stayed self-consistent, so the skew was + // invisible in results — but live laps, tones, and callouts all arrived that much + // late; a DB-bloated RH stretched it past 15s). By now the seat's race_status has + // long since folded, so `prepare_instant_start` targets the effective format — + // and RH is READY here, which `alter_race_format` requires. Idempotent. + if conn.prepare_instant_start().is_err() { + return true; + } // Drop any churn accumulated since the prepare so it isn't remapped as race passes. let _ = conn.events(); if conn.stage_race().is_err() { @@ -689,6 +722,23 @@ fn maintain( } else if armed.lock().expect("armed-heat lock poisoned").is_none() { // Nothing armed: clear any stale stage wait. stage_deadline = None; + } else if stage_deadline.is_some() { + // Staged but RH has not confirmed RACING yet (no SessionStarted this arming): a + // busy RH — the previous race's dense save still settling — logs "Attempted to + // stage race while status is not 'ready'" and DROPS the stage on the floor. The + // race would then never start (and, before the pass gate above, the replayed old + // snapshot contaminated the new heat). Re-emit the stage every couple of seconds + // until RH takes it or the settle window gives up. + let needs_restage = { + let slot = armed.lock().expect("armed-heat lock poisoned"); + matches!(slot.as_ref(), Some(heat) if heat.staged && !heat.started) + }; + if needs_restage && last_stage_retry.elapsed() >= STAGE_RETRY_INTERVAL { + last_stage_retry = Instant::now(); + if conn.stage_race().is_err() { + return true; + } + } } if just_staged { last_activity = Instant::now(); @@ -796,6 +846,16 @@ fn maintain( for event in drained { if matches!(event, Event::SessionStarted { .. }) { saw_start = true; + heat.started = true; + } + // Gate LAP RECORDS on RH having gone RACING for THIS arming: anything + // earlier is the previous race's snapshot replayed by a still-busy RH — + // remapping it used to contaminate the fresh heat with the last race's + // laps. (Processed in drain order, so passes in the same batch AFTER the + // RACING transition flow normally; signal facts always flow — a pre-start + // trace baseline is harmless and useful.) + if matches!(event, Event::Pass(_)) && !heat.started { + continue; } if let Some(remapped) = remap(event, &heat.lineup, &adapter) { if heat.sink.append_event(remapped).is_err() {