Skip to content

Local connection double-close (use-after-free) on teardown — sqlite3_close_v2 called twice #2251

Description

@WilliamIsted

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::new guards sqlite3_config(SQLITE_CONFIG_SERIALIZED) + sqlite3_initialize() behind a Once.
  • ffi is built with -DSQLITE_THREADSAFE=1.
  • libsql: Use RwLock instead of RefCell for local connection #2118 (RefCellRwLock) 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions