返回 DeepSeek-Reasonix
session_lease_keeper.go
根目录 / internal / control / session_lease_keeper.go
1 package control
2
3 import (
4 "errors"
5 "fmt"
6 "log/slog"
7 "strings"
8 "sync"
9
10 "reasonix/internal/agent"
11 )
12
13 // SessionLeaseKeeper owns at most one session lease on behalf of a frontend
14 // that binds session files for writing (the CLI chat/run commands, `reasonix
15 // serve`, one ACP session). Desktop tabs keep their own per-tab lease
16 // management; this keeper is the equivalent for the single-session surfaces:
17 // it follows the active session path across resumes, forks, and fresh-session
18 // rotations, holding exactly one lease at a time.
19 //
20 // The zero value is not ready for use; construct with NewSessionLeaseKeeper.
21 type SessionLeaseKeeper struct {
22 mu sync.Mutex
23 lease *agent.SessionLease
24 controller *Controller
25 retired []<-chan struct{}
26 ownershipBinder func(*Controller, *SessionLeaseKeeper)
27 }
28
29 func NewSessionLeaseKeeper() *SessionLeaseKeeper {
30 return &SessionLeaseKeeper{}
31 }
32
33 // Rebind points the keeper at path: it acquires path's session lease and only
34 // then releases the previously held one, so the outgoing session stays
35 // protected until the new one is secured. Rebinding to the path already held
36 // is a no-op; an empty path (session persistence disabled) just releases.
37 // On failure the keeper is unchanged — the caller still holds its previous
38 // lease and must not bind path for writing. A held path surfaces as an error
39 // wrapping agent.ErrSessionLeaseHeld; format it with SessionInUseMessage.
40 func (k *SessionLeaseKeeper) Rebind(path string) error {
41 return k.rebindWith(path, agent.TryAcquireSessionLease)
42 }
43
44 // RebindWithHandoff consumes an explicit cross-process lease reservation. It
45 // has the same failure-atomic ownership semantics as Rebind.
46 func (k *SessionLeaseKeeper) RebindWithHandoff(path, sourceWriterID, handoffID string) error {
47 return k.rebindWith(path, func(target string) (*agent.SessionLease, error) {
48 return agent.TryAcquireSessionLeaseWithHandoff(target, sourceWriterID, handoffID)
49 })
50 }
51
52 func (k *SessionLeaseKeeper) rebindWith(path string, acquire func(string) (*agent.SessionLease, error)) error {
53 if k == nil {
54 return nil
55 }
56 k.mu.Lock()
57 defer k.mu.Unlock()
58 if strings.TrimSpace(path) == "" {
59 k.releaseLocked()
60 return nil
61 }
62 if k.lease != nil && k.lease.Path() == agent.CanonicalSessionPath(path) {
63 return nil
64 }
65 lease, err := acquire(path)
66 if err != nil {
67 return err
68 }
69 k.releaseLocked()
70 k.lease = lease
71 return nil
72 }
73
74 // ReleaseForHandoff drops the keeper's current ownership into a reservation
75 // for targetWriterID. The controller loses write authority only after the
76 // reservation is durably published.
77 func (k *SessionLeaseKeeper) ReleaseForHandoff(targetWriterID, handoffID string) error {
78 if k == nil {
79 return nil
80 }
81 k.mu.Lock()
82 defer k.mu.Unlock()
83 if k.lease == nil {
84 return fmt.Errorf("no session lease held")
85 }
86 if err := k.lease.ReleaseForHandoff(targetWriterID, handoffID); err != nil {
87 return err
88 }
89 k.lease = nil
90 k.unbindControllerLocked()
91 return nil
92 }
93
94 // RebindReturningCurrent acquires path before returning the currently held
95 // session through its reverse reservation. It is the failure-atomic switch
96 // primitive for an external writer that wants to leave a mirrored session:
97 // if either the target acquire or reservation publication fails, the keeper
98 // remains bound to the current session.
99 func (k *SessionLeaseKeeper) RebindReturningCurrent(path, targetWriterID, handoffID string) error {
100 return k.rebindReturningCurrentWith(path, targetWriterID, handoffID, agent.TryAcquireSessionLease)
101 }
102
103 // RebindWithHandoffReturningCurrent is the two-sided handoff variant: acquire
104 // the new session through its forward reservation, then return the current
105 // session through its reverse reservation as one keeper transaction. If both
106 // the source return and the target rollback reservation fail, the source is
107 // restored and pending retains the live target lease for retry. Callers must
108 // keep pending until RetireDetachedForHandoff succeeds.
109 func (k *SessionLeaseKeeper) RebindWithHandoffReturningCurrent(
110 path, sourceWriterID, acquireHandoffID, acquiredReturnHandoffID, targetWriterID, returnHandoffID string,
111 ) (pending *SessionLeaseKeeper, err error) {
112 return k.rebindWithHandoffReturningCurrentWith(
113 path,
114 func(target string) (*agent.SessionLease, error) {
115 return agent.TryAcquireSessionLeaseWithHandoff(target, sourceWriterID, acquireHandoffID)
116 },
117 func(previous *SessionLeaseKeeper) error {
118 return previous.RetireDetachedForHandoff(targetWriterID, returnHandoffID)
119 },
120 func(target *agent.SessionLease) error {
121 return target.ReleaseForHandoff(sourceWriterID, acquiredReturnHandoffID)
122 },
123 )
124 }
125
126 func (k *SessionLeaseKeeper) rebindWithHandoffReturningCurrentWith(
127 path string,
128 acquire func(string) (*agent.SessionLease, error),
129 returnPrevious func(*SessionLeaseKeeper) error,
130 returnTarget func(*agent.SessionLease) error,
131 ) (*SessionLeaseKeeper, error) {
132 previous, err := k.rebindDetachingWith(path, acquire)
133 if err != nil || previous == nil {
134 return nil, err
135 }
136 if err := returnPrevious(previous); err != nil {
137 pending, rollbackErr := k.restoreDetachedReturningCurrentWith(previous, returnTarget)
138 if rollbackErr != nil {
139 return pending, errors.Join(err, fmt.Errorf("return acquired target: %w", rollbackErr))
140 }
141 return nil, err
142 }
143 return nil, nil
144 }
145
146 func (k *SessionLeaseKeeper) rebindReturningCurrentWith(path, targetWriterID, handoffID string, acquire func(string) (*agent.SessionLease, error)) error {
147 if k == nil {
148 return nil
149 }
150 k.mu.Lock()
151 defer k.mu.Unlock()
152 if k.lease == nil {
153 return fmt.Errorf("no session lease held")
154 }
155 canonical := agent.CanonicalSessionPath(path)
156 if strings.TrimSpace(canonical) == "" {
157 return fmt.Errorf("target session path is empty")
158 }
159 if k.lease.Path() == canonical {
160 return nil
161 }
162 next, err := acquire(canonical)
163 if err != nil {
164 return err
165 }
166 if err := k.lease.ReleaseForHandoff(targetWriterID, handoffID); err != nil {
167 next.Release()
168 return err
169 }
170 k.lease = next
171 k.unbindControllerLocked()
172 return nil
173 }
174
175 // HandleSessionRecovered moves the single-session frontend lease before a
176 // controller commits to a recovery branch. It is suitable for
177 // Options.OnSessionRecovered in CLI chat/run/serve surfaces. Rebind acquires the
178 // recovery path before releasing the original lease, so a failed handoff keeps
179 // the previous session protected.
180 func (k *SessionLeaseKeeper) HandleSessionRecovered(info SessionRecoveryInfo) error {
181 _, err := k.handleSessionRecovered(nil, false, info)
182 return err
183 }
184
185 // HandleSessionRecoveredFor applies a recovery only while this keeper still
186 // owns c. Multi-session frontends use the boolean to retry against the
187 // controller's newly published keeper when a captured callback races an
188 // ownership transfer.
189 func (k *SessionLeaseKeeper) HandleSessionRecoveredFor(c *Controller, info SessionRecoveryInfo) (bool, error) {
190 return k.handleSessionRecovered(c, true, info)
191 }
192
193 func (k *SessionLeaseKeeper) handleSessionRecovered(c *Controller, requireOwner bool, info SessionRecoveryInfo) (bool, error) {
194 recoveryPath := strings.TrimSpace(info.RecoveryPath)
195 if k == nil || recoveryPath == "" {
196 return true, nil
197 }
198 k.mu.Lock()
199 if requireOwner && k.controller != c {
200 k.mu.Unlock()
201 return false, nil
202 }
203 if k.lease != nil && k.lease.Path() == agent.CanonicalSessionPath(recoveryPath) {
204 k.mu.Unlock()
205 return true, nil
206 }
207 lease, err := agent.TryAcquireSessionLease(recoveryPath)
208 if err == nil && k.controller != nil {
209 err = k.controller.BindSessionWriteAuthority(lease)
210 }
211 if err != nil {
212 if lease != nil {
213 lease.Release()
214 }
215 k.mu.Unlock()
216 if errors.Is(err, agent.ErrSessionLeaseHeld) {
217 return true, fmt.Errorf("bind recovery session: %s; %s",
218 SessionInUseMessage(err), SessionLeaseCloseHint)
219 }
220 // The detailed error can contain a machine-local path. Keep it in
221 // diagnostics and return path-free text to every frontend.
222 slog.Error("control: bind recovery session lease", "err", err)
223 return true, fmt.Errorf("bind recovery session: unable to secure recovered transcript")
224 }
225 old := k.lease
226 k.lease = lease
227 var retired chan struct{}
228 if old != nil {
229 retired = make(chan struct{})
230 k.retired = append(k.retired, retired)
231 }
232 k.mu.Unlock()
233 // Recovery callbacks run inside the authority-guarded save that still owns
234 // old. Releasing synchronously here would wait on that same save forever.
235 // Retirement is bounded to one goroutine per committed path handoff.
236 if old != nil {
237 go func() {
238 old.Release()
239 close(retired)
240 }()
241 }
242 return true, nil
243 }
244
245 // HandleSessionTransition acquires and binds an intentional path-change target
246 // before the controller swaps Sessions. Acquisition is failure-atomic: the old
247 // lease remains held unless the target lease and candidate authority are ready.
248 func (k *SessionLeaseKeeper) HandleSessionTransition(info SessionTransitionInfo) error {
249 return k.handleSessionTransitionWith(info, agent.TryAcquireSessionLease)
250 }
251
252 // HandleSessionTransitionWithHandoff binds a private transition candidate with
253 // an explicitly reserved lease before the controller publishes it.
254 func (k *SessionLeaseKeeper) HandleSessionTransitionWithHandoff(info SessionTransitionInfo, sourceWriterID, handoffID string) error {
255 return k.handleSessionTransitionWith(info, func(path string) (*agent.SessionLease, error) {
256 return agent.TryAcquireSessionLeaseWithHandoff(path, sourceWriterID, handoffID)
257 })
258 }
259
260 func (k *SessionLeaseKeeper) handleSessionTransitionWith(info SessionTransitionInfo, acquire func(string) (*agent.SessionLease, error)) error {
261 targetPath := strings.TrimSpace(info.TargetPath)
262 if k == nil || targetPath == "" {
263 return nil
264 }
265 k.mu.Lock()
266 canonical := agent.CanonicalSessionPath(targetPath)
267 if k.lease != nil && k.lease.Path() == canonical {
268 err := info.BindWriteAuthority(k.lease)
269 k.mu.Unlock()
270 return err
271 }
272 lease, err := acquire(targetPath)
273 if err == nil {
274 err = info.BindWriteAuthority(lease)
275 }
276 if err != nil {
277 if lease != nil {
278 lease.Release()
279 }
280 k.mu.Unlock()
281 if errors.Is(err, agent.ErrSessionLeaseHeld) {
282 return fmt.Errorf("bind target session: %s; %s",
283 SessionInUseMessage(err), SessionLeaseCloseHint)
284 }
285 slog.Error("control: bind target session lease", "reason", info.Reason, "err", err)
286 return fmt.Errorf("bind target session: unable to secure transcript")
287 }
288 old := k.lease
289 k.lease = lease
290 k.mu.Unlock()
291 if old != nil {
292 old.Release()
293 }
294 return nil
295 }
296
297 // Release drops the held lease, if any. Idempotent; call it on frontend
298 // teardown after the controller has finished its final writes.
299 func (k *SessionLeaseKeeper) Release() {
300 if k == nil {
301 return
302 }
303 k.mu.Lock()
304 k.releaseLocked()
305 retired := append([]<-chan struct{}(nil), k.retired...)
306 k.mu.Unlock()
307 for _, done := range retired {
308 <-done
309 }
310 }
311
312 // WaitForRetiredLeases waits until the most recent recovery handoff has
313 // released its outgoing lease. Runtime paths do not need to call it; tests and
314 // shutdown use it when they require deterministic cleanup observation.
315 func (k *SessionLeaseKeeper) WaitForRetiredLeases() {
316 if k == nil {
317 return
318 }
319 k.mu.Lock()
320 retired := append([]<-chan struct{}(nil), k.retired...)
321 k.mu.Unlock()
322 for _, done := range retired {
323 <-done
324 }
325 }
326
327 // HeldPath reports the canonical session path the keeper currently guards,
328 // or "" when it holds nothing.
329 func (k *SessionLeaseKeeper) HeldPath() string {
330 if k == nil {
331 return ""
332 }
333 k.mu.Lock()
334 defer k.mu.Unlock()
335 if k.lease == nil {
336 return ""
337 }
338 return k.lease.Path()
339 }
340
341 // Lease returns the held lease for authority issuance. Callers must not
342 // Release it; use Release/Rebind on the keeper instead.
343 func (k *SessionLeaseKeeper) Lease() *agent.SessionLease {
344 if k == nil {
345 return nil
346 }
347 k.mu.Lock()
348 defer k.mu.Unlock()
349 return k.lease
350 }
351
352 // BindControllerAuthority issues a fresh write authority from the held lease
353 // onto c. Safe no-op when the keeper holds nothing.
354 func (k *SessionLeaseKeeper) BindControllerAuthority(c *Controller) error {
355 if k == nil || c == nil {
356 return nil
357 }
358 k.mu.Lock()
359 defer k.mu.Unlock()
360 if err := c.BindSessionWriteAuthority(k.lease); err != nil {
361 return err
362 }
363 k.controller = c
364 c.SetOnSessionTransition(k.HandleSessionTransition)
365 if k.ownershipBinder != nil {
366 k.ownershipBinder(c, k)
367 }
368 return nil
369 }
370
371 // BindSessionAuthority issues the held lease's next write generation directly
372 // onto a private session candidate. Resume callers use it before publishing
373 // that candidate through their controller; binding the controller itself would
374 // only update the outgoing executor session that Resume is about to replace.
375 func (k *SessionLeaseKeeper) BindSessionAuthority(sess *agent.Session) error {
376 if k == nil || sess == nil {
377 return nil
378 }
379 k.mu.Lock()
380 defer k.mu.Unlock()
381 sess.RequireWriteAuthority()
382 if k.lease == nil {
383 sess.ClearWriteAuthority()
384 return agent.ErrSessionWriteAuthorityMissing
385 }
386 return k.lease.Writer().Bind(sess, agent.NextSessionWriteGeneration())
387 }
388
389 // SetControllerOwnershipBinder lets an owning frontend compose its routing
390 // callbacks with lease handoff. The binder follows a controller through
391 // Split, RebindDetaching, and Adopt instead of those transfers replacing the
392 // frontend callback with the keeper-only default.
393 func (k *SessionLeaseKeeper) SetControllerOwnershipBinder(bind func(*Controller, *SessionLeaseKeeper)) {
394 if k == nil {
395 return
396 }
397 k.mu.Lock()
398 k.ownershipBinder = bind
399 if k.controller != nil && bind != nil {
400 bind(k.controller, k)
401 }
402 k.mu.Unlock()
403 }
404
405 func (k *SessionLeaseKeeper) bindTransferredController(c *Controller) {
406 if c == nil {
407 return
408 }
409 c.SetOnSessionTransition(k.HandleSessionTransition)
410 if k.ownershipBinder != nil {
411 k.ownershipBinder(c, k)
412 } else {
413 c.SetOnSessionRecovered(k.HandleSessionRecovered)
414 }
415 }
416
417 // Split moves the held lease and controller binding into a new keeper without
418 // releasing either. Multi-session Serve uses this when a busy controller moves
419 // to the background and must keep saving its own transcript to completion.
420 func (k *SessionLeaseKeeper) Split() *SessionLeaseKeeper {
421 if k == nil {
422 return nil
423 }
424 k.mu.Lock()
425 defer k.mu.Unlock()
426 if k.lease == nil && k.controller == nil && len(k.retired) == 0 {
427 return nil
428 }
429 dst := &SessionLeaseKeeper{lease: k.lease, controller: k.controller, retired: k.retired, ownershipBinder: k.ownershipBinder}
430 if dst.controller != nil {
431 dst.bindTransferredController(dst.controller)
432 }
433 k.lease, k.controller, k.retired = nil, nil, nil
434 return dst
435 }
436
437 // RebindDetaching acquires path and returns the previous binding in a separate
438 // keeper. Acquisition is failure-atomic: on error the receiver is unchanged.
439 func (k *SessionLeaseKeeper) RebindDetaching(path string) (*SessionLeaseKeeper, error) {
440 return k.rebindDetachingWith(path, agent.TryAcquireSessionLease)
441 }
442
443 // RebindDetachingWithHandoff is RebindDetaching for a targeted reservation.
444 func (k *SessionLeaseKeeper) RebindDetachingWithHandoff(path, sourceWriterID, handoffID string) (*SessionLeaseKeeper, error) {
445 return k.rebindDetachingWith(path, func(target string) (*agent.SessionLease, error) {
446 return agent.TryAcquireSessionLeaseWithHandoff(target, sourceWriterID, handoffID)
447 })
448 }
449
450 // RetireDetached releases a keeper returned by RebindDetaching after the
451 // caller has authorized the replacement session. Unlike Release, it does not
452 // clear the shared controller's authority or callbacks: the receiving keeper
453 // binds those immediately after publishing the replacement session.
454 func (k *SessionLeaseKeeper) RetireDetached() {
455 if k == nil {
456 return
457 }
458 k.mu.Lock()
459 if k.lease != nil {
460 k.lease.Release()
461 k.lease = nil
462 }
463 k.controller = nil
464 retired := append([]<-chan struct{}(nil), k.retired...)
465 k.retired = nil
466 k.mu.Unlock()
467 for _, done := range retired {
468 <-done
469 }
470 }
471
472 // RetireDetachedForHandoff is RetireDetached with a durable target-writer
473 // reservation. A persistence failure leaves the detached keeper unchanged.
474 func (k *SessionLeaseKeeper) RetireDetachedForHandoff(targetWriterID, handoffID string) error {
475 if k == nil {
476 return fmt.Errorf("no detached session lease held")
477 }
478 k.mu.Lock()
479 if k.lease == nil {
480 k.mu.Unlock()
481 return fmt.Errorf("no detached session lease held")
482 }
483 if err := k.lease.ReleaseForHandoff(targetWriterID, handoffID); err != nil {
484 k.mu.Unlock()
485 return err
486 }
487 k.lease = nil
488 k.controller = nil
489 retired := append([]<-chan struct{}(nil), k.retired...)
490 k.retired = nil
491 k.mu.Unlock()
492 for _, done := range retired {
493 <-done
494 }
495 return nil
496 }
497
498 // RestoreDetachedReturningCurrent rolls a RebindDetaching transaction back to
499 // previous while returning the receiver's newly acquired lease through a
500 // reverse reservation. The old binding is restored even when publishing that
501 // reservation fails. In that case the returned detached keeper still owns the
502 // target lease and must be retained and retried with RetireDetachedForHandoff;
503 // releasing it while the process remains alive would reopen a third-writer
504 // race before the intended owner can consume the reservation.
505 //
506 // Callers use this before publishing a controller for the receiver's target.
507 func (k *SessionLeaseKeeper) RestoreDetachedReturningCurrent(previous *SessionLeaseKeeper, targetWriterID, handoffID string) (*SessionLeaseKeeper, error) {
508 return k.restoreDetachedReturningCurrentWith(previous, func(lease *agent.SessionLease) error {
509 return lease.ReleaseForHandoff(targetWriterID, handoffID)
510 })
511 }
512
513 func (k *SessionLeaseKeeper) restoreDetachedReturningCurrentWith(previous *SessionLeaseKeeper, release func(*agent.SessionLease) error) (*SessionLeaseKeeper, error) {
514 if k == nil || previous == nil || k == previous {
515 return nil, fmt.Errorf("invalid detached session rollback")
516 }
517 k.mu.Lock()
518 previous.mu.Lock()
519 defer previous.mu.Unlock()
520 defer k.mu.Unlock()
521 if k.lease == nil {
522 return nil, fmt.Errorf("no current session lease held")
523 }
524 if previous.lease == nil && previous.controller == nil && len(previous.retired) == 0 {
525 return nil, fmt.Errorf("no previous session binding held")
526 }
527 if k.controller != nil || len(k.retired) != 0 {
528 return nil, fmt.Errorf("current session binding was already published")
529 }
530
531 current := k.lease
532 releaseErr := release(current)
533 var pending *SessionLeaseKeeper
534 if releaseErr != nil {
535 pending = &SessionLeaseKeeper{lease: current, ownershipBinder: k.ownershipBinder}
536 }
537
538 inLease, inCtrl, inRetired, inBinder := previous.lease, previous.controller, previous.retired, previous.ownershipBinder
539 previous.lease, previous.controller, previous.retired = nil, nil, nil
540 if k.ownershipBinder == nil {
541 k.ownershipBinder = inBinder
542 }
543 k.lease, k.controller, k.retired = inLease, inCtrl, inRetired
544 if inCtrl != nil {
545 k.bindTransferredController(inCtrl)
546 }
547 return pending, releaseErr
548 }
549
550 func (k *SessionLeaseKeeper) rebindDetachingWith(path string, acquire func(string) (*agent.SessionLease, error)) (*SessionLeaseKeeper, error) {
551 if k == nil {
552 return nil, nil
553 }
554 if strings.TrimSpace(path) == "" {
555 return k.Split(), nil
556 }
557 k.mu.Lock()
558 canonical := agent.CanonicalSessionPath(path)
559 if k.lease != nil && k.lease.Path() == canonical {
560 k.mu.Unlock()
561 return nil, nil
562 }
563 lease, err := acquire(path)
564 if err != nil {
565 k.mu.Unlock()
566 return nil, err
567 }
568 var dst *SessionLeaseKeeper
569 if k.lease != nil || k.controller != nil || len(k.retired) > 0 {
570 dst = &SessionLeaseKeeper{lease: k.lease, controller: k.controller, retired: k.retired, ownershipBinder: k.ownershipBinder}
571 }
572 // dst stays nil when the keeper holds nothing at all (e.g. its lease was
573 // released by a session handoff); there is no controller to rebind then.
574 if dst != nil && dst.controller != nil {
575 dst.bindTransferredController(dst.controller)
576 }
577 k.lease, k.controller, k.retired = lease, nil, nil
578 k.mu.Unlock()
579 return dst, nil
580 }
581
582 // Adopt transfers another keeper's lease and controller binding into the
583 // receiver. The source is emptied; any receiver binding is released first.
584 func (k *SessionLeaseKeeper) Adopt(other *SessionLeaseKeeper) {
585 if k == nil || other == nil || k == other {
586 return
587 }
588 other.mu.Lock()
589 inLease, inCtrl, inRetired, inBinder := other.lease, other.controller, other.retired, other.ownershipBinder
590 other.lease, other.controller, other.retired = nil, nil, nil
591 other.mu.Unlock()
592 k.mu.Lock()
593 k.releaseLocked()
594 if k.ownershipBinder == nil {
595 k.ownershipBinder = inBinder
596 }
597 k.lease, k.controller, k.retired = inLease, inCtrl, inRetired
598 if inCtrl != nil {
599 k.bindTransferredController(inCtrl)
600 }
601 k.mu.Unlock()
602 }
603
604 func (k *SessionLeaseKeeper) releaseLocked() {
605 if k.lease != nil {
606 k.lease.Release()
607 k.lease = nil
608 }
609 k.unbindControllerLocked()
610 }
611
612 func (k *SessionLeaseKeeper) unbindControllerLocked() {
613 if k.controller != nil {
614 k.controller.SetOnSessionTransition(nil)
615 k.controller.SetOnSessionRecovered(nil)
616 _ = k.controller.BindSessionWriteAuthority(nil)
617 k.controller = nil
618 }
619 }
620
621 // SessionLeaseCloseHint is the universal way out of a lease refusal, appended
622 // by surfaces that have no copy escape hatch (in-TUI switches, serve, ACP).
623 const SessionLeaseCloseHint = "close the other Reasonix window or process first"
624
625 // SessionInUseMessage renders a lease-acquisition failure as the shared
626 // operator-facing "who is holding this" line used by the CLI, serve, and ACP.
627 // It names the holder from the lease info when available and degrades to a
628 // generic line otherwise. The session file path is deliberately omitted — the
629 // caller already knows which session it asked for.
630 func SessionInUseMessage(err error) string {
631 const fallback = "this session is in use by another Reasonix window or process"
632 var leaseErr *agent.SessionLeaseError
633 if !errors.As(err, &leaseErr) || leaseErr == nil || leaseErr.Info == nil || leaseErr.Info.PID <= 0 {
634 return fallback
635 }
636 info := leaseErr.Info
637 var b strings.Builder
638 fmt.Fprintf(&b, "this session is in use by another Reasonix process (pid %d", info.PID)
639 if host := strings.TrimSpace(info.Hostname); host != "" {
640 b.WriteString(" on " + host)
641 }
642 if !info.AcquiredAt.IsZero() {
643 b.WriteString(", since " + info.AcquiredAt.Local().Format("15:04"))
644 }
645 b.WriteString(")")
646 return b.String()
647 }
648
648 lines GO