bun:sqlite: fix use-after-free closing a database with FTS5 tables - #37045
Conversation
close() swept every statement on the connection via sqlite3_next_stmt and finalized the ones bun didn't keep. Virtual-table modules like FTS5 cache their own prepared statements and finalize them during vtab disconnect inside sqlite3_close, so the sweep finalized them first and the disconnect then used freed memory: a use-after-free that crashed close() on any database with an FTS5 table. Only finalize the statements bun tracks in VersionSqlite3::statements. If untracked statements still hold the connection busy (a re-entrant close() from a bound-parameter getter during db.run()), defer the close with sqlite3_close_v2 instead of finalizing them behind their owner's back.
|
Warning Review limit reached
Next review available in: 18 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughSQLite statement closing now defers database shutdown until tracked statements drain, preserves retained prepared statements for ChangesSQLite close lifecycle
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
…e3_next_stmt closeIfDrained() gated on sqlite3_next_stmt() returning null, which never happens while a virtual-table module like FTS5 holds its cached statements, so a close() deferred by a kept prepare() statement never completed and the file handle stayed open until GC. Check the tracked statement list instead; sqlite3_close_v2 finalizes vtab-owned statements via disconnect.
|
Good catch on |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/bun/sqlite/sqlite.test.js`:
- Line 1796: Replace the dynamic require in the child fixture with a static
Database import from bun:sqlite, leaving the fixture’s usage unchanged and
avoiding unrelated test changes.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3140b3b3-8c2c-4da9-9a8f-fbf01ec8f5cf
📒 Files selected for processing (2)
src/jsc/bindings/sqlite/JSSQLStatement.cpptest/js/bun/sqlite/sqlite.test.js
There was a problem hiding this comment.
LGTM — the follow-up on closeIfDrained() addresses my earlier concern; it now drains on the bun-tracked statement list and sqlite3_close_v2 handles vtab-owned statements via disconnect.
What was reviewed:
- Traced
close()/close(true)with FTS5:sqlite3CloserunsdisconnectAllVtabbeforeconnectionIsBusy, so FTS5's cached statements are gone before the busy check; theSQLITE_BUSYfallback is only reachable for the re-entrantdb.run()transient statement. - Verified
AutoDestructingSQLiteStatementfinalizessql.stmton scope exit against a zombified connection, completing the deferred close (previously the sweep freed it mid-bind, so this is strictly safer). - Checked
~JSSQLStatementremovesthisfromstatementsbeforecloseIfDrained()iterates it, andjsSQLStatementFunctionFinalizenullsstmtfirst — no self-blocking on the drain check.
Extended reasoning...
Overview
Fixes a heap-use-after-free when db.close() is called on a bun:sqlite database that has an FTS5 virtual table. #36573 introduced a sweep over sqlite3_next_stmt() that finalized every statement on the connection, including FTS5's internally-cached prepared statements; FTS5 then finalized those same (freed) statements again during vtab disconnect inside sqlite3_close. The fix stops touching statements bun doesn't own: close() now iterates only versionDB->statements (bun's tracked wrappers), and if sqlite3_close() reports SQLITE_BUSY for a genuinely-untracked transient statement (the re-entrant db.run() bind case), it falls back to sqlite3_close_v2() which zombifies until the scoped owner finalizes.
I flagged a follow-up in my first pass: closeIfDrained() still gated on !sqlite3_next_stmt(db, nullptr), which never goes null while FTS5 holds cached statements, so a close(false)-deferred close would never complete for FTS5 databases. That was fixed in 97187c8 — closeIfDrained() now checks whether every bun-tracked JSSQLStatement::stmt is null and calls closeHandle() (sqlite3_close_v2), which handles vtab-owned statements via disconnectAllVtab. A new test covers exactly that path (FTS5 + prepare() + close() + finalize() → rmSync).
Security risks
None. This is a crash/UAF fix — it narrows what bun finalizes to what bun owns, which is the correct ownership discipline. No new user-controlled input reaches native code.
Level of scrutiny
High — native C++ memory-safety code in a widely-used API. I traced each path:
close(false)with FTS5 only: bun's statements are finalized,keptAny=false,sqlite3_close_v2runsdisconnectAllVtab(FTS5 finalizes its own), returns OK.close(true)with FTS5 only: same, viasqlite3_close(disconnect precedes the busy check insqlite3Close).- Re-entrant
db.close()from a bound-parameter getter duringdb.run(): the transientAutoDestructingSQLiteStatementis not inversionDB->statements;sqlite3_close_v2zombifies,versionDB->db=nullptr, the caller detectshandle()!=db, throws, and~AutoDestructingSQLiteStatementfinalizes the transient statement on the zombie — completing the close. Covered by the existing "run() reports a closed database when a bound parameter's getter closes it" test. closeIfDrained()callers:jsSQLStatementFunctionFinalizenullsstmtbefore calling it;~JSSQLStatementremovesthisfrom the set before calling it. Neither self-blocks the drain.SQLITE_BUSYfallback narrowed from!= SQLITE_OK && forceto== SQLITE_BUSY:sqlite3_closeonly returns OK/BUSY/MISUSE, andsqlite3_close_v2never returns BUSY, so the non-force path never hits the fallback and MISUSE correctly still surfaces via the later throw check.
Other factors
Two new tests (subprocess crash-repro looping over both close(true)/close(false), and the FTS5+prepare deferred-close file-release check) plus the full existing close-semantics suite (114 pass in sqlite.test.js, 115 in node/sqlite, 5 in the #14709 regression) per the PR description, verified under ASAN. All prior review feedback (mine, comment-cop on comment length, CodeRabbit on static import) has been addressed and resolved. The fix is architecturally the right layer: stop finalizing what bun doesn't own, let SQLite's documented zombie/disconnect semantics handle the rest.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Why does this not feel like the correct fix?isn’t the issue that we’re closing statements not ones by us? The issue is not that we’re closing our statements.
|
Right, that is exactly the bug and exactly what this changes. #36573 added a The other two hunks are fallout from the sweep having done double duty:
No behavior change for bun-owned statements: they are all finalized on |
|
so whats the plan here? |
|
The fix is complete and CI is green across all platforms, including regression tests for your exact repro (the close() crash loop and the deferred-close file release with FTS5). It is now waiting on maintainer review and merge; once merged it will be in the next canary build. |
Fixes #37044
Repro
Crashes in
sqlite3Fts5IndexClose/fts5DisconnectMethodundersqlite3_close. Reproduces on Linux and macOS; ASAN reports a heap-use-after-free withsqlite3_finalize(fromjsSQLStatementCloseStatementFunction) as the free site andsqlite3Fts5IndexCloseas the read.Cause
Regression from #36573.
close()sweeps every prepared statement on the connection viasqlite3_next_stmt()and finalizes the ones bun isn't keeping. Virtual-table modules like FTS5 cache their own internal prepared statements and finalize them themselves during vtab disconnect insidesqlite3_close*, so the sweep finalized them first and the disconnect then calledsqlite3_finalizeon dangling pointers. This also matches the reporter's observations:DROP TABLEbeforeclose()avoids it (teardown goes through xDestroy while the statements are still valid), as does never callingclose().Since #36573, every bun-created statement wrapper is tracked in
VersionSqlite3::statements, so the sweep's only remaining job was the transient statement indb.run()when a bound-parameter getter re-entrantly closes the database mid-bind.Fix
Only finalize the statements bun tracks. If untracked statements still make
sqlite3_close()returnSQLITE_BUSY(the re-entrantdb.run()case), retire the handle withsqlite3_close_v2(), which defers the close until the owner finalizes them, instead of finalizing other code's statements behind its back. Thedb.run()path now lets its transient statement be finalized by its scoped owner, which completes the deferred close.Verification
New test fails on the unfixed build (segfault) and passes with the fix.
bun bd test test/js/bun/sqlite/sqlite.test.js114 pass (including the #36573/#36793 close semantics tests),test/js/node/sqlite/115 pass,test/regression/issue/14709.test.ts5 pass. The reporter's 500-iteration loop printssurvivedunder the ASAN debug build.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/sqlite/sqlite.test.js