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? |
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 noreply@anthropic.com