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
264 changes: 263 additions & 1 deletion crates/app/src/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,7 @@ fn seats_of(state: &AppState, registry: &EventRegistry, heat: &HeatId) -> Vec<(u

use gridfpv_engine::heat::{GraceWindow, ProtestWindow};
use gridfpv_engine::scoring::race_end_reached;
use gridfpv_server::control_handler::open_protest_count;
use gridfpv_server::events::{RoundDef, StartProcedure};

/// How often the completion driver re-evaluates the win condition over the running passes.
Expand Down Expand Up @@ -1592,7 +1593,18 @@ fn now_micros() -> i64 {
/// console can render a live "auto-official in M:SS" countdown and a replay reads the same
/// deadline (mirroring how the start driver logs `HeatStarting`);
/// 2. holds `micros` of real time;
/// 3. appends the auto `HeatStateChanged { Finalized }` (the `Unofficial → Final` step).
/// 3. appends the auto `HeatStateChanged { Finalized }` (the `Unofficial → Final` step) —
/// **unless the heat has an open protest** (issue #338, below).
///
/// **The open-protest gate (issue #338).** The manual `Finalize` command is gated on open protests
/// (release-hardening P1-4): a filed, unresolved protest means the result is still contested. The
/// auto-official append does not run through the command handler, so it checks the *same shared
/// predicate* ([`open_protest_count`]) at window expiry. If protests are open when the window
/// elapses, the driver **stands down**: it appends nothing and leaves the heat `Unofficial` for
/// the RD. There is deliberately **no retry**: a protest pulls a human into the loop, and the RD's
/// follow-up may be several rulings (resolve, then a penalty, then finalize) — an auto-finalize
/// firing at the surprising instant the last protest resolves could race those. The RD's manual
/// `Finalize` (one click, re-checked by the same gate on the command path) closes the heat.
///
/// The returned task is cancelled by the bridge (`cancel_for`) the moment the heat leaves
/// `Unofficial` — a manual early `Finalize`, a `Revert`, an abort/restart — so a superseded window
Expand Down Expand Up @@ -1628,6 +1640,39 @@ fn spawn_auto_official_driver(
return;
}
tokio::time::sleep(hold).await;
// The expired window must not finalize over an **open protest** (issue #338): check the
// same predicate the manual `Finalize` command is gated on (P1-4). A protest filed during
// (or before) the window means the result is still contested — stand down and leave the
// heat `Unofficial` for the RD (see the doc comment for why there is no retry). A log read
// failure also stands down: fail closed, never finalize blind.
let open = state
.log()
.lock()
.ok()
.and_then(|g| g.read_all().ok())
.map(|stored| {
let events: Vec<Event> = stored.into_iter().map(|s| s.event).collect();
open_protest_count(&events, &heat)
});
match open {
Some(0) => {}
Some(n) => {
eprintln!(
"gridfpv: auto-official window for heat {:?} expired with {n} open protest(s); \
leaving it Unofficial for the RD to resolve and finalize",
heat.0
);
return;
}
None => {
eprintln!(
"gridfpv: auto-official driver could not read the log for heat {:?}; \
leaving it Unofficial",
heat.0
);
return;
}
}
// Auto-finalize Unofficial → Final. If the heat already left Unofficial (a manual early
// Finalize, a Revert, an abort), this task has been cancelled by the bridge and never reaches
// here — so the auto-finalize never fights a manual action.
Expand Down Expand Up @@ -2445,6 +2490,223 @@ mod tests {
bridge.abort();
}

// --- issue #338: the auto-official driver respects the open-protest gate ---------------------

/// Add a normal scored round (`timed_qual`) with an **armed protest window** to Practice and
/// return its `RoundId` — the config the auto-official driver reads (`ProtestWindow::After` ⇒
/// auto-finalize once the window elapses). Uses the registry's `add_round` so the bridge
/// resolves the round through `rounds_of` exactly as in production.
fn add_protest_window_round(registry: &EventRegistry, window_micros: i64) -> RoundId {
use gridfpv_engine::scoring::WinCondition;
use gridfpv_server::events::{NewRoundReq, SeedingRule};
use gridfpv_server::scope::EventId as ScopeEventId;
let req = NewRoundReq {
label: "Qualifying".into(),
classes: vec![],
format: "timed_qual".into(),
params: std::collections::BTreeMap::new(),
win_condition: Some(WinCondition::Timed {
window_micros: 120_000_000,
}),
time_limit_secs: None,
seeding: SeedingRule::FromRoster,
channel_mode: None,
staging_timer_secs: None,
start_procedure: None,
grace_window: None,
protest_window: Some(ProtestWindow::After {
micros: window_micros,
}),
};
registry
.add_round(&ScopeEventId(PRACTICE_EVENT_ID.to_string()), req)
.expect("protest-window round added")
.id
}

/// Schedule a heat tagged with `round` and end its race directly (`Finished` lands it in
/// `Unofficial`) — the transition the bridge observes to arm the auto-official driver.
fn finish_round_heat(state: &AppState, round: &RoundId) -> HeatId {
let heat = HeatId("q-1".into());
state
.append(
Event::HeatScheduled {
heat: heat.clone(),
lineup: vec![CompetitorRef("A".into())],
class: None,
round: Some(round.clone()),
frequencies: vec![],
label: None,
},
None,
)
.unwrap();
state
.append(
Event::HeatStateChanged {
heat: heat.clone(),
transition: HeatTransition::Finished,
},
None,
)
.unwrap();
heat
}

/// Whether the log carries the auto (or manual) `Finalized` transition for `heat`.
fn finalized_in(events: &[Event], heat: &HeatId) -> bool {
events.iter().any(|e| {
matches!(
e,
Event::HeatStateChanged {
heat: h,
transition: HeatTransition::Finalized,
} if h == heat
)
})
}

#[tokio::test]
async fn auto_official_finalizes_after_the_window_with_no_protests() {
// The happy path (marshaling Slice 5): a round with a protest window auto-finalizes its
// Unofficial heat once the window elapses — the driver logs the `HeatFinalizing` deadline,
// holds the window, and appends the `Finalized` transition, with no protest on file.
let registry = fast_registry(3, 1);
let round = add_protest_window_round(&registry, 200_000); // a 0.2 s window
let (bridge, state) = spawn_bridge_for(&registry);

let heat = finish_round_heat(&state, &round);

let target = heat.clone();
timeout(
Duration::from_secs(5),
wait_until(&state, Duration::from_secs(5), move |events| {
finalized_in(events, &target)
}),
)
.await
.expect("the auto-official driver should finalize once the window elapses");

let events = read_all_events(&state);
assert!(
events
.iter()
.any(|e| matches!(e, Event::HeatFinalizing { heat: h, .. } if *h == heat)),
"the driver logs the deadline fact before the hold"
);
assert_eq!(
gridfpv_engine::heat::heat_state(&events, &heat),
Some(gridfpv_engine::heat::HeatState::Final),
"the heat folds to Final after the auto-official append"
);
bridge.abort();
}

#[tokio::test]
async fn auto_official_stands_down_on_an_open_protest_until_resolved() {
// Issue #338: the protest window expiring must NOT finalize over an OPEN protest — the
// same gate the manual `Finalize` command enforces (P1-4). The driver stands down and
// leaves the heat Unofficial; resolving the protest then lets the RD's manual `Finalize`
// (the chosen behavior — no auto-retry) close the heat.
use gridfpv_events::{LogRef, ProtestOutcome};
use gridfpv_server::control::Command;
use gridfpv_server::control_handler::apply_command;

let registry = fast_registry(3, 1);
let round = add_protest_window_round(&registry, 200_000); // a 0.2 s window
let (bridge, state) = spawn_bridge_for(&registry);

// File the protest BEFORE the race ends, so it is open for the whole window — no timing
// race between the filing and the driver's expiry check.
let heat = HeatId("q-1".into());
state
.append(
Event::HeatScheduled {
heat: heat.clone(),
lineup: vec![CompetitorRef("A".into())],
class: None,
round: Some(round.clone()),
frequencies: vec![],
label: None,
},
None,
)
.unwrap();
let filed = state
.append(
Event::ProtestFiled {
heat: heat.clone(),
competitor: CompetitorRef("A".into()),
note: "contested line cut".into(),
},
None,
)
.unwrap();
state
.append(
Event::HeatStateChanged {
heat: heat.clone(),
transition: HeatTransition::Finished,
},
None,
)
.unwrap();

// The driver still arms (it logs the deadline — the console countdown runs as usual)...
let target = heat.clone();
timeout(
Duration::from_secs(5),
wait_until(&state, Duration::from_secs(5), move |events| {
events
.iter()
.any(|e| matches!(e, Event::HeatFinalizing { heat: h, .. } if *h == target))
}),
)
.await
.expect("the driver logs the deadline even with a protest on file");

// ...but well past the window (bridge poll + 0.2 s hold + slack) it has appended NO
// `Finalized`: the heat stays Unofficial for the RD.
sleep(Duration::from_millis(800)).await;
let events = read_all_events(&state);
assert!(
!finalized_in(&events, &heat),
"the expired window must not finalize over an open protest"
);
assert_eq!(
gridfpv_engine::heat::heat_state(&events, &heat),
Some(gridfpv_engine::heat::HeatState::Unofficial),
"the heat is left Unofficial for the RD"
);

// The manual path agrees while the protest is open (the shared predicate)...
let ack = apply_command(&state, Command::Finalize { heat: heat.clone() });
assert!(
!ack.ok,
"manual Finalize is blocked by the same open-protest gate"
);

// ...and resolving the protest unblocks the RD's manual Finalize (the chosen behavior:
// once a protest pulled a human into the loop, closing the heat is the RD's click).
state
.append(
Event::ProtestResolved {
target: LogRef(filed),
outcome: ProtestOutcome::Denied,
},
None,
)
.unwrap();
let ack = apply_command(&state, Command::Finalize { heat: heat.clone() });
assert!(ack.ok, "Finalize succeeds once the protest is resolved");
assert_eq!(
gridfpv_engine::heat::heat_state(&read_all_events(&state), &heat),
Some(gridfpv_engine::heat::HeatState::Final),
"the resolved-then-finalized heat folds to Final"
);
bridge.abort();
}

#[test]
fn source_config_defaults_to_sim_and_describes_itself() {
// No env reliance: build a sim config directly and confirm the banner text.
Expand Down
12 changes: 9 additions & 3 deletions crates/server/src/control_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,8 @@ fn command_to_event(state: &AppState, command: Command) -> Result<Event, Protoco
// Gate finalize on **open protests** (release-hardening P1-4): a heat with a filed,
// unresolved protest must not be finalized — the result is still contested. The RD
// resolves (or reverses a resolution of) the protest first. The auto-official protest
// window is a separate, additive timer and does not run through this command path.
// window appends its `Finalized` directly (not through this command path) but checks
// this same `open_protest_count` predicate before doing so (issue #338).
let (events, _cursor) = state.read()?;
let open = open_protest_count(&events, &heat);
if open > 0 {
Expand Down Expand Up @@ -1023,8 +1024,13 @@ fn require_ruling_target(state: &AppState, target: LogRef) -> Result<(), Protoco
/// A protest (filed at offset `f`) is closed by a [`Event::ProtestResolved`] whose `target` is `f`
/// — **unless** that resolution was itself reversed by a [`Event::RulingReversed`] (the structural
/// "void the void"), which re-opens the protest. So the open set is: filed-for-this-heat minus the
/// filings that carry a non-reversed resolution. Used to gate `Finalize`.
fn open_protest_count(events: &[Event], heat: &HeatId) -> usize {
/// filings that carry a non-reversed resolution.
///
/// This is **the** open-protest predicate: it gates the manual [`Command::Finalize`] here *and*
/// the runtime's auto-official driver (`spawn_auto_official_driver` in the app crate, issue #338)
/// — both finalize paths must agree on what "still contested" means, so the definition lives in
/// exactly one place. `pub` for that reuse.
pub fn open_protest_count(events: &[Event], heat: &HeatId) -> usize {
use std::collections::HashSet;
// Ruling offsets reversed by a `RulingReversed` (a reversed protest-resolution re-opens it).
let reversed: HashSet<u64> = events
Expand Down