Files
Agent-System-Backend/internal/instanceguard/guard.go
T
weichenandCursor ef66bfb331
ci / vet-and-test (pull_request) Successful in 51s
Reject a rebuilt lock even when its inode is reused
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>
2026-10-12 15:07:29 +00:00

200 lines
5.1 KiB
Go

// Package instanceguard 强制同一 SQLite 数据文件只有一个活动服务。
// 锁在打开数据库(迁移发生在打开内部)之前取得。账户 CLI 不使用本包。
package instanceguard
import (
"crypto/rand"
"encoding/hex"
"errors"
"strconv"
"strings"
"agent-system-backend/internal/store"
)
var (
ErrBusy = errors.New("instance guard: sqlite service already running")
ErrHardlink = errors.New("instance guard: sqlite database has a hardlink alias")
ErrDBReplaced = errors.New("instance guard: sqlite database replaced")
ErrDBMissing = errors.New("instance guard: recorded sqlite database is missing")
ErrLockRebuilt = errors.New("instance guard: lock file was rebuilt")
ErrLockCorrupt = errors.New("instance guard: lock record is corrupt")
ErrInstanceMismatch = errors.New("instance guard: instance id mismatch")
ErrUnsupported = errors.New("instance guard: sqlite activity lock is not available on this platform")
ErrLockAliased = errors.New("instance guard: lock file aliases the sqlite database")
)
// Record 是锁文件里记下的数据库身份。没有记录表示锁文件是新建的。
type Record struct {
DBDev uint64
DBIno uint64
InstanceID string
LockGen string
}
// Lease 持有活动服务锁,直到 Release。关闭顺序由调用方保证:先关存储,再 Release。
type Lease struct {
fd int
dbPath string
lockDev uint64
lockIno uint64
dbDev uint64
dbIno uint64
dbExists bool
record *Record
parseErr error
release func()
}
// Release 放开锁并关闭锁文件描述符。锁文件留在磁盘上。
func (l *Lease) Release() {
if l == nil || l.release == nil {
return
}
l.release()
l.release = nil
}
// DBPath 是解析后的数据库路径。符号链接指向真实文件。
func (l *Lease) DBPath() string {
if l == nil {
return ""
}
return l.dbPath
}
// AutomaticRefusal 在打开数据库之前调用。
// 尚无记录时返回 nil(首次建立,含旧库升级)。硬链接在 Acquire 阶段已经拒绝。
func (l *Lease) AutomaticRefusal() error {
if l.parseErr != nil {
return l.parseErr
}
if l.record == nil {
return nil
}
if !l.dbExists {
return ErrDBMissing
}
if l.record.DBDev != l.dbDev || l.record.DBIno != l.dbIno {
return ErrDBReplaced
}
return nil
}
// BindOptions 控制守卫建立后的比对。
type BindOptions struct {
Adopt bool
InstanceID string
}
// Bind 在数据库已经打开并完成迁移之后调用。
// 锁文件 inode 与表不一致则拒绝,除非 Adopt。通过时重写锁文件内容与表。
func Bind(st *store.Store, lease *Lease, opt BindOptions) error {
if lease == nil {
return errors.New("instance guard: nil lease")
}
marker, ok, err := st.LoadInstanceGuard()
if err != nil {
return err
}
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 {
return ErrInstanceMismatch
}
if id == "" && ok {
id = marker.InstanceID
}
dev, ino, exists, err := lease.currentDB()
if err != nil {
return err
}
if !exists {
return ErrDBMissing
}
lease.dbDev, lease.dbIno, lease.dbExists = dev, ino, true
gen := ""
if ok && marker.LockGen != "" && !opt.Adopt {
gen = marker.LockGen
} else {
gen, err = newLockGen()
if err != nil {
return err
}
}
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 {
if err := st.SaveInstanceGuard(next); err != nil {
return err
}
}
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
}
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 !seen["db_dev"] || !seen["db_ino"] || !seen["instance_id"] {
return nil, ErrLockCorrupt
}
return rec, nil
}