tursodatabase / tursodatabase/libsql
Local connection double-close (use-after-free) on teardown — sqlite3_close_v2 called twice
Nobody has claimed this yet.
- Dominant language
- C
- Stars
- 17.2k
- Forks
- 531
- Avg merge
- 1h 12m
- Merged PRs (30d)
- 1
Description
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with local::connection::Connection in connection.rs, especially disconnect at line 110 and its Drop implementation, then inspect LibsqlConnection's Drop in impls.rs. Run the provided 50,000-iteration reproduction under Valgrind or on Windows and trace both teardown paths. Done means the handle is closed only once without the reported invalid read or access violation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust, sqlite
- Domain
- databases
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100