fix: null sqlite3 handle after close to prevent double-free (#2251) - #2261
fix: null sqlite3 handle after close to prevent double-free (#2251)#2261wasim-builds wants to merge 2 commits into
Conversation
Set raw pointer to null after sqlite3_close_v2 in disconnect() to prevent use-after-free when the handle is closed twice. This fixes issue tursodatabase#2251 where LibsqlConnection::Drop calls disconnect() and then the inner Connection::Drop also calls disconnect(), causing a double-close of the sqlite3 handle. The fix ensures disconnect() is idempotent by nulling the raw pointer after the first close, so subsequent calls are safe no-ops.
|
Hi! Bumping this — it's a small 4-line fix that nulls the sqlite3 handle after close to prevent double-free. Simple and ready for review! |
|
We hit this in production and have been carrying this exact line as a patch on a vendored 0.9.30 since mid-August, so I can confirm it fixes it. Our setup opens a local connection per request on a multi-threaded runtime, so it was double-closing on every query. Symptom was The valgrind repro in #2251 is deterministic on Linux but doesn't fault. macOS Guard Malloc unmaps the freed page instead of leaving the stale [dependencies]
libsql = { version = "=0.9.30", default-features = false, features = ["core"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }#[tokio::main]
async fn main() {
let db = libsql::Builder::new_local(":memory:").build().await.unwrap();
for i in 0..5 {
let conn = db.connect().unwrap();
let mut rows = conn.query("SELECT 1", ()).await.unwrap();
rows.next().await.unwrap();
drop(rows);
drop(conn);
println!("closed connection {i}");
}
}Ran it again today against crates.io 0.9.30 on macOS 26.6.2 / arm64 / rustc 1.92.0, 5 of 5 crash. With this PR applied, 5 of 5 clean. Backtrace from when we first diagnosed it: The inner We looked at the other suggestion in #2251, removing the wrapper's Since we've been running it: test suite went from ~1 failure in 260 runs to 400 runs clean, and a loop of 8 threads doing 2000 connect/query/drop cycles each, which used to segfault or hang most of the time, has been clean. That repro is macOS-only, so it's no use in CI here. A plain unit test does the job though, and it fails on main: #[tokio::test]
async fn disconnect_is_idempotent() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("local.db");
let db = Database::new(path.to_str().unwrap().to_string(), OpenFlags::default());
let mut conn = Connection::connect(&db).unwrap();
assert!(!conn.handle().is_null());
conn.disconnect();
assert!(conn.handle().is_null(), "disconnect() left a dangling handle");
conn.disconnect();
}Goes in the existing The failing check doesn't look related to the diff, fwiw. It's Minor nit, ignore if you like: the assignment doesn't need to be inside the @penberg any chance of getting this one reviewed? |
|
Confirming this fixes a real problem on macOS too. I ran into the same double close through my own application's test suite, which With this patch applied the repro goes from 2 clean runs out of 30 to 30 out of One extra symptom that might be worth knowing, since it isn't only a teardown Every iteration had its own database name, so my guess is that the freed handle Worth flagging that the repro only shows anything on macOS. On Linux glibc it Thanks for putting this up, and thanks for maintaining this awesome tool! |
Problem
Closes #2251
The local connection's
sqlite3handle issqlite3_close_v2'd twice on teardown.LibsqlConnection'sDropcallsdisconnect(), then the innerConnection'sDropcalls it again. Both go throughConnection::disconnect(connection.rs:110).disconnect()isn't idempotent — theArc::get_mut(drop_ref)guard only checks unique ownership (true both times) and doesn't record that the handle was already closed.On Windows this is a hard
STATUS_ACCESS_VIOLATION(0xC0000005); on Linux it doesn't fault (glibc keeps the freed page mapped) but the double free is real, as valgrind shows — 800 errors from 1 context at 800 iterations, one per connection.Root Cause
Both calls succeed because
Arc::get_mut(drop_ref)returnsSomeboth times (the first close doesn't mark the handle as closed).Fix
After calling
sqlite3_close_v2, setself.rawtostd::ptr::null_mut(). This makesdisconnect()idempotent — the second call sees a null pointer, and even ifArc::get_mutpasses, callingsqlite3_close_v2(null)is documented as safe (no-op) in SQLite.Changes
libsql/src/local/connection.rs: Nullrawaftersqlite3_close_v2indisconnect()Reproduction (from issue)
With valgrind: 800 errors at 800 iterations. On Windows:
STATUS_ACCESS_VIOLATIONwithin first few hundred iterations.Testing
cargo checkpassesdisconnect()idempotent as suggested in the issueCo-Authored-By: Claude [email protected]