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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions