fix(deps): stop libsql closing every connection twice (#367, #308) - #370
Merged
aovestdipaperino merged 1 commit intoAug 7, 2026
Merged
Conversation
…no#367, aovestdipaperino#308) libsql's `local::Connection::disconnect` is not idempotent. `LibsqlConnection`'s `Drop` calls it, and then the inner `Connection` field's own `Drop` calls it again; the `Arc::get_mut(drop_ref)` guard only proves unique ownership, which is true on both passes, and `raw` is left dangling after the first `sqlite3_close_v2`. The second call reads an already-freed `sqlite3` object. Instrumenting the driver made the ratio exact rather than inferred: one run of `db_query_test` performs 76 `sqlite3_open_v2` calls and 152 `sqlite3_close_v2` calls, 76 returning `SQLITE_OK` and 76 returning `SQLITE_MISUSE`, and the same 1:2 ratio holds on the `GlobalDb` path. On glibc and macOS the freed page stays mapped, so the second close reads stale bytes, fails its state check, and returns harmlessly — which is why Linux and macOS have never gone red. On Windows the block can be recycled or decommitted first, and the read then either faults immediately or closes a garbage handle and corrupts the heap, which detonates later, at process exit, after every test has already reported `ok`. That is the signature aovestdipaperino#367 describes, and it explains why the same bytes pass on one run and fail on the next. Measured on Windows: `db_query_test` crashed 29 times in 500 runs (5.8%) on the unpatched build, every one of them `0xC0000005`, and 0 times in 500 runs with the fix. `resolution_test`, the suite aovestdipaperino#308 was filed against, crashes the same way (13 in 500) and is likewise clean afterwards, which ties the two issues to one cause. Serialising with `--test-threads=1` does not hide it but roughly triples it (47 in 300), and quadrupling `RUST_MIN_STACK` leaves it unchanged (12 in 300), so neither aovestdipaperino#181's `RUST_MIN_STACK` nor aovestdipaperino#337's `/STACK:8388608` could have addressed this — both target stack exhaustion, and this is a heap lifetime bug. The fix is one line: null `raw` after closing so the second pass is a no-op. It is applied in `vendor/libsql`, a copy of the published 0.9.30 crate wired in through `[patch.crates-io]`, because the bug is still present upstream in both 0.9.30 and 0.10.0-pre.4 (libsql#2251, with the equivalent fix waiting in libsql#2261). `tests/vendored_libsql_test.rs` fails if the vendored copy loses the patch, inverts it, or drifts from the requested version. `cargo package` strips `[patch.crates-io]` and leaves `vendor/libsql` out of the published archive, so this covers CI and anyone building from a checkout, while binaries produced by `cargo install tokensave` keep the upstream double free until libsql itself ships the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
oscaruiz
force-pushed
the
fix/367-libsql-double-close
branch
from
August 4, 2026 13:52
c35ab01 to
f263a27
Compare
aovestdipaperino
added a commit
to oscaruiz/tokensave
that referenced
this pull request
Aug 7, 2026
Owner
|
Thanks for this — the double-close was a nasty use-after-free and the 500-run batching made the fix airtight. Merged. |
rNoz
pushed a commit
to rNoz/tokensave
that referenced
this pull request
Aug 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
resolution_test) reproduces here and is clean after this change. Whether to reopen it or leave it closed as superseded is your call.sqlite3_close_v2reads an already-freedsqlite3object, which is a use-after-free on every platform and an intermittentSTATUS_ACCESS_VIOLATIONon Windows.[patch.crates-io], because the bug is still present upstream.Motivation
libsql::local::Connection::disconnectis not idempotent:The
Arc::get_mut(drop_ref)guard only proves unique ownership, which is true on both passes, andrawis left dangling after the first close.This was counted, not inferred. Instrumenting the driver with a print around the FFI call:
sqlite3_open_v2sqlite3_close_v2SQLITE_OKSQLITE_MISUSEdb_query_testdiscover_test+gain_test(GlobalDbpath)Exactly two closes per handle, the second always failing its state check because the handle is gone.
On glibc and macOS the freed page stays mapped, so the second close returns
SQLITE_MISUSEharmlessly — that part is measured. That the same read faults on Windows once the block has been recycled or decommitted is the explanation for the platform asymmetry, not a second measurement. It also explains the timing #367 reports: heap corruption from a double free detonates later, at process exit, after every test has already printedok.Neither #181's
RUST_MIN_STACKnor #337's/STACK:8388608could have addressed this. Both target stack exhaustion; this is a heap lifetime bug. That is also why the crash survived #308 being closed: the fix that closed it was aimed at a different mechanism.Changes
vendor/libsql/— a copy of the published libsql 0.9.30 crate. Of its 44 source files, onlysrc/local/connection.rsdiffers from the published crate, by two lines plus the comment explaining why. Upstream's MIT licence is included. Examples, tests, benches, test assets, lockfile and contributor notes are dropped;[lints]and[workspace]tables are added to its manifest.Cargo.toml—[patch.crates-io]pointing libsql at the vendored copy, with the rationale and the condition for removing it.Cargo.lock— libsql resolves through the path patch instead of the registry.tests/vendored_libsql_test.rs— four guards, so the patch cannot be lost silently: the patch entry and the vendored directory must exist together; a source checkout must carry the patch; the vendored version must match the requested one; and the fix must both be present and close the handle before nulling it, since the inverse order would pass a naive string check while closing nothing.scripts/win-stress.ps1— the batch runner used for the measurements below. It counts access violations separately from other non-zero exits, and takes a-TempDirso a long batch does not fill the system drive..gitignore— ignore the separate Windows target directory that script uses.Test plan
cargo test --workspacepasses, with one pre-existing exception noted belowcargo clippy --workspace --all-targetshas no new warningsRun against this branch's head: 67 suites green, clippy clean,
cargo fmt --all -- --checkclean.The single failure is
mcp_handler_test::run_affected_tests_dispatches_directly_changed_test_files. It fails identically onmasterwithout this branch — 190 passed, 1 failed in both — so it is pre-existing and unrelated. It passes when run on its own, so it looks like a timing flake under suite load rather than a real regression.The meaningful verification is on Windows, since the crash cannot be reproduced on Linux or macOS. 500 iterations per suite, per build:
db_query_test(named in #367)accounting_test(named in #367)resolution_test(named in #308)db_testgraph_testextraction_test(opens no database, control)Every failure is exit code
0xC0000005, and every one lands after the last test has printedok. Had the rate been unchanged, 500 clean runs would carry probability around 10⁻¹³.Two controls, both on the unpatched build, retire the standing theories:
--test-threads=1does not hide the crash but roughly triples it (47/300), so parallel test execution is not what drives it.RUST_MIN_STACKat 32 MB leaves the rate unchanged (12/300), so no amount of stack is relevant.All 111 test binaries in the workspace were also built for Windows and run twice with this fix and #367's companion drop-order fix applied together: 0 access violations, 0 other failures across 222 runs. That sweep bounds the two changes jointly, not this one alone.
These numbers come from the
x86_64-pc-windows-gnutoolchain; this machine has no Visual Studio Build Tools. The crash reproduces there anyway, which shows it does not depend on the MSVC CRT or on #337's link flag, but equivalence with the MSVC toolchain CI uses has not been demonstrated directly.Note on scope
cargo packagestrips[patch.crates-io]and leavesvendor/libsqlout of the published archive — verified against the generated.crate. So this covers CI and anyone building from a checkout, while binaries produced bycargo install tokensavekeep the upstream double free until libsql itself ships the fix.The bug is reported upstream as libsql#2251, with the equivalent fix waiting in libsql#2261 since July. Both
0.9.30and0.10.0-pre.4still carry it. The vendored copy and the[patch]entry should be deleted together the moment an upstream release lands;tests/vendored_libsql_test.rsis written so that removing all three at once is the only clean way to do it.Checklist
CHANGELOG.mdupdated under[Unreleased].envfiles includedvendor/libsql/LICENSE.md.