package agent import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "sync" "sync/atomic" "time" "reasonix/internal/fileutil" fileencoding "reasonix/internal/fileutil/encoding" "reasonix/internal/store" ) var ErrSessionLeaseHeld = errors.New("session lease held by another runtime") // sessionLeaseOwners maps the canonical session path to the owning lease's // unique id (from sessionLeaseSeq). Storing an identity instead of a bare // sentinel lets Release and the acquire/reclaim failure paths use // CompareAndDelete, so a racing caller can never evict an entry it does not // own. Ids stay unique for the process lifetime, so a stale lease released // after its entry was reclaimed cannot delete the new owner's entry either. var ( sessionLeaseOwners sync.Map sessionLeaseSeq atomic.Uint64 ) type SessionLeaseInfo struct { SessionPath string `json:"session_path"` WriterID string `json:"writer_id"` PID int `json:"pid"` Hostname string `json:"hostname,omitempty"` AcquiredAt time.Time `json:"acquired_at"` } type SessionLeaseError struct { Path string Info *SessionLeaseInfo } func (e *SessionLeaseError) Error() string { if e == nil { return ErrSessionLeaseHeld.Error() } if e.Info != nil && e.Info.WriterID != "" { return fmt.Sprintf("%s: %s is held by %s", ErrSessionLeaseHeld, e.Path, e.Info.WriterID) } return fmt.Sprintf("%s: %s", ErrSessionLeaseHeld, e.Path) } func (e *SessionLeaseError) Unwrap() error { return ErrSessionLeaseHeld } type SessionLease struct { path string ownerID uint64 unlock func() once sync.Once } func TryAcquireSessionLease(path string) (*SessionLease, error) { if strings.TrimSpace(path) == "" { return nil, fmt.Errorf("empty session path") } path = canonicalSessionSavePath(path) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err } ownerID := sessionLeaseSeq.Add(1) if _, loaded := sessionLeaseOwners.LoadOrStore(path, ownerID); loaded { info, _ := LoadSessionLeaseInfo(path) return nil, &SessionLeaseError{Path: path, Info: info} } unlock, err := tryLockSessionLeaseFile(path) if err != nil { sessionLeaseOwners.CompareAndDelete(path, ownerID) if errors.Is(err, ErrSessionLeaseHeld) { info, _ := LoadSessionLeaseInfo(path) return nil, &SessionLeaseError{Path: path, Info: info} } return nil, err } lease := &SessionLease{path: path, ownerID: ownerID, unlock: unlock} if err := SaveSessionLeaseInfo(path, newSessionLeaseInfo(path)); err != nil { lease.Release() return nil, err } return lease, nil } // TryReclaimCurrentProcessSessionLease re-acquires a lease whose in-process // owner entry was orphaned (a lease dropped without Release). The OS lease // lock is the arbiter: an active holder keeps its lock file locked for the // whole hold, so reclaiming from one fails with ErrSessionLeaseHeld without // touching the holder's entry. Holding the lock proves nobody does, which // also covers metadata-damage states — a missing or unreadable lease info // (deleted by the user, quarantined by AV, torn by a crash) with a free lock // is a leftover, not a holder, and must not wedge the session as busy. func TryReclaimCurrentProcessSessionLease(path string) (*SessionLease, error) { path = canonicalSessionSavePath(path) info, err := LoadSessionLeaseInfo(path) switch { case err == nil: if info == nil || info.PID != os.Getpid() || info.WriterID != SessionWriterID() { // A readable info naming another live runtime: never steal it. // (A crashed foreign leftover is separated from a live holder by // the lock probe in SessionLeaseHeldByOtherRuntime; reclaim is // only for leases this process lost track of.) return nil, &SessionLeaseError{Path: path, Info: info} } case os.IsNotExist(err): // The holder finished releasing (info removed first) or the sidecar // was deleted out from under an orphaned entry. Either way the lock // probe below decides; info identity has nothing left to say. info = nil default: // Unreadable info hides the holder's identity, but the lock still // tells the truth: a live holder keeps it locked. Fall through to the // probe instead of wedging on metadata damage. info = nil } unlock, err := tryLockSessionLeaseFile(path) if err != nil { if errors.Is(err, ErrSessionLeaseHeld) { return nil, &SessionLeaseError{Path: path, Info: info} } return nil, err } // Holding the OS lock proves no live lease owns this path right now, so // overwriting the stale owner entry is safe; concurrent reclaimers fail // the lock above and never reach this store, and a stale lease released // later misses its CompareAndDelete against the new owner id. ownerID := sessionLeaseSeq.Add(1) lease := &SessionLease{path: path, ownerID: ownerID, unlock: unlock} sessionLeaseOwners.Store(path, ownerID) if err := SaveSessionLeaseInfo(path, newSessionLeaseInfo(path)); err != nil { lease.Release() return nil, err } return lease, nil } // SessionLeaseHeldByOtherRuntime reports whether path's session lease is held // by a live runtime other than the calling process. Callers use it to keep // destructive operations away from sessions another process may be writing; // leases held by this process report false because callers tear their own // runtimes down before acting. The lock file is only probed when a foreign // lease info file exists, so the common uncontended case never touches the // lock; a probe cannot steal a live lease because holders keep the lock held // for their whole lifetime. func SessionLeaseHeldByOtherRuntime(path string) bool { if strings.TrimSpace(path) == "" { return false } path = canonicalSessionSavePath(path) if _, ok := sessionLeaseOwners.Load(path); ok { // Held by this process; no need to touch the lock file. return false } info, err := LoadSessionLeaseInfo(path) if err != nil { if os.IsNotExist(err) { // No info file means no holder: live holders keep it present for // their whole hold. return false } unlock, lockErr := tryLockSessionLeaseFile(path) if lockErr == nil { // Corrupt/empty info with a free lock is a crash leftover. Remove the // bad metadata so future probes do not keep reporting a ghost owner. _ = os.Remove(sessionLeaseInfoPath(path)) unlock() return false } // An unreadable info file with a live lock still hides the holder's // identity, so err on the side of treating the session as busy. return true } if info != nil && info.PID == os.Getpid() && info.WriterID == SessionWriterID() { return false } unlock, err := tryLockSessionLeaseFile(path) if err == nil { // Foreign info but a free lock: leftover from a crashed process. _ = os.Remove(sessionLeaseInfoPath(path)) unlock() return false } return true } func (l *SessionLease) Path() string { if l == nil { return "" } return l.path } func (l *SessionLease) Release() { if l == nil { return } l.once.Do(func() { _ = os.Remove(sessionLeaseInfoPath(l.path)) if l.unlock != nil { l.unlock() } // Only remove the entry this lease owns: after a reclaim the map may // already point at a newer lease for the same path. sessionLeaseOwners.CompareAndDelete(l.path, l.ownerID) // Best-effort retirement of the lock sidecars this session no longer // needs. Historically they were left behind on every release and only // swept on the next boot reconcile, so ordinary use accumulated // .lock/.lease.lock files (#6014). The helpers re-take each lock // non-blocking and delete it atomically with the release, so a new // holder or an in-flight save simply turns this into a no-op. This // must run after CompareAndDelete: the lease-lock helper skips paths // the owner registry still reports as held by this process. _ = removeStaleSessionLeaseLockSidecar(l.path, store.SessionLeaseLock(l.path)) _ = removeStaleSessionLockSidecar(l.path, store.SessionLockFile(l.path)) }) } func newSessionLeaseInfo(path string) SessionLeaseInfo { host, _ := os.Hostname() return SessionLeaseInfo{ SessionPath: path, WriterID: SessionWriterID(), PID: os.Getpid(), Hostname: host, AcquiredAt: time.Now().UTC(), } } func LoadSessionLeaseInfo(path string) (*SessionLeaseInfo, error) { b, err := fileencoding.ReadFileUTF8(sessionLeaseInfoPath(path)) if err != nil { return nil, err } var info SessionLeaseInfo if err := json.Unmarshal(b, &info); err != nil { return nil, err } return &info, nil } func SaveSessionLeaseInfo(path string, info SessionLeaseInfo) error { leasePath := sessionLeaseInfoPath(path) if err := os.MkdirAll(filepath.Dir(leasePath), 0o755); err != nil { return err } b, err := json.MarshalIndent(info, "", " ") if err != nil { return err } b = append(b, '\n') tmp, err := os.CreateTemp(filepath.Dir(leasePath), ".lease.*.tmp") if err != nil { return err } tmpPath := tmp.Name() if _, err := tmp.Write(b); err != nil { tmp.Close() os.Remove(tmpPath) return err } if err := tmp.Close(); err != nil { os.Remove(tmpPath) return err } if err := fileutil.ReplaceFile(tmpPath, leasePath); err != nil { os.Remove(tmpPath) return err } return nil } func sessionLeaseInfoPath(path string) string { return store.SessionLeaseInfo(canonicalSessionSavePath(path)) }