Repository navigation
fix: null sqlite3 handle after close to prevent double-free (#2251) - #2261
Conversation
|
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! |
|
Another confirmation, from the JavaScript side: Our test suite (718 files, file-backed SQLite, Measured on Bun 1.4.2, macOS arm64, We still load the crate for embedded replica sync in production, so we would much prefer the fix upstream. Happy to run a release candidate against the suite. |
|
Hello everyone, Checking in regarding this contribution for tursodatabase/libsql (fix: null sqlite3 handle after close to prevent double-free (#2251)). Contributing around distributed SQLite architecture, memory safety, and database engine internals has been exciting, and I'd love to help out with additional improvements and features across tursodatabase/libsql. I am available for freelance/contract projects, full-time opportunities, or joining the team as a regular contributor. You're welcome to connect or view my profile and repositories at https://github.com/wasim-builds. |
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.
d84ca6f to
4e06af5
Compare
libsql 0.10.0 ships the tursodatabase/libsql#2261 fix, the same commit the Devolutions fork tag carried, so drop the [patch.crates-io] entry and bump the libsql requirement to 0.10 in every crate. Issue: #2070 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libsql 0.9.30 closes a local connection twice when it's dropped ([tursodatabase/libsql#2251](tursodatabase/libsql#2251)). Since #2039 Gateway drops connections on startup, so some upgrades crash with `0xc0000005`. libsql 0.10.0 ships the upstream fix ([tursodatabase/libsql#2261](tursodatabase/libsql#2261)), so this moves Gateway to it. 0.10.0 also updates the bundled SQLite from 3.45.1 to 3.47.0. The database file format doesn't change, so existing databases are unaffected. Fixes #2070 Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Benoît CORTIER <git.divisible626@passmail.com>
Windows Rust CI aborted pnpr-auth libsql tests with 0xc0000005 (four times recently: registration_cap_is_strict_across_backend_instances three times, sync_server::bulk_revocation_refreshes_a_replica_whose_tokens_went_elsewhere once). libsql's LibsqlConnection::drop calls disconnect() and then the inner local::Connection's own Drop calls disconnect() again. Both see the drop_ref Arc as unique, so sqlite3_close_v2 runs a second time on a handle SQLite already freed. Usually it reads freed memory as "closed" and reports misuse. When another thread reused that memory, it read garbage and crashed. The tests that crashed run several connections concurrently, which makes reuse likely. Upstream fixed it in tursodatabase/libsql#2261 (released in 0.10.0), which nulls the handle after the first close. pnpr stays on the pnpm fork because tursodatabase/libsql#2301 is still unreleased, so the fix is cherry-picked onto the fork's pnpm-0.9.30 branch. The new test installs an SQLite log callback, drops one connection and asserts no misuse was logged. It logged two before the fork bump. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows Rust CI aborted pnpr-auth libsql tests with 0xc0000005 (four times recently: registration_cap_is_strict_across_backend_instances three times, sync_server::bulk_revocation_refreshes_a_replica_whose_tokens_went_elsewhere once). libsql's LibsqlConnection::drop calls disconnect() and then the inner local::Connection's own Drop calls disconnect() again. Both see the drop_ref Arc as unique, so sqlite3_close_v2 runs a second time on a handle SQLite already freed. Usually it reads freed memory as "closed" and reports misuse. When another thread reused that memory, it read garbage and crashed. The tests that crashed run several connections concurrently, which makes reuse likely. Upstream fixed it in tursodatabase/libsql#2261 (released in 0.10.0), which nulls the handle after the first close. pnpr stays on the pnpm fork because tursodatabase/libsql#2301 is still unreleased, so the fix is cherry-picked onto the fork's pnpm-0.9.30 branch. The new test installs an SQLite log callback, drops one connection and asserts no misuse was logged. It logged two before the fork bump. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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