@@ -34,9 +34,14 @@ pub struct LeaderElectionManager {
3434 pub state : Rc < RefCell < LeaderElectionState > > ,
3535 broadcast_channel : Option < BroadcastChannel > ,
3636 pub heartbeat_interval : Option < i32 > ,
37- pub heartbeat_closure : Option < Closure < dyn FnMut ( ) > > ,
37+ // NOTE: heartbeat_closure is intentionally leaked via Closure::forget()
38+ // to prevent "closure invoked after being dropped" errors from pending ticks.
39+ // The heartbeat_valid flag makes the leaked closure a no-op after stop.
3840 message_listener : Option < Closure < dyn FnMut ( web_sys:: MessageEvent ) > > ,
3941 lease_duration_ms : u64 ,
42+ /// Validity flag - set to false before clearing interval to prevent
43+ /// leaked closure from doing any work after stop_election is called
44+ heartbeat_valid : Rc < RefCell < bool > > ,
4045}
4146
4247impl LeaderElectionManager {
@@ -60,9 +65,9 @@ impl LeaderElectionManager {
6065 } ) ) ,
6166 broadcast_channel : None ,
6267 heartbeat_interval : None ,
63- heartbeat_closure : None ,
6468 message_listener : None ,
6569 lease_duration_ms : 1000 , // 1 second - fast leader election
70+ heartbeat_valid : Rc :: new ( RefCell :: new ( false ) ) ,
6671 }
6772 }
6873
@@ -420,8 +425,20 @@ impl LeaderElectionManager {
420425
421426 // Now set up interval for periodic updates
422427 let state_clone = self . state . clone ( ) ;
428+ let valid_clone = self . heartbeat_valid . clone ( ) ;
429+
430+ // Mark heartbeat as valid before starting
431+ * self . heartbeat_valid . borrow_mut ( ) = true ;
423432
424433 let closure = Closure :: wrap ( Box :: new ( move || {
434+ // CRITICAL: Check validity FIRST before any other operations
435+ // This prevents "closure invoked after being dropped" errors
436+ // when a pending setInterval tick fires after stop_election invalidates
437+ if !* valid_clone. borrow ( ) {
438+ // Heartbeat has been invalidated - don't execute
439+ return ;
440+ }
441+
425442 // Reentrancy guard: skip if heartbeat is already running
426443 // This prevents "closure invoked recursively" errors from wasm-bindgen
427444 let already_running = HEARTBEAT_RUNNING . with ( |running| {
@@ -497,17 +514,30 @@ impl LeaderElectionManager {
497514 )
498515 } ) ?;
499516
500- // CRITICAL: Store closure instead of forgetting it, so it can be properly cleaned up
501517 self . heartbeat_interval = Some ( interval_id) ;
502- self . heartbeat_closure = Some ( closure) ;
518+
519+ // CRITICAL: Intentionally leak the closure via forget() to prevent
520+ // "closure invoked after being dropped" errors.
521+ //
522+ // When the manager is dropped:
523+ // 1. heartbeat_valid is set to false (closure becomes no-op)
524+ // 2. clearInterval is called (stops future scheduling)
525+ // 3. But pending callbacks in JS event queue may still fire
526+ // 4. Since the closure is leaked (never dropped), those callbacks
527+ // will safely invoke the Rust closure which immediately returns
528+ // due to the validity check.
529+ //
530+ // Trade-off: Small memory leak (~100 bytes per database) but prevents
531+ // runtime errors. Databases are typically long-lived so this is acceptable.
532+ closure. forget ( ) ;
503533
504534 Ok ( ( ) )
505535 }
506536
507537 /// Stop leader election (e.g., when tab is closing)
508538 pub async fn stop_election ( & mut self ) -> Result < ( ) , DatabaseError > {
509539 // CRITICAL: Check if already stopped (idempotent)
510- if self . heartbeat_interval . is_none ( ) && self . heartbeat_closure . is_none ( ) {
540+ if self . heartbeat_interval . is_none ( ) && ! * self . heartbeat_valid . borrow ( ) {
511541 web_sys:: console:: log_1 ( & "[STOP] Already stopped - skipping" . into ( ) ) ;
512542 return Ok ( ( ) ) ;
513543 }
@@ -524,7 +554,13 @@ impl LeaderElectionManager {
524554 was_leader
525555 ) ;
526556
527- // CRITICAL: Clear interval and closure FIRST to release Rc references
557+ // CRITICAL: Invalidate heartbeat FIRST, before clearing interval
558+ // This ensures any pending setInterval tick will bail out immediately
559+ // and won't try to execute with freed resources
560+ * self . heartbeat_valid . borrow_mut ( ) = false ;
561+ web_sys:: console:: log_1 ( & format ! ( "[STOP] Invalidated heartbeat for {}" , db_name) . into ( ) ) ;
562+
563+ // Now clear interval (closure is intentionally leaked, no need to drop)
528564 if let Some ( interval_id) = self . heartbeat_interval . take ( ) {
529565 web_sys:: console:: log_1 (
530566 & format ! ( "[STOP] Clearing interval {} for {}" , interval_id, db_name) . into ( ) ,
@@ -534,13 +570,6 @@ impl LeaderElectionManager {
534570 }
535571 }
536572
537- // Drop the closure to release any Rc<RefCell<State>> references
538- if let Some ( _closure) = self . heartbeat_closure . take ( ) {
539- web_sys:: console:: log_1 (
540- & format ! ( "[STOP] Dropped heartbeat closure for {}" , db_name) . into ( ) ,
541- ) ;
542- }
543-
544573 // CRITICAL: Close the BroadcastChannel to prevent test interference
545574 if let Some ( channel) = self . broadcast_channel . take ( ) {
546575 channel. close ( ) ;
@@ -726,3 +755,40 @@ impl LeaderElectionManager {
726755 state. last_heartbeat
727756 }
728757}
758+
759+ /// Drop implementation to prevent "closure invoked after being dropped" errors
760+ ///
761+ /// CRITICAL: Invalidate heartbeat_valid BEFORE clearing interval.
762+ /// The heartbeat closure is intentionally leaked via forget() so it's never dropped.
763+ /// Any pending setInterval ticks will safely invoke the leaked closure which
764+ /// immediately returns due to the validity check.
765+ impl Drop for LeaderElectionManager {
766+ fn drop ( & mut self ) {
767+ // CRITICAL: Invalidate heartbeat FIRST - leaked closure will become no-op
768+ * self . heartbeat_valid . borrow_mut ( ) = false ;
769+
770+ // Clear heartbeat interval (stops future scheduling)
771+ if let Some ( interval_id) = self . heartbeat_interval . take ( ) {
772+ if let Some ( window) = web_sys:: window ( ) {
773+ window. clear_interval_with_handle ( interval_id) ;
774+ log:: debug!(
775+ "LeaderElectionManager::drop() - Cleared heartbeat interval {}" ,
776+ interval_id
777+ ) ;
778+ }
779+ }
780+
781+ // Close broadcast channel
782+ if let Some ( channel) = self . broadcast_channel . take ( ) {
783+ channel. close ( ) ;
784+ log:: debug!( "LeaderElectionManager::drop() - Closed BroadcastChannel" ) ;
785+ }
786+
787+ // Note: heartbeat closure is intentionally leaked (never dropped)
788+ // message_listener will be dropped here
789+ log:: debug!(
790+ "LeaderElectionManager::drop() - Cleanup complete for {}" ,
791+ self . state. borrow( ) . db_name
792+ ) ;
793+ }
794+ }
0 commit comments