From eb7fd82ad4346b926fd5ee238cb8856180b911ff Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Mon, 27 Jul 2026 00:30:59 +0200 Subject: [PATCH] fix(live-atc): arm the silence timer on readback states too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timer was only armed for states carrying auto_advance_on_silence. Readback states carry none — they wait for the pilot — so nothing was armed and ATC never asked again when the readback did not arrive. Which window applies to a state is now a pure function: the flow-authored one for an auto-advance state, the much shorter server-published one for a readback state. That also makes both cases testable instead of inline in the composable. --- app/composables/useLiveAtcSession.ts | 27 ++++++----- shared/utils/silenceTimer.ts | 60 ++++++++++++++++++++++++ tests/shared/silenceTimer.test.ts | 70 ++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 shared/utils/silenceTimer.ts create mode 100644 tests/shared/silenceTimer.test.ts diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index f1c2cb3..6cf7b62 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -9,6 +9,7 @@ import type { useRadioSpeech } from '~/composables/useRadioSpeech' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' import { generateGermanRegistration } from '../../shared/utils/registration' import { gateTransmission } from '../../shared/utils/transmissionGate' +import { silenceWindowFor } from '../../shared/utils/silenceTimer' import { isSimControlMatch, isSimControlRejection, @@ -100,14 +101,16 @@ export function useLiveAtcSession( const transmitInFlightCount = ref(0) const transmitInFlight = computed(() => transmitInFlightCount.value > 0) - // --- Silence auto-advance ---------------------------------------------------- - // Some pilot states let ATC continue on its own when the pilot stays quiet - // (e.g. the takeoff roll in tower-v1: no report needed, Tower hands off after a - // while anyway). Such states carry auto_advance_on_silence + - // auto_advance_timeout_ms in the runtime tree; whenever the session lands on - // one, arm a timer that fires the backend /timeout endpoint. Any pilot - // transmission or telemetry-fired advance re-arms or clears it via - // applyBackendDecision. + // --- Silence timers ---------------------------------------------------------- + // Two kinds of wait fire the backend /timeout endpoint, both armed here (see + // silenceWindowFor for which applies where): + // auto-advance — the pilot owes nothing and ATC carries on by itself (the + // takeoff roll in tower-v1), on the flow-authored window. + // readback — the pilot owes a mandatory readback and has not given it, + // so ATC asks again on the much shorter server-published window. + // Any pilot transmission or telemetry-fired advance re-arms or clears the + // timer via applyBackendDecision, and a fire is dropped if the session or the + // state moved on while it was pending. let silenceTimer: ReturnType | null = null // Consecutive silence-timeouts fired for the SAME state. If the backend keeps @@ -132,7 +135,9 @@ export function useLiveAtcSession( function armSilenceTimer() { clearSilenceTimer() const state = currentState.value as any - if (!state?.auto_advance_on_silence || !backendSessionId.value) return + if (!backendSessionId.value) return + const window = silenceWindowFor(state) + if (!window) return if (silenceFireCount.stateId !== state.id) { silenceFireCount = { stateId: state.id, count: 0 } @@ -142,10 +147,10 @@ export function useLiveAtcSession( return } - const ms = Math.max(1000, Number(state.auto_advance_timeout_ms ?? 30000)) + const ms = window.ms const sessionAtArm = backendSessionId.value const stateAtArm = state.id - pmLog.debug('SILENCE TIMER armed', { state: stateAtArm, ms }) + pmLog.debug('SILENCE TIMER armed', { state: stateAtArm, ms, kind: window.kind }) const fire = async () => { silenceTimer = null // Never fire stale: the session ended or the state moved on while waiting. diff --git a/shared/utils/silenceTimer.ts b/shared/utils/silenceTimer.ts new file mode 100644 index 0000000..6fbb62a --- /dev/null +++ b/shared/utils/silenceTimer.ts @@ -0,0 +1,60 @@ +/** + * Which silence timer, if any, applies to the state the session is resting on. + * + * Two different waits look the same from the outside but mean opposite things: + * + * - **auto-advance** — the pilot owes nothing. The takeoff roll, a climb to the + * cleared level: ATC carries on by itself once enough time has passed, and the + * window is long because nothing is wrong. + * + * - **readback** — the pilot owes a mandatory readback and has not given it. A + * controller chases that within seconds, so the window is short. These states + * carry no `auto_advance_timeout_ms` (they wait for the pilot rather than + * advancing on their own), which is why the timer was never armed for them at + * all and the re-request never fired. + */ + +export type SilenceTimerKind = 'auto_advance' | 'readback' + +export interface SilenceTimerState { + auto_advance_on_silence?: boolean + auto_advance_timeout_ms?: number + readback_required?: string[] + /** Server-side policy, published on readback states by the runtime tree. */ + readback_silence_ms?: number +} + +export interface SilenceTimerWindow { + kind: SilenceTimerKind + ms: number +} + +/** Never fire faster than this, whatever the flow or server says. */ +const MIN_WINDOW_MS = 1000 + +export const DEFAULT_AUTO_ADVANCE_MS = 30_000 +/** Only used when the backend published no window (older server). */ +export const DEFAULT_READBACK_SILENCE_MS = 12_000 + +export function silenceWindowFor( + state: SilenceTimerState | null | undefined, +): SilenceTimerWindow | null { + if (!state) return null + + // Auto-advance wins: a state carrying it has an explicit flow-authored window. + if (state.auto_advance_on_silence) { + return { + kind: 'auto_advance', + ms: Math.max(MIN_WINDOW_MS, Number(state.auto_advance_timeout_ms ?? DEFAULT_AUTO_ADVANCE_MS)), + } + } + + if (state.readback_required?.length) { + return { + kind: 'readback', + ms: Math.max(MIN_WINDOW_MS, Number(state.readback_silence_ms ?? DEFAULT_READBACK_SILENCE_MS)), + } + } + + return null +} diff --git a/tests/shared/silenceTimer.test.ts b/tests/shared/silenceTimer.test.ts new file mode 100644 index 0000000..71408bd --- /dev/null +++ b/tests/shared/silenceTimer.test.ts @@ -0,0 +1,70 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { + DEFAULT_READBACK_SILENCE_MS, + silenceWindowFor, +} from '~~/shared/utils/silenceTimer' + +describe('silenceWindowFor', () => { + it('arms the flow-authored window on an auto-advance state', () => { + const window = silenceWindowFor({ + auto_advance_on_silence: true, + auto_advance_timeout_ms: 45000, + }) + assert.deepEqual(window, { kind: 'auto_advance', ms: 45000 }) + }) + + // The bug: a readback state carries no auto_advance_timeout_ms, so nothing + // was ever armed and ATC never asked again when the readback did not come. + it('arms a readback state, which has no auto-advance window of its own', () => { + const window = silenceWindowFor({ + readback_required: ['squawk', 'sid'], + readback_silence_ms: 12000, + }) + assert.deepEqual(window, { kind: 'readback', ms: 12000 }) + }) + + it('chases a missing readback far sooner than an auto-advance', () => { + const readback = silenceWindowFor({ + readback_required: ['runway'], + readback_silence_ms: 12000, + })! + const autoAdvance = silenceWindowFor({ + auto_advance_on_silence: true, + auto_advance_timeout_ms: 45000, + })! + assert.ok(readback.ms < autoAdvance.ms) + assert.ok(readback.ms >= 10_000 && readback.ms <= 15_000, `${readback.ms}ms`) + }) + + it('falls back to the built-in window when the server published none', () => { + const window = silenceWindowFor({ readback_required: ['runway'] }) + assert.equal(window?.ms, DEFAULT_READBACK_SILENCE_MS) + }) + + it('arms nothing where the pilot holds the next transmission', () => { + assert.equal(silenceWindowFor({}), null) + assert.equal(silenceWindowFor({ readback_required: [] }), null) + assert.equal(silenceWindowFor(null), null) + assert.equal(silenceWindowFor(undefined), null) + }) + + it('prefers the auto-advance window when a state somehow has both', () => { + const window = silenceWindowFor({ + auto_advance_on_silence: true, + auto_advance_timeout_ms: 45000, + readback_required: ['runway'], + readback_silence_ms: 12000, + }) + assert.equal(window?.kind, 'auto_advance') + }) + + it('never arms a timer that would fire immediately', () => { + assert.equal(silenceWindowFor({ readback_required: ['x'], readback_silence_ms: 0 })?.ms, 1000) + assert.equal( + silenceWindowFor({ auto_advance_on_silence: true, auto_advance_timeout_ms: -5 })?.ms, + 1000, + ) + }) +})