Commit 5dcac5fb authored by cznic's avatar cznic
Browse files

sqlite: add the opt-in OFDLocking switch to Linux OFD locks

The C side of #255 ships in the
transpiled SQLite 3.53.4 sources: opt-in, off by default, process-wide,
frozen at the process's first lock attempt, with a latched POSIX fallback
on kernels that reject F_OFD_*. This adds the Go-facing half: OFDLocking
switches the mode (overriding MODERNC_SQLITE_OFD_LOCK) and
OFDLockingEnabled reports it, with ErrOFDLockingTooLate and
ErrOFDLockingUnavailable mapping the gate's refusals. The scenario tests
from merge request !136 now run in a re-executed child process with the
mode really on (and the both-modes invariants also in the parent's
inherited mode), TestOFDLockingSetter covers the Go call path end to end
against /proc/locks, and the other platforms assert the switch reports
itself unavailable.

Co-Authored-By: default avatarClaude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qsot8zfLpqv4jHfpRgEq94
parent 51d26771
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -11,8 +11,8 @@ The hand-written Go on top of that transpiled core implements the `database/sql/
## Repository layout (the parts that aren't self-evident)

- `sqlite.go`, `conn.go`, `driver.go`, `stmt.go`, `rows.go`, `tx.go`, `backup.go`, `error.go`, `result.go`, `convert.go` — hand-written `database/sql/driver` implementation calling into `lib/`.
- `vtab.go`, `pre_update_hook.go`, `fcntl.go`, `mutex.go` — Go-facing extensions wired to SQLite hooks/trampolines.
- `lib/` — transpiled SQLite 3.53.3. One `sqlite_<goos>_<goarch>.go` per supported triple; `defs.go`, `hooks.go`, `hooks_linux_arm64.go`, `mutex.go`, plus `libsqlite3_freebsd.go`/`libsqlite3_windows.go` hold hand-written patches that augment the generated code. Import as `sqlite3 "modernc.org/sqlite/lib"`.
- `vtab.go`, `pre_update_hook.go`, `fcntl.go`, `mutex.go`, `ofd.go` — Go-facing extensions wired to SQLite hooks/trampolines (`ofd.go`: the process-wide opt-in switch to Linux OFD locks, backed by `modernc_ofd_locking()` in the transpiled library; the C side lives in `../libsqlite3/internal/sqlite_issue255.patch{,2}`).
- `lib/` — transpiled SQLite 3.53.4. One `sqlite_<goos>_<goarch>.go` per supported triple plus build-tagged `sqlite_g_*.go` files holding declarations `modernc.org/undup` deduplicated across triples (so to check what code a target compiles, resolve its full GoFiles via `go list`, not by filename); `defs.go`, `hooks.go`, `hooks_linux_arm64.go`, `mutex.go`, plus `libsqlite3_freebsd.go`/`libsqlite3_windows.go` hold hand-written patches that augment the generated code. Import as `sqlite3 "modernc.org/sqlite/lib"`.
- `vec/` — transpiled `sqlite-vec` v0.1.9, auto-registers via `sqlite3_auto_extension` in `patches.go` on package init. Activate by blank-importing: `_ "modernc.org/sqlite/vec"`. Covers the same 19 targets `lib/` does; `vec_test.go`'s `//go:build` constrains by GOOS only.
- `vfs/` — exposes a Go `fs.FS` as a read-only SQLite VFS. `vfs.New(fsys)` returns a registered VFS name; open with `?vfs=<name>`. C side is transpiled per platform from `vfs/c/vfs.c` via the `vfs/Makefile`.
- `vtab/` — Go-facing virtual-table API (no dependency on the transpiled C). `vtab.RegisterModule(db, name, module)` registers modules on **new connections only**; a nil `db` targets the driver registered as `sqlite`, a non-nil `db` the driver backing it (via `vtab.ModuleRegisterer`). The bridge to C lives in the top-level `vtab.go`. See `vtab/doc.go` for the contract (Updater/Renamer/Transactional optional interfaces, re-entrancy rules, ArgIndex/Omit semantics).
+10 −0
Original line number Diff line number Diff line
@@ -19,6 +19,16 @@
// and re-consults Cache.Fetch on every SQLite request, so a bounded
// and evicting purgeable cache works as the C contract intends.
//
// # OFD locking (Linux)
//
// On Linux the library can take Open File Description (OFD) locks instead of
// POSIX record locks on database files, which stops an unrelated os.File
// close anywhere in the process from silently stripping SQLite's transaction
// locks. The switch is process-wide, off by default, and must happen before
// the first connection is opened: set MODERNC_SQLITE_OFD_LOCK=1 in the
// environment the process starts with, or call [OFDLocking] from Go. See the
// [OFDLocking] documentation for the full contract.
//
// # Fragile modernc.org/libc dependency
//
// When you import this package you should use in your go.mod file the exact

ofd.go

0 → 100644
+99 −0
Original line number Diff line number Diff line
// Copyright 2026 The Sqlite Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package sqlite // import "modernc.org/sqlite"

import "errors"

// ErrOFDLockingUnavailable is returned by [OFDLocking] where OFD locks do
// not exist: on every platform but Linux, and on Linux after the kernel or
// the filesystem has rejected them and the library has fallen back to POSIX
// locks for the remainder of the process.
var ErrOFDLockingUnavailable = errors.New(
	"sqlite: OFD locking is not available on this platform or kernel")

// ErrOFDLockingTooLate is returned by [OFDLocking] when the locking mode can
// no longer be changed: a database file lock has already been attempted in
// this process, and from the first lock attempt on the mode is fixed,
// because locks of the two kinds do not release one another and switching
// with locks in the wild would leave them behind. Enable OFD locking before
// the first connection is opened.
var ErrOFDLockingTooLate = errors.New(
	"sqlite: OFDLocking called after the first database file lock in this process")

// OFDLocking switches SQLite between POSIX record locks (the default) and
// Linux Open File Description (OFD) locks for database files, process-wide.
// It returns the setting previously in effect; when err is non-nil the
// returned prev is meaningless — use [OFDLockingEnabled] for the current
// state.
//
// A POSIX record lock belongs to the (process, inode) pair: the kernel drops
// every POSIX lock the process holds on a file at any close(2) of any
// descriptor of that file. Code as innocent as
//
//	f, _ := os.Open(dbPath) // hash, back up, inspect, ...
//	f.Close()
//
// anywhere in the process — a third-party library included — therefore
// silently strips SQLite's own transaction locks and leaves the database
// unprotected against other processes. An OFD lock belongs to the open file
// description through which it was placed and survives such a close. OFD
// locking is opt-in and off by default: unless it is enabled, nothing about
// locking changes. See https://gitlab.com/cznic/sqlite/-/issues/255 for
// background and measurements.
//
// The switch is process-wide by necessity, which is why it is not a DSN
// parameter: POSIX and OFD locks taken by one process are different owners
// to the kernel and conflict with each other, so every connection to a given
// database file inside one process must use the same kind.
//
// OFDLocking must be called before the first database file is opened. From
// the process's first lock attempt on, the mode is fixed and calls
// attempting to change it return [ErrOFDLockingTooLate]; querying with
// [OFDLockingEnabled], and calling OFDLocking with the value already in
// effect, always work. The call overrides the MODERNC_SQLITE_OFD_LOCK
// environment variable, which enables OFD locking when set to anything but
// the empty string or a value starting with "0" and which the library reads
// once, at initialization time, from the environment the process started
// with — to switch from Go, prefer this call over os.Setenv.
//
// OFD locks exist on Linux only; everywhere else OFDLocking returns
// [ErrOFDLockingUnavailable]. On Linux kernels older than 3.15, and on
// filesystems that reject OFD locks, the first lock attempt fails with
// EINVAL and the library permanently falls back to POSIX locks: OFDLocking
// returns [ErrOFDLockingUnavailable] from then on and [OFDLockingEnabled]
// reports false, which is how a caller can detect the fallback.
//
// Enabling OFD locking covers the locks on the database file itself; the
// locks coordinating WAL mode through the -shm file remain POSIX locks. And
// one visible behavior change comes with the immunity: code in the same
// process that takes fcntl record locks of its own on a database file used
// to share ownership with SQLite's locks — never conflicting, while quietly
// destroying them — whereas with OFD locking enabled such locks conflict
// with SQLite's and fail loudly instead.
//
// OFDLocking is safe for concurrent use.
func OFDLocking(on bool) (prev bool, err error) {
	arg := int32(0)
	if on {
		arg = 1
	}
	switch rc := ofdLocking(arg); rc {
	case -1:
		return false, ErrOFDLockingUnavailable
	case -2:
		return false, ErrOFDLockingTooLate
	default:
		return rc != 0, nil
	}
}

// OFDLockingEnabled reports whether Linux Open File Description locks are in
// effect for database files in this process; see [OFDLocking]. It reports
// false where OFD locks are unavailable: on every platform but Linux, and on
// Linux once the kernel has rejected them and the library has fallen back to
// POSIX locks.
func OFDLockingEnabled() bool {
	return ofdLocking(-1) == 1
}
+231 −11
Original line number Diff line number Diff line
@@ -4,27 +4,126 @@

//go:build linux

package sqlite
package sqlite // import "modernc.org/sqlite"

import (
	"bytes"
	"context"
	"database/sql"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"syscall"
	"testing"
)

const (
	fOfdSetlk = 37

	// ofdChildEnvVar marks the child process a test re-executed with OFD
	// locking enabled; see inOFDChild.
	ofdChildEnvVar = "MODERNC_SQLITE_TEST_OFD_CHILD"
)

// TestOFDLockSurvivesOSClose verifies that closing an unrelated os.File descriptor
// pointing to the same inode does not strip the active SQLite database lock on Linux.
// Under standard POSIX inode locks (F_SETLK), close() on any descriptor drops
// all locks for that inode across the entire process. With Open File Description
// (OFD) locking (F_OFD_SETLK), locks are attached to the open file description,
// reexecTest re-runs the calling test alone in a child process with the
// given environment and propagates the child's outcome: a skipped child
// skips the caller, a failed or absent run fails it.
func reexecTest(t *testing.T, env []string) {
	t.Helper()
	exe, err := os.Executable()
	if err != nil {
		t.Fatal(err)
	}

	cmd := exec.Command(exe, "-test.run=^"+t.Name()+"$", "-test.v")
	cmd.Env = env
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("child process: %v\n%s", err, out)
	}

	switch {
	case bytes.Contains(out, []byte("--- SKIP")):
		t.Skipf("child process skipped:\n%s", out)
	case !bytes.Contains(out, []byte("--- PASS")):
		t.Fatalf("child process did not run the test:\n%s", out)
	}
}

// inOFDChild makes the calling test run with OFD locking enabled. The mode
// is process-wide and frozen at the process's first database file lock, so a
// test cannot enable it inside the test binary the rest of the suite runs
// in; instead the test re-executes itself in a child process with
// MODERNC_SQLITE_OFD_LOCK=1 set.
//
// In the child (recognized by ofdChildEnvVar) it verifies the environment
// variable really switched the mode on — the positive control that keeps
// this coverage from silently reverting to POSIX mode — and returns true:
// the caller proceeds with the scenario. In the parent it runs the child,
// propagates its outcome, and returns false; a caller whose scenario holds
// under both locking modes then runs it in the parent process too, in
// whatever mode that process inherited.
func inOFDChild(t *testing.T) bool {
	t.Helper()
	if os.Getenv(ofdChildEnvVar) != "" {
		if !OFDLockingEnabled() {
			t.Fatal("child process: MODERNC_SQLITE_OFD_LOCK=1 did not enable OFD locking")
		}

		return true
	}

	reexecTest(t, append(os.Environ(), "MODERNC_SQLITE_OFD_LOCK=1", ofdChildEnvVar+"=1"))
	return false
}

// procLocks returns the /proc/locks lines describing the locks held on
// path's inode, skipping the calling test where /proc/locks cannot be read.
func procLocks(t *testing.T, path string) string {
	t.Helper()
	var st syscall.Stat_t
	if err := syscall.Stat(path, &st); err != nil {
		t.Fatal(err)
	}

	b, err := os.ReadFile("/proc/locks")
	if err != nil {
		t.Skipf("cannot read /proc/locks: %v", err)
	}

	ino := fmt.Sprint(st.Ino)
	var sb strings.Builder
	for _, ln := range strings.Split(string(b), "\n") {
		f := strings.Fields(ln)
		if len(f) < 6 {
			continue
		}

		if seg := strings.Split(f[5], ":"); seg[len(seg)-1] == ino {
			sb.WriteString(ln)
			sb.WriteByte('\n')
		}
	}
	return sb.String()
}

// TestOFDLockSurvivesOSClose verifies that closing an unrelated os.File
// descriptor pointing to the same inode does not strip the active SQLite
// database lock when OFD locking is enabled. Under standard POSIX inode
// locks (F_SETLK), close() on any descriptor drops all locks for that inode
// across the entire process — which is the default behavior and the hazard
// OFD locking exists to close. With Open File Description (OFD) locking
// (F_OFD_SETLK), locks are attached to the open file description,
// preventing accidental unlock on os.Close().
func TestOFDLockSurvivesOSClose(t *testing.T) {
	if !inOFDChild(t) {
		// Under POSIX locks the close() does strip the lock; the scenario
		// only holds in the OFD-enabled child.
		return
	}

	dbPath := filepath.Join(t.TempDir(), "ofd_test.db")

	db, err := sql.Open(driverName, dbPath)
@@ -98,10 +197,14 @@ func TestOFDLockSurvivesOSClose(t *testing.T) {
// TestOFDLockInterleavedReadersWrite verifies that after two interleaved read
// transactions on separate connections in the same process have both committed,
// a subsequent write transaction on either connection succeeds without leaking
// an OFD read lock (which would cause SQLITE_BUSY).
// a read lock (which would cause SQLITE_BUSY). The invariant must hold under
// both locking modes: the scenario runs in an OFD-enabled child process and
// once more in this process's inherited mode.
//
// Authored by Jan Mercl (@cznic) in https://gitlab.com/cznic/libsqlite3/-/merge_requests/3#note_3726270793.
func TestOFDLockInterleavedReadersWrite(t *testing.T) {
	inOFDChild(t)

	dbPath := filepath.Join(t.TempDir(), "interleaved_ofd_test.db")

	db, err := sql.Open(driverName, dbPath)
@@ -127,7 +230,7 @@ func TestOFDLockInterleavedReadersWrite(t *testing.T) {
		t.Fatal(err)
	}

	// 1. Begin read transaction on c1 (acquires kernel OFD read lock via c1's fd).
	// 1. Begin read transaction on c1 (acquires the kernel read lock).
	var count int
	if _, err := c1.ExecContext(ctx, "BEGIN;"); err != nil {
		t.Fatal(err)
@@ -153,10 +256,10 @@ func TestOFDLockInterleavedReadersWrite(t *testing.T) {
	}

	// 4. Now attempt a write transaction on c2.
	// If c1's kernel OFD read lock leaked because c2's unlock was a no-op on c1's fd,
	// If c1's kernel read lock leaked because c2's unlock was a no-op on c1's fd,
	// this write will fail with SQLITE_BUSY.
	if _, err := c2.ExecContext(ctx, "BEGIN IMMEDIATE; INSERT INTO t VALUES(2); COMMIT;"); err != nil {
		t.Fatalf("write after interleaved reads failed (leaked OFD read lock): %v", err)
		t.Fatalf("write after interleaved reads failed (leaked read lock): %v", err)
	}
}

@@ -170,6 +273,8 @@ const (
// probeSharedRange attempts a conflicting F_OFD_SETLK write lock on the SHARED
// range from an independent descriptor and returns the fcntl error: nil means
// the kernel granted it, i.e. no connection in this process holds a read lock.
// Note that the probe descriptor's own close() strips POSIX locks, so probing
// is only meaningful with OFD locking enabled.
func probeSharedRange(t *testing.T, dbPath string) error {
	t.Helper()
	fd, err := syscall.Open(dbPath, syscall.O_RDWR, 0)
@@ -191,6 +296,12 @@ func probeSharedRange(t *testing.T, dbPath string) error {
// process must keep a kernel read lock on the SHARED range throughout, and the
// read-write connection must still be able to upgrade.
func TestOFDLockReadOnlyFirstLocker(t *testing.T) {
	if !inOFDChild(t) {
		// probeSharedRange's own close() would strip POSIX locks, so the
		// probe sequence only holds in the OFD-enabled child.
		return
	}

	dbPath := filepath.Join(t.TempDir(), "ofd_ro_first.db")
	ctx := context.Background()

@@ -255,8 +366,12 @@ func TestOFDLockReadOnlyFirstLocker(t *testing.T) {
// TestOFDLockFailedFirstLock: the first SHARED attempt on an inode fails after
// its PENDING lock succeeded (a foreign write lock covers the SHARED range
// only), that connection is closed, and another connection that kept the inode
// alive then reads. The read must not go through the closed descriptor.
// alive then reads. The read must not go through the closed descriptor. The
// invariant must hold under both locking modes: the scenario runs in an
// OFD-enabled child process and once more in this process's inherited mode.
func TestOFDLockFailedFirstLock(t *testing.T) {
	inOFDChild(t)

	dbPath := filepath.Join(t.TempDir(), "ofd_failed_first.db")
	ctx := context.Background()

@@ -319,3 +434,108 @@ func TestOFDLockFailedFirstLock(t *testing.T) {
		t.Fatalf("read on the surviving connection failed: %v", err)
	}
}

// TestOFDLockingTooLate: once a database file lock has been attempted in
// this process the locking mode is frozen — OFDLocking refuses to change it,
// while no-change calls and queries keep working.
func TestOFDLockingTooLate(t *testing.T) {
	// Freeze the mode by taking a lock ourselves rather than relying on the
	// rest of the suite having run first.
	dbPath := filepath.Join(t.TempDir(), "frozen.db")
	db, err := sql.Open(driverName, dbPath)
	if err != nil {
		t.Fatal(err)
	}
	defer db.Close()
	if _, err := db.Exec("CREATE TABLE t(x)"); err != nil {
		t.Fatal(err)
	}

	cur := OFDLockingEnabled()
	switch _, err := OFDLocking(!cur); err {
	case ErrOFDLockingTooLate:
		// ok
	case ErrOFDLockingUnavailable:
		// The suite ran with OFD locking requested on a kernel or
		// filesystem that rejected it.
		t.Skip("OFD locks unavailable on this kernel/filesystem")
	default:
		t.Fatalf("OFDLocking(%v) after first lock: err = %v, want ErrOFDLockingTooLate", !cur, err)
	}

	if prev, err := OFDLocking(cur); prev != cur || err != nil {
		t.Fatalf("no-change OFDLocking(%v): prev = %v, err = %v, want %v, nil", cur, prev, err, cur)
	}
	if got := OFDLockingEnabled(); got != cur {
		t.Fatalf("OFDLockingEnabled() = %v, want %v", got, cur)
	}
}

// TestOFDLockingSetter exercises the Go call path end to end in a fresh
// child process with no MODERNC_SQLITE_OFD_LOCK in the environment: enabling
// before the first connection, the kernel-visible OFDLCK record as the
// positive control, and the freeze after the first lock.
func TestOFDLockingSetter(t *testing.T) {
	const childEnvVar = "MODERNC_SQLITE_TEST_OFD_SETTER_CHILD"
	if os.Getenv(childEnvVar) == "" {
		env := []string{childEnvVar + "=1"}
		for _, kv := range os.Environ() {
			if !strings.HasPrefix(kv, "MODERNC_SQLITE_OFD_LOCK=") {
				env = append(env, kv)
			}
		}
		reexecTest(t, env)
		return
	}

	if OFDLockingEnabled() {
		t.Fatal("OFD locking on by default")
	}
	if prev, err := OFDLocking(true); prev || err != nil {
		t.Fatalf("OFDLocking(true): prev = %v, err = %v, want false, nil", prev, err)
	}
	if !OFDLockingEnabled() {
		t.Fatal("OFDLockingEnabled() = false after OFDLocking(true)")
	}

	dbPath := filepath.Join(t.TempDir(), "setter.db")
	db, err := sql.Open(driverName, dbPath)
	if err != nil {
		t.Fatal(err)
	}
	defer db.Close()
	if _, err := db.Exec("CREATE TABLE t(x)"); err != nil {
		t.Fatal(err)
	}
	tx, err := db.Begin()
	if err != nil {
		t.Fatal(err)
	}
	defer tx.Rollback()
	if _, err := tx.Exec("INSERT INTO t VALUES(1)"); err != nil {
		t.Fatal(err)
	}

	if !OFDLockingEnabled() {
		t.Skip("kernel or filesystem rejected OFD locks; POSIX fallback in effect")
	}

	// The write transaction must be holding OFDLCK, and no POSIX, locks.
	switch locks := procLocks(t, dbPath); {
	case !strings.Contains(locks, "OFDLCK"):
		t.Fatalf("no OFDLCK lock on the database file:\n%s", locks)
	case strings.Contains(locks, "POSIX"):
		t.Fatalf("unexpected POSIX lock on the database file:\n%s", locks)
	}

	if _, err := OFDLocking(false); err != ErrOFDLockingTooLate {
		t.Fatalf("OFDLocking(false) after first lock: err = %v, want ErrOFDLockingTooLate", err)
	}
	if prev, err := OFDLocking(true); !prev || err != nil {
		t.Fatalf("no-change OFDLocking(true): prev = %v, err = %v, want true, nil", prev, err)
	}

	if err := tx.Commit(); err != nil {
		t.Fatal(err)
	}
}

ofd_other_test.go

0 → 100644
+22 −0
Original line number Diff line number Diff line
// Copyright 2026 The Sqlite Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//go:build !linux

package sqlite // import "modernc.org/sqlite"

import "testing"

// TestOFDLockingUnavailable: everywhere but Linux the OFD locking switch
// reports itself unavailable and changes nothing.
func TestOFDLockingUnavailable(t *testing.T) {
	if OFDLockingEnabled() {
		t.Fatal("OFDLockingEnabled() = true")
	}
	for _, on := range []bool{true, false} {
		if _, err := OFDLocking(on); err != ErrOFDLockingUnavailable {
			t.Fatalf("OFDLocking(%v): err = %v, want ErrOFDLockingUnavailable", on, err)
		}
	}
}
Loading