2727//! The macro can't pass an `AppHandle` (it'd require every `log_error!` site to thread
2828//! one in). We stash a `tauri::AppHandle<tauri::Wry>` in [`APP_HANDLE`] at startup via
2929//! [`set_app_handle`], called from `lib.rs::setup`. If the handle isn't set yet (before
30- //! setup runs, or in unit tests), [`on_error_logged`] still bumps the counter so the
31- //! debounce window is correct, but the spawn/upload is skipped.
30+ //! setup runs, or in unit tests), [`on_error_logged`] still bumps the counter and stores
31+ //! the debounce state, but skips the spawn (no handle to clone). The state's
32+ //! `flush_spawned` flag tracks this; when [`set_app_handle`] later runs, it picks up the
33+ //! orphaned window and spawns the flush task with the remaining time. If the deadline
34+ //! has already elapsed, [`sleep_until`] is a no-op and `flush` runs immediately.
3235
3336use crate :: error_reporter:: { self , BundleKind } ;
3437use rand:: Rng ;
@@ -74,11 +77,14 @@ struct DebounceState {
7477 first_category : String ,
7578 first_message : String ,
7679 error_count : usize ,
77- /// Wall-clock target for the flush. Used by tests to assert jitter bounds and by
78- /// the spawned task to know when to wake up. The spawned task captures this value
79- /// before storing the state, so the field itself is only read in the test seam.
80- #[ allow( dead_code, reason = "Read via snapshot_for_test in cfg(test) builds" ) ]
80+ /// Wall-clock target for the flush. Read by the late-spawn path in
81+ /// [`set_app_handle`] to compute the remaining delay when a window opened before
82+ /// the AppHandle was ready, and by tests to assert jitter bounds.
8183 scheduled_send_at : Instant ,
84+ /// True once a flush task has been spawned for this window. If `set_app_handle`
85+ /// runs after a window opened without the handle, this lets us spawn exactly once
86+ /// without racing with [`on_error_logged`].
87+ flush_spawned : bool ,
8288}
8389
8490static STATE : Mutex < Option < DebounceState > > = Mutex :: new ( None ) ;
@@ -99,8 +105,37 @@ pub fn is_enabled() -> bool {
99105
100106/// Stash the app handle so the macro-driven entry point can spawn flush tasks without
101107/// receiving an `AppHandle` argument. Called once from `lib.rs::setup`.
108+ ///
109+ /// If a debounce window is already active and never got its flush task (because an error
110+ /// fired before the handle was wired up), spawn one now. Compute the remaining time
111+ /// from `scheduled_send_at`; if it's already past, fire immediately.
102112pub fn set_app_handle ( handle : AppHandle < Wry > ) {
103- let _ = APP_HANDLE . set ( handle) ;
113+ if APP_HANDLE . set ( handle. clone ( ) ) . is_err ( ) {
114+ // Already set — nothing more to do. Tests reset the handle differently; in prod
115+ // setup runs once.
116+ return ;
117+ }
118+ // Atomically peek at the state under the lock. If a window is open without a
119+ // spawned flush, mark it as spawned and kick the task off.
120+ let scheduled_at = {
121+ let mut guard = match STATE . lock ( ) {
122+ Ok ( g) => g,
123+ Err ( p) => p. into_inner ( ) ,
124+ } ;
125+ match guard. as_mut ( ) {
126+ Some ( state) if !state. flush_spawned => {
127+ state. flush_spawned = true ;
128+ Some ( state. scheduled_send_at )
129+ }
130+ _ => None ,
131+ }
132+ } ;
133+ if let Some ( deadline) = scheduled_at {
134+ tauri:: async_runtime:: spawn ( async move {
135+ sleep_until ( deadline) . await ;
136+ flush ( handle) . await ;
137+ } ) ;
138+ }
104139}
105140
106141/// Records an error against the auto-dispatcher. If the opt-in flag is off, returns
@@ -120,19 +155,41 @@ pub fn on_error_logged(category: &str, message: &str) {
120155 None => return , // Already had an active debounce; only the counter changed.
121156 } ;
122157
123- // Spawn the flush task only if the AppHandle has been wired up. In unit tests and
124- // during the brief window before `set_app_handle` runs in `setup()`, we'll capture
125- // the error in the debounce state but never fire the send. Acceptable — the next
126- // error after init will trigger one normally .
158+ // Spawn the flush task only if the AppHandle has been wired up. If it's not, the
159+ // debounce state is preserved with `flush_spawned = false` — when `set_app_handle`
160+ // eventually runs, it'll spawn the task with the remaining time (or fire immediately
161+ // if the deadline has already passed) .
127162 let Some ( app) = APP_HANDLE . get ( ) . cloned ( ) else {
128163 return ;
129164 } ;
165+ if !mark_flush_spawned ( ) {
166+ // Lost the race: someone else (e.g. set_app_handle catching up) already spawned
167+ // the flush task for this window. Don't double-spawn.
168+ return ;
169+ }
130170 tauri:: async_runtime:: spawn ( async move {
131171 sleep_until ( scheduled_send_at) . await ;
132172 flush ( app) . await ;
133173 } ) ;
134174}
135175
176+ /// Atomically mark the active window as having a spawned flush task. Returns `true` if
177+ /// this caller is the one that flipped the flag (and so should spawn), `false` if it was
178+ /// already set (someone else won the race).
179+ fn mark_flush_spawned ( ) -> bool {
180+ let mut guard = match STATE . lock ( ) {
181+ Ok ( g) => g,
182+ Err ( p) => p. into_inner ( ) ,
183+ } ;
184+ match guard. as_mut ( ) {
185+ Some ( state) if !state. flush_spawned => {
186+ state. flush_spawned = true ;
187+ true
188+ }
189+ _ => false ,
190+ }
191+ }
192+
136193/// Lock the state, register the error, and return the scheduled flush time iff this
137194/// call started a new debounce window. Returns `None` if a window was already active
138195/// (in which case the caller should NOT spawn a duplicate flush task).
@@ -154,6 +211,7 @@ fn record_error(category: &str, message: &str) -> Option<Instant> {
154211 first_message : message. to_string ( ) ,
155212 error_count : 1 ,
156213 scheduled_send_at,
214+ flush_spawned : false ,
157215 } ) ;
158216 Some ( scheduled_send_at)
159217}
@@ -259,6 +317,36 @@ pub fn snapshot_for_test() -> Option<(String, String, usize, Instant)> {
259317 } )
260318}
261319
320+ /// Test seam: returns `Some(true)` if a window is active and its flush task has been
321+ /// spawned, `Some(false)` if a window is active but no spawn happened yet, `None` if
322+ /// no window is active.
323+ #[ cfg( test) ]
324+ pub fn flush_spawned_for_test ( ) -> Option < bool > {
325+ let guard = match STATE . lock ( ) {
326+ Ok ( g) => g,
327+ Err ( p) => p. into_inner ( ) ,
328+ } ;
329+ guard. as_ref ( ) . map ( |s| s. flush_spawned )
330+ }
331+
332+ /// Test seam: simulates the late-arriving AppHandle path without needing a Tauri runtime.
333+ /// Returns `Some(deadline)` if a window was active and not yet spawned (so the production
334+ /// `set_app_handle` would spawn a task for it), `None` otherwise.
335+ #[ cfg( test) ]
336+ pub fn simulate_late_app_handle_for_test ( ) -> Option < Instant > {
337+ let mut guard = match STATE . lock ( ) {
338+ Ok ( g) => g,
339+ Err ( p) => p. into_inner ( ) ,
340+ } ;
341+ match guard. as_mut ( ) {
342+ Some ( state) if !state. flush_spawned => {
343+ state. flush_spawned = true ;
344+ Some ( state. scheduled_send_at )
345+ }
346+ _ => None ,
347+ }
348+ }
349+
262350#[ cfg( test) ]
263351pub fn jitter_window ( ) -> ( Duration , Duration ) {
264352 ( DEBOUNCE_BASE - JITTER , DEBOUNCE_BASE + JITTER )
0 commit comments