Local connection double-close (use-after-free) on teardown — sqlite3_close_v2 called twice
Repro
Cargo.toml:
[dependencies]
libsql = { version = "0.9.30", default-features = false, features = ["core"] }
tokio = { version = "1", features = ["rt", "macros"] }src/main.rs:
fn main() {
for _ in 0..50_000 {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async {
let db = libsql::Builder::new_local(":memory:").build().await.unwrap();
let conn = db.connect().unwrap();
conn.execute("CREATE TABLE t(x INTEGER)", ()).await.unwrap();
});
}
}What happens
The local connection's sqlite3 handle is sqlite3_close_v2'd twice on teardown. Under valgrind this is fully deterministic — 800 errors from 1 context at 800 iterations, one per connection:
Invalid read of size 1
at sqlite3SafetyCheckSickOrOk (sqlite3.c)
by sqlite3_close_v2
by libsql::local::connection::Connection::disconnect (connection.rs:110)
by <Connection as Drop>::drop (connection.rs:40) <- second close
Address ... is freed by
by sqlite3_close_v2
by libsql::local::connection::Connection::disconnect (connection.rs:110)
by <LibsqlConnection as Drop>::drop (impls.rs:110) <- first close
Block was alloc'd at
by sqlite3_open_v2
by libsql::local::connection::Connection::connect (connection.rs:55)LibsqlConnection's Drop calls disconnect(), then the inner Connection's Drop calls it again, both via Connection::disconnect (connection.rs:110). disconnect() isn't idempotent, and the Arc::get_mut(drop_ref) guard only checks unique ownership (true both times) — it doesn't record that the handle was already closed.
On Windows this is a hard STATUS_ACCESS_VIOLATION (0xC0000005): cargo run --release exits -1073741819, usually within the first few hundred iterations (it's stochastic — rerun if a run finishes). On Linux it doesn't fault (glibc keeps the freed page mapped) so it passes silently, but the double free is real, as valgrind shows. Building and dropping a fresh current-thread runtime per iteration (what every #[tokio::test] does) is what made it reliable for me.
Environment
- libsql / libsql-ffi / libsql-sys 0.9.30, stable Rust
- Native crash: Windows 11,
x86_64-pc-windows-msvc - valgrind: Linux
x86_64(valgrind /target/debug/lsrepro 800) - Same behavior with features
["core", "remote", "tls"]
Ruled out
- Not the cold-init race —
local::Database::newguardssqlite3_config(SQLITE_CONFIG_SERIALIZED)+sqlite3_initialize()behind aOnce. - ffi is built with
-DSQLITE_THREADSAFE=1. - #2118 (
RefCell→RwLock) is already in 0.9.30. - Not test-harness parallelism — the repro is single-threaded.
Looks like either disconnect() should be idempotent (null raw after sqlite3_close_v2) or only one of LibsqlConnection / Connection should own the close. Happy to test a patch.
Source: tursodatabase/libsql