Reject a rebuilt lock even when its inode is reused
ci / vet-and-test (pull_request) Successful in 51s

Why: deleting the lock and creating a new file can recycle the same inode, so an inode-only check treated the rebuilt lock as the original.
Evidence: SELF go test ./internal/instanceguard ./internal/store ./internal/app, including TestClearedLockRecordRefusedWhenInodeStays.
Impact: instance_guard and the lock file now share a random lock_gen. A missing or different generation refuses Bind unless adopt.
Refs: docs/recovery/u8-sqlite-instance-guard.md
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 15:07:29 +00:00
co-authored by Cursor
parent 15e7ec5f1f
commit ef66bfb331
6 changed files with 111 additions and 29 deletions
+68 -18
View File
@@ -3,8 +3,10 @@
package instanceguard
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"agent-system-backend/internal/store"
@@ -27,6 +29,7 @@ type Record struct {
DBDev uint64
DBIno uint64
InstanceID string
LockGen string
}
// Lease 持有活动服务锁,直到 Release。关闭顺序由调用方保证:先关存储,再 Release。
@@ -94,8 +97,12 @@ func Bind(st *store.Store, lease *Lease, opt BindOptions) error {
if err != nil {
return err
}
if ok && (marker.LockDev != lease.lockDev || marker.LockIno != lease.lockIno) && !opt.Adopt {
return ErrLockRebuilt
if ok && !opt.Adopt {
inodeMismatch := marker.LockDev != lease.lockDev || marker.LockIno != lease.lockIno
genMismatch := marker.LockGen != "" && (lease.record == nil || lease.record.LockGen != marker.LockGen)
if inodeMismatch || genMismatch {
return ErrLockRebuilt
}
}
id := opt.InstanceID
if ok && marker.InstanceID != "" && id != "" && marker.InstanceID != id && !opt.Adopt {
@@ -112,10 +119,20 @@ func Bind(st *store.Store, lease *Lease, opt BindOptions) error {
return ErrDBMissing
}
lease.dbDev, lease.dbIno, lease.dbExists = dev, ino, true
next := store.InstanceGuardMarker{
DBDev: dev, DBIno: ino, LockDev: lease.lockDev, LockIno: lease.lockIno, InstanceID: id,
gen := ""
if ok && marker.LockGen != "" && !opt.Adopt {
gen = marker.LockGen
} else {
gen, err = newLockGen()
if err != nil {
return err
}
}
if err := lease.writeRecord(dev, ino, id); err != nil {
next := store.InstanceGuardMarker{
DBDev: dev, DBIno: ino, LockDev: lease.lockDev, LockIno: lease.lockIno,
InstanceID: id, LockGen: gen,
}
if err := lease.writeRecord(dev, ino, id, gen); err != nil {
return err
}
if !ok || opt.Adopt || marker != next {
@@ -126,24 +143,57 @@ func Bind(st *store.Store, lease *Lease, opt BindOptions) error {
return nil
}
func newLockGen() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func parseRecord(raw string) (*Record, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var dev, ino uint64
var id string
if _, err := fmt.Sscanf(raw, "v1\tdb_dev=%d\tdb_ino=%d\tinstance_id=%s", &dev, &ino, &id); err == nil {
want := fmt.Sprintf("v1\tdb_dev=%d\tdb_ino=%d\tinstance_id=%s", dev, ino, id)
if raw == want && !strings.ContainsAny(id, " \t") {
return &Record{DBDev: dev, DBIno: ino, InstanceID: id}, nil
parts := strings.Split(raw, "\t")
if parts[0] != "v1" {
return nil, ErrLockCorrupt
}
rec := &Record{}
seen := map[string]bool{}
for _, part := range parts[1:] {
key, val, ok := strings.Cut(part, "=")
if !ok || seen[key] || strings.ContainsAny(val, " \t") {
return nil, ErrLockCorrupt
}
seen[key] = true
switch key {
case "db_dev":
n, err := strconv.ParseUint(val, 10, 64)
if err != nil {
return nil, ErrLockCorrupt
}
rec.DBDev = n
case "db_ino":
n, err := strconv.ParseUint(val, 10, 64)
if err != nil {
return nil, ErrLockCorrupt
}
rec.DBIno = n
case "instance_id":
rec.InstanceID = val
case "lock_gen":
if val == "" {
return nil, ErrLockCorrupt
}
rec.LockGen = val
default:
return nil, ErrLockCorrupt
}
}
if _, err := fmt.Sscanf(raw, "v1\tdb_dev=%d\tdb_ino=%d\tinstance_id=", &dev, &ino); err == nil {
want := fmt.Sprintf("v1\tdb_dev=%d\tdb_ino=%d\tinstance_id=", dev, ino)
if raw == want {
return &Record{DBDev: dev, DBIno: ino}, nil
}
if !seen["db_dev"] || !seen["db_ino"] || !seen["instance_id"] {
return nil, ErrLockCorrupt
}
return nil, ErrLockCorrupt
return rec, nil
}
+1 -1
View File
@@ -9,7 +9,7 @@ func Acquire(string) (*Lease, error) {
return nil, ErrUnsupported
}
func (l *Lease) writeRecord(uint64, uint64, string) error {
func (l *Lease) writeRecord(uint64, uint64, string, string) error {
return ErrUnsupported
}
+29
View File
@@ -181,6 +181,35 @@ func TestRebuiltLockRefusedUntilAdopt(t *testing.T) {
}
}
func TestClearedLockRecordRefusedWhenInodeStays(t *testing.T) {
dir := t.TempDir()
st, release, err := openAndBind(t, dir, "inst-a")
if err != nil {
t.Fatal(err)
}
release()
_ = st.Close()
lockPath := filepath.Join(dir, "agent-system.db.lock")
if err := os.Truncate(lockPath, 0); err != nil {
t.Fatal(err)
}
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-a"})
_ = opened.Close()
lease.Release()
if !errors.Is(err, ErrLockRebuilt) {
t.Fatalf("cleared lock: %v", err)
}
}
func TestInstanceMismatchPreservesEmpty(t *testing.T) {
dir := t.TempDir()
st, release, err := openAndBind(t, dir, "inst-a")
+2 -2
View File
@@ -115,11 +115,11 @@ func acquireOnce(dbPath, lockPath string) (*Lease, bool, error) {
return lease, false, nil
}
func (l *Lease) writeRecord(dev, ino uint64, instanceID string) error {
func (l *Lease) writeRecord(dev, ino uint64, instanceID, lockGen string) error {
if strings.ContainsAny(instanceID, " \t\r\n") {
return fmt.Errorf("instance guard: instance id contains whitespace")
}
line := fmt.Sprintf("v1\tdb_dev=%d\tdb_ino=%d\tinstance_id=%s\n", dev, ino, instanceID)
line := fmt.Sprintf("v1\tdb_dev=%d\tdb_ino=%d\tinstance_id=%s\tlock_gen=%s\n", dev, ino, instanceID, lockGen)
if _, err := syscall.Seek(l.fd, 0, 0); err != nil {
return err
}
+7 -6
View File
@@ -12,14 +12,15 @@ type InstanceGuardMarker struct {
LockDev uint64
LockIno uint64
InstanceID string
LockGen string
}
// LoadInstanceGuard 读取守卫标记。没有行时 ok 为 false。
func (s *Store) LoadInstanceGuard() (InstanceGuardMarker, bool, error) {
var m InstanceGuardMarker
var dbDev, dbIno, lockDev, lockIno int64
err := s.db.QueryRow(`SELECT db_dev, db_ino, lock_dev, lock_ino, instance_id FROM instance_guard WHERE id=1`).
Scan(&dbDev, &dbIno, &lockDev, &lockIno, &m.InstanceID)
err := s.db.QueryRow(`SELECT db_dev, db_ino, lock_dev, lock_ino, instance_id, lock_gen FROM instance_guard WHERE id=1`).
Scan(&dbDev, &dbIno, &lockDev, &lockIno, &m.InstanceID, &m.LockGen)
if errors.Is(err, sql.ErrNoRows) {
return InstanceGuardMarker{}, false, nil
}
@@ -35,12 +36,12 @@ func (s *Store) LoadInstanceGuard() (InstanceGuardMarker, bool, error) {
// SaveInstanceGuard 写入或覆盖唯一一行。
func (s *Store) SaveInstanceGuard(m InstanceGuardMarker) error {
_, err := s.db.Exec(s.q(`INSERT INTO instance_guard(id, db_dev, db_ino, lock_dev, lock_ino, instance_id)
VALUES(1,?,?,?,?,?)
_, err := s.db.Exec(s.q(`INSERT INTO instance_guard(id, db_dev, db_ino, lock_dev, lock_ino, instance_id, lock_gen)
VALUES(1,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
db_dev=excluded.db_dev, db_ino=excluded.db_ino,
lock_dev=excluded.lock_dev, lock_ino=excluded.lock_ino,
instance_id=excluded.instance_id`),
int64(m.DBDev), int64(m.DBIno), int64(m.LockDev), int64(m.LockIno), m.InstanceID)
instance_id=excluded.instance_id, lock_gen=excluded.lock_gen`),
int64(m.DBDev), int64(m.DBIno), int64(m.LockDev), int64(m.LockIno), m.InstanceID, m.LockGen)
return err
}
+4 -2
View File
@@ -787,7 +787,8 @@ func init() {
db_ino INTEGER NOT NULL,
lock_dev INTEGER NOT NULL,
lock_ino INTEGER NOT NULL,
instance_id TEXT NOT NULL DEFAULT ''
instance_id TEXT NOT NULL DEFAULT '',
lock_gen TEXT NOT NULL DEFAULT ''
)`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS instance_guard (
@@ -796,7 +797,8 @@ func init() {
db_ino BIGINT NOT NULL,
lock_dev BIGINT NOT NULL,
lock_ino BIGINT NOT NULL,
instance_id TEXT NOT NULL DEFAULT ''
instance_id TEXT NOT NULL DEFAULT '',
lock_gen TEXT NOT NULL DEFAULT ''
)`,
}},
down: map[string][]string{"sqlite": {