返回 DeepSeek-Reasonix
session_lease.go
根目录 / internal / agent / session_lease.go
1 package agent
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "time"
14
15 "reasonix/internal/fileutil"
16 fileencoding "reasonix/internal/fileutil/encoding"
17 "reasonix/internal/store"
18 )
19
20 var ErrSessionLeaseHeld = errors.New("session lease held by another runtime")
21
22 // sessionLeaseOwners reserves a canonical session path for one in-process
23 // acquisition attempt or live lease. sessionLeaseActiveOwners contains only
24 // generations that have acquired the cross-process lock and written their
25 // lease metadata. Keeping the two states separate prevents ownership-sensitive
26 // repair from treating a pending or failed acquisition as proof of ownership.
27 // Storing identities instead of bare sentinels lets release and reclaim use
28 // CompareAndDelete without an old generation evicting a newer one.
29 var (
30 sessionLeaseOwners sync.Map
31 sessionLeaseActiveOwners sync.Map
32 sessionLeaseSeq atomic.Uint64
33 )
34
35 type SessionLeaseInfo struct {
36 SessionPath string `json:"session_path"`
37 WriterID string `json:"writer_id"`
38 PID int `json:"pid"`
39 Hostname string `json:"hostname,omitempty"`
40 AcquiredAt time.Time `json:"acquired_at"`
41 }
42
43 type SessionLeaseError struct {
44 Path string
45 Info *SessionLeaseInfo
46 }
47
48 func (e *SessionLeaseError) Error() string {
49 if e == nil {
50 return ErrSessionLeaseHeld.Error()
51 }
52 if e.Info != nil && e.Info.WriterID != "" {
53 return fmt.Sprintf("%s: %s is held by %s", ErrSessionLeaseHeld, e.Path, e.Info.WriterID)
54 }
55 return fmt.Sprintf("%s: %s", ErrSessionLeaseHeld, e.Path)
56 }
57
58 func (e *SessionLeaseError) Unwrap() error {
59 return ErrSessionLeaseHeld
60 }
61
62 type SessionLease struct {
63 path string
64 ownerID uint64
65 unlock func()
66 once sync.Once
67 }
68
69 func TryAcquireSessionLease(path string) (*SessionLease, error) {
70 if strings.TrimSpace(path) == "" {
71 return nil, fmt.Errorf("empty session path")
72 }
73 path = canonicalSessionSavePath(path)
74 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
75 return nil, err
76 }
77 ownerID := sessionLeaseSeq.Add(1)
78 if _, loaded := sessionLeaseOwners.LoadOrStore(path, ownerID); loaded {
79 info, _ := LoadSessionLeaseInfo(path)
80 return nil, &SessionLeaseError{Path: path, Info: info}
81 }
82 unlock, err := tryLockSessionLeaseFile(path)
83 if err != nil {
84 sessionLeaseOwners.CompareAndDelete(path, ownerID)
85 if errors.Is(err, ErrSessionLeaseHeld) {
86 info, _ := LoadSessionLeaseInfo(path)
87 return nil, &SessionLeaseError{Path: path, Info: info}
88 }
89 return nil, err
90 }
91 // The OS lock proves any active-registry entry left without its reservation
92 // is stale. Clear it before publishing this generation.
93 sessionLeaseActiveOwners.Delete(path)
94 lease := &SessionLease{path: path, ownerID: ownerID, unlock: unlock}
95 if err := SaveSessionLeaseInfo(path, newSessionLeaseInfo(path)); err != nil {
96 lease.Release()
97 return nil, err
98 }
99 sessionLeaseActiveOwners.Store(path, ownerID)
100 return lease, nil
101 }
102
103 // TryReclaimCurrentProcessSessionLease re-acquires a lease whose in-process
104 // owner entry was orphaned (a lease dropped without Release). The OS lease
105 // lock is the arbiter: an active holder keeps its lock file locked for the
106 // whole hold, so reclaiming from one fails with ErrSessionLeaseHeld without
107 // touching the holder's entry. Holding the lock proves nobody does, which
108 // also covers metadata-damage states — a missing or unreadable lease info
109 // (deleted by the user, quarantined by AV, torn by a crash) with a free lock
110 // is a leftover, not a holder, and must not wedge the session as busy.
111 func TryReclaimCurrentProcessSessionLease(path string) (*SessionLease, error) {
112 path = canonicalSessionSavePath(path)
113 info, err := LoadSessionLeaseInfo(path)
114 switch {
115 case err == nil:
116 if info == nil || info.PID != os.Getpid() || info.WriterID != SessionWriterID() {
117 // A readable info naming another live runtime: never steal it.
118 // (A crashed foreign leftover is separated from a live holder by
119 // the lock probe in SessionLeaseHeldByOtherRuntime; reclaim is
120 // only for leases this process lost track of.)
121 return nil, &SessionLeaseError{Path: path, Info: info}
122 }
123 case os.IsNotExist(err):
124 // The holder finished releasing (info removed first) or the sidecar
125 // was deleted out from under an orphaned entry. Either way the lock
126 // probe below decides; info identity has nothing left to say.
127 info = nil
128 default:
129 // Unreadable info hides the holder's identity, but the lock still
130 // tells the truth: a live holder keeps it locked. Fall through to the
131 // probe instead of wedging on metadata damage.
132 info = nil
133 }
134 unlock, err := tryLockSessionLeaseFile(path)
135 if err != nil {
136 if errors.Is(err, ErrSessionLeaseHeld) {
137 return nil, &SessionLeaseError{Path: path, Info: info}
138 }
139 return nil, err
140 }
141 // Holding the OS lock proves no live lease owns this path right now, so
142 // overwriting the stale owner entry is safe; concurrent reclaimers fail
143 // the lock above and never reach this store, and a stale lease released
144 // later misses its CompareAndDelete against the new owner id.
145 ownerID := sessionLeaseSeq.Add(1)
146 lease := &SessionLease{path: path, ownerID: ownerID, unlock: unlock}
147 sessionLeaseActiveOwners.Delete(path)
148 sessionLeaseOwners.Store(path, ownerID)
149 if err := SaveSessionLeaseInfo(path, newSessionLeaseInfo(path)); err != nil {
150 lease.Release()
151 return nil, err
152 }
153 sessionLeaseActiveOwners.Store(path, ownerID)
154 return lease, nil
155 }
156
157 // SessionLeaseHeldByOtherRuntime reports whether path's session lease is held
158 // by a live runtime other than the calling process. Callers use it to keep
159 // destructive operations away from sessions another process may be writing;
160 // leases held by this process report false because callers tear their own
161 // runtimes down before acting. The lock file is only probed when a foreign
162 // lease info file exists, so the common uncontended case never touches the
163 // lock; a probe cannot steal a live lease because holders keep the lock held
164 // for their whole lifetime.
165 func SessionLeaseHeldByOtherRuntime(path string) bool {
166 if strings.TrimSpace(path) == "" {
167 return false
168 }
169 path = canonicalSessionSavePath(path)
170 if _, ok := sessionLeaseActiveOwners.Load(path); ok {
171 // Held by this process; no need to touch the lock file.
172 return false
173 }
174 info, err := LoadSessionLeaseInfo(path)
175 if err != nil {
176 if os.IsNotExist(err) {
177 // No info file means no holder: live holders keep it present for
178 // their whole hold.
179 return false
180 }
181 unlock, lockErr := tryLockSessionLeaseFile(path)
182 if lockErr == nil {
183 // Corrupt/empty info with a free lock is a crash leftover. Remove the
184 // bad metadata so future probes do not keep reporting a ghost owner.
185 _ = os.Remove(sessionLeaseInfoPath(path))
186 unlock()
187 return false
188 }
189 // An unreadable info file with a live lock still hides the holder's
190 // identity, so err on the side of treating the session as busy.
191 return true
192 }
193 if info != nil && info.PID == os.Getpid() && info.WriterID == SessionWriterID() {
194 return false
195 }
196 unlock, err := tryLockSessionLeaseFile(path)
197 if err == nil {
198 // Foreign info but a free lock: leftover from a crashed process.
199 _ = os.Remove(sessionLeaseInfoPath(path))
200 unlock()
201 return false
202 }
203 return true
204 }
205
206 // SessionLeaseHeldByCurrentRuntime reports whether this process has completed
207 // acquisition of path's session lease. Pending reservations and generations
208 // already retiring report false, so callers cannot authorize destructive repair
209 // before the OS lock is held or after release has begun.
210 func SessionLeaseHeldByCurrentRuntime(path string) bool {
211 if strings.TrimSpace(path) == "" {
212 return false
213 }
214 _, ok := sessionLeaseActiveOwners.Load(canonicalSessionSavePath(path))
215 return ok
216 }
217
218 func (l *SessionLease) Path() string {
219 if l == nil {
220 return ""
221 }
222 return l.path
223 }
224
225 func (l *SessionLease) Release() {
226 if l == nil {
227 return
228 }
229 l.once.Do(func() {
230 // Revoke ownership-sensitive repair before the OS lock becomes available
231 // to a successor. CompareAndDelete keeps a stale generation from
232 // deauthorizing a newer reclaimed lease.
233 sessionLeaseActiveOwners.CompareAndDelete(l.path, l.ownerID)
234 _ = os.Remove(sessionLeaseInfoPath(l.path))
235 if l.unlock != nil {
236 l.unlock()
237 }
238 // Only remove the entry this lease owns: after a reclaim the map may
239 // already point at a newer lease for the same path.
240 sessionLeaseOwners.CompareAndDelete(l.path, l.ownerID)
241 // Best-effort retirement of the lock sidecars this session no longer
242 // needs. Historically they were left behind on every release and only
243 // swept on the next boot reconcile, so ordinary use accumulated
244 // .lock/.lease.lock files (#6014). The helpers re-take each lock
245 // non-blocking and delete it atomically with the release, so a new
246 // holder or an in-flight save simply turns this into a no-op. This
247 // must run after CompareAndDelete: the lease-lock helper skips paths
248 // the owner registry still reports as held by this process.
249 _ = removeStaleSessionLeaseLockSidecar(l.path, store.SessionLeaseLock(l.path))
250 _ = removeStaleSessionLockSidecar(l.path, store.SessionLockFile(l.path))
251 })
252 }
253
254 func newSessionLeaseInfo(path string) SessionLeaseInfo {
255 host, _ := os.Hostname()
256 return SessionLeaseInfo{
257 SessionPath: path,
258 WriterID: SessionWriterID(),
259 PID: os.Getpid(),
260 Hostname: host,
261 AcquiredAt: time.Now().UTC(),
262 }
263 }
264
265 func LoadSessionLeaseInfo(path string) (*SessionLeaseInfo, error) {
266 b, err := fileencoding.ReadFileUTF8(sessionLeaseInfoPath(path))
267 if err != nil {
268 return nil, err
269 }
270 var info SessionLeaseInfo
271 if err := json.Unmarshal(b, &info); err != nil {
272 return nil, err
273 }
274 return &info, nil
275 }
276
277 func SaveSessionLeaseInfo(path string, info SessionLeaseInfo) error {
278 leasePath := sessionLeaseInfoPath(path)
279 if err := os.MkdirAll(filepath.Dir(leasePath), 0o755); err != nil {
280 return err
281 }
282 b, err := json.MarshalIndent(info, "", " ")
283 if err != nil {
284 return err
285 }
286 b = append(b, '\n')
287 tmp, err := os.CreateTemp(filepath.Dir(leasePath), ".lease.*.tmp")
288 if err != nil {
289 return err
290 }
291 tmpPath := tmp.Name()
292 if _, err := tmp.Write(b); err != nil {
293 tmp.Close()
294 os.Remove(tmpPath)
295 return err
296 }
297 if err := tmp.Close(); err != nil {
298 os.Remove(tmpPath)
299 return err
300 }
301 if err := fileutil.ReplaceFile(tmpPath, leasePath); err != nil {
302 os.Remove(tmpPath)
303 return err
304 }
305 return nil
306 }
307
308 func sessionLeaseInfoPath(path string) string {
309 return store.SessionLeaseInfo(canonicalSessionSavePath(path))
310 }
311
312 // unleasedWriteObserved dedupes the write-authority probe below to one report
313 // per canonical path per process.
314 var unleasedWriteObserved sync.Map
315
316 // observeUnleasedSessionWrite is the store-P2 write-authority probe: the target
317 // model is "the lease holder is the only writer of a session's content", but
318 // enforcement can't land before we know every writer that currently saves
319 // without holding the lease (fresh-session creation saves before the first
320 // Rebind, headless runs, recovery tooling, ...). Until then this only records
321 // evidence: one structured warning per path per process, never a failure. The
322 // snapshot-conflict machinery stays the safety net for the writers this
323 // surfaces.
324 func observeUnleasedSessionWrite(path string, mode sessionSaveMode) {
325 canonical := canonicalSessionSavePath(path)
326 if _, ok := sessionLeaseOwners.Load(canonical); ok {
327 return
328 }
329 if _, seen := unleasedWriteObserved.LoadOrStore(canonical, struct{}{}); seen {
330 return
331 }
332 slog.Warn("session: save without a held lease (write-authority probe, store P2)",
333 "path", filepath.Base(path),
334 "mode", int(mode),
335 "writer", SessionWriterID(),
336 )
337 }
338
338 lines GO