Skip to content

handles.cs: Interlocked CAS for sqlite3 extra slot#666

Merged
ericsink merged 2 commits into
ericsink:mainfrom
jamescater2:fix/handles-extra-interlocked
May 6, 2026
Merged

handles.cs: Interlocked CAS for sqlite3 extra slot#666
ericsink merged 2 commits into
ericsink:mainfrom
jamescater2:fix/handles-extra-interlocked

Conversation

@jamescater2
Copy link
Copy Markdown
Contributor

handles.cs: Interlocked CAS for sqlite3 extra slot

Summary

Make sqlite3.GetOrCreateExtra and dispose_extra use Interlocked so concurrent hook registration and disposal can't race the backing extra field.

Context

sqlite3.extra is a lazily-created container that holds managed state for hooks — commit / rollback / update hooks, authoriser, progress handler, trace, profile, and the hook_handles that pin user delegates via GCHandle so SQLite's native function pointers remain valid.

#7GetOrCreateExtra TOCTOU race

Current code:

public T GetOrCreateExtra<T>(Func<T> f) where T : class, IDisposable
{
    if (extra != null) return (T)extra;
    else { var q = f(); extra = q; return q; }
}

Two threads racing first registration:

  1. Both observe extra == null
  2. Both call f() (each builds its own container)
  3. Both write extra = q; one store wins, the other's q is orphaned

The orphaned q holds GCHandles for the user's delegates, but the db's extra slot now points at a different container. On close, only extra's container is disposed — the orphaned GCHandles leak. Worse, if both threads registered their callback via the native API, the later native registration overwrites the earlier one; SQLite still holds the overwritten function pointer, but the GCHandle protecting that delegate is no longer reachable from extra → use-after-free when SQLite next invokes it.

Fix: Volatile.Read for the fast path, Interlocked.CompareExchange for the slow path, dispose the losing candidate on CAS miss.

#8dispose_extra non-atomic read-and-null

Current code:

private void dispose_extra()
{
    if (extra != null) { extra.Dispose(); extra = null; }
}

Narrow but real: a finalizer-thread dispose_extra concurrent with a user-thread GetOrCreateExtra can reorder around the non-atomic read/write. Pairing dispose_extra with Interlocked.Exchange makes the read-and-null a single atomic step and composes cleanly with the CAS in #7.

Changes

File Change
src/SQLitePCLRaw.core/handles.cs Add using System.Threading; rewrite GetOrCreateExtra with CAS; rewrite dispose_extra with Interlocked.Exchange
src/common/tests_xunit.cs test_get_or_create_extra_returns_same_instance_under_contention

Tests

test_get_or_create_extra_returns_same_instance_under_contention spawns max(4, ProcessorCount) threads that race GetOrCreateExtra, all unblocked by a ManualResetEventSlim, and asserts every caller gets the same instance back via Assert.Same. On unpatched code under contention it eventually observes the race and returns distinct instances; the assertion fails. Patched code passes deterministically.

Heads-up on CI

The lib project (SQLitePCLRaw.core.csproj) builds clean on main. The test project (tests.csproj) currently does not — src/tests/my_batteries_v2.cs on main references NativeLibrary.Load(name, assy, flags) and NativeLibrary.WHERE_PLAIN, neither of which exist in the current core surface. This is pre-existing test-infrastructure drift unrelated to this PR; #663's test-harness refactor resolves it as a side effect. CI on this PR will be red until that lands; the handles.cs changes themselves are correct on main.

Independence

This PR is independent of #663 at the library levelhandles.cs is untouched by #663. The only overlap is the shared test-harness breakage noted above. The fix stands on its own merits.

Two related fixes around the sqlite3.extra slot that holds hook-container
state (commit hooks, update hooks, etc.):

Audit ericsink#7 — GetOrCreateExtra used to do a plain check-and-set:
    if (extra != null) return (T)extra;
    else { var q = f(); extra = q; return q; }
Two threads racing the first hook registration can both observe extra
== null, both create T, and both write into extra.  One write wins;
the other container holds GCHandles for delegates the db no longer
tracks.  When the db closes, only the winning container is disposed —
the loser's GCHandles leak, and if SQLite still holds the loser's
native function pointer, it outlives managed tracking and can be
invoked on a freed handle.  Replace with Volatile.Read + Interlocked.
CompareExchange; dispose the losing candidate on a CAS miss.

Audit ericsink#8 — dispose_extra used a non-atomic null-and-dispose:
    if (extra != null) { extra.Dispose(); extra = null; }
Pair it with Interlocked.Exchange so disposal and a concurrent
creation path can't race on a half-swapped field.

Adds test_get_or_create_extra_returns_same_instance_under_contention
which spawns N threads racing GetOrCreateExtra and asserts every
caller gets the same instance back.

Note: this branch is independent of the PR currently open for the
span-overload pzTail fix (fix/prepare-v2-pztail-bounds-check).  The
new test compiles fine but main's tests.csproj currently fails to
build due to pre-existing test-infrastructure drift that the other
PR's test refactor also fixes; the two will need to land together
for CI to go green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ericsink
Copy link
Copy Markdown
Owner

What's the context for these pull requests? Are they AI-generated?

@jamescater2
Copy link
Copy Markdown
Contributor Author

jamescater2 commented Apr 24, 2026 via email

@ericsink
Copy link
Copy Markdown
Owner

Thanks for the background. I'll be looking more closely at these soon.

@ericsink ericsink merged commit 9e9fd97 into ericsink:main May 6, 2026
0 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants