1package daemon
2
3import (
4 "context"
5 "errors"
6
7 "github.com/bnema/vev/internal/domain"
8)
9
10// moveTopology is the private policy Interface consumed by moveTransaction.
11// Admission runs under daemon and ordered Session locks and must be read-only.
12// Publication runs under daemon, routing, ordered Session, and resize fences;
13// it must finish every fallible check before its first write.
14type moveTopology interface {
15 transactionRequest() moveTransactionRequest
16 validRequest() bool
17 admitLocked(*Daemon, *session, *session) error
18 afterAdmission(*Daemon)
19 willRetireSource() bool
20 resizeFences(*session, *session) *moveResizeFences
21 publishLocked(*Daemon, *session, *session) (moveTopologyPublication, error)
22}
23
24type moveTransactionRequest struct {
25 follow bool
26 beforeFollow func() error
27 afterFollow func(*attachmentEffect)
28 operation string
29 attachment *attachedClient
30 attachmentCapability attachmentCapability
31 source moveSessionLocator
32 destination moveSessionLocator
33 logAttrs []any
34}
35
36// moveTopologyPublication contains immutable topology outputs needed after the
37// in-memory publication point. No field grants authority to perform additional
38// topology writes.
39type moveTopologyPublication struct {
40 sourceTab, destinationTab *tab
41 movedPane *pane
42 movedPanes []*pane
43 syncCleanup syncTimerCleanup
44 oldTabCancel context.CancelFunc
45 sourceTabRemoved bool
46 sourceEmpty bool
47}
48
49// moveTransaction owns the shared pane/tab Move phase protocol. Topology and
50// owner policy remain private Implementations behind moveTopology.
51type moveTransaction struct {
52 daemon *Daemon
53 topology moveTopology
54 request moveTransactionRequest
55
56 source *session
57 destination *session
58
59 sourceAttachments []*attachedClient
60 sourceTransports map[*attachedClient]transportSnapshot
61 frozenEffects attachmentTransitionGuard
62 effectsFrozen bool
63
64 followResult attachmentTransitionResult
65
66 publication moveTopologyPublication
67 publishErr error
68
69 retiredParked []parkedAttachmentRetirement
70 retiredAttachments []detachedAttachmentSnapshot
71 sourceMetadata domain.CatalogueMetadataUpdate
72 sourceMetadataOK bool
73 destMetadata domain.CatalogueMetadataUpdate
74 destMetadataOK bool
75 sourceName string
76 destinationName string
77}
78
79func (d *Daemon) executeMove(topology moveTopology) (result error) {
80 if d == nil || topology == nil {
81 return errMovePaneInvalid
82 }
83 t := moveTransaction{daemon: d, topology: topology, request: topology.transactionRequest()}
84 d.log.Info(t.request.operation+" move requested", t.request.logAttrs...)
85 defer func() {
86 result = normalizeMoveRejection(result)
87 if result != nil {
88 attrs := make([]any, 0, len(t.request.logAttrs)+2)
89 attrs = append(attrs, "err", result)
90 attrs = append(attrs, t.request.logAttrs...)
91 d.log.Warn(t.request.operation+" move rejected", attrs...)
92 }
93 }()
94 if !t.validRequest() {
95 return errMovePaneInvalid
96 }
97 if err := t.resolveSessions(); err != nil {
98 return err
99 }
100
101 if d.beforeMoveDispatch != nil {
102 d.beforeMoveDispatch()
103 }
104 unlockDispatch := lockMoveDispatch(t.source, t.destination)
105 dispatchHeld := true
106 defer func() {
107 if dispatchHeld {
108 unlockDispatch()
109 }
110 }()
111
112 reservation, err := d.reserveMoveLifecycles(t.source, t.destination)
113 if err != nil {
114 // Preserve a typed transient admission rejection (a KillAll purge owning
115 // the exact set) so the command path maps it retryably instead of
116 // misreporting the destination as gone.
117 if errors.Is(err, errPurgeAdmissionClosed) {
118 return err
119 }
120 return errMoveStaleTarget
121 }
122 reservationHeld := true
123 defer func() {
124 if reservationHeld {
125 reservation.Release()
126 }
127 }()
128 if d.afterMoveLifecycleReserved != nil {
129 d.afterMoveLifecycleReserved()
130 }
131
132 if err := t.admit(); err != nil {
133 return err
134 }
135 t.topology.afterAdmission(d)
136 if t.follows() && t.request.beforeFollow != nil {
137 if err := t.request.beforeFollow(); err != nil {
138 return err
139 }
140 }
141 if err := t.freezeSourceAttachments(); err != nil {
142 return err
143 }
144 defer func() {
145 if t.effectsFrozen {
146 t.frozenEffects.unfreeze()
147 }
148 }()
149
150 fences := t.topology.resizeFences(t.source, t.destination)
151 if fences == nil || !fences.acquire(func() bool {
152 t.publishErr = t.publishLocked()
153 return t.publishErr == nil
154 }) {
155 if t.publishErr != nil {
156 return t.publishErr
157 }
158 return errMoveStaleTarget
159 }
160 fences.Release()
161
162 postcommit := t.postcommitPlan(unlockDispatch, reservation)
163 t.effectsFrozen = false
164 dispatchHeld = false
165 reservationHeld = false
166 postcommit.execute(d)
167 return nil
168}
169
170func (t *moveTransaction) validRequest() bool {
171 return t != nil && t.topology != nil && t.request.operation != "" &&
172 t.request.source.ID != "" && t.request.destination.ID != "" && t.topology.validRequest()
173}
174
175func (t *moveTransaction) resolveSessions() error {
176 d := t.daemon
177 d.mu.Lock()
178 t.source = moveSessionForLocatorLocked(d, t.request.source)
179 t.destination = moveSessionForLocatorLocked(d, t.request.destination)
180 d.mu.Unlock()
181 if t.source == nil || t.destination == nil {
182 return errMoveStaleTarget
183 }
184 return nil
185}
186
187func (t *moveTransaction) attachmentAuthorityCurrentLocked() bool {
188 req := t.request
189 if req.attachment != nil && !attachmentRegisteredLocked(t.source, req.attachment) {
190 return false
191 }
192 if req.attachmentCapability.ac != nil &&
193 (req.attachmentCapability.ac != req.attachment || !req.attachmentCapability.currentInSessionLocked(t.source)) {
194 return false
195 }
196 return true
197}
198
199// admit snapshots exact common Attachment participants while the topology
200// Implementation snapshots only its private identity and layout state.
201func (t *moveTransaction) admit() error {
202 d := t.daemon
203 d.mu.Lock()
204 defer d.mu.Unlock()
205 if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination {
206 return errMoveStaleTarget
207 }
208 unlockSessions := lockAttachmentSessions(t.source, t.destination)
209 defer unlockSessions()
210 if !moveSessionLocatorCurrentLocked(t.source, t.request.source) ||
211 !moveSessionLocatorCurrentLocked(t.destination, t.request.destination) ||
212 !t.attachmentAuthorityCurrentLocked() {
213 return errMoveStaleTarget
214 }
215 if err := t.topology.admitLocked(d, t.source, t.destination); err != nil {
216 return err
217 }
218 t.sourceAttachments = t.source.snapshotAttachmentsLocked()
219 t.sourceTransports = make(map[*attachedClient]transportSnapshot, len(t.sourceAttachments))
220 for _, attachment := range t.sourceAttachments {
221 t.sourceTransports[attachment] = attachment.transportSnapshot()
222 }
223 return nil
224}
225
226// freezeSourceAttachments delegates effect admission and drain authority to the
227// attachment lifecycle Module. Move uses nonblocking acquisition to preserve
228// its ordering with final teardown.
229func (t *moveTransaction) freezeSourceAttachments() error {
230 if (!t.topology.willRetireSource() && !t.follows()) || len(t.sourceAttachments) == 0 {
231 return nil
232 }
233 participants := t.sourceAttachments
234 if !t.topology.willRetireSource() {
235 participants = []*attachedClient{t.request.attachment}
236 }
237 interrupts := make([]attachmentTransportInterrupt, 0, len(participants))
238 for _, attachment := range participants {
239 if t.follows() && attachment == t.request.attachment {
240 continue // The following connection must remain usable for first paint.
241 }
242 if transport := t.sourceTransports[attachment]; transport.transport != nil {
243 interrupts = append(interrupts, attachmentTransportInterrupt{ac: attachment, transport: transport})
244 }
245 }
246 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{
247 interrupts: interrupts,
248 nonblocking: true,
249 afterFrozen: func(attachment *attachedClient) {
250 if t.daemon.afterAttachmentEffectGateFrozen != nil {
251 t.daemon.afterAttachmentEffectGateFrozen("move-"+t.request.operation, attachment)
252 }
253 },
254 }, participants...)
255 if !frozen.acquired || !frozen.drained {
256 frozen.unfreeze()
257 return errMovePaneInvalid
258 }
259 t.frozenEffects = frozen
260 t.effectsFrozen = true
261 return nil
262}
263
264// publishLocked is the only shared architecture-lock window. Topology
265// Implementations perform all fallible private validation before their first
266// write. Common retirement and metadata capture are non-failing in-memory work.
267func (t *moveTransaction) publishLocked() error {
268 d := t.daemon
269 d.mu.Lock()
270 defer d.mu.Unlock()
271 if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination {
272 return errMoveStaleTarget
273 }
274 d.notices.routingMu.Lock()
275 defer d.notices.routingMu.Unlock()
276 unlockSessions := lockAttachmentSessions(t.source, t.destination)
277 defer unlockSessions()
278 if !moveSessionLocatorCurrentLocked(t.source, t.request.source) ||
279 !moveSessionLocatorCurrentLocked(t.destination, t.request.destination) ||
280 !t.attachmentAuthorityCurrentLocked() {
281 return errMoveStaleTarget
282 }
283 if t.topology.willRetireSource() && !sameMoveAttachmentsLocked(t.source, t.sourceAttachments) {
284 return errMoveStaleTarget
285 }
286
287 var follow *attachmentPublication
288 if t.follows() {
289 req := attachmentTransitionRequest{
290 source: t.source, target: t.destination, next: t.request.attachment,
291 expectedTransport: t.sourceTransports[t.request.attachment],
292 preflighted: true, attachmentEffectsFrozen: true, ready: true,
293 }
294 if t.request.attachmentCapability.ac != nil {
295 req.sourceCapability = &t.request.attachmentCapability
296 }
297 var err error
298 follow, err = d.validateAttachmentTransitionPrelocked(req)
299 if err != nil {
300 return errMoveStaleTarget
301 }
302 // Topology sync migration takes coordinator locks itself. Session locks
303 // and the frozen gate keep membership/lease authority pinned meanwhile.
304 follow.unlockCoordinators()
305 }
306 publication, err := t.topology.publishLocked(d, t.source, t.destination)
307 if err != nil {
308 return err
309 }
310 t.publication = publication
311 if follow != nil {
312 follow.releaseCoordinators = lockAttachmentCoordinators(t.source, follow.sourceCoordinator, t.destination, follow.targetCoordinator)
313 follow.req.activateTargetTab = true
314 follow.req.targetTabIndex = indexMoveTabLocked(t.destination, publication.destinationTab)
315 t.followResult = d.publishAttachmentTransitionPrelocked(follow)
316 follow.unlockCoordinators()
317 if t.source != t.destination && !publication.sourceEmpty {
318 t.followResult.sourceGeometrySession = t.source
319 }
320 }
321 if publication.sourceEmpty {
322 d.unregisterSessionLocked(t.source)
323 t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports)
324 t.source.tabs = nil
325 d.purgeParkingForSessionLocked(t.source)
326 d.purgeSuspendedForSessionLocked(t.source)
327 t.retiredParked = d.purgeParkedForSessionLocked(t.source)
328 }
329 t.sourceName = t.source.name
330 t.destinationName = t.destination.name
331 if !t.source.ephemeral {
332 t.sourceMetadata = t.source.persistRecordLocked(max(d.nowUnixNano(), t.source.createdAt, int64(1))).MetadataUpdate()
333 t.sourceMetadataOK = true
334 }
335 if !t.destination.ephemeral && t.destination != t.source {
336 t.destMetadata = t.destination.persistRecordLocked(max(d.nowUnixNano(), t.destination.createdAt, int64(1))).MetadataUpdate()
337 t.destMetadataOK = true
338 }
339 return nil
340}
341
342func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *moveLifecycleReservation) movePostcommitPlan {
343 publication := t.publication
344 return movePostcommitPlan{
345 followResult: t.followResult,
346 afterFollow: t.request.afterFollow,
347 source: t.source,
348 destination: t.destination,
349 sourceName: t.sourceName,
350 destinationName: t.destinationName,
351 sourceTab: publication.sourceTab,
352 destinationTab: publication.destinationTab,
353 movedPane: publication.movedPane,
354 movedPanes: publication.movedPanes,
355 operation: t.request.operation,
356 sourceAttachments: t.sourceAttachments,
357 syncCleanup: publication.syncCleanup,
358 frozenEffects: t.frozenEffects,
359 effectsFrozen: t.effectsFrozen,
360 unlockDispatch: unlockDispatch,
361 reservation: reservation,
362 oldTabCancel: publication.oldTabCancel,
363 sourceTabRemoved: publication.sourceTabRemoved,
364 sourceEmpty: publication.sourceEmpty,
365 retiredParked: t.retiredParked,
366 retiredAttachments: t.retiredAttachments,
367 sourceMetadata: t.sourceMetadata,
368 sourceMetadataValid: t.sourceMetadataOK,
369 destinationMetadata: t.destMetadata,
370 destinationMetadataValid: t.destMetadataOK,
371 }
372}
373
374// Picker pane moves follow only when their source retires; tab moves always
375// follow. Command/API moves retain their existing topology-only semantics.
376func (t *moveTransaction) follows() bool {
377 return t.request.follow && t.request.attachment != nil &&
378 (t.request.operation == "tab" || t.topology.willRetireSource())
379}