diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index 0cdd28e..a34b518 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -107,6 +107,14 @@ export function useLiveAtcSession( // applyBackendDecision. let silenceTimer: ReturnType | null = null + // Consecutive silence-timeouts fired for the SAME state. If the backend keeps + // re-prompting the identical auto_advance_on_silence state (e.g. it re-asks the + // same read-back-less confirmation every timeout window), re-arming forever + // reads as ATC repeating itself on a loop. Reset on any pilot transmission or + // once the decision actually lands on a different state. + let silenceFireCount: { stateId: string | null; count: number } = { stateId: null, count: 0 } + const MAX_CONSECUTIVE_SILENCE_FIRES = 2 + function clearSilenceTimer() { if (silenceTimer) { clearTimeout(silenceTimer) @@ -114,10 +122,23 @@ export function useLiveAtcSession( } } + function resetSilenceFireCount() { + silenceFireCount = { stateId: null, count: 0 } + } + function armSilenceTimer() { clearSilenceTimer() const state = currentState.value as any if (!state?.auto_advance_on_silence || !backendSessionId.value) return + + if (silenceFireCount.stateId !== state.id) { + silenceFireCount = { stateId: state.id, count: 0 } + } + if (silenceFireCount.count >= MAX_CONSECUTIVE_SILENCE_FIRES) { + pmLog.warn('SILENCE TIMER not re-armed — state fired', silenceFireCount.count, 'times already:', state.id) + return + } + const ms = Math.max(1000, Number(state.auto_advance_timeout_ms ?? 30000)) const sessionAtArm = backendSessionId.value const stateAtArm = state.id @@ -134,6 +155,10 @@ export function useLiveAtcSession( } try { pmLog.info('SILENCE TIMEOUT fired →', stateAtArm) + silenceFireCount = { + stateId: stateAtArm, + count: (silenceFireCount.stateId === stateAtArm ? silenceFireCount.count : 0) + 1, + } const response = await radioBackend.timeout(sessionAtArm) applyBackendDecision(response) } catch (e: any) { @@ -145,6 +170,15 @@ export function useLiveAtcSession( silenceTimer = setTimeout(fire, ms) } + // Guards the ATC reply (log entry + TTS) against being applied twice for the + // same decision — applyBackendDecision can be reached from four sources + // (transmit reply, telemetry tick, silence timeout, bug-report restore) and + // two of them can legitimately fire back-to-back for the same outcome (e.g. + // a silence timeout racing an in-flight transmit reply), which otherwise + // spoke/logged the same confirmation 2-3x (design doc WP1 Fix 2). + let lastAppliedSay: { text: string; stateId: string; atMs: number } | null = null + const APPLIED_SAY_DEDUPE_MS = 15_000 + // Apply a backend decision (from a pilot transmission, a telemetry tick, or a // silence timeout) to the local engine + UI: sync the // flow/cursor/variables/flags, speak the controller reply, and surface @@ -166,14 +200,18 @@ export function useLiveAtcSession( } // Advance local cursor through every state the backend auto-walked, then the - // final state. moveToSilent updates current_unit, actions, handoffs and the - // communication log without scheduling further auto-transitions. + // final state. moveToSilent updates current_unit, actions and handoffs + // without scheduling further auto-transitions. suppressSay: the ATC line for + // this decision is logged exactly once below, from the backend-rendered + // sayText — logging each walked state's own say_tpl too would duplicate it + // (2-3x on chained flows with multiple auto-advanced ATC/system states). + // TTS is unaffected either way: only scheduleControllerSpeech below plays audio. for (const stateId of response.auto_advanced_states ?? []) { pmLog.debug('moveToSilent ← auto_advanced:', stateId) - moveToSilent(stateId) + moveToSilent(stateId, { suppressSay: true }) } pmLog.debug('moveToSilent ← next_state_id:', response.next_state_id) - moveToSilent(response.next_state_id) + moveToSilent(response.next_state_id, { suppressSay: true }) // Capture the per-field readback diagnostic for the STT debug panel. lastReadbackReport.value = response.readback_report ?? [] @@ -201,13 +239,23 @@ export function useLiveAtcSession( sayText = `Readback correct. ${sayText}` } if (sayText) { - pmLog.info('TTS →', sayText) - lastControllerSay.value = sayText - scheduleControllerSpeech(sayText) - appendLogEntry('atc', sayText, response.next_state_id, { - flow: response.active_flow, - frequency: frequencies.value.active, - }) + const isDuplicate = + lastAppliedSay + && lastAppliedSay.text === sayText + && lastAppliedSay.stateId === response.next_state_id + && Date.now() - lastAppliedSay.atMs < APPLIED_SAY_DEDUPE_MS + if (isDuplicate) { + pmLog.info('TTS SKIPPED (duplicate of last-applied decision) →', sayText) + } else { + pmLog.info('TTS →', sayText) + lastControllerSay.value = sayText + scheduleControllerSpeech(sayText) + appendLogEntry('atc', sayText, response.next_state_id, { + flow: response.active_flow, + frequency: frequencies.value.active, + }) + lastAppliedSay = { text: sayText, stateId: response.next_state_id, atMs: Date.now() } + } } if (response.fallback_used) { @@ -393,9 +441,11 @@ export function useLiveAtcSession( transmitInFlightCount.value++ try { - // The pilot spoke — a pending silence auto-advance no longer applies. The - // response re-arms it if the next state also allows silence. + // The pilot spoke — a pending silence auto-advance no longer applies, and + // a live transmission means the loop-guard's job is done for this state. + // The response re-arms it (fresh count) if the next state also allows silence. clearSilenceTimer() + resetSilenceFireCount() const response = await radioBackend.transmit(backendSessionId.value, transcript) // Drop a stale reply if the pilot already transmitted again while this call @@ -432,9 +482,14 @@ export function useLiveAtcSession( currentScreen.value = 'scenario' return } - pmLog.error('TRANSMIT FAILED', { transcript, session: backendSessionId.value, error: e }) + const isTimeout = + e?.name === 'TimeoutError' + || e?.name === 'AbortError' + || e?.cause?.name === 'TimeoutError' + || e?.cause?.name === 'AbortError' + pmLog.error(isTimeout ? 'TRANSMIT TIMED OUT' : 'TRANSMIT FAILED', { transcript, session: backendSessionId.value, error: e }) console.error('Backend transmission failed', e) - setLastTransmission(`${prefix}: ${transcript} (backend failed)`) + setLastTransmission(`${prefix}: ${transcript} (${isTimeout ? 'backend timeout' : 'backend failed'})`) } finally { transmitInFlightCount.value = Math.max(0, transmitInFlightCount.value - 1) } diff --git a/app/composables/useRadioBackend.ts b/app/composables/useRadioBackend.ts index c925592..e286933 100644 --- a/app/composables/useRadioBackend.ts +++ b/app/composables/useRadioBackend.ts @@ -63,6 +63,12 @@ export function useRadioBackend() { return (config.public.radioBackendUrl as string) || 'http://127.0.0.1:8000' } + // Without a timeout, a hung Python-backend call never resolves — its + // `finally` (which clears transmitInFlightCount / re-arms the silence timer) + // never runs either, so the session reads as permanently stuck. + const TRANSMIT_TIMEOUT_MS = 30_000 + const CREATE_SESSION_TIMEOUT_MS = 60_000 // taxi-route computation is slow + async function createSession( flowSlug: string, variables?: Record, @@ -84,6 +90,7 @@ export function useRadioBackend() { aircraft_lat: aircraftPosition?.lat ?? null, aircraft_lon: aircraftPosition?.lon ?? null, }, + signal: AbortSignal.timeout(CREATE_SESSION_TIMEOUT_MS), }) } @@ -93,6 +100,7 @@ export function useRadioBackend() { { method: 'POST', body: { pilot_utterance: pilotUtterance }, + signal: AbortSignal.timeout(TRANSMIT_TIMEOUT_MS), }, ) } @@ -111,6 +119,7 @@ export function useRadioBackend() { { method: 'POST', body: { telemetry }, + signal: AbortSignal.timeout(TRANSMIT_TIMEOUT_MS), }, ) } @@ -123,7 +132,7 @@ export function useRadioBackend() { async function timeout(sessionId: string): Promise { return await $fetch( `${baseUrl()}/api/radio/session/${sessionId}/timeout`, - { method: 'POST' }, + { method: 'POST', signal: AbortSignal.timeout(TRANSMIT_TIMEOUT_MS) }, ) } diff --git a/app/composables/useRadioSpeech.ts b/app/composables/useRadioSpeech.ts index 8e2dab9..4ad395a 100644 --- a/app/composables/useRadioSpeech.ts +++ b/app/composables/useRadioSpeech.ts @@ -214,10 +214,16 @@ export function useRadioSpeech( } } + /** A hung /api/atc/say request would otherwise block the whole speech queue + * forever (enqueueSpeech serializes playback on one promise chain) — the + * session would read as "frozen" with a dead frequency. */ + const TTS_FETCH_TIMEOUT_MS = 20_000 + /** Kick off TTS generation immediately (parallel to whatever is playing). */ const fetchSpeechAudio = (prepared: PreparedSpeech, options: SpeechOptions = {}): Promise => { const abort = new AbortController() addPendingAbort(abort) + const watchdog = setTimeout(() => abort.abort(), TTS_FETCH_TIMEOUT_MS) return (async () => { try { const speed = options.speed ?? speechSpeed.value @@ -239,13 +245,14 @@ export function useRadioSpeech( return response } catch (err: any) { if (err?.name === 'AbortError' || abort.signal.aborted) { - pmLog.info('TTS cancelled (frequency change)') + pmLog.info('TTS cancelled (frequency change or timeout)') return null } pmLog.error('TTS FAILED', err) console.error('TTS failed:', err) return null } finally { + clearTimeout(watchdog) deletePendingAbort(abort) } })() diff --git a/app/composables/useSimBridgeSync.ts b/app/composables/useSimBridgeSync.ts index 562c20f..6213d9b 100644 --- a/app/composables/useSimBridgeSync.ts +++ b/app/composables/useSimBridgeSync.ts @@ -17,6 +17,13 @@ export interface SimBridgeSyncDeps { stopRecording: () => void /** A frequency-sim-control command the bridge has finished (or the server expired). */ onCommandResult: (result: SimControlCommandResult) => void + /** + * True while a pilot transmission is out at the backend (useLiveAtcSession). + * A telemetry-fired decision landing mid-transmit would apply on top of (or + * race) the transmit reply — double-says, conflicting cursor moves — so + * telemetry forwarding pauses until the transmission settles. + */ + transmitInFlight: Ref } /** @@ -37,6 +44,7 @@ export function useSimBridgeSync( const { backendSessionId, radioBackend, applyBackendDecision, stopCurrentSpeech, resumePrerecIfSuspended, startRecording, stopRecording, onCommandResult, + transmitInFlight, } = deps const bridgeToken = computed(() => { @@ -62,14 +70,25 @@ export function useSimBridgeSync( // Only forward telemetry that meaningfully changed, so idle cruise doesn't POST // an identical tick every poll. Rounded so tiny jitter doesn't count as change. let lastSentTelemetrySig: string | null = null + // Guards against a second telemetry POST going out while one is still awaiting + // its response — a slow backend + the 3s poll interval could otherwise stack + // concurrent requests, each capable of firing its own (possibly conflicting) decision. + let telemetryPostInFlight = false async function forwardTelemetryToBackend(rawTelemetry: any) { if (!backendSessionId.value) return + // A pilot transmission is awaiting its backend reply — a telemetry-fired + // decision landing in the middle of that would race/duplicate the reply + // (design doc WP1 Fix 4). Skip this tick without recording the signature, + // so the next poll (once the transmission has settled) retries it. + if (transmitInFlight.value) return + if (telemetryPostInFlight) return const normalized = normalizeBridgeTelemetry(rawTelemetry) if (!normalized) return const sig = telemetrySignature(normalized) if (sig === lastSentTelemetrySig) return // nothing meaningful changed lastSentTelemetrySig = sig + telemetryPostInFlight = true try { const response = await radioBackend.sendTelemetry(backendSessionId.value, normalized) if (response.telemetry_fired) { @@ -84,6 +103,8 @@ export function useSimBridgeSync( } else { pmLog.warn('TELEMETRY forward failed', e) } + } finally { + telemetryPostInFlight = false } } diff --git a/app/pages/live-atc.vue b/app/pages/live-atc.vue index bb89f39..a9a64d6 100644 --- a/app/pages/live-atc.vue +++ b/app/pages/live-atc.vue @@ -169,7 +169,7 @@