Why: Two processes opening the same SQLite file both migrate and reconcile, so the second one seals the first one's live runs. The activity lock is taken before open, and durability settings are read back on the application connection. Evidence: SELF go test ./internal/instanceguard ./internal/store ./internal/app (filtered T8 cases) on feature/u8-sqlite-instance-guard. PG durability readback is NOT_RUN locally; Dev CI test-backend-pg is the GATE. Impact: SQLite serve and migrate share one non-blocking flock. A replaced database or rebuilt lock file refuses start unless --adopt-instance-guard. PostgreSQL serve refuses when fsync, full_page_writes, or synchronous_commit is off. Refs: docs/recovery/u8-sqlite-instance-guard.md Co-authored-by: Cursor <cursoragent@cursor.com>
311 lines
7.0 KiB
Go
311 lines
7.0 KiB
Go
//go:build unix
|
|
|
|
package instanceguard
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"agent-system-backend/internal/store"
|
|
)
|
|
|
|
func TestAcquireSameResourceAcrossAliases(t *testing.T) {
|
|
base := t.TempDir()
|
|
real := filepath.Join(base, "real")
|
|
if err := os.Mkdir(real, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
link := filepath.Join(base, "link")
|
|
if err := os.Symlink(real, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
held, err := Acquire(link)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer held.Release()
|
|
if _, err := Acquire(real); !errors.Is(err, ErrBusy) {
|
|
t.Fatalf("symlink alias: %v", err)
|
|
}
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(base); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(wd) })
|
|
if _, err := Acquire(filepath.Join(".", "real")); !errors.Is(err, ErrBusy) {
|
|
t.Fatalf("relative path: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHardlinkRejected(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db := filepath.Join(dir, "agent-system.db")
|
|
st, err := store.OpenSQLite(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Link(db, filepath.Join(dir, "alias.db")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Acquire(dir); !errors.Is(err, ErrHardlink) {
|
|
t.Fatalf("hardlink: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDatabaseReplacementRefusedBeforeOpen(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, release, err := openAndBind(t, dir, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db := filepath.Join(dir, "agent-system.db")
|
|
release()
|
|
_ = st
|
|
if err := os.Remove(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(db, []byte("replaced"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer lease.Release()
|
|
if err := lease.AutomaticRefusal(); !errors.Is(err, ErrDBReplaced) {
|
|
t.Fatalf("replaced db: %v", err)
|
|
}
|
|
if _, err := os.Stat(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestMissingRecordedDatabaseRefused(t *testing.T) {
|
|
dir := t.TempDir()
|
|
_, release, err := openAndBind(t, dir, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release()
|
|
db := filepath.Join(dir, "agent-system.db")
|
|
if err := os.Remove(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer lease.Release()
|
|
if err := lease.AutomaticRefusal(); !errors.Is(err, ErrDBMissing) {
|
|
t.Fatalf("missing db: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRebuiltLockRefusedUntilAdopt(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, release, err := openAndBind(t, dir, "inst-a")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release()
|
|
_ = st.Close()
|
|
if err := os.Remove(filepath.Join(dir, "agent-system.db.lock")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := lease.AutomaticRefusal(); err != nil {
|
|
lease.Release()
|
|
t.Fatal(err)
|
|
}
|
|
opened, err := store.OpenSQLite(lease.DBPath())
|
|
if err != nil {
|
|
lease.Release()
|
|
t.Fatal(err)
|
|
}
|
|
err = Bind(opened, lease, BindOptions{InstanceID: "inst-a"})
|
|
_ = opened.Close()
|
|
lease.Release()
|
|
if !errors.Is(err, ErrLockRebuilt) {
|
|
t.Fatalf("rebuilt lock: %v", err)
|
|
}
|
|
|
|
adopted, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer adopted.Release()
|
|
opened, err = store.OpenSQLite(adopted.DBPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer opened.Close()
|
|
if err := Bind(opened, adopted, BindOptions{Adopt: true, InstanceID: "inst-a"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestInstanceMismatchPreservesEmpty(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, release, err := openAndBind(t, dir, "inst-a")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release()
|
|
_ = st.Close()
|
|
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
opened, err := store.OpenSQLite(lease.DBPath())
|
|
if err != nil {
|
|
lease.Release()
|
|
t.Fatal(err)
|
|
}
|
|
err = Bind(opened, lease, BindOptions{InstanceID: "inst-b"})
|
|
_ = opened.Close()
|
|
lease.Release()
|
|
if !errors.Is(err, ErrInstanceMismatch) {
|
|
t.Fatalf("mismatch: %v", err)
|
|
}
|
|
|
|
lease, err = Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer lease.Release()
|
|
opened, err = store.OpenSQLite(lease.DBPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer opened.Close()
|
|
if err := Bind(opened, lease, BindOptions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
marker, ok, err := opened.LoadInstanceGuard()
|
|
if err != nil || !ok || marker.InstanceID != "inst-a" {
|
|
t.Fatalf("empty id should keep marker: ok=%v id=%q err=%v", ok, marker.InstanceID, err)
|
|
}
|
|
}
|
|
|
|
func TestLockReleasedAfterKill(t *testing.T) {
|
|
if os.Getenv("U8_GUARD_ROLE") == "hold" {
|
|
lease, err := Acquire(os.Getenv("U8_GUARD_DIR"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(os.Getenv("U8_GUARD_READY"), []byte("ready"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(30 * time.Second)
|
|
lease.Release()
|
|
return
|
|
}
|
|
dir := t.TempDir()
|
|
ready := filepath.Join(dir, "ready")
|
|
cmd := exec.Command(os.Args[0], "-test.run=TestLockReleasedAfterKill")
|
|
cmd.Env = append(os.Environ(), "U8_GUARD_ROLE=hold", "U8_GUARD_DIR="+dir, "U8_GUARD_READY="+ready)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
_, _ = cmd.Process.Wait()
|
|
}
|
|
})
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(ready); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
if _, err := os.Stat(ready); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Acquire(dir); !errors.Is(err, ErrBusy) {
|
|
t.Fatalf("held: %v", err)
|
|
}
|
|
if err := cmd.Process.Kill(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _ = cmd.Process.Wait()
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lease.Release()
|
|
}
|
|
|
|
func TestSymlinkedDatabaseLocksResolvedFile(t *testing.T) {
|
|
base := t.TempDir()
|
|
realDir := filepath.Join(base, "real")
|
|
aliasDir := filepath.Join(base, "alias")
|
|
if err := os.Mkdir(realDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(aliasDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(realDir, "agent-system.db"), []byte{}, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(filepath.Join(realDir, "agent-system.db"), filepath.Join(aliasDir, "agent-system.db")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
held, err := Acquire(aliasDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer held.Release()
|
|
if _, err := os.Stat(filepath.Join(realDir, "agent-system.db.lock")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Acquire(realDir); !errors.Is(err, ErrBusy) {
|
|
t.Fatalf("resolved db lock: %v", err)
|
|
}
|
|
}
|
|
|
|
func openAndBind(t *testing.T, dir, instanceID string) (*store.Store, func(), error) {
|
|
t.Helper()
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := lease.AutomaticRefusal(); err != nil {
|
|
lease.Release()
|
|
return nil, nil, err
|
|
}
|
|
st, err := store.OpenSQLite(lease.DBPath())
|
|
if err != nil {
|
|
lease.Release()
|
|
return nil, nil, err
|
|
}
|
|
if err := Bind(st, lease, BindOptions{InstanceID: instanceID}); err != nil {
|
|
_ = st.Close()
|
|
lease.Release()
|
|
return nil, nil, err
|
|
}
|
|
return st, func() {
|
|
_ = st.Close()
|
|
lease.Release()
|
|
}, nil
|
|
}
|