1package daemon
2
3import (
4 "cmp"
5 "errors"
6 "slices"
7 "sync"
8)
9
10var errMoveLifecycleUnavailable = errors.New("move lifecycle is no longer available")
11
12// moveLifecycleReservation pins a unique, canonically ordered set of sessions
13// plus the daemon move gate. Release is idempotent so every abort path can use
14// defer without coordinating ownership with its caller.
15type moveLifecycleReservation struct {
16 daemon *Daemon
17 sessions []*session
18 once sync.Once
19}
20
21// reserveMoveLifecycles admits a move at the daemon gate before touching any
22// per-session lifecycle state. All session counts publish atomically while the
23// complete ordered teardown lock set is held.
24func (d *Daemon) reserveMoveLifecycles(source, destination *session) (*moveLifecycleReservation, error) {
25 if d == nil || source == nil || destination == nil {
26 return nil, errMoveLifecycleUnavailable
27 }
28
29 d.moveLifecycleMu.Lock()
30 if d.moveLifecycleClosing {
31 d.moveLifecycleMu.Unlock()
32 return nil, errMoveLifecycleUnavailable
33 }
34 d.mu.Lock()
35 closing := d.closing
36 purgeClosing := d.purgeAdmissionClosing
37 if !closing && !purgeClosing {
38 // A move transfers live state across the exact lifecycle set a KillAll
39 // purge captured, so it reserves the purge admission gate too: the purge
40 // drains this reservation before it snapshots, and a move arriving during
41 // the purge is rejected instead of racing the set.
42 d.purgeAdmissionActive++
43 }
44 d.mu.Unlock()
45 if closing {
46 d.moveLifecycleMu.Unlock()
47 return nil, errMoveLifecycleUnavailable
48 }
49 if purgeClosing {
50 // A transient KillAll purge owns the exact lifecycle set, so no move can
51 // reserve it. Unlike a shutdown this is retry state, so report the same
52 // typed admission outcome as the other command paths instead of a
53 // permanent "target gone" verdict.
54 d.moveLifecycleMu.Unlock()
55 return nil, errPurgeAdmissionClosed
56 }
57 d.moveLifecycleActive++
58 d.signalMoveLifecycleChangedLocked()
59 d.moveLifecycleMu.Unlock()
60 if d.afterMoveLifecycleGateBeforeTeardownLocks != nil {
61 d.afterMoveLifecycleGateBeforeTeardownLocks()
62 }
63
64 sessions := uniqueMoveSessions(source, destination)
65 for _, sess := range sessions {
66 sess.teardownMu.Lock()
67 }
68 for _, sess := range sessions {
69 if sess.teardownActive {
70 unlockMoveSessions(sessions)
71 d.moveLifecycleMu.Lock()
72 d.releaseMoveAdmissionLocked()
73 d.moveLifecycleMu.Unlock()
74 return nil, errMoveLifecycleUnavailable
75 }
76 }
77 for _, sess := range sessions {
78 sess.moveReservations++
79 sess.signalTeardownChangedLocked()
80 }
81 unlockMoveSessions(sessions)
82
83 return &moveLifecycleReservation{daemon: d, sessions: sessions}, nil
84}
85
86func uniqueMoveSessions(source, destination *session) []*session {
87 sessions := []*session{source}
88 if destination != source {
89 sessions = append(sessions, destination)
90 }
91 slices.SortFunc(sessions, func(left, right *session) int {
92 return cmp.Compare(left.id, right.id)
93 })
94 return sessions
95}
96
97func unlockMoveSessions(sessions []*session) {
98 for i := len(sessions) - 1; i >= 0; i-- {
99 sessions[i].teardownMu.Unlock()
100 }
101}
102
103func (r *moveLifecycleReservation) Release() {
104 if r == nil {
105 return
106 }
107 r.once.Do(func() {
108 r.daemon.moveLifecycleMu.Lock()
109 for _, sess := range r.sessions {
110 sess.teardownMu.Lock()
111 }
112 for _, sess := range r.sessions {
113 sess.moveReservations--
114 sess.signalTeardownChangedLocked()
115 }
116 unlockMoveSessions(r.sessions)
117 r.daemon.releaseMoveAdmissionLocked()
118 r.daemon.moveLifecycleMu.Unlock()
119 })
120}
121
122// closeMoveLifecycles rejects new moves, publishes daemon shutdown to ordinary
123// routing, then drains reservations without holding either daemon mutex. It
124// deliberately does not cancel pane processes: the caller must first publish the
125// shutdown-preservation policy for every live session, then call
126// cancelPaneProcesses, so a PTY EOF caused by the cancellation can never be
127// mistaken for a natural final-shell exit that purges a named session.
128func (d *Daemon) closeMoveLifecycles() {
129 if d == nil {
130 return
131 }
132 d.moveLifecycleMu.Lock()
133 if !d.moveLifecycleClosing {
134 d.moveLifecycleClosing = true
135 d.signalMoveLifecycleChangedLocked()
136 }
137 d.moveLifecycleMu.Unlock()
138
139 // Publish ordinary shutdown admission separately: no move gate mutex is held
140 // while taking the architecture registry lock.
141 d.mu.Lock()
142 d.closing = true
143 d.mu.Unlock()
144
145 d.moveLifecycleMu.Lock()
146 for d.moveLifecycleActive != 0 {
147 changed := d.moveLifecycleChangeLocked()
148 d.moveLifecycleMu.Unlock()
149 <-changed
150 d.moveLifecycleMu.Lock()
151 }
152 d.moveLifecycleMu.Unlock()
153}
154
155// cancelPaneProcesses cancels the daemon-wide pane process lifetime, closing
156// every PTY. It must run only after closeMoveLifecycles has drained the final
157// move reservation and after every live session has reserved its shutdown
158// teardown (see terminateAllForShutdown).
159func (d *Daemon) cancelPaneProcesses() {
160 if d == nil {
161 return
162 }
163 d.moveLifecycleMu.Lock()
164 cancel := d.paneProcessCancel
165 d.moveLifecycleMu.Unlock()
166 if cancel != nil {
167 cancel()
168 }
169}
170
171// releaseMoveAdmissionLocked drops one move's move-gate and KillAll purge-gate
172// reservations. Caller holds moveLifecycleMu; the purge gate is guarded by mu.
173func (d *Daemon) releaseMoveAdmissionLocked() {
174 d.moveLifecycleActive--
175 d.signalMoveLifecycleChangedLocked()
176 d.mu.Lock()
177 d.purgeAdmissionActive--
178 d.signalPurgeAdmissionChangedLocked()
179 d.mu.Unlock()
180}
181
182func (d *Daemon) moveLifecycleChangeLocked() chan struct{} {
183 if d.moveLifecycleChanged == nil {
184 d.moveLifecycleChanged = make(chan struct{})
185 }
186 return d.moveLifecycleChanged
187}
188
189func (d *Daemon) signalMoveLifecycleChangedLocked() {
190 close(d.moveLifecycleChangeLocked())
191 d.moveLifecycleChanged = make(chan struct{})
192}