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>
200 lines
5.1 KiB
Go
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
|
|
}
|