Skip to content

Commit eb40e11

Browse files
committed
fix: prevent heartbeat closure errors by leaking closure via forget()
Uses Closure::forget() to intentionally leak the heartbeat closure, preventing "closure invoked after being dropped" WASM errors when databases are closed while heartbeat intervals are pending. The heartbeat_valid flag makes the leaked closure a no-op after stop_election is called. Trade-off: ~100 bytes memory leak per database but prevents runtime errors.
1 parent dd0f3e6 commit eb40e11

9 files changed

Lines changed: 226 additions & 30 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "absurder-sql"
3-
version = "0.1.20"
3+
version = "0.1.21"
44
authors = ["Nicholas G. Piesco"]
55
description = "AbsurderSQL - SQLite + IndexedDB that's absurdly better than absurd-sql"
66
license = "AGPL-3.0"

examples/vite-app/main.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ async function initDB() {
7373

7474
// Expose db wrapper on window for testing
7575
window.db = db;
76+
window.__db__ = db; // Test parity with other e2e tests
7677

7778
await db.init();
7879

@@ -109,6 +110,9 @@ async function initDB() {
109110
}, 2000);
110111

111112
status('Ready!');
113+
114+
// Signal that database is fully initialized for e2e tests
115+
window.__db__ = db;
112116
}
113117

114118
async function runTest() {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@npiesco/absurder-sql",
3-
"version": "0.1.20",
3+
"version": "0.1.21",
44
"description": "High-performance SQLite for browsers with IndexedDB persistence. Export/import databases, multi-tab coordination, dual-mode (browser + native).",
55
"type": "module",
66
"main": "./pkg/absurder_sql.js",

src/lib.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1732,12 +1732,8 @@ impl Drop for Database {
17321732
);
17331733
}
17341734
}
1735-
// Drop closure to release Rc references
1736-
if manager.heartbeat_closure.take().is_some() {
1737-
web_sys::console::log_1(
1738-
&format!("DROP: Released heartbeat closure for {}", self.name).into(),
1739-
);
1740-
}
1735+
// Note: heartbeat closure is intentionally leaked (via forget())
1736+
// and becomes a no-op when heartbeat_valid is set to false
17411737
}
17421738
} else {
17431739
// Manager already borrowed - skip (first DB is cleaning up)

src/storage/block_storage.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2043,12 +2043,8 @@ impl BlockStorage {
20432043
);
20442044
}
20452045
}
2046-
// Also drop the closure to release Rc references
2047-
if manager.heartbeat_closure.take().is_some() {
2048-
web_sys::console::log_1(
2049-
&format!("[DROP] Released heartbeat closure for {}", self.db_name).into(),
2050-
);
2051-
}
2046+
// Note: heartbeat closure is intentionally leaked (via forget())
2047+
// and becomes a no-op when heartbeat_valid is set to false
20522048
}
20532049
} else {
20542050
// Already borrowed (e.g., first DB is still dropping) - skip silently

src/storage/leader_election.rs

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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

4247
impl 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+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* Test for "closure invoked after being dropped" error
3+
*
4+
* Root cause: LeaderElectionManager has no Drop impl, so when the manager
5+
* is dropped, clearInterval is never called. The setInterval keeps firing
6+
* and invokes a freed closure.
7+
*
8+
* Expected behavior: No errors after database is closed/dropped
9+
* Current behavior: "closure invoked recursively or after being dropped" errors
10+
*/
11+
import { test, expect } from '@playwright/test';
12+
13+
test.describe('Heartbeat Drop-After-Freed Bug', () => {
14+
test.beforeEach(async ({ page }) => {
15+
// Navigate to vite app
16+
await page.goto('http://localhost:3000');
17+
18+
// Wait for database to be ready (window.__db__ exposed after init)
19+
await page.waitForFunction(() => window.__db__ !== undefined, { timeout: 30000 });
20+
});
21+
22+
test('should NOT error when database is dropped while heartbeat is running', async ({ page }) => {
23+
const errors = [];
24+
25+
// Capture all closure-related errors
26+
page.on('console', msg => {
27+
if (msg.type() === 'error') {
28+
const text = msg.text();
29+
if (text.includes('closure invoked') || text.includes('after being dropped')) {
30+
errors.push({ type: 'console', text });
31+
}
32+
}
33+
});
34+
35+
page.on('pageerror', err => {
36+
if (err.message.includes('closure invoked') || err.message.includes('after being dropped')) {
37+
errors.push({ type: 'pageerror', text: err.message });
38+
}
39+
});
40+
41+
console.log('[TEST] Database initialized, creating additional databases...');
42+
43+
// Create and immediately destroy multiple databases to trigger the bug
44+
const result = await page.evaluate(async () => {
45+
const results = [];
46+
const Database = window.Database;
47+
48+
for (let i = 0; i < 3; i++) {
49+
try {
50+
// Create a new database (starts heartbeat via leader election)
51+
const db = await Database.newDatabase(`drop_test_${i}_${Date.now()}`);
52+
results.push({ step: `create_${i}`, success: true });
53+
54+
// Do a quick operation to ensure it's fully initialized
55+
await db.execute('SELECT 1');
56+
results.push({ step: `query_${i}`, success: true });
57+
58+
// Wait a bit to let heartbeat establish
59+
await new Promise(r => setTimeout(r, 1500));
60+
61+
// Close/drop the database (should clearInterval, but doesn't!)
62+
await db.close();
63+
results.push({ step: `close_${i}`, success: true });
64+
65+
} catch (e) {
66+
results.push({ step: `error_${i}`, success: false, error: e.message });
67+
}
68+
}
69+
70+
return results;
71+
});
72+
73+
console.log('[TEST] Database operations:', JSON.stringify(result, null, 2));
74+
75+
// Wait 3 seconds for any post-drop interval fires
76+
// Heartbeat is 1 second, so 3 seconds = 3 potential ghost invocations per dropped db
77+
console.log('[TEST] Waiting 3 seconds for ghost heartbeat invocations...');
78+
await page.waitForTimeout(3000);
79+
80+
console.log('[TEST] Errors captured:', errors.length);
81+
if (errors.length > 0) {
82+
console.log('[TEST] Error details:', JSON.stringify(errors, null, 2));
83+
}
84+
85+
// ASSERTION: No "after being dropped" errors should occur
86+
// This test will FAIL until the Drop impl is added to LeaderElectionManager
87+
expect(errors.length).toBe(0);
88+
});
89+
90+
test('rapid create-destroy cycle should not leak intervals', async ({ page }) => {
91+
const errors = [];
92+
93+
page.on('console', msg => {
94+
if (msg.type() === 'error') {
95+
errors.push(msg.text());
96+
}
97+
});
98+
99+
page.on('pageerror', err => {
100+
errors.push(err.message);
101+
});
102+
103+
// Rapid create-destroy cycle
104+
const result = await page.evaluate(async () => {
105+
const Database = window.Database;
106+
const ops = [];
107+
108+
// Rapid fire: create and immediately close 10 databases
109+
for (let i = 0; i < 10; i++) {
110+
const db = await Database.newDatabase(`rapid_${i}_${Date.now()}`);
111+
await db.execute('SELECT 1');
112+
await db.close();
113+
ops.push(i);
114+
}
115+
116+
return ops;
117+
});
118+
119+
console.log('[TEST] Completed rapid cycle:', result.length, 'databases');
120+
121+
// Wait for potential ghost intervals (10 dbs × 1s heartbeat × 5 seconds = many potential errors)
122+
await page.waitForTimeout(5000);
123+
124+
// Filter for closure errors specifically
125+
const closureErrors = errors.filter(e =>
126+
e.includes('closure invoked') || e.includes('after being dropped')
127+
);
128+
129+
console.log('[TEST] Closure errors after rapid cycle:', closureErrors.length);
130+
131+
// This will FAIL until Drop impl is added
132+
expect(closureErrors.length).toBe(0);
133+
});
134+
});

tests/e2e/heartbeat-reentrancy.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
* invoked recursively if the previous tick hasn't completed. This test
66
* verifies that the reentrancy guard prevents "closure invoked recursively" errors.
77
*/
8-
const { test, expect } = require('@playwright/test');
8+
import { test, expect } from '@playwright/test';
99

1010
test.describe('Heartbeat Reentrancy Guard', () => {
1111
test.beforeEach(async ({ page }) => {
1212
// Navigate to the vite example app
13-
await page.goto('http://localhost:5173');
13+
await page.goto('http://localhost:3000');
1414

1515
// Wait for the app to load and database to initialize
1616
await page.waitForFunction(() => {

0 commit comments

Comments
 (0)