Skip to content

SQLite: do not clear the rollback hook through a handle that is closing - #38880

Open
HuzaifaChaudary wants to merge 1 commit into
dotnet:mainfrom
HuzaifaChaudary:fix/rollback-hook-on-closing-handle
Open

SQLite: do not clear the rollback hook through a handle that is closing#38880
HuzaifaChaudary wants to merge 1 commit into
dotnet:mainfrom
HuzaifaChaudary:fix/rollback-hook-on-closing-handle

Conversation

@HuzaifaChaudary

Copy link
Copy Markdown

Fixes#38854

the crash

PruneCallback disposes a pooled connection, and the reporter's stack ends like this:

at Microsoft.Data.Sqlite.SqliteTransaction.RollbackExternal(Object userData)
at SQLitePCL.SQLite3Provider_e_sqlite3.rollback_hook_bridge_impl(IntPtr p)
at SQLitePCL.SQLite3Provider_e_sqlite3.NativeMethods.sqlite3_close_v2(IntPtr db)
at SQLitePCL.sqlite3.ReleaseHandle()
at System.Runtime.InteropServices.SafeHandle.Dispose()
at Microsoft.Data.Sqlite.SqliteConnectionInternal.Dispose()
at Microsoft.Data.Sqlite.SqliteConnectionPool.DisposeConnection(...)
at Microsoft.Data.Sqlite.SqliteConnectionPool.PruneCallback(Object _)

reading it from the bottom, the pool disposes the connection, sqlite3_close_v2 rolls back the transaction that was still open, and that fires the rollback hook. RollbackExternal then asks the same handle to clear the hook:

sqlite3_rollback_hook(_connection!.Handle,null,null);

the handle is inside ReleaseHandle at that moment, so DangerousAddRef throws ObjectDisposedException. it comes out of a native callback, so there is nothing to catch it, and on the prune timer that means an unhandled exception on a thread pool thread. the process is gone.

#38574 guarded SqliteConnectionInternal.Deactivate for the same underlying reason, but as the reporter says, that is not the frame that kills the process.

the fix

skip clearing the hook when the handle is already closed. it is being torn down with the connection anyway, so there is nothing to unregister:

varhandle=_connection!.Handle;if(handleis{IsClosed:false,IsInvalid:false}){sqlite3_rollback_hook(handle,null,null);}

that is the same shape as the guard #38574 put in Deactivate, so the two read alike.

i put the same guard in RollbackInternal, because the second stack in the report goes through there:

at Microsoft.Data.Sqlite.SqliteTransaction.RollbackInternal()
at Microsoft.EntityFrameworkCore.Storage.RelationalTransaction.DisposeAsync()

that one is caught by EF and only logged, so it is not fatal, but it is the same call into a dead handle. when the handle is gone there is no transaction left to roll back either, so the ROLLBACK; is skipped with it. happy to drop that half if you would rather keep this to the fatal path only, it is the one part of the change i could not write a test for.

reproducing it

the reporter's snippet, as a test:

usingvarconnection=newSqliteConnection("Data Source=:memory:");connection.Open();usingvartransaction=connection.BeginTransaction();connection.Handle!.Dispose();Assert.True(transaction.ExternalRollback);

this does not fail on main, it takes the test host down with it:

Unhandled exception. System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'SQLitePCL.sqlite3'.
at System.Runtime.InteropServices.SafeHandle.DangerousAddRef(Boolean& success)
at Microsoft.Data.Sqlite.SqliteTransaction.RollbackExternal(Object userData) in SqliteTransaction.cs:line 239
at SQLitePCL.SQLite3Provider_sqlite3.rollback_hook_bridge_impl(IntPtr p)
Exit code: 134

which is why the before column below is short. the run does not finish.

verification

suitebeforeafter
Microsoft.Data.Sqlite.sqlite3.Testsexit 134, run aborted at 683, error: 1exit 0, 701 total, 0 failed
Microsoft.Data.Sqlite.sqlite3mc.Testsexit 0, 702 total, 0 failed
EFCore.Sqlite.Testsexit 0, 890 total, 0 failed

one thing i did not change

SqliteConnectionPool.PruneCallback and SqliteConnectionFactory.PruneCallback are both Timer callbacks with no try. anything that throws inside either one still ends the process, so this fix removes the cause that was reported rather than the class of failure. i did not add a blanket catch because swallowing errors on a background timer is a call for you to make, not something to slip into a bug fix. tell me if you want it and i will add it here.


disclaimer: this contribution was prepared with the assistance of an ai agent. i reproduced the crash from the report first, confirmed the exact frame, and ran the three suites above locally before opening this.

sqlite calls the rollback hook while it rolls back, and that includes the
rollback it does for you inside sqlite3_close_v2. by that point the handle is
already inside ReleaseHandle, so RollbackExternal asking it to clear the hook
hits DangerousAddRef on a closed handle and throws
the throw comes out of a native callback so there is nowhere for it to go. when
the close happens on the pool prune timer it is an unhandled exception on a
thread pool thread and the process dies. that is the crash in the report
the hook is being torn down with the connection anyway so skipping it when the
handle is already closed loses nothing. did the same in RollbackInternal since
the second stack in the report goes through there
before this the test suite aborts with exit 134 partway through. after it runs
to the end
@HuzaifaChaudary
HuzaifaChaudary requested a review from a team as a code ownerAugust 28, 2026 21:46
CopilotAI lite review requested due to automatic review settings August 28, 2026 21:46

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The fatal path is correctly guarded and tested; the noted supplemental coverage gap is non-blocking.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment on lines +228 to +229
if (!ExternalRollback
&& _connection!.Handle is { IsClosed: false, IsInvalid: false })
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SQLite: unhandled ObjectDisposedException can terminate process during connection pool pruning

3 participants

@HuzaifaChaudary@AndriySvyryd