返回 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/pathidentity"
18 "reasonix/internal/store"
19 )
20
21 var ErrSessionLeaseHeld = errors.New("session lease held by another runtime")
22
23 // sessionLeaseOwners reserves a canonical session path for one in-process
24 // acquisition attempt or live lease. sessionLeaseActiveOwners contains only
25 // generations that have acquired the cross-process lock and written their
26 // lease metadata. Keeping the two states separate prevents ownership-sensitive
27 // repair from treating a pending or failed acquisition as proof of ownership.
28 // Storing identities instead of bare sentinels lets release and reclaim use
29 // CompareAndDelete without an old generation evicting a newer one.
30 var (
31 sessionLeaseOwners sync.Map
32 sessionLeaseActiveOwners sync.Map
33 sessionLeaseSeq atomic.Uint64
34 )
35
36 type SessionLeaseInfo struct {
37 SessionPath string `json:"session_path"`
38 WriterID string `json:"writer_id"`
39 PID int `json:"pid"`
40 Hostname string `json:"hostname,omitempty"`
41 AcquiredAt time.Time `json:"acquired_at"`
42 HandoffTo string `json:"handoff_to,omitempty"`
43 HandoffID string `json:"handoff_id,omitempty"`
44 HandoffExpiresAt time.Time `json:"handoff_expires_at,omitempty"`
45 }
46
47 // MarshalJSON makes the zero time genuinely optional. encoding/json does not
48 // apply omitempty to a value time.Time, and writing year 1 would make legacy
49 // metadata look like an explicit (expired) reservation. Older readers still
50 // ignore these unknown fields, but they do not enforce an active reservation:
51 // every concurrent writer sharing a state directory must therefore be upgraded
52 // before takeover is used.
53 func (i SessionLeaseInfo) MarshalJSON() ([]byte, error) {
54 type wire struct {
55 SessionPath string `json:"session_path"`
56 WriterID string `json:"writer_id"`
57 PID int `json:"pid"`
58 Hostname string `json:"hostname,omitempty"`
59 AcquiredAt time.Time `json:"acquired_at"`
60 HandoffTo string `json:"handoff_to,omitempty"`
61 HandoffID string `json:"handoff_id,omitempty"`
62 HandoffExpiresAt *time.Time `json:"handoff_expires_at,omitempty"`
63 }
64 var expires *time.Time
65 if !i.HandoffExpiresAt.IsZero() {
66 value := i.HandoffExpiresAt
67 expires = &value
68 }
69 return json.Marshal(wire{
70 SessionPath: i.SessionPath, WriterID: i.WriterID, PID: i.PID, Hostname: i.Hostname,
71 AcquiredAt: i.AcquiredAt, HandoffTo: i.HandoffTo, HandoffID: i.HandoffID, HandoffExpiresAt: expires,
72 })
73 }
74
75 // SessionLeaseHandoffWindow bounds how long a released lease stays reserved
76 // for its explicitly named successor. The OS lock is free during this window,
77 // but new-version callers must present the matching writer and generation.
78 const SessionLeaseHandoffWindow = 30 * time.Second
79
80 const sessionLeaseOwnerOffset int64 = 1
81
82 func sessionLeaseOwnerBytes(b []byte) []byte {
83 payload := make([]byte, sessionLeaseOwnerOffset+int64(len(b)))
84 payload[0] = ' '
85 copy(payload[sessionLeaseOwnerOffset:], b)
86 return payload
87 }
88
89 type SessionLeaseError struct {
90 Path string
91 Info *SessionLeaseInfo
92 }
93
94 func (e *SessionLeaseError) Error() string {
95 if e == nil {
96 return ErrSessionLeaseHeld.Error()
97 }
98 if e.Info != nil && e.Info.WriterID != "" {
99 return fmt.Sprintf("%s: %s is held by %s", ErrSessionLeaseHeld, e.Path, e.Info.WriterID)
100 }
101 return fmt.Sprintf("%s: %s", ErrSessionLeaseHeld, e.Path)
102 }
103
104 func (e *SessionLeaseError) Unwrap() error {
105 return ErrSessionLeaseHeld
106 }
107
108 type SessionLease struct {
109 path string // filesystem identity key
110 accessPath string // physical path used for file access
111 legacyAccessPath string // frozen v1 path used by older runtime locks
112 ownerID uint64
113 mu sync.Mutex
114 leaseLock *sessionLockFile
115 legacyLeaseLock *sessionLockFile
116 released bool
117 writeGeneration uint64
118 // activeSaves counts authority-guarded save cycles still inside path/file
119 // locks. Release waits for this to reach zero so a rebind cannot revoke
120 // mid-write and create an ABA ownership hole.
121 activeSaves int
122 // releaseWait is closed when activeSaves drains to zero while a Release
123 // is waiting. At most one waiter is parked.
124 releaseWait chan struct{}
125 // writerOnce caches the SessionWriter facade for this lease. One writer
126 // per lease keeps the writer's save serialization meaningful across
127 // controller rebinds.
128 writerOnce sync.Once
129 writer *SessionWriter
130 // beforeReleaseLock is a test hook for the registry-before-unlock invariant.
131 beforeReleaseLock func()
132 // beforeReleaseWait is a test hook reached only after Release observes an
133 // in-flight authority-guarded save and before it parks.
134 beforeReleaseWait func()
135 // beforeHandoffWrite is a test hook for reservation persistence failures.
136 beforeHandoffWrite func() error
137 }
138
139 // Writer returns the single SessionWriter facade bound to this lease. The
140 // first call creates it; every authority minted for a controller rebind goes
141 // through the same writer, so all of the lease's saves serialize together.
142 func (l *SessionLease) Writer() *SessionWriter {
143 if l == nil {
144 return nil
145 }
146 l.writerOnce.Do(func() {
147 info, err := LoadSessionLeaseInfo(l.accessPath)
148 if err != nil || info == nil {
149 info = &SessionLeaseInfo{}
150 }
151 l.writer = &SessionWriter{lease: l, info: *info}
152 })
153 return l.writer
154 }
155
156 func TryAcquireSessionLease(path string) (*SessionLease, error) {
157 if strings.TrimSpace(path) == "" {
158 return nil, fmt.Errorf("empty session path")
159 }
160 identity, err := resolveSessionPathIdentity(path)
161 if err != nil {
162 return nil, err
163 }
164 path, accessPath := identity.Key, identity.PhysicalPath
165 if err := os.MkdirAll(filepath.Dir(accessPath), 0o755); err != nil {
166 return nil, err
167 }
168 ownerID := sessionLeaseSeq.Add(1)
169 if _, loaded := sessionLeaseOwners.LoadOrStore(path, ownerID); loaded {
170 info, _ := LoadSessionLeaseInfo(accessPath)
171 return nil, &SessionLeaseError{Path: path, Info: info}
172 }
173 legacyAccessPath := pathidentity.Canonical(identity.AccessPath)
174 leaseLock, legacyLeaseLock, err := tryTakeCompatibleSessionLeaseLocks(accessPath, legacyAccessPath)
175 if err != nil {
176 sessionLeaseOwners.CompareAndDelete(path, ownerID)
177 if errors.Is(err, ErrSessionLeaseHeld) {
178 info, _ := LoadSessionLeaseInfo(accessPath)
179 return nil, &SessionLeaseError{Path: path, Info: info}
180 }
181 return nil, err
182 }
183 // A handoff reservation is stored inside the lock file before the previous
184 // holder unlocks it. Re-check only after acquiring the OS lock so a plain
185 // contender cannot race between reservation publication and unlock.
186 if info, infoErr := LoadSessionLeaseInfo(accessPath); infoErr == nil && handoffReservationActive(info, time.Now().UTC()) {
187 unlockSessionLeaseLocks(leaseLock, legacyLeaseLock)
188 sessionLeaseOwners.CompareAndDelete(path, ownerID)
189 return nil, &SessionLeaseError{Path: path, Info: info}
190 }
191 // The OS lock proves any active-registry entry left without its reservation
192 // is stale. Clear it before publishing this generation.
193 sessionLeaseActiveOwners.Delete(path)
194 lease := &SessionLease{
195 path: path, accessPath: accessPath, legacyAccessPath: legacyAccessPath,
196 ownerID: ownerID, leaseLock: leaseLock, legacyLeaseLock: legacyLeaseLock,
197 }
198 if err := lease.publishOwner(); err != nil {
199 lease.Release()
200 return nil, err
201 }
202 sessionLeaseActiveOwners.Store(path, ownerID)
203 return lease, nil
204 }
205
206 // TryReclaimCurrentProcessSessionLease re-acquires a lease whose in-process
207 // owner entry was orphaned (a lease dropped without Release). The OS lease
208 // lock is the arbiter: an active holder keeps its lock file locked for the
209 // whole hold, so reclaiming from one fails with ErrSessionLeaseHeld without
210 // touching the holder's entry. Holding the lock proves nobody does, which
211 // also covers metadata-damage states — a missing or unreadable lease info
212 // (deleted by the user, quarantined by AV, torn by a crash) with a free lock
213 // is a leftover, not a holder, and must not wedge the session as busy.
214 func TryReclaimCurrentProcessSessionLease(path string) (*SessionLease, error) {
215 identity, err := resolveSessionPathIdentity(path)
216 if err != nil {
217 return nil, err
218 }
219 path, accessPath := identity.Key, identity.PhysicalPath
220 info, err := LoadSessionLeaseInfo(accessPath)
221 switch {
222 case err == nil:
223 if handoffReservationActive(info, time.Now().UTC()) {
224 return nil, &SessionLeaseError{Path: path, Info: info}
225 }
226 if info == nil || info.PID != os.Getpid() || info.WriterID != SessionWriterID() {
227 // A readable info naming another live runtime: never steal it.
228 // (A crashed foreign leftover is separated from a live holder by
229 // the lock probe in SessionLeaseHeldByOtherRuntime; reclaim is
230 // only for leases this process lost track of.)
231 return nil, &SessionLeaseError{Path: path, Info: info}
232 }
233 case os.IsNotExist(err):
234 // The holder finished releasing (info removed first) or the sidecar
235 // was deleted out from under an orphaned entry. Either way the lock
236 // probe below decides; info identity has nothing left to say.
237 info = nil
238 default:
239 // Unreadable info hides the holder's identity, but the lock still
240 // tells the truth: a live holder keeps it locked. Fall through to the
241 // probe instead of wedging on metadata damage.
242 info = nil
243 }
244 legacyAccessPath := pathidentity.Canonical(identity.AccessPath)
245 leaseLock, legacyLeaseLock, err := tryTakeCompatibleSessionLeaseLocks(accessPath, legacyAccessPath)
246 if err != nil {
247 if errors.Is(err, ErrSessionLeaseHeld) {
248 return nil, &SessionLeaseError{Path: path, Info: info}
249 }
250 return nil, err
251 }
252 // Holding the OS lock proves no live lease owns this path right now, so
253 // overwriting the stale owner entry is safe; concurrent reclaimers fail
254 // the lock above and never reach this store, and a stale lease released
255 // later misses its CompareAndDelete against the new owner id.
256 ownerID := sessionLeaseSeq.Add(1)
257 lease := &SessionLease{
258 path: path, accessPath: accessPath, legacyAccessPath: legacyAccessPath,
259 ownerID: ownerID, leaseLock: leaseLock, legacyLeaseLock: legacyLeaseLock,
260 }
261 sessionLeaseActiveOwners.Delete(path)
262 sessionLeaseOwners.Store(path, ownerID)
263 if err := lease.publishOwner(); err != nil {
264 lease.Release()
265 return nil, err
266 }
267 sessionLeaseActiveOwners.Store(path, ownerID)
268 return lease, nil
269 }
270
271 // SessionLeaseHeldByOtherRuntime reports whether path's session lease is held
272 // by a live runtime other than the calling process. Callers use it to keep
273 // destructive operations away from sessions another process may be writing;
274 // leases held by this process report false because callers tear their own
275 // runtimes down before acting. The lock file is only probed when a foreign
276 // lease info file exists, so the common uncontended case never touches the
277 // lock; a probe cannot steal a live lease because holders keep the lock held
278 // for their whole lifetime.
279 func SessionLeaseHeldByOtherRuntime(path string) bool {
280 if strings.TrimSpace(path) == "" {
281 return false
282 }
283 identity, identityErr := resolveSessionPathIdentity(path)
284 if identityErr != nil {
285 return true
286 }
287 key, accessPath := identity.Key, identity.PhysicalPath
288 legacyAccessPath := pathidentity.Canonical(identity.AccessPath)
289 if _, ok := sessionLeaseActiveOwners.Load(key); ok {
290 // Held by this process; no need to touch the lock file.
291 return false
292 }
293 info, err := loadCompatibleSessionLeaseInfo(accessPath, legacyAccessPath)
294 if err != nil {
295 if os.IsNotExist(err) {
296 // No info file means no holder: live holders keep it present for
297 // their whole hold.
298 return false
299 }
300 unlock, lockErr := tryLockCompatibleSessionLeaseFiles(accessPath, legacyAccessPath)
301 if lockErr == nil {
302 // Corrupt/empty info with a free lock is a crash leftover. Remove the
303 // bad metadata so future probes do not keep reporting a ghost owner.
304 _ = os.Remove(sessionLeaseInfoPath(accessPath))
305 unlock()
306 return false
307 }
308 // An unreadable info file with a live lock still hides the holder's
309 // identity, so err on the side of treating the session as busy.
310 return true
311 }
312 if info != nil && info.PID == os.Getpid() && info.WriterID == SessionWriterID() {
313 return false
314 }
315 unlock, err := tryLockCompatibleSessionLeaseFiles(accessPath, legacyAccessPath)
316 if err == nil {
317 if handoffReservationActive(info, time.Now().UTC()) {
318 unlock()
319 return false
320 }
321 // Foreign info but a free lock: leftover from a crashed process.
322 _ = os.Remove(sessionLeaseInfoPath(accessPath))
323 unlock()
324 return false
325 }
326 return true
327 }
328
329 // InspectSessionLease reports the published owner and whether the OS lock is
330 // currently held. It never acquires ownership and preserves live handoff
331 // reservations. Serve uses it to prove that /adopt callers really own the
332 // session they claim.
333 func InspectSessionLease(path string) (*SessionLeaseInfo, bool, error) {
334 if strings.TrimSpace(path) == "" {
335 return nil, false, fmt.Errorf("empty session path")
336 }
337 identity, err := resolveSessionPathIdentity(path)
338 if err != nil {
339 return nil, false, err
340 }
341 key, accessPath := identity.Key, identity.PhysicalPath
342 legacyAccessPath := pathidentity.Canonical(identity.AccessPath)
343 info, err := loadCompatibleSessionLeaseInfo(accessPath, legacyAccessPath)
344 if err != nil {
345 return nil, false, err
346 }
347 if _, ok := sessionLeaseActiveOwners.Load(key); ok {
348 return info, true, nil
349 }
350 unlock, lockErr := tryLockCompatibleSessionLeaseFiles(accessPath, legacyAccessPath)
351 if lockErr != nil {
352 if errors.Is(lockErr, ErrSessionLeaseHeld) {
353 return info, true, nil
354 }
355 return info, false, lockErr
356 }
357 unlock()
358 return info, false, nil
359 }
360
361 // SessionLeaseHeldByCurrentRuntime reports whether this process has completed
362 // acquisition of path's session lease. Pending reservations and generations
363 // already retiring report false, so callers cannot authorize destructive repair
364 // before the OS lock is held or after release has begun.
365 func SessionLeaseHeldByCurrentRuntime(path string) bool {
366 if strings.TrimSpace(path) == "" {
367 return false
368 }
369 _, ok := sessionLeaseActiveOwners.Load(CanonicalSessionPath(path))
370 return ok
371 }
372
373 func (l *SessionLease) Path() string {
374 if l == nil {
375 return ""
376 }
377 return l.path
378 }
379
380 // ReleaseForHandoff publishes a target-writer reservation while the current
381 // lease lock is still held, then releases the OS lock without deleting the
382 // metadata. A persistence failure leaves the current lease fully active.
383 func (l *SessionLease) ReleaseForHandoff(targetWriterID, handoffID string) error {
384 if l == nil {
385 return nil
386 }
387 targetWriterID = strings.TrimSpace(targetWriterID)
388 handoffID = strings.TrimSpace(handoffID)
389 if targetWriterID == "" || handoffID == "" {
390 return fmt.Errorf("handoff target writer id and generation are required")
391 }
392 for {
393 l.mu.Lock()
394 if l.released {
395 l.mu.Unlock()
396 return ErrSessionLeaseHeld
397 }
398 if l.activeSaves == 0 {
399 break
400 }
401 if l.releaseWait == nil {
402 l.releaseWait = make(chan struct{})
403 }
404 wait := l.releaseWait
405 beforeReleaseWait := l.beforeReleaseWait
406 l.mu.Unlock()
407 if beforeReleaseWait != nil {
408 beforeReleaseWait()
409 }
410 <-wait
411 }
412 if l.beforeHandoffWrite != nil {
413 if err := l.beforeHandoffWrite(); err != nil {
414 l.mu.Unlock()
415 return err
416 }
417 }
418 info := newSessionLeaseInfo(l.accessPath)
419 info.HandoffTo = targetWriterID
420 info.HandoffID = handoffID
421 info.HandoffExpiresAt = time.Now().UTC().Add(SessionLeaseHandoffWindow)
422 if err := l.writeOwnerInfo(info); err != nil {
423 l.mu.Unlock()
424 return err
425 }
426 l.released = true
427 leaseLock := l.leaseLock
428 legacyLeaseLock := l.legacyLeaseLock
429 l.leaseLock = nil
430 l.legacyLeaseLock = nil
431 l.mu.Unlock()
432
433 sessionLeaseActiveOwners.CompareAndDelete(l.path, l.ownerID)
434 sessionLeaseOwners.CompareAndDelete(l.path, l.ownerID)
435 unlockSessionLeaseLocks(leaseLock, legacyLeaseLock)
436 _ = os.Remove(sessionLeaseInfoPath(l.accessPath))
437 _ = removeStaleSessionLockSidecar(l.accessPath, store.SessionLockFile(l.accessPath))
438 return nil
439 }
440
441 // TryAcquireSessionLeaseWithHandoff consumes one unexpired reservation for the
442 // current process writer. The reservation is checked again while holding the
443 // OS lock, fencing stale grants and check-then-use races.
444 func TryAcquireSessionLeaseWithHandoff(path, sourceWriterID, handoffID string) (*SessionLease, error) {
445 if strings.TrimSpace(path) == "" {
446 return nil, fmt.Errorf("empty session path")
447 }
448 identity, err := resolveSessionPathIdentity(path)
449 if err != nil {
450 return nil, err
451 }
452 path, accessPath := identity.Key, identity.PhysicalPath
453 if err := os.MkdirAll(filepath.Dir(accessPath), 0o755); err != nil {
454 return nil, err
455 }
456 ownerID := sessionLeaseSeq.Add(1)
457 if _, loaded := sessionLeaseOwners.LoadOrStore(path, ownerID); loaded {
458 info, _ := LoadSessionLeaseInfo(accessPath)
459 return nil, &SessionLeaseError{Path: path, Info: info}
460 }
461 legacyAccessPath := pathidentity.Canonical(identity.AccessPath)
462 leaseLock, legacyLeaseLock, err := tryTakeCompatibleSessionLeaseLocks(accessPath, legacyAccessPath)
463 if err != nil {
464 sessionLeaseOwners.CompareAndDelete(path, ownerID)
465 info, _ := LoadSessionLeaseInfo(accessPath)
466 if errors.Is(err, ErrSessionLeaseHeld) {
467 return nil, &SessionLeaseError{Path: path, Info: info}
468 }
469 return nil, err
470 }
471 info, infoErr := LoadSessionLeaseInfo(accessPath)
472 if infoErr != nil || !handoffReservationMatches(info, sourceWriterID, SessionWriterID(), handoffID, time.Now().UTC()) {
473 unlockSessionLeaseLocks(leaseLock, legacyLeaseLock)
474 sessionLeaseOwners.CompareAndDelete(path, ownerID)
475 return nil, &SessionLeaseError{Path: path, Info: info}
476 }
477 sessionLeaseActiveOwners.Delete(path)
478 lease := &SessionLease{
479 path: path, accessPath: accessPath, legacyAccessPath: legacyAccessPath,
480 ownerID: ownerID, leaseLock: leaseLock, legacyLeaseLock: legacyLeaseLock,
481 }
482 if err := lease.publishOwner(); err != nil {
483 lease.Release()
484 return nil, err
485 }
486 sessionLeaseActiveOwners.Store(path, ownerID)
487 _ = os.Remove(sessionLeaseInfoPath(accessPath))
488 return lease, nil
489 }
490
491 func handoffReservationActive(info *SessionLeaseInfo, now time.Time) bool {
492 if info == nil || strings.TrimSpace(info.HandoffTo) == "" || strings.TrimSpace(info.HandoffID) == "" {
493 return false
494 }
495 return info.HandoffExpiresAt.IsZero() || now.Before(info.HandoffExpiresAt)
496 }
497
498 func handoffReservationMatches(info *SessionLeaseInfo, sourceWriterID, targetWriterID, handoffID string, now time.Time) bool {
499 if !handoffReservationActive(info, now) {
500 return false
501 }
502 return strings.TrimSpace(info.WriterID) == strings.TrimSpace(sourceWriterID) &&
503 strings.TrimSpace(info.HandoffTo) == strings.TrimSpace(targetWriterID) &&
504 strings.TrimSpace(info.HandoffID) == strings.TrimSpace(handoffID)
505 }
506
507 func (l *SessionLease) Release() {
508 if l == nil {
509 return
510 }
511 // Wait for authority-guarded saves to finish before revoking ownership.
512 // Without this, a concurrent save that already passed Valid() can finish
513 // after a successor lease is issued for the same path (ABA).
514 for {
515 l.mu.Lock()
516 if l.released {
517 l.mu.Unlock()
518 return
519 }
520 if l.activeSaves == 0 {
521 break
522 }
523 if l.releaseWait == nil {
524 l.releaseWait = make(chan struct{})
525 }
526 wait := l.releaseWait
527 beforeReleaseWait := l.beforeReleaseWait
528 l.mu.Unlock()
529 if beforeReleaseWait != nil {
530 beforeReleaseWait()
531 }
532 <-wait
533 }
534 l.released = true
535 leaseLock := l.leaseLock
536 legacyLeaseLock := l.legacyLeaseLock
537 l.leaseLock = nil
538 l.legacyLeaseLock = nil
539 beforeReleaseLock := l.beforeReleaseLock
540 l.mu.Unlock()
541
542 // Revoke ownership-sensitive repair before the OS lock becomes available
543 // to a successor. CompareAndDelete keeps a stale generation from
544 // deauthorizing a newer reclaimed lease.
545 sessionLeaseActiveOwners.CompareAndDelete(l.path, l.ownerID)
546 _ = os.Remove(sessionLeaseInfoPath(l.accessPath))
547 // Only remove the entry this lease owns: after a reclaim the map may
548 // already point at a newer lease for the same path.
549 sessionLeaseOwners.CompareAndDelete(l.path, l.ownerID)
550 if beforeReleaseLock != nil {
551 beforeReleaseLock()
552 }
553 if leaseLock != nil {
554 // Delete the exact lock file while its lock is still held. Besides
555 // retiring the sidecar, retaining the lock object enables an atomic
556 // handoff to SessionRemovalGuard without an unlock/reacquire window.
557 _ = leaseLock.RemoveAndUnlock()
558 }
559 if legacyLeaseLock != nil {
560 _ = legacyLeaseLock.RemoveAndUnlock()
561 }
562 _ = removeStaleSessionLockSidecar(l.accessPath, store.SessionLockFile(l.accessPath))
563 }
564
565 func newSessionLeaseInfo(path string) SessionLeaseInfo {
566 host, _ := os.Hostname()
567 return SessionLeaseInfo{
568 SessionPath: path,
569 WriterID: SessionWriterID(),
570 PID: os.Getpid(),
571 Hostname: host,
572 AcquiredAt: time.Now().UTC(),
573 }
574 }
575
576 func (l *SessionLease) publishOwner() error {
577 info := newSessionLeaseInfo(l.accessPath)
578 return l.writeOwnerInfo(info)
579 }
580
581 func (l *SessionLease) writeOwnerInfo(info SessionLeaseInfo) error {
582 if err := writeSessionLeaseInfo(l.leaseLock, info); err != nil {
583 return err
584 }
585 if l.legacyLeaseLock != nil {
586 if err := writeSessionLeaseInfo(l.legacyLeaseLock, info); err != nil {
587 return err
588 }
589 }
590 return nil
591 }
592
593 // tryTakeCompatibleSessionLeaseLocks preserves the v1.38.10 lock name while
594 // making the physical-path lock authoritative. The compatibility lock is only
595 // opened when its directory entry has a distinct identity, avoiding a second
596 // lock attempt on the same file on case-insensitive filesystems.
597 func tryTakeCompatibleSessionLeaseLocks(accessPath, legacyAccessPath string) (*sessionLockFile, *sessionLockFile, error) {
598 primary, err := tryTakeSessionLeaseLock(accessPath)
599 if err != nil {
600 return nil, nil, err
601 }
602 if sameSessionLeaseLockIdentity(accessPath, legacyAccessPath) {
603 return primary, nil, nil
604 }
605 legacy, err := tryTakeSessionLeaseLock(legacyAccessPath)
606 if err != nil {
607 // Preserve any reservation metadata already stored in the primary
608 // lock file. Stale empty lock files are harmless and cleaned later.
609 primary.Unlock()
610 return nil, nil, err
611 }
612 return primary, legacy, nil
613 }
614
615 func sameSessionLeaseLockIdentity(a, b string) bool {
616 if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
617 return true
618 }
619 left, leftErr := pathidentity.Resolve(store.SessionLeaseLock(a), pathidentity.Options{FollowLeaf: false})
620 right, rightErr := pathidentity.Resolve(store.SessionLeaseLock(b), pathidentity.Options{FollowLeaf: false})
621 return leftErr == nil && rightErr == nil && left.Key == right.Key
622 }
623
624 func unlockSessionLeaseLocks(primary, legacy *sessionLockFile) {
625 if legacy != nil {
626 legacy.Unlock()
627 }
628 if primary != nil {
629 primary.Unlock()
630 }
631 }
632
633 func tryLockCompatibleSessionLeaseFiles(accessPath, legacyAccessPath string) (func(), error) {
634 primary, legacy, err := tryTakeCompatibleSessionLeaseLocks(accessPath, legacyAccessPath)
635 if err != nil {
636 return nil, err
637 }
638 return func() { unlockSessionLeaseLocks(primary, legacy) }, nil
639 }
640
641 func loadCompatibleSessionLeaseInfo(accessPath, legacyAccessPath string) (*SessionLeaseInfo, error) {
642 info, err := LoadSessionLeaseInfo(accessPath)
643 if err == nil || !os.IsNotExist(err) || sameSessionLeaseLockIdentity(accessPath, legacyAccessPath) {
644 return info, err
645 }
646 return LoadSessionLeaseInfo(legacyAccessPath)
647 }
648
649 func writeSessionLeaseInfo(leaseLock *sessionLockFile, info SessionLeaseInfo) error {
650 b, err := json.MarshalIndent(info, "", " ")
651 if err != nil {
652 return err
653 }
654 b = append(b, '\n')
655 if leaseLock == nil {
656 return errors.New("session lease lock not held")
657 }
658 return leaseLock.writeOwnerInfo(b)
659 }
660
661 // errSessionLeaseInfoCorrupt marks a present-but-undecodable lease-info
662 // source (empty or invalid bytes). Readers treat it as "identity hidden,
663 // let the lock decide" instead of "no holder".
664 var errSessionLeaseInfoCorrupt = errors.New("session lease info corrupt")
665
666 // LoadSessionLeaseInfo reports the holder identity for path. New writers
667 // publish it inside .lease.lock; the .lease.json sidecar is a read-only
668 // compatibility source for sessions last held by older builds. An empty or
669 // undecodable source reads as corrupt (the live lock is the truth); only the
670 // absence of both sources reads as no-holder.
671 func LoadSessionLeaseInfo(path string) (*SessionLeaseInfo, error) {
672 lockPath := store.SessionLeaseLock(canonicalSessionSavePath(path))
673 if raw, err := readSessionLeaseLockFile(lockPath); err == nil {
674 b := fileencoding.DecodeToUTF8(raw)
675 if info, decodeErr := decodeSessionLeaseInfo(b); decodeErr == nil {
676 return info, nil
677 } else if !errors.Is(decodeErr, os.ErrNotExist) {
678 return nil, decodeErr
679 }
680 } else if !os.IsNotExist(err) {
681 // An unreadable lock file (permission damage, torn disk) still hides
682 // the holder identity; fail on the read rather than silently falling
683 // back to the legacy sidecar.
684 return nil, err
685 }
686 b, err := fileencoding.ReadFileUTF8(sessionLeaseInfoPath(path))
687 if err != nil {
688 return nil, err
689 }
690 return decodeSessionLeaseInfo(b)
691 }
692
693 func decodeSessionLeaseInfo(b []byte) (*SessionLeaseInfo, error) {
694 if len(strings.TrimSpace(string(b))) == 0 {
695 return nil, errSessionLeaseInfoCorrupt
696 }
697 var info SessionLeaseInfo
698 if err := json.Unmarshal(b, &info); err != nil {
699 return nil, fmt.Errorf("%w: %w", errSessionLeaseInfoCorrupt, err)
700 }
701 return &info, nil
702 }
703
704 // SaveSessionLeaseInfo writes the legacy .lease.json sidecar. Production
705 // writers publish owner identity inside .lease.lock instead; this remains for
706 // tests and tooling that need to stage the compatibility read path.
707 func SaveSessionLeaseInfo(path string, info SessionLeaseInfo) error {
708 leasePath := sessionLeaseInfoPath(path)
709 if err := os.MkdirAll(filepath.Dir(leasePath), 0o755); err != nil {
710 return err
711 }
712 b, err := json.MarshalIndent(info, "", " ")
713 if err != nil {
714 return err
715 }
716 b = append(b, '\n')
717 tmp, err := os.CreateTemp(filepath.Dir(leasePath), ".lease.*.tmp")
718 if err != nil {
719 return err
720 }
721 tmpPath := tmp.Name()
722 if _, err := tmp.Write(b); err != nil {
723 tmp.Close()
724 os.Remove(tmpPath)
725 return err
726 }
727 if err := tmp.Close(); err != nil {
728 os.Remove(tmpPath)
729 return err
730 }
731 if err := fileutil.ReplaceFile(tmpPath, leasePath); err != nil {
732 os.Remove(tmpPath)
733 return err
734 }
735 return nil
736 }
737
738 func sessionLeaseInfoPath(path string) string {
739 return store.SessionLeaseInfo(canonicalSessionSavePath(path))
740 }
741
742 // unleasedWriteObserved dedupes the write-authority probe below to one report
743 // per canonical path per process.
744 var unleasedWriteObserved sync.Map
745
746 // observeUnleasedSessionWrite is the store-P2 write-authority probe: the target
747 // model is "the lease holder is the only writer of a session's content", but
748 // enforcement can't land before we know every writer that currently saves
749 // without holding the lease (fresh-session creation saves before the first
750 // Rebind, headless runs, recovery tooling, ...). Until then this only records
751 // evidence: one structured warning per path per process, never a failure. The
752 // snapshot-conflict machinery stays the safety net for the writers this
753 // surfaces.
754 func observeUnleasedSessionWrite(path string, mode sessionSaveMode) {
755 canonical := CanonicalSessionPath(path)
756 if _, ok := sessionLeaseOwners.Load(canonical); ok {
757 return
758 }
759 if _, seen := unleasedWriteObserved.LoadOrStore(canonical, struct{}{}); seen {
760 return
761 }
762 slog.Warn("session: save without a held lease (write-authority probe, store P2)",
763 "path", filepath.Base(path),
764 "mode", int(mode),
765 "writer", SessionWriterID(),
766 )
767 }
768
768 lines GO