From b2f4b41692c9e9043882df90e9e8b3675bb120e0 Mon Sep 17 00:00:00 2001 From: Percy Wegmann Date: Tue, 24 Feb 2026 17:48:45 -0600 Subject: [PATCH] Add support for SQLITE_ENABLE_MULTITHREADED_CHECKS The build tag `sqlite_enable_multithreaded_checks` enables `SQLITE_ENABLE_MULTITHREADED_CHECKS` in sqlite. Note, these are not supported when using `SQLITE_OPEN_FULLMUTEX`, only when using `SQLITE_OPEN_NOMUTEX`. Updates tailscale/corp#37652 Signed-off-by: Percy Wegmann --- .github/workflows/test-sqlite.yml | 4 +- cgosqlite/cgosqlite.go | 13 +++ .../multithreaded_checks_disabled_test.go | 11 +++ .../multithreaded_checks_enabled_test.go | 11 +++ cgosqlite/multithreaded_checks_test.go | 79 +++++++++++++++++++ 5 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 cgosqlite/multithreaded_checks_disabled_test.go create mode 100644 cgosqlite/multithreaded_checks_enabled_test.go create mode 100644 cgosqlite/multithreaded_checks_test.go diff --git a/.github/workflows/test-sqlite.yml b/.github/workflows/test-sqlite.yml index 1c3c0c9..6ea6f03 100644 --- a/.github/workflows/test-sqlite.yml +++ b/.github/workflows/test-sqlite.yml @@ -28,7 +28,9 @@ jobs: - name: With sqlite_enable_tmstmpvfs run: go test -v -tags sqlite_enable_tmstmpvfs - + + - name: With sqlite_enable_multithreaded_checks + run: go test -v -tags sqlite_enable_multithreaded_checks - name: Race run: go test -v -race ./... diff --git a/cgosqlite/cgosqlite.go b/cgosqlite/cgosqlite.go index 7a5f617..dd61314 100644 --- a/cgosqlite/cgosqlite.go +++ b/cgosqlite/cgosqlite.go @@ -63,6 +63,14 @@ int tmstmpvfs_enabled=1; int tmstmpvfs_enabled=0; #endif +// Enable mutex contention warnings. +#cgo sqlite_enable_multithreaded_checks CFLAGS: -DSQLITE_ENABLE_MULTITHREADED_CHECKS +#ifdef SQLITE_ENABLE_MULTITHREADED_CHECKS +int multithreaded_checks_enabled=1; +#else +int multithreaded_checks_enabled=0; +#endif + #include "cgosqlite.h" */ import "C" @@ -529,3 +537,8 @@ func APIArmorEnabled() bool { func TimestampVFSEnabled() bool { return C.tmstmpvfs_enabled == 1 } + +// MultithreadedChecksEnabled reports whether or not sqlite was compiled with SQLITE_ENABLE_MULTITHREADED_CHECKS. +func MultithreadedChecksEnabled() bool { + return C.multithreaded_checks_enabled == 1 +} diff --git a/cgosqlite/multithreaded_checks_disabled_test.go b/cgosqlite/multithreaded_checks_disabled_test.go new file mode 100644 index 0000000..98ae540 --- /dev/null +++ b/cgosqlite/multithreaded_checks_disabled_test.go @@ -0,0 +1,11 @@ +//go:build !sqlite_enable_multithreaded_checks + +package cgosqlite + +import ( + "testing" +) + +func TestMultithreadedChecksDisabled(t *testing.T) { + testMultithreadedChecks(t, false) +} diff --git a/cgosqlite/multithreaded_checks_enabled_test.go b/cgosqlite/multithreaded_checks_enabled_test.go new file mode 100644 index 0000000..cf18c31 --- /dev/null +++ b/cgosqlite/multithreaded_checks_enabled_test.go @@ -0,0 +1,11 @@ +//go:build sqlite_enable_multithreaded_checks + +package cgosqlite + +import ( + "testing" +) + +func TestMultithreadedChecksEnabled(t *testing.T) { + testMultithreadedChecks(t, true) +} diff --git a/cgosqlite/multithreaded_checks_test.go b/cgosqlite/multithreaded_checks_test.go new file mode 100644 index 0000000..8cff87e --- /dev/null +++ b/cgosqlite/multithreaded_checks_test.go @@ -0,0 +1,79 @@ +// + +package cgosqlite + +import ( + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "testing" + + "github.com/tailscale/sqlite/sqliteh" +) + +// testMultithreadedChecks provides a common function for testing SQLITE_ENABLE_MULTITHREADED_CHECKS. +func testMultithreadedChecks(t *testing.T, wantThreadingWarning bool) { + if wantThreadingWarning && !MultithreadedChecksEnabled() { + t.Fatal("Multithreaded checks are not enabled") + } else if !wantThreadingWarning && MultithreadedChecksEnabled() { + t.Fatal("Multithreaded checks are enabled") + } + + var gotMisuseLog atomic.Bool + err := SetLogCallback(func(code sqliteh.Code, msg string) { + if code == sqliteh.SQLITE_MISUSE && msg == "illegal multi-threaded access to database connection" { + gotMisuseLog.Store(true) + } + }) + if err != nil { + t.Fatal(err) + } + + // Lock this goroutine to a thread (preventing other goroutines from using that thread) + runtime.LockOSThread() + + flags := sqliteh.SQLITE_OPEN_READWRITE | + sqliteh.SQLITE_OPEN_CREATE | + sqliteh.SQLITE_OPEN_WAL | + sqliteh.SQLITE_OPEN_URI | + sqliteh.SQLITE_OPEN_NOMUTEX + db, err := Open(filepath.Join(t.TempDir(), "test.db"), flags, "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + hitAPI := func() { + for i := 0; i < 1000 && !gotMisuseLog.Load(); i++ { + // Prepare a statement on this thread, mostly ignoring errors. + stmt, _, err := db.Prepare("CREATE TABLE t(c INTEGER PRIMARY KEY)", 0) + if err != nil { + continue + } + if _, err := stmt.Step(nil); err != nil { + continue + } + _ = stmt.Finalize() + } + } + + // Hit API on a separate goroutine as well as in this goroutine. + // Because the original goroutine locked the OS thread, this new goroutine + // will execute on a separate thread. + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + hitAPI() + }() + hitAPI() + wg.Wait() + + if wantThreadingWarning && !gotMisuseLog.Load() { + t.Fatal("did not get SQLITE_MISUSE in LogCallback") + } + if !wantThreadingWarning && gotMisuseLog.Load() { + t.Fatal("got SQLITE_MISUSE in LogCallback") + } +}