From 67d7236eddfa381ff073f07bb09fc2f2dd8b9801 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:03:35 +0530 Subject: [PATCH] fix: retain dictation events until listener acknowledgement --- frontend/src-tauri/src/commands.rs | 14 +++ frontend/src-tauri/src/lib.rs | 89 ++++++++++++++++--- frontend/src/components/CaptureWidget.jsx | 57 ++++++++---- .../src/test/CaptureWidgetSetupRace.test.jsx | 40 ++++++++- 4 files changed, 170 insertions(+), 30 deletions(-) diff --git a/frontend/src-tauri/src/commands.rs b/frontend/src-tauri/src/commands.rs index 675737a6..985f1fa4 100644 --- a/frontend/src-tauri/src/commands.rs +++ b/frontend/src-tauri/src/commands.rs @@ -1077,6 +1077,20 @@ pub fn mark_dictation_capture_ready(app: tauri::AppHandle, registration_id: u64) } } +#[tauri::command] +pub fn acknowledge_dictation_capture_delivery( + app: tauri::AppHandle, + registration_id: u64, + delivery_id: u64, +) { + let flags = app.state::(); + let Ok(mut capture) = flags.capture.lock() else { + log::warn!("Dictation capture state lock poisoned"); + return; + }; + capture.acknowledge(registration_id, delivery_id); +} + #[tauri::command] pub fn end_dictation_capture_registration(app: tauri::AppHandle, registration_id: u64) { let flags = app.state::(); diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 208cdbf1..273adab5 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -104,6 +104,7 @@ pub struct CaptureDispatchState { pub(crate) ready: bool, pub(crate) pending: VecDeque, registration_counter: u64, + delivery_counter: u64, active_registration: Option, } @@ -113,6 +114,7 @@ impl Default for CaptureDispatchState { ready: false, pending: VecDeque::new(), registration_counter: 0, + delivery_counter: 0, active_registration: None, } } @@ -134,7 +136,36 @@ impl CaptureDispatchState { return VecDeque::new(); } self.ready = true; - std::mem::take(&mut self.pending) + self.pending + .iter() + .cloned() + .map(|mut event| { + event.payload.registration_id = registration_id; + event + }) + .collect() + } + + pub(crate) fn enqueue(&mut self, mut event: CaptureEvent) -> Option { + self.delivery_counter = self.delivery_counter.wrapping_add(1).max(1); + event.payload.delivery_id = self.delivery_counter; + self.pending.push_back(event.clone()); + let registration_id = self.active_registration.filter(|_| self.ready)?; + event.payload.registration_id = registration_id; + Some(event) + } + + pub(crate) fn acknowledge(&mut self, registration_id: u64, delivery_id: u64) { + if self.active_registration != Some(registration_id) { + return; + } + if let Some(index) = self + .pending + .iter() + .position(|event| event.payload.delivery_id == delivery_id) + { + self.pending.remove(index); + } } pub(crate) fn end_registration(&mut self, registration_id: u64) { @@ -149,8 +180,11 @@ impl CaptureDispatchState { #[serde(rename_all = "camelCase")] pub(crate) struct DictationCapturePayload { pub(crate) session_id: u64, + pub(crate) delivery_id: u64, + pub(crate) registration_id: u64, } +#[derive(Clone)] pub(crate) struct CaptureEvent { pub(crate) name: &'static str, pub(crate) payload: DictationCapturePayload, @@ -186,13 +220,18 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin: }; let capture_event = CaptureEvent { name: event, - payload: DictationCapturePayload { session_id }, + payload: DictationCapturePayload { + session_id, + delivery_id: 0, + registration_id: 0, + }, }; let Ok(mut capture) = flags.capture.lock() else { log::warn!("Dictation capture state lock poisoned"); return; }; - if capture.ready { + if let Some(capture_event) = capture.enqueue(capture_event) { + drop(capture); // A press that reaches Rust but produces no recording is otherwise // indistinguishable from one the compositor never delivered, so say // which side of the handshake the press left on. @@ -205,7 +244,6 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin: log::warn!( "Dictation capture '{action}' queued — the capture window has not registered yet" ); - capture.pending.push_back(capture_event); } } @@ -215,6 +253,17 @@ mod dictation_capture_tests { dictation_capture_event, CaptureDispatchState, CaptureEvent, DictationCapturePayload, }; + fn capture_event(name: &'static str) -> CaptureEvent { + CaptureEvent { + name, + payload: DictationCapturePayload { + session_id: 7, + delivery_id: 0, + registration_id: 0, + }, + } + } + #[test] fn toggle_starts_when_idle_and_stops_when_recording() { assert_eq!(dictation_capture_event("toggle", false), "tray-dictate"); @@ -224,18 +273,33 @@ mod dictation_capture_tests { #[test] fn readiness_queue_preserves_press_then_release() { let mut state = CaptureDispatchState::default(); - state.pending.push_back(CaptureEvent { - name: "tray-dictate", - payload: DictationCapturePayload { session_id: 7 }, - }); - state.pending.push_back(CaptureEvent { - name: "tray-dictate-stop", - payload: DictationCapturePayload { session_id: 7 }, - }); + state.enqueue(capture_event("tray-dictate")); + state.enqueue(capture_event("tray-dictate-stop")); let names: Vec<_> = state.pending.into_iter().map(|event| event.name).collect(); assert_eq!(names, ["tray-dictate", "tray-dictate-stop"]); } + #[test] + fn unacknowledged_delivery_survives_listener_replacement() { + let mut state = CaptureDispatchState::default(); + state.enqueue(capture_event("tray-dictate")); + let stale = state.begin_registration(); + let first_delivery = state.mark_registration_ready(stale); + let delivery_id = first_delivery[0].payload.delivery_id; + + state.end_registration(stale); + let current = state.begin_registration(); + let retried = state.mark_registration_ready(current); + assert_eq!(retried.len(), 1); + assert_eq!(retried[0].payload.delivery_id, delivery_id); + assert_eq!(retried[0].payload.registration_id, current); + + state.acknowledge(stale, delivery_id); + assert_eq!(state.pending.len(), 1); + state.acknowledge(current, delivery_id); + assert!(state.pending.is_empty()); + } + #[test] fn stale_listener_cannot_claim_or_clear_a_newer_registration() { let mut state = CaptureDispatchState::default(); @@ -661,6 +725,7 @@ pub fn run() { commands::request_dictation_capture, commands::begin_dictation_capture_registration, commands::mark_dictation_capture_ready, + commands::acknowledge_dictation_capture_delivery, commands::end_dictation_capture_registration, commands::show_dictation_pill, commands::get_launch_as_widget, diff --git a/frontend/src/components/CaptureWidget.jsx b/frontend/src/components/CaptureWidget.jsx index b5a4ed35..8304dd65 100644 --- a/frontend/src/components/CaptureWidget.jsx +++ b/frontend/src/components/CaptureWidget.jsx @@ -684,16 +684,50 @@ export default function CaptureWidget({ onDismiss }) { if (!inTauri()) return; // browser webui — the keyboard fallback below runs let unlistenStart, unlistenStop; let registrationId; + let endedRegistrationId; let cancelled = false; + const teardownRegistration = async () => { + const stopStart = unlistenStart; + const stopStop = unlistenStop; + unlistenStart = undefined; + unlistenStop = undefined; + try { + stopStart?.(); + } catch (err) { + console.warn('tray-dictate unlisten failed:', err); + } + try { + stopStop?.(); + } catch (err) { + console.warn('tray-dictate-stop unlisten failed:', err); + } + if (registrationId && endedRegistrationId !== registrationId) { + endedRegistrationId = registrationId; + await tauriInvoke('end_dictation_capture_registration', { registrationId }); + } + }; + const acknowledgeDelivery = (event) => { + const deliveryId = event?.payload?.deliveryId; + const eventRegistrationId = event?.payload?.registrationId; + if (eventRegistrationId != null && eventRegistrationId !== registrationId) return false; + if (deliveryId != null) { + void tauriInvoke('acknowledge_dictation_capture_delivery', { + registrationId, + deliveryId, + }).catch((err) => console.warn('dictation delivery acknowledgement failed:', err)); + } + return true; + }; (async () => { try { registrationId = await tauriInvoke('begin_dictation_capture_registration'); if (cancelled) { - await tauriInvoke('end_dictation_capture_registration', { registrationId }); + await teardownRegistration(); return; } const { listen } = await import('@tauri-apps/api/event'); unlistenStart = await listen('tray-dictate', async (event) => { + if (!acknowledgeDelivery(event)) return; const now = Date.now(); if (now - nativeEventAtRef.current.start < 150) return; nativeEventAtRef.current.start = now; @@ -796,7 +830,8 @@ export default function CaptureWidget({ onDismiss }) { startRecordingRef.current?.(true, sessionId); } }); - unlistenStop = await listen('tray-dictate-stop', async () => { + unlistenStop = await listen('tray-dictate-stop', async (event) => { + if (!acknowledgeDelivery(event)) return; const now = Date.now(); if (now - nativeEventAtRef.current.stop < 150) return; nativeEventAtRef.current.stop = now; @@ -810,23 +845,17 @@ export default function CaptureWidget({ onDismiss }) { }); await ensureDictationPrefsHydrated(); if (cancelled) { - unlistenStart?.(); - unlistenStop?.(); - await tauriInvoke('end_dictation_capture_registration', { registrationId }); + await teardownRegistration(); return; } await tauriInvoke('mark_dictation_capture_ready', { registrationId }); // Unmounted while the dynamic import was in flight — drop the // subscriptions we just created rather than leaking them. if (cancelled) { - unlistenStart?.(); - unlistenStop?.(); - await tauriInvoke('end_dictation_capture_registration', { registrationId }); + await teardownRegistration(); } } catch (err) { - if (registrationId) { - void tauriInvoke('end_dictation_capture_registration', { registrationId }); - } + await teardownRegistration().catch(() => {}); // Hotkey wiring failed inside Tauri — dictation still works via the // in-page shortcut, but say so in the console for bug reports. console.warn('tray-dictate listen failed:', err); @@ -835,11 +864,7 @@ export default function CaptureWidget({ onDismiss }) { return () => { cancelled = true; nativeStartSequenceRef.current += 1; - if (unlistenStart) unlistenStart(); - if (unlistenStop) unlistenStop(); - if (registrationId) { - void tauriInvoke('end_dictation_capture_registration', { registrationId }); - } + void teardownRegistration(); }; // Attach ONCE — see stateRef above. Adding a dependency here reintroduces // the dropped-press window that stranded the widget. diff --git a/frontend/src/test/CaptureWidgetSetupRace.test.jsx b/frontend/src/test/CaptureWidgetSetupRace.test.jsx index ef8f69a7..b1a0f1ec 100644 --- a/frontend/src/test/CaptureWidgetSetupRace.test.jsx +++ b/frontend/src/test/CaptureWidgetSetupRace.test.jsx @@ -10,7 +10,7 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; -const { toastMock, eventHandlers, eventState } = vi.hoisted(() => ({ +const { toastMock, eventHandlers, eventState, eventUnlisteners } = vi.hoisted(() => ({ toastMock: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), @@ -19,6 +19,7 @@ const { toastMock, eventHandlers, eventState } = vi.hoisted(() => ({ }), eventHandlers: {}, eventState: { pendingStart: false }, + eventUnlisteners: [], })); vi.mock('react-hot-toast', () => ({ default: toastMock, toast: toastMock })); @@ -29,7 +30,9 @@ vi.mock('@tauri-apps/api/core', () => ({ vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(async (name, handler) => { eventHandlers[name] = handler; - return () => delete eventHandlers[name]; + const unlisten = vi.fn(() => delete eventHandlers[name]); + eventUnlisteners.push(unlisten); + return unlisten; }), })); vi.mock('@tauri-apps/api/window', () => ({ @@ -131,6 +134,7 @@ beforeEach(() => { return undefined; }); eventState.pendingStart = false; + eventUnlisteners.length = 0; FakeWS.instances = []; storeState.dictationModelId = 'sherpa-parakeet-v3'; realWebSocket = globalThis.WebSocket; @@ -156,6 +160,38 @@ afterEach(() => { }); describe('CaptureWidget — connect-time asr_model_missing during mic setup', () => { + it('removes both native listeners when registration readiness fails', async () => { + invokeMock.mockImplementation(async (cmd) => { + if (cmd === 'begin_dictation_capture_registration') return 1; + if (cmd === 'mark_dictation_capture_ready') throw new Error('registration failed'); + return undefined; + }); + + render(); + + await waitFor(() => expect(eventUnlisteners).toHaveLength(2)); + await waitFor(() => + expect(eventUnlisteners.every((unlisten) => unlisten.mock.calls.length)).toBe(true), + ); + expect(invokeMock).toHaveBeenCalledWith('end_dictation_capture_registration', { + registrationId: 1, + }); + }); + + it('acknowledges a queued native event after the listener receives it', async () => { + render(); + await waitFor(() => expect(eventHandlers['tray-dictate-stop']).toBeTypeOf('function')); + + await eventHandlers['tray-dictate-stop']({ + payload: { sessionId: 7, deliveryId: 9, registrationId: 1 }, + }); + + expect(invokeMock).toHaveBeenCalledWith('acknowledge_dictation_capture_delivery', { + registrationId: 1, + deliveryId: 9, + }); + }); + it('turns a PCM-fallback socket failure into a terminal error', async () => { storeState.dictationModelId = 'whisperx'; render();