package daemon import ( "context" "errors" "github.com/bnema/vev/internal/domain" ) // moveTopology is the private policy Interface consumed by moveTransaction. // Admission runs under daemon and ordered Session locks and must be read-only. // Publication runs under daemon, routing, ordered Session, and resize fences; // it must finish every fallible check before its first write. type moveTopology interface { transactionRequest() moveTransactionRequest validRequest() bool admitLocked(*Daemon, *session, *session) error afterAdmission(*Daemon) willRetireSource() bool resizeFences(*session, *session) *moveResizeFences publishLocked(*Daemon, *session, *session) (moveTopologyPublication, error) } type moveTransactionRequest struct { follow bool beforeFollow func() error afterFollow func(*attachmentEffect) operation string attachment *attachedClient attachmentCapability attachmentCapability source moveSessionLocator destination moveSessionLocator logAttrs []any } // moveTopologyPublication contains immutable topology outputs needed after the // in-memory publication point. No field grants authority to perform additional // topology writes. type moveTopologyPublication struct { sourceTab, destinationTab *tab movedPane *pane movedPanes []*pane syncCleanup syncTimerCleanup oldTabCancel context.CancelFunc sourceTabRemoved bool sourceEmpty bool } // moveTransaction owns the shared pane/tab Move phase protocol. Topology and // owner policy remain private Implementations behind moveTopology. type moveTransaction struct { daemon *Daemon topology moveTopology request moveTransactionRequest source *session destination *session sourceAttachments []*attachedClient sourceTransports map[*attachedClient]transportSnapshot frozenEffects attachmentTransitionGuard effectsFrozen bool followResult attachmentTransitionResult publication moveTopologyPublication publishErr error retiredParked []parkedAttachmentRetirement retiredAttachments []detachedAttachmentSnapshot sourceMetadata domain.CatalogueMetadataUpdate sourceMetadataOK bool destMetadata domain.CatalogueMetadataUpdate destMetadataOK bool sourceName string destinationName string } func (d *Daemon) executeMove(topology moveTopology) (result error) { if d == nil || topology == nil { return errMovePaneInvalid } t := moveTransaction{daemon: d, topology: topology, request: topology.transactionRequest()} d.log.Info(t.request.operation+" move requested", t.request.logAttrs...) defer func() { result = normalizeMoveRejection(result) if result != nil { attrs := make([]any, 0, len(t.request.logAttrs)+2) attrs = append(attrs, "err", result) attrs = append(attrs, t.request.logAttrs...) d.log.Warn(t.request.operation+" move rejected", attrs...) } }() if !t.validRequest() { return errMovePaneInvalid } if err := t.resolveSessions(); err != nil { return err } if d.beforeMoveDispatch != nil { d.beforeMoveDispatch() } unlockDispatch := lockMoveDispatch(t.source, t.destination) dispatchHeld := true defer func() { if dispatchHeld { unlockDispatch() } }() reservation, err := d.reserveMoveLifecycles(t.source, t.destination) if err != nil { // Preserve a typed transient admission rejection (a KillAll purge owning // the exact set) so the command path maps it retryably instead of // misreporting the destination as gone. if errors.Is(err, errPurgeAdmissionClosed) { return err } return errMoveStaleTarget } reservationHeld := true defer func() { if reservationHeld { reservation.Release() } }() if d.afterMoveLifecycleReserved != nil { d.afterMoveLifecycleReserved() } if err := t.admit(); err != nil { return err } t.topology.afterAdmission(d) if t.follows() && t.request.beforeFollow != nil { if err := t.request.beforeFollow(); err != nil { return err } } if err := t.freezeSourceAttachments(); err != nil { return err } defer func() { if t.effectsFrozen { t.frozenEffects.unfreeze() } }() fences := t.topology.resizeFences(t.source, t.destination) if fences == nil || !fences.acquire(func() bool { t.publishErr = t.publishLocked() return t.publishErr == nil }) { if t.publishErr != nil { return t.publishErr } return errMoveStaleTarget } fences.Release() postcommit := t.postcommitPlan(unlockDispatch, reservation) t.effectsFrozen = false dispatchHeld = false reservationHeld = false postcommit.execute(d) return nil } func (t *moveTransaction) validRequest() bool { return t != nil && t.topology != nil && t.request.operation != "" && t.request.source.ID != "" && t.request.destination.ID != "" && t.topology.validRequest() } func (t *moveTransaction) resolveSessions() error { d := t.daemon d.mu.Lock() t.source = moveSessionForLocatorLocked(d, t.request.source) t.destination = moveSessionForLocatorLocked(d, t.request.destination) d.mu.Unlock() if t.source == nil || t.destination == nil { return errMoveStaleTarget } return nil } func (t *moveTransaction) attachmentAuthorityCurrentLocked() bool { req := t.request if req.attachment != nil && !attachmentRegisteredLocked(t.source, req.attachment) { return false } if req.attachmentCapability.ac != nil && (req.attachmentCapability.ac != req.attachment || !req.attachmentCapability.currentInSessionLocked(t.source)) { return false } return true } // admit snapshots exact common Attachment participants while the topology // Implementation snapshots only its private identity and layout state. func (t *moveTransaction) admit() error { d := t.daemon d.mu.Lock() defer d.mu.Unlock() if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination { return errMoveStaleTarget } unlockSessions := lockAttachmentSessions(t.source, t.destination) defer unlockSessions() if !moveSessionLocatorCurrentLocked(t.source, t.request.source) || !moveSessionLocatorCurrentLocked(t.destination, t.request.destination) || !t.attachmentAuthorityCurrentLocked() { return errMoveStaleTarget } if err := t.topology.admitLocked(d, t.source, t.destination); err != nil { return err } t.sourceAttachments = t.source.snapshotAttachmentsLocked() t.sourceTransports = make(map[*attachedClient]transportSnapshot, len(t.sourceAttachments)) for _, attachment := range t.sourceAttachments { t.sourceTransports[attachment] = attachment.transportSnapshot() } return nil } // freezeSourceAttachments delegates effect admission and drain authority to the // attachment lifecycle Module. Move uses nonblocking acquisition to preserve // its ordering with final teardown. func (t *moveTransaction) freezeSourceAttachments() error { if (!t.topology.willRetireSource() && !t.follows()) || len(t.sourceAttachments) == 0 { return nil } participants := t.sourceAttachments if !t.topology.willRetireSource() { participants = []*attachedClient{t.request.attachment} } interrupts := make([]attachmentTransportInterrupt, 0, len(participants)) for _, attachment := range participants { if t.follows() && attachment == t.request.attachment { continue // The following connection must remain usable for first paint. } if transport := t.sourceTransports[attachment]; transport.transport != nil { interrupts = append(interrupts, attachmentTransportInterrupt{ac: attachment, transport: transport}) } } frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{ interrupts: interrupts, nonblocking: true, afterFrozen: func(attachment *attachedClient) { if t.daemon.afterAttachmentEffectGateFrozen != nil { t.daemon.afterAttachmentEffectGateFrozen("move-"+t.request.operation, attachment) } }, }, participants...) if !frozen.acquired || !frozen.drained { frozen.unfreeze() return errMovePaneInvalid } t.frozenEffects = frozen t.effectsFrozen = true return nil } // publishLocked is the only shared architecture-lock window. Topology // Implementations perform all fallible private validation before their first // write. Common retirement and metadata capture are non-failing in-memory work. func (t *moveTransaction) publishLocked() error { d := t.daemon d.mu.Lock() defer d.mu.Unlock() if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination { return errMoveStaleTarget } d.notices.routingMu.Lock() defer d.notices.routingMu.Unlock() unlockSessions := lockAttachmentSessions(t.source, t.destination) defer unlockSessions() if !moveSessionLocatorCurrentLocked(t.source, t.request.source) || !moveSessionLocatorCurrentLocked(t.destination, t.request.destination) || !t.attachmentAuthorityCurrentLocked() { return errMoveStaleTarget } if t.topology.willRetireSource() && !sameMoveAttachmentsLocked(t.source, t.sourceAttachments) { return errMoveStaleTarget } var follow *attachmentPublication if t.follows() { req := attachmentTransitionRequest{ source: t.source, target: t.destination, next: t.request.attachment, expectedTransport: t.sourceTransports[t.request.attachment], preflighted: true, attachmentEffectsFrozen: true, ready: true, } if t.request.attachmentCapability.ac != nil { req.sourceCapability = &t.request.attachmentCapability } var err error follow, err = d.validateAttachmentTransitionPrelocked(req) if err != nil { return errMoveStaleTarget } // Topology sync migration takes coordinator locks itself. Session locks // and the frozen gate keep membership/lease authority pinned meanwhile. follow.unlockCoordinators() } publication, err := t.topology.publishLocked(d, t.source, t.destination) if err != nil { return err } t.publication = publication if follow != nil { follow.releaseCoordinators = lockAttachmentCoordinators(t.source, follow.sourceCoordinator, t.destination, follow.targetCoordinator) follow.req.activateTargetTab = true follow.req.targetTabIndex = indexMoveTabLocked(t.destination, publication.destinationTab) t.followResult = d.publishAttachmentTransitionPrelocked(follow) follow.unlockCoordinators() if t.source != t.destination && !publication.sourceEmpty { t.followResult.sourceGeometrySession = t.source } } if publication.sourceEmpty { d.unregisterSessionLocked(t.source) t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports) t.source.tabs = nil d.purgeParkingForSessionLocked(t.source) d.purgeSuspendedForSessionLocked(t.source) t.retiredParked = d.purgeParkedForSessionLocked(t.source) } t.sourceName = t.source.name t.destinationName = t.destination.name if !t.source.ephemeral { t.sourceMetadata = t.source.persistRecordLocked(max(d.nowUnixNano(), t.source.createdAt, int64(1))).MetadataUpdate() t.sourceMetadataOK = true } if !t.destination.ephemeral && t.destination != t.source { t.destMetadata = t.destination.persistRecordLocked(max(d.nowUnixNano(), t.destination.createdAt, int64(1))).MetadataUpdate() t.destMetadataOK = true } return nil } func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *moveLifecycleReservation) movePostcommitPlan { publication := t.publication return movePostcommitPlan{ followResult: t.followResult, afterFollow: t.request.afterFollow, source: t.source, destination: t.destination, sourceName: t.sourceName, destinationName: t.destinationName, sourceTab: publication.sourceTab, destinationTab: publication.destinationTab, movedPane: publication.movedPane, movedPanes: publication.movedPanes, operation: t.request.operation, sourceAttachments: t.sourceAttachments, syncCleanup: publication.syncCleanup, frozenEffects: t.frozenEffects, effectsFrozen: t.effectsFrozen, unlockDispatch: unlockDispatch, reservation: reservation, oldTabCancel: publication.oldTabCancel, sourceTabRemoved: publication.sourceTabRemoved, sourceEmpty: publication.sourceEmpty, retiredParked: t.retiredParked, retiredAttachments: t.retiredAttachments, sourceMetadata: t.sourceMetadata, sourceMetadataValid: t.sourceMetadataOK, destinationMetadata: t.destMetadata, destinationMetadataValid: t.destMetadataOK, } } // Picker pane moves follow only when their source retires; tab moves always // follow. Command/API moves retain their existing topology-only semantics. func (t *moveTransaction) follows() bool { return t.request.follow && t.request.attachment != nil && (t.request.operation == "tab" || t.topology.willRetireSource()) }