refactor(daemon): deepen move transaction (#226)
- commit
- 040f5764e121c5e4775e7a96bfc5b5c1c13e7041
- author
- bnema · · 2026-08-27 20:41 UTC
- committer
- GitHub · · 2026-08-27 20:41 UTC
- parent
- cbfd03f
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
7 files · +651 -531
M internal/usecase/daemon/move.go +2 -168
| @@ -27,174 +27,8 @@ type moveTabRequest struct { | |||
| 27 | 27 | Destination moveSessionLocator | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | - | // movePane resolves, reserves, validates, and commits one pane relocation. All | |
| 31 | - | // fallible work happens before the membership publication section. Once the | |
| 32 | - | // section starts, the caller holds the ordered architecture locks and only | |
| 33 | - | // non-failing in-memory writes are permitted. | |
| 34 | - | func (d *Daemon) movePane(req movePaneRequest) (result error) { | |
| 35 | - | if d == nil { | |
| 36 | - | return errMovePaneInvalid | |
| 37 | - | } | |
| 38 | - | d.log.Info("pane move requested", | |
| 39 | - | "source_session_id", req.Source.ID, | |
| 40 | - | "source_tab_id", req.SourceTabID, | |
| 41 | - | "source_pane_id", req.SourcePaneID, | |
| 42 | - | "destination_session_id", req.Destination.ID, | |
| 43 | - | "destination_tab_id", req.DestinationTabID, | |
| 44 | - | ) | |
| 45 | - | defer func() { | |
| 46 | - | result = normalizeMoveRejection(result) | |
| 47 | - | if result != nil { | |
| 48 | - | d.log.Warn("pane move rejected", | |
| 49 | - | "err", result, | |
| 50 | - | "source_session_id", req.Source.ID, | |
| 51 | - | "source_tab_id", req.SourceTabID, | |
| 52 | - | "source_pane_id", req.SourcePaneID, | |
| 53 | - | "destination_session_id", req.Destination.ID, | |
| 54 | - | "destination_tab_id", req.DestinationTabID, | |
| 55 | - | ) | |
| 56 | - | } | |
| 57 | - | }() | |
| 58 | - | if req.Source.ID == "" || req.Destination.ID == "" || | |
| 59 | - | req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" { | |
| 60 | - | return errMovePaneInvalid | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | d.mu.Lock() | |
| 64 | - | source := moveSessionForLocatorLocked(d, req.Source) | |
| 65 | - | destination := moveSessionForLocatorLocked(d, req.Destination) | |
| 66 | - | d.mu.Unlock() | |
| 67 | - | if source == nil || destination == nil { | |
| 68 | - | return errMoveStaleTarget | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // Dispatch admission must precede lifecycle reservation. A final close holds | |
| 72 | - | // dispatchMu while it waits for teardown ownership; reserving first would let | |
| 73 | - | // that close wait on a move which is itself blocked on dispatchMu. | |
| 74 | - | if d.beforeMoveDispatch != nil { | |
| 75 | - | d.beforeMoveDispatch() | |
| 76 | - | } | |
| 77 | - | unlockDispatch := lockMoveDispatch(source, destination) | |
| 78 | - | dispatchHeld := true | |
| 79 | - | defer func() { | |
| 80 | - | if dispatchHeld { | |
| 81 | - | unlockDispatch() | |
| 82 | - | } | |
| 83 | - | }() | |
| 84 | - | ||
| 85 | - | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 86 | - | if err != nil { | |
| 87 | - | return errMoveStaleTarget | |
| 88 | - | } | |
| 89 | - | reservationHeld := true | |
| 90 | - | defer func() { | |
| 91 | - | if reservationHeld { | |
| 92 | - | reservation.Release() | |
| 93 | - | } | |
| 94 | - | }() | |
| 95 | - | if d.afterMoveLifecycleReserved != nil { | |
| 96 | - | d.afterMoveLifecycleReserved() | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | // Snapshot the exact live objects and generations before waiting on resize | |
| 100 | - | // fences. The locked commit repeats every authority check before publication. | |
| 101 | - | admission, err := d.snapshotMovePaneAdmission(req, source, destination) | |
| 102 | - | if err != nil { | |
| 103 | - | return err | |
| 104 | - | } | |
| 105 | - | sourceTab := admission.sourceTab | |
| 106 | - | destinationTab := admission.destinationTab | |
| 107 | - | movedPane := admission.movedPane | |
| 108 | - | sourceTabInitialGeneration := admission.sourceGeneration | |
| 109 | - | destinationTabInitialGeneration := admission.destinationGeneration | |
| 110 | - | finalSourceTab := admission.finalSourceTab | |
| 111 | - | if d.afterMovePaneSourceSnapshot != nil { | |
| 112 | - | d.afterMovePaneSourceSnapshot() | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | // A final source tab is retired without transferring any attachment to the | |
| 116 | - | // destination. Freeze every affected source connection before publication so | |
| 117 | - | // teardown invalidates exact generations rather than racing input or render. | |
| 118 | - | var frozen attachmentTransitionGuard | |
| 119 | - | var sourceEffectsFrozen bool | |
| 120 | - | if finalSourceTab && source != destination && len(admission.sourceAttachments) != 0 { | |
| 121 | - | interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments)) | |
| 122 | - | for _, ac := range admission.sourceAttachments { | |
| 123 | - | if transport := admission.sourceTransports[ac]; transport.transport != nil { | |
| 124 | - | interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport}) | |
| 125 | - | } | |
| 126 | - | } | |
| 127 | - | frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) { | |
| 128 | - | if d.afterAttachmentEffectGateFrozen != nil { | |
| 129 | - | d.afterAttachmentEffectGateFrozen("move-pane", ac) | |
| 130 | - | } | |
| 131 | - | }}, admission.sourceAttachments...) | |
| 132 | - | if !frozen.acquired || !frozen.drained { | |
| 133 | - | frozen.unfreeze() | |
| 134 | - | return errMovePaneInvalid | |
| 135 | - | } | |
| 136 | - | sourceEffectsFrozen = true | |
| 137 | - | } | |
| 138 | - | if sourceEffectsFrozen { | |
| 139 | - | defer func() { | |
| 140 | - | if sourceEffectsFrozen { | |
| 141 | - | frozen.unfreeze() | |
| 142 | - | } | |
| 143 | - | }() | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | commit := movePaneCommit{ | |
| 147 | - | req: req, | |
| 148 | - | source: source, | |
| 149 | - | destination: destination, | |
| 150 | - | sourceTab: sourceTab, | |
| 151 | - | destinationTab: destinationTab, | |
| 152 | - | movedPane: movedPane, | |
| 153 | - | sourceAttachments: admission.sourceAttachments, | |
| 154 | - | sourceTransports: admission.sourceTransports, | |
| 155 | - | sourceGeneration: sourceTabInitialGeneration, | |
| 156 | - | destinationGeneration: destinationTabInitialGeneration, | |
| 157 | - | frozenEffects: frozen, | |
| 158 | - | } | |
| 159 | - | fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane) | |
| 160 | - | if !fences.acquire(func() bool { return commit.publishLocked(d) }) { | |
| 161 | - | if commit.err != nil { | |
| 162 | - | return commit.err | |
| 163 | - | } | |
| 164 | - | return errMoveStaleTarget | |
| 165 | - | } | |
| 166 | - | // Owner/layout fences end at the atomic publication boundary. The immutable | |
| 167 | - | // postcommit plan owns every later cleanup and effect. | |
| 168 | - | fences.Release() | |
| 169 | - | postcommit := movePanePostcommitPlan{ | |
| 170 | - | source: source, | |
| 171 | - | destination: destination, | |
| 172 | - | sourceTab: sourceTab, | |
| 173 | - | destinationTab: destinationTab, | |
| 174 | - | movedPane: movedPane, | |
| 175 | - | operation: "pane", | |
| 176 | - | sourceAttachments: admission.sourceAttachments, | |
| 177 | - | syncCleanup: commit.syncCleanup, | |
| 178 | - | frozenEffects: frozen, | |
| 179 | - | effectsFrozen: sourceEffectsFrozen, | |
| 180 | - | unlockDispatch: unlockDispatch, | |
| 181 | - | reservation: reservation, | |
| 182 | - | sourceTabRemoved: commit.sourceTabRemoved, | |
| 183 | - | sourceEmpty: commit.sourceEmpty, | |
| 184 | - | retiredParked: commit.retiredParked, | |
| 185 | - | retiredAttachments: commit.retiredAttachments, | |
| 186 | - | sourceMetadata: commit.sourceMetadata, | |
| 187 | - | sourceMetadataValid: commit.sourceMetadataValid, | |
| 188 | - | destinationMetadata: commit.destinationMetadata, | |
| 189 | - | destinationMetadataValid: commit.destinationMetadataValid, | |
| 190 | - | } | |
| 191 | - | // Transfer cleanup ownership away from the admission defers only after the | |
| 192 | - | // complete postcommit plan exists. | |
| 193 | - | sourceEffectsFrozen = false | |
| 194 | - | dispatchHeld = false | |
| 195 | - | reservationHeld = false | |
| 196 | - | postcommit.execute(d) | |
| 197 | - | return nil | |
| 30 | + | func (d *Daemon) movePane(req movePaneRequest) error { | |
| 31 | + | return d.executeMove(&movePaneTopology{req: req}) | |
| 198 | 32 | } | |
| 199 | 33 | ||
| 200 | 34 | // moveSessionForLocatorLocked resolves only live sessions. The name is | |
| @@ -27,174 +27,8 @@ type moveTabRequest struct { | |||||
| 27 | Destination moveSessionLocator | 27 | Destination moveSessionLocator | ||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | - | // movePane resolves, reserves, validates, and commits one pane relocation. All | 30 | + | func (d *Daemon) movePane(req movePaneRequest) error { |
| 31 | - | // fallible work happens before the membership publication section. Once the | 31 | + | return d.executeMove(&movePaneTopology{req: req}) |
| 32 | - | // section starts, the caller holds the ordered architecture locks and only | |||
| 33 | - | // non-failing in-memory writes are permitted. | |||
| 34 | - | func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 35 | - | if d == nil { | |||
| 36 | - | return errMovePaneInvalid | |||
| 37 | - | } | |||
| 38 | - | d.log.Info("pane move requested", | |||
| 39 | - | "source_session_id", req.Source.ID, | |||
| 40 | - | "source_tab_id", req.SourceTabID, | |||
| 41 | - | "source_pane_id", req.SourcePaneID, | |||
| 42 | - | "destination_session_id", req.Destination.ID, | |||
| 43 | - | "destination_tab_id", req.DestinationTabID, | |||
| 44 | - | ) | |||
| 45 | - | defer func() { | |||
| 46 | - | result = normalizeMoveRejection(result) | |||
| 47 | - | if result != nil { | |||
| 48 | - | d.log.Warn("pane move rejected", | |||
| 49 | - | "err", result, | |||
| 50 | - | "source_session_id", req.Source.ID, | |||
| 51 | - | "source_tab_id", req.SourceTabID, | |||
| 52 | - | "source_pane_id", req.SourcePaneID, | |||
| 53 | - | "destination_session_id", req.Destination.ID, | |||
| 54 | - | "destination_tab_id", req.DestinationTabID, | |||
| 55 | - | ) | |||
| 56 | - | } | |||
| 57 | - | }() | |||
| 58 | - | if req.Source.ID == "" || req.Destination.ID == "" || | |||
| 59 | - | req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" { | |||
| 60 | - | return errMovePaneInvalid | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | d.mu.Lock() | |||
| 64 | - | source := moveSessionForLocatorLocked(d, req.Source) | |||
| 65 | - | destination := moveSessionForLocatorLocked(d, req.Destination) | |||
| 66 | - | d.mu.Unlock() | |||
| 67 | - | if source == nil || destination == nil { | |||
| 68 | - | return errMoveStaleTarget | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | // Dispatch admission must precede lifecycle reservation. A final close holds | |||
| 72 | - | // dispatchMu while it waits for teardown ownership; reserving first would let | |||
| 73 | - | // that close wait on a move which is itself blocked on dispatchMu. | |||
| 74 | - | if d.beforeMoveDispatch != nil { | |||
| 75 | - | d.beforeMoveDispatch() | |||
| 76 | - | } | |||
| 77 | - | unlockDispatch := lockMoveDispatch(source, destination) | |||
| 78 | - | dispatchHeld := true | |||
| 79 | - | defer func() { | |||
| 80 | - | if dispatchHeld { | |||
| 81 | - | unlockDispatch() | |||
| 82 | - | } | |||
| 83 | - | }() | |||
| 84 | - | ||||
| 85 | - | reservation, err := d.reserveMoveLifecycles(source, destination) | |||
| 86 | - | if err != nil { | |||
| 87 | - | return errMoveStaleTarget | |||
| 88 | - | } | |||
| 89 | - | reservationHeld := true | |||
| 90 | - | defer func() { | |||
| 91 | - | if reservationHeld { | |||
| 92 | - | reservation.Release() | |||
| 93 | - | } | |||
| 94 | - | }() | |||
| 95 | - | if d.afterMoveLifecycleReserved != nil { | |||
| 96 | - | d.afterMoveLifecycleReserved() | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | // Snapshot the exact live objects and generations before waiting on resize | |||
| 100 | - | // fences. The locked commit repeats every authority check before publication. | |||
| 101 | - | admission, err := d.snapshotMovePaneAdmission(req, source, destination) | |||
| 102 | - | if err != nil { | |||
| 103 | - | return err | |||
| 104 | - | } | |||
| 105 | - | sourceTab := admission.sourceTab | |||
| 106 | - | destinationTab := admission.destinationTab | |||
| 107 | - | movedPane := admission.movedPane | |||
| 108 | - | sourceTabInitialGeneration := admission.sourceGeneration | |||
| 109 | - | destinationTabInitialGeneration := admission.destinationGeneration | |||
| 110 | - | finalSourceTab := admission.finalSourceTab | |||
| 111 | - | if d.afterMovePaneSourceSnapshot != nil { | |||
| 112 | - | d.afterMovePaneSourceSnapshot() | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | // A final source tab is retired without transferring any attachment to the | |||
| 116 | - | // destination. Freeze every affected source connection before publication so | |||
| 117 | - | // teardown invalidates exact generations rather than racing input or render. | |||
| 118 | - | var frozen attachmentTransitionGuard | |||
| 119 | - | var sourceEffectsFrozen bool | |||
| 120 | - | if finalSourceTab && source != destination && len(admission.sourceAttachments) != 0 { | |||
| 121 | - | interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments)) | |||
| 122 | - | for _, ac := range admission.sourceAttachments { | |||
| 123 | - | if transport := admission.sourceTransports[ac]; transport.transport != nil { | |||
| 124 | - | interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport}) | |||
| 125 | - | } | |||
| 126 | - | } | |||
| 127 | - | frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) { | |||
| 128 | - | if d.afterAttachmentEffectGateFrozen != nil { | |||
| 129 | - | d.afterAttachmentEffectGateFrozen("move-pane", ac) | |||
| 130 | - | } | |||
| 131 | - | }}, admission.sourceAttachments...) | |||
| 132 | - | if !frozen.acquired || !frozen.drained { | |||
| 133 | - | frozen.unfreeze() | |||
| 134 | - | return errMovePaneInvalid | |||
| 135 | - | } | |||
| 136 | - | sourceEffectsFrozen = true | |||
| 137 | - | } | |||
| 138 | - | if sourceEffectsFrozen { | |||
| 139 | - | defer func() { | |||
| 140 | - | if sourceEffectsFrozen { | |||
| 141 | - | frozen.unfreeze() | |||
| 142 | - | } | |||
| 143 | - | }() | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | commit := movePaneCommit{ | |||
| 147 | - | req: req, | |||
| 148 | - | source: source, | |||
| 149 | - | destination: destination, | |||
| 150 | - | sourceTab: sourceTab, | |||
| 151 | - | destinationTab: destinationTab, | |||
| 152 | - | movedPane: movedPane, | |||
| 153 | - | sourceAttachments: admission.sourceAttachments, | |||
| 154 | - | sourceTransports: admission.sourceTransports, | |||
| 155 | - | sourceGeneration: sourceTabInitialGeneration, | |||
| 156 | - | destinationGeneration: destinationTabInitialGeneration, | |||
| 157 | - | frozenEffects: frozen, | |||
| 158 | - | } | |||
| 159 | - | fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane) | |||
| 160 | - | if !fences.acquire(func() bool { return commit.publishLocked(d) }) { | |||
| 161 | - | if commit.err != nil { | |||
| 162 | - | return commit.err | |||
| 163 | - | } | |||
| 164 | - | return errMoveStaleTarget | |||
| 165 | - | } | |||
| 166 | - | // Owner/layout fences end at the atomic publication boundary. The immutable | |||
| 167 | - | // postcommit plan owns every later cleanup and effect. | |||
| 168 | - | fences.Release() | |||
| 169 | - | postcommit := movePanePostcommitPlan{ | |||
| 170 | - | source: source, | |||
| 171 | - | destination: destination, | |||
| 172 | - | sourceTab: sourceTab, | |||
| 173 | - | destinationTab: destinationTab, | |||
| 174 | - | movedPane: movedPane, | |||
| 175 | - | operation: "pane", | |||
| 176 | - | sourceAttachments: admission.sourceAttachments, | |||
| 177 | - | syncCleanup: commit.syncCleanup, | |||
| 178 | - | frozenEffects: frozen, | |||
| 179 | - | effectsFrozen: sourceEffectsFrozen, | |||
| 180 | - | unlockDispatch: unlockDispatch, | |||
| 181 | - | reservation: reservation, | |||
| 182 | - | sourceTabRemoved: commit.sourceTabRemoved, | |||
| 183 | - | sourceEmpty: commit.sourceEmpty, | |||
| 184 | - | retiredParked: commit.retiredParked, | |||
| 185 | - | retiredAttachments: commit.retiredAttachments, | |||
| 186 | - | sourceMetadata: commit.sourceMetadata, | |||
| 187 | - | sourceMetadataValid: commit.sourceMetadataValid, | |||
| 188 | - | destinationMetadata: commit.destinationMetadata, | |||
| 189 | - | destinationMetadataValid: commit.destinationMetadataValid, | |||
| 190 | - | } | |||
| 191 | - | // Transfer cleanup ownership away from the admission defers only after the | |||
| 192 | - | // complete postcommit plan exists. | |||
| 193 | - | sourceEffectsFrozen = false | |||
| 194 | - | dispatchHeld = false | |||
| 195 | - | reservationHeld = false | |||
| 196 | - | postcommit.execute(d) | |||
| 197 | - | return nil | |||
| 198 | } | 32 | } | ||
| 199 | 33 | ||||
| 200 | // moveSessionForLocatorLocked resolves only live sessions. The name is | 34 | // moveSessionForLocatorLocked resolves only live sessions. The name is | ||
M internal/usecase/daemon/move_pane_admission.go +71 -32
| @@ -2,47 +2,60 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import "github.com/bnema/vev/internal/usecase/layout" | |
| 4 | 4 | ||
| 5 | - | // movePaneAdmission is an immutable snapshot of the exact live objects and | |
| 6 | - | // generations a move may later commit. Resize fences are acquired only after | |
| 7 | - | // this snapshot; the locked commit revalidates every field before publication. | |
| 5 | + | // movePaneTopology is the private pane topology Implementation consumed by the | |
| 6 | + | // shared moveTransaction Module. | |
| 7 | + | type movePaneTopology struct { | |
| 8 | + | req movePaneRequest | |
| 9 | + | admission movePaneAdmission | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // movePaneAdmission is an immutable snapshot of the exact live topology and | |
| 13 | + | // generations a move may later publish. Common Session and Attachment authority | |
| 14 | + | // is snapshotted by moveTransaction. | |
| 8 | 15 | type movePaneAdmission struct { | |
| 9 | 16 | source *session | |
| 10 | 17 | destination *session | |
| 11 | 18 | sourceTab *tab | |
| 12 | 19 | destinationTab *tab | |
| 13 | 20 | movedPane *pane | |
| 14 | - | sourceAttachments []*attachedClient | |
| 15 | - | sourceTransports map[*attachedClient]transportSnapshot | |
| 16 | 21 | sourceGeneration uint64 | |
| 17 | 22 | destinationGeneration uint64 | |
| 18 | 23 | finalSourceTab bool | |
| 19 | 24 | } | |
| 20 | 25 | ||
| 21 | - | func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destination *session) (*movePaneAdmission, error) { | |
| 22 | - | d.mu.Lock() | |
| 23 | - | defer d.mu.Unlock() | |
| 24 | - | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | |
| 25 | - | return nil, errMoveStaleTarget | |
| 26 | - | } | |
| 27 | - | unlockSessions := lockAttachmentSessions(source, destination) | |
| 28 | - | defer unlockSessions() | |
| 29 | - | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | |
| 30 | - | return nil, errMoveStaleTarget | |
| 31 | - | } | |
| 32 | - | if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) { | |
| 33 | - | return nil, errMoveStaleTarget | |
| 34 | - | } | |
| 35 | - | if req.AttachmentCapability.ac != nil && (req.AttachmentCapability.ac != req.Attachment || !req.AttachmentCapability.currentInSessionLocked(source)) { | |
| 36 | - | return nil, errMoveStaleTarget | |
| 26 | + | func (p *movePaneTopology) transactionRequest() moveTransactionRequest { | |
| 27 | + | req := p.req | |
| 28 | + | return moveTransactionRequest{ | |
| 29 | + | operation: "pane", | |
| 30 | + | attachment: req.Attachment, | |
| 31 | + | attachmentCapability: req.AttachmentCapability, | |
| 32 | + | source: req.Source, | |
| 33 | + | destination: req.Destination, | |
| 34 | + | logAttrs: []any{ | |
| 35 | + | "source_session_id", req.Source.ID, | |
| 36 | + | "source_tab_id", req.SourceTabID, | |
| 37 | + | "source_pane_id", req.SourcePaneID, | |
| 38 | + | "destination_session_id", req.Destination.ID, | |
| 39 | + | "destination_tab_id", req.DestinationTabID, | |
| 40 | + | }, | |
| 37 | 41 | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (p *movePaneTopology) validRequest() bool { | |
| 45 | + | return p != nil && p.req.SourceTabID != "" && p.req.SourcePaneID != "" && p.req.DestinationTabID != "" | |
| 46 | + | } | |
| 38 | 47 | ||
| 48 | + | // admitLocked runs with daemon and ordered Session locks held. It acquires only | |
| 49 | + | // the private ordered Tab locks and performs no publication. | |
| 50 | + | func (p *movePaneTopology) admitLocked(_ *Daemon, source, destination *session) error { | |
| 51 | + | req := p.req | |
| 39 | 52 | sourceTab := findMoveTabLocked(source, req.SourceTabID) | |
| 40 | 53 | destinationTab := findMoveTabLocked(destination, req.DestinationTabID) | |
| 41 | 54 | if sourceTab == nil || destinationTab == nil { | |
| 42 | - | return nil, errMoveStaleTarget | |
| 55 | + | return errMoveStaleTarget | |
| 43 | 56 | } | |
| 44 | 57 | if sourceTab == destinationTab { | |
| 45 | - | return nil, errMovePaneInvalid | |
| 58 | + | return errMovePaneInvalid | |
| 46 | 59 | } | |
| 47 | 60 | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | |
| 48 | 61 | defer unlockTabs() | |
| @@ -55,19 +68,45 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat | |||
| 55 | 68 | } | |
| 56 | 69 | } | |
| 57 | 70 | if movedPane == nil { | |
| 58 | - | return nil, errMoveStaleTarget | |
| 71 | + | return errMoveStaleTarget | |
| 59 | 72 | } | |
| 60 | 73 | ||
| 61 | - | attachments := source.snapshotAttachmentsLocked() | |
| 62 | - | transports := make(map[*attachedClient]transportSnapshot, len(attachments)) | |
| 63 | - | for _, ac := range attachments { | |
| 64 | - | transports[ac] = ac.transportSnapshot() | |
| 65 | - | } | |
| 66 | - | return &movePaneAdmission{ | |
| 74 | + | p.admission = movePaneAdmission{ | |
| 67 | 75 | source: source, destination: destination, sourceTab: sourceTab, destinationTab: destinationTab, | |
| 68 | - | movedPane: movedPane, sourceAttachments: attachments, sourceTransports: transports, | |
| 76 | + | movedPane: movedPane, | |
| 69 | 77 | sourceGeneration: sourceTab.layoutGeneration, destinationGeneration: destinationTab.layoutGeneration, | |
| 70 | 78 | finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab && | |
| 71 | 79 | sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1, | |
| 72 | - | }, nil | |
| 80 | + | } | |
| 81 | + | return nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (p *movePaneTopology) afterAdmission(d *Daemon) { | |
| 85 | + | if d.afterMovePaneSourceSnapshot != nil { | |
| 86 | + | d.afterMovePaneSourceSnapshot() | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (p *movePaneTopology) willRetireSource() bool { | |
| 91 | + | return p != nil && p.admission.finalSourceTab && p.admission.source != p.admission.destination | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (p *movePaneTopology) resizeFences(source, destination *session) *moveResizeFences { | |
| 95 | + | admission := p.admission | |
| 96 | + | return newMovePaneResizeFences(source, destination, admission.sourceTab, admission.destinationTab, admission.movedPane) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (p *movePaneTopology) publishLocked(d *Daemon, source, destination *session) (moveTopologyPublication, error) { | |
| 100 | + | admission := p.admission | |
| 101 | + | commit := movePaneCommit{ | |
| 102 | + | req: p.req, | |
| 103 | + | source: source, | |
| 104 | + | destination: destination, | |
| 105 | + | sourceTab: admission.sourceTab, | |
| 106 | + | destinationTab: admission.destinationTab, | |
| 107 | + | movedPane: admission.movedPane, | |
| 108 | + | sourceGeneration: admission.sourceGeneration, | |
| 109 | + | destinationGeneration: admission.destinationGeneration, | |
| 110 | + | } | |
| 111 | + | return commit.publishTopologyLocked(d) | |
| 73 | 112 | } | |
| @@ -2,47 +2,60 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import "github.com/bnema/vev/internal/usecase/layout" | 3 | import "github.com/bnema/vev/internal/usecase/layout" | ||
| 4 | 4 | ||||
| 5 | - | // movePaneAdmission is an immutable snapshot of the exact live objects and | 5 | + | // movePaneTopology is the private pane topology Implementation consumed by the |
| 6 | - | // generations a move may later commit. Resize fences are acquired only after | 6 | + | // shared moveTransaction Module. |
| 7 | - | // this snapshot; the locked commit revalidates every field before publication. | 7 | + | type movePaneTopology struct { |
| 8 | + | req movePaneRequest | |||
| 9 | + | admission movePaneAdmission | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // movePaneAdmission is an immutable snapshot of the exact live topology and | |||
| 13 | + | // generations a move may later publish. Common Session and Attachment authority | |||
| 14 | + | // is snapshotted by moveTransaction. | |||
| 8 | type movePaneAdmission struct { | 15 | type movePaneAdmission struct { | ||
| 9 | source *session | 16 | source *session | ||
| 10 | destination *session | 17 | destination *session | ||
| 11 | sourceTab *tab | 18 | sourceTab *tab | ||
| 12 | destinationTab *tab | 19 | destinationTab *tab | ||
| 13 | movedPane *pane | 20 | movedPane *pane | ||
| 14 | - | sourceAttachments []*attachedClient | |||
| 15 | - | sourceTransports map[*attachedClient]transportSnapshot | |||
| 16 | sourceGeneration uint64 | 21 | sourceGeneration uint64 | ||
| 17 | destinationGeneration uint64 | 22 | destinationGeneration uint64 | ||
| 18 | finalSourceTab bool | 23 | finalSourceTab bool | ||
| 19 | } | 24 | } | ||
| 20 | 25 | ||||
| 21 | - | func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destination *session) (*movePaneAdmission, error) { | 26 | + | func (p *movePaneTopology) transactionRequest() moveTransactionRequest { |
| 22 | - | d.mu.Lock() | 27 | + | req := p.req |
| 23 | - | defer d.mu.Unlock() | 28 | + | return moveTransactionRequest{ |
| 24 | - | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | 29 | + | operation: "pane", |
| 25 | - | return nil, errMoveStaleTarget | 30 | + | attachment: req.Attachment, |
| 26 | - | } | 31 | + | attachmentCapability: req.AttachmentCapability, |
| 27 | - | unlockSessions := lockAttachmentSessions(source, destination) | 32 | + | source: req.Source, |
| 28 | - | defer unlockSessions() | 33 | + | destination: req.Destination, |
| 29 | - | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | 34 | + | logAttrs: []any{ |
| 30 | - | return nil, errMoveStaleTarget | 35 | + | "source_session_id", req.Source.ID, |
| 31 | - | } | 36 | + | "source_tab_id", req.SourceTabID, |
| 32 | - | if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) { | 37 | + | "source_pane_id", req.SourcePaneID, |
| 33 | - | return nil, errMoveStaleTarget | 38 | + | "destination_session_id", req.Destination.ID, |
| 34 | - | } | 39 | + | "destination_tab_id", req.DestinationTabID, |
| 35 | - | if req.AttachmentCapability.ac != nil && (req.AttachmentCapability.ac != req.Attachment || !req.AttachmentCapability.currentInSessionLocked(source)) { | 40 | + | }, |
| 36 | - | return nil, errMoveStaleTarget | |||
| 37 | } | 41 | } | ||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (p *movePaneTopology) validRequest() bool { | |||
| 45 | + | return p != nil && p.req.SourceTabID != "" && p.req.SourcePaneID != "" && p.req.DestinationTabID != "" | |||
| 46 | + | } | |||
| 38 | 47 | ||||
| 48 | + | // admitLocked runs with daemon and ordered Session locks held. It acquires only | |||
| 49 | + | // the private ordered Tab locks and performs no publication. | |||
| 50 | + | func (p *movePaneTopology) admitLocked(_ *Daemon, source, destination *session) error { | |||
| 51 | + | req := p.req | |||
| 39 | sourceTab := findMoveTabLocked(source, req.SourceTabID) | 52 | sourceTab := findMoveTabLocked(source, req.SourceTabID) | ||
| 40 | destinationTab := findMoveTabLocked(destination, req.DestinationTabID) | 53 | destinationTab := findMoveTabLocked(destination, req.DestinationTabID) | ||
| 41 | if sourceTab == nil || destinationTab == nil { | 54 | if sourceTab == nil || destinationTab == nil { | ||
| 42 | - | return nil, errMoveStaleTarget | 55 | + | return errMoveStaleTarget |
| 43 | } | 56 | } | ||
| 44 | if sourceTab == destinationTab { | 57 | if sourceTab == destinationTab { | ||
| 45 | - | return nil, errMovePaneInvalid | 58 | + | return errMovePaneInvalid |
| 46 | } | 59 | } | ||
| 47 | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | 60 | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | ||
| 48 | defer unlockTabs() | 61 | defer unlockTabs() | ||
| @@ -55,19 +68,45 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat | |||||
| 55 | } | 68 | } | ||
| 56 | } | 69 | } | ||
| 57 | if movedPane == nil { | 70 | if movedPane == nil { | ||
| 58 | - | return nil, errMoveStaleTarget | 71 | + | return errMoveStaleTarget |
| 59 | } | 72 | } | ||
| 60 | 73 | ||||
| 61 | - | attachments := source.snapshotAttachmentsLocked() | 74 | + | p.admission = movePaneAdmission{ |
| 62 | - | transports := make(map[*attachedClient]transportSnapshot, len(attachments)) | |||
| 63 | - | for _, ac := range attachments { | |||
| 64 | - | transports[ac] = ac.transportSnapshot() | |||
| 65 | - | } | |||
| 66 | - | return &movePaneAdmission{ | |||
| 67 | source: source, destination: destination, sourceTab: sourceTab, destinationTab: destinationTab, | 75 | source: source, destination: destination, sourceTab: sourceTab, destinationTab: destinationTab, | ||
| 68 | - | movedPane: movedPane, sourceAttachments: attachments, sourceTransports: transports, | 76 | + | movedPane: movedPane, |
| 69 | sourceGeneration: sourceTab.layoutGeneration, destinationGeneration: destinationTab.layoutGeneration, | 77 | sourceGeneration: sourceTab.layoutGeneration, destinationGeneration: destinationTab.layoutGeneration, | ||
| 70 | finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab && | 78 | finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab && | ||
| 71 | sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1, | 79 | sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1, | ||
| 72 | - | }, nil | 80 | + | } |
| 81 | + | return nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (p *movePaneTopology) afterAdmission(d *Daemon) { | |||
| 85 | + | if d.afterMovePaneSourceSnapshot != nil { | |||
| 86 | + | d.afterMovePaneSourceSnapshot() | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (p *movePaneTopology) willRetireSource() bool { | |||
| 91 | + | return p != nil && p.admission.finalSourceTab && p.admission.source != p.admission.destination | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (p *movePaneTopology) resizeFences(source, destination *session) *moveResizeFences { | |||
| 95 | + | admission := p.admission | |||
| 96 | + | return newMovePaneResizeFences(source, destination, admission.sourceTab, admission.destinationTab, admission.movedPane) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (p *movePaneTopology) publishLocked(d *Daemon, source, destination *session) (moveTopologyPublication, error) { | |||
| 100 | + | admission := p.admission | |||
| 101 | + | commit := movePaneCommit{ | |||
| 102 | + | req: p.req, | |||
| 103 | + | source: source, | |||
| 104 | + | destination: destination, | |||
| 105 | + | sourceTab: admission.sourceTab, | |||
| 106 | + | destinationTab: admission.destinationTab, | |||
| 107 | + | movedPane: admission.movedPane, | |||
| 108 | + | sourceGeneration: admission.sourceGeneration, | |||
| 109 | + | destinationGeneration: admission.destinationGeneration, | |||
| 110 | + | } | |||
| 111 | + | return commit.publishTopologyLocked(d) | |||
| 73 | } | 112 | } | ||
M internal/usecase/daemon/move_pane_commit.go +28 -80
| @@ -1,60 +1,20 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/vev/internal/domain" | |
| 5 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | - | ) | |
| 3 | + | import "github.com/bnema/vev/internal/usecase/layout" | |
| 7 | 4 | ||
| 8 | - | // movePaneCommit owns the single architecture-locked publication section. Its | |
| 9 | - | // inputs are immutable admission/fence snapshots; outputs form the postcommit | |
| 10 | - | // plan. publishLocked performs all fallible checks before its first write. | |
| 5 | + | // movePaneCommit owns pane-private validation and publication. The shared | |
| 6 | + | // moveTransaction already holds daemon, routing, ordered Session, and resize | |
| 7 | + | // fences. Every error below is returned before the first topology write. | |
| 11 | 8 | type movePaneCommit struct { | |
| 12 | 9 | req movePaneRequest | |
| 13 | 10 | source, destination *session | |
| 14 | 11 | sourceTab, destinationTab *tab | |
| 15 | 12 | movedPane *pane | |
| 16 | - | sourceAttachments []*attachedClient | |
| 17 | - | sourceTransports map[*attachedClient]transportSnapshot | |
| 18 | 13 | sourceGeneration uint64 | |
| 19 | 14 | destinationGeneration uint64 | |
| 20 | - | frozenEffects attachmentTransitionGuard | |
| 21 | - | err error | |
| 22 | - | ||
| 23 | - | syncCleanup syncTimerCleanup | |
| 24 | - | sourceEmpty bool | |
| 25 | - | sourceTabRemoved bool | |
| 26 | - | retiredParked []parkedAttachmentRetirement | |
| 27 | - | retiredAttachments []detachedAttachmentSnapshot | |
| 28 | - | sourceMetadata domain.CatalogueMetadataUpdate | |
| 29 | - | sourceMetadataValid bool | |
| 30 | - | destinationMetadata domain.CatalogueMetadataUpdate | |
| 31 | - | destinationMetadataValid bool | |
| 32 | 15 | } | |
| 33 | 16 | ||
| 34 | - | func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |
| 35 | - | var candidate *movePaneCandidate | |
| 36 | - | var err error | |
| 37 | - | d.mu.Lock() | |
| 38 | - | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | |
| 39 | - | d.mu.Unlock() | |
| 40 | - | return false | |
| 41 | - | } | |
| 42 | - | d.notices.routingMu.Lock() | |
| 43 | - | unlockSessions := lockAttachmentSessions(c.source, c.destination) | |
| 44 | - | defer unlockSessions() | |
| 45 | - | defer d.notices.routingMu.Unlock() | |
| 46 | - | defer d.mu.Unlock() | |
| 47 | - | ||
| 48 | - | if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) { | |
| 49 | - | return false | |
| 50 | - | } | |
| 51 | - | if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) { | |
| 52 | - | return false | |
| 53 | - | } | |
| 54 | - | if c.req.AttachmentCapability.ac != nil && (c.req.AttachmentCapability.ac != c.req.Attachment || !c.req.AttachmentCapability.currentInSessionLocked(c.source)) { | |
| 55 | - | return false | |
| 56 | - | } | |
| 57 | - | ||
| 17 | + | func (c *movePaneCommit) publishTopologyLocked(d *Daemon) (moveTopologyPublication, error) { | |
| 58 | 18 | unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab) | |
| 59 | 19 | c.movedPane.mu.Lock() | |
| 60 | 20 | defer c.movedPane.mu.Unlock() | |
| @@ -65,38 +25,34 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 65 | 25 | c.movedPane.stableID != string(c.req.SourcePaneID) || c.sourceTab.panes[c.movedPane.id] != c.movedPane || | |
| 66 | 26 | c.sourceTab.tree == nil || c.sourceTab.tree.Root == nil || !layout.ContainsLeaf(c.sourceTab.tree.Root, c.movedPane.id) || | |
| 67 | 27 | c.destinationTab.tree == nil || c.destinationTab.tree.Root == nil { | |
| 68 | - | return false | |
| 28 | + | return moveTopologyPublication{}, errMoveStaleTarget | |
| 69 | 29 | } | |
| 70 | 30 | owner := c.movedPane.ownerSnapshot() | |
| 71 | 31 | if owner != nil && (owner.session != c.source || owner.tab != c.sourceTab) { | |
| 72 | - | return false | |
| 32 | + | return moveTopologyPublication{}, errMoveStaleTarget | |
| 73 | 33 | } | |
| 74 | - | candidate, err = prepareMovePaneCandidate(c.sourceTab, c.destinationTab, c.movedPane) | |
| 34 | + | candidate, err := prepareMovePaneCandidate(c.sourceTab, c.destinationTab, c.movedPane) | |
| 75 | 35 | if err != nil { | |
| 76 | - | c.err = err | |
| 77 | - | return false | |
| 36 | + | return moveTopologyPublication{}, err | |
| 78 | 37 | } | |
| 79 | 38 | if candidate.sourceGeneration != c.sourceGeneration || candidate.destinationGeneration != c.destinationGeneration { | |
| 80 | - | return false | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1 | |
| 84 | - | if sourceWillEmpty && c.source != c.destination && !sameMoveAttachmentsLocked(c.source, c.sourceAttachments) { | |
| 85 | - | return false | |
| 39 | + | return moveTopologyPublication{}, errMoveStaleTarget | |
| 86 | 40 | } | |
| 87 | - | ||
| 88 | - | // Everything above is fallible. Shared topology and owner publication are | |
| 89 | - | // committed once; attachment views are repaired after these locks release. | |
| 41 | + | idx := -1 | |
| 90 | 42 | if candidate.removeSourceTab { | |
| 91 | - | idx := indexMoveTabLocked(c.source, c.sourceTab) | |
| 43 | + | idx = indexMoveTabLocked(c.source, c.sourceTab) | |
| 92 | 44 | if idx < 0 { | |
| 93 | - | return false | |
| 45 | + | return moveTopologyPublication{}, errMoveStaleTarget | |
| 94 | 46 | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Everything above is fallible. The remaining topology and owner writes form | |
| 50 | + | // one in-memory publication and cannot turn into a Move rejection. | |
| 51 | + | if candidate.removeSourceTab { | |
| 95 | 52 | if len(c.source.tabs) > 1 { | |
| 96 | 53 | c.source.prepareAttachmentViewsForRemovedTabLocked(c.sourceTab, idx) | |
| 97 | 54 | } | |
| 98 | 55 | c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...) | |
| 99 | - | c.sourceTabRemoved = true | |
| 100 | 56 | } else { | |
| 101 | 57 | c.sourceTab.tree = candidate.sourceTree | |
| 102 | 58 | c.sourceTab.bumpLayoutGenerationLocked() | |
| @@ -111,23 +67,15 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 111 | 67 | c.destinationTab.bumpLayoutGenerationLocked() | |
| 112 | 68 | c.movedPane.id = candidate.destinationID | |
| 113 | 69 | oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0) | |
| 114 | - | c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner)) | |
| 70 | + | var syncCleanup syncTimerCleanup | |
| 71 | + | syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner)) | |
| 115 | 72 | ||
| 116 | - | if sourceWillEmpty { | |
| 117 | - | d.unregisterSessionLocked(c.source) | |
| 118 | - | c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.sourceTransports) | |
| 119 | - | c.source.tabs = nil | |
| 120 | - | d.purgeParkingForSessionLocked(c.source) | |
| 121 | - | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |
| 122 | - | } | |
| 123 | - | c.sourceEmpty = len(c.source.tabs) == 0 | |
| 124 | - | if !c.source.ephemeral { | |
| 125 | - | c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate() | |
| 126 | - | c.sourceMetadataValid = true | |
| 127 | - | } | |
| 128 | - | if !c.destination.ephemeral && c.destination != c.source { | |
| 129 | - | c.destinationMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate() | |
| 130 | - | c.destinationMetadataValid = true | |
| 131 | - | } | |
| 132 | - | return true | |
| 73 | + | return moveTopologyPublication{ | |
| 74 | + | sourceTab: c.sourceTab, | |
| 75 | + | destinationTab: c.destinationTab, | |
| 76 | + | movedPane: c.movedPane, | |
| 77 | + | syncCleanup: syncCleanup, | |
| 78 | + | sourceTabRemoved: candidate.removeSourceTab, | |
| 79 | + | sourceEmpty: len(c.source.tabs) == 0, | |
| 80 | + | }, nil | |
| 133 | 81 | } | |
| @@ -1,60 +1,20 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | - | import ( | 3 | + | import "github.com/bnema/vev/internal/usecase/layout" |
| 4 | - | "github.com/bnema/vev/internal/domain" | |||
| 5 | - | "github.com/bnema/vev/internal/usecase/layout" | |||
| 6 | - | ) | |||
| 7 | 4 | ||||
| 8 | - | // movePaneCommit owns the single architecture-locked publication section. Its | 5 | + | // movePaneCommit owns pane-private validation and publication. The shared |
| 9 | - | // inputs are immutable admission/fence snapshots; outputs form the postcommit | 6 | + | // moveTransaction already holds daemon, routing, ordered Session, and resize |
| 10 | - | // plan. publishLocked performs all fallible checks before its first write. | 7 | + | // fences. Every error below is returned before the first topology write. |
| 11 | type movePaneCommit struct { | 8 | type movePaneCommit struct { | ||
| 12 | req movePaneRequest | 9 | req movePaneRequest | ||
| 13 | source, destination *session | 10 | source, destination *session | ||
| 14 | sourceTab, destinationTab *tab | 11 | sourceTab, destinationTab *tab | ||
| 15 | movedPane *pane | 12 | movedPane *pane | ||
| 16 | - | sourceAttachments []*attachedClient | |||
| 17 | - | sourceTransports map[*attachedClient]transportSnapshot | |||
| 18 | sourceGeneration uint64 | 13 | sourceGeneration uint64 | ||
| 19 | destinationGeneration uint64 | 14 | destinationGeneration uint64 | ||
| 20 | - | frozenEffects attachmentTransitionGuard | |||
| 21 | - | err error | |||
| 22 | - | ||||
| 23 | - | syncCleanup syncTimerCleanup | |||
| 24 | - | sourceEmpty bool | |||
| 25 | - | sourceTabRemoved bool | |||
| 26 | - | retiredParked []parkedAttachmentRetirement | |||
| 27 | - | retiredAttachments []detachedAttachmentSnapshot | |||
| 28 | - | sourceMetadata domain.CatalogueMetadataUpdate | |||
| 29 | - | sourceMetadataValid bool | |||
| 30 | - | destinationMetadata domain.CatalogueMetadataUpdate | |||
| 31 | - | destinationMetadataValid bool | |||
| 32 | } | 15 | } | ||
| 33 | 16 | ||||
| 34 | - | func (c *movePaneCommit) publishLocked(d *Daemon) bool { | 17 | + | func (c *movePaneCommit) publishTopologyLocked(d *Daemon) (moveTopologyPublication, error) { |
| 35 | - | var candidate *movePaneCandidate | |||
| 36 | - | var err error | |||
| 37 | - | d.mu.Lock() | |||
| 38 | - | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | |||
| 39 | - | d.mu.Unlock() | |||
| 40 | - | return false | |||
| 41 | - | } | |||
| 42 | - | d.notices.routingMu.Lock() | |||
| 43 | - | unlockSessions := lockAttachmentSessions(c.source, c.destination) | |||
| 44 | - | defer unlockSessions() | |||
| 45 | - | defer d.notices.routingMu.Unlock() | |||
| 46 | - | defer d.mu.Unlock() | |||
| 47 | - | ||||
| 48 | - | if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) { | |||
| 49 | - | return false | |||
| 50 | - | } | |||
| 51 | - | if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) { | |||
| 52 | - | return false | |||
| 53 | - | } | |||
| 54 | - | if c.req.AttachmentCapability.ac != nil && (c.req.AttachmentCapability.ac != c.req.Attachment || !c.req.AttachmentCapability.currentInSessionLocked(c.source)) { | |||
| 55 | - | return false | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab) | 18 | unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab) | ||
| 59 | c.movedPane.mu.Lock() | 19 | c.movedPane.mu.Lock() | ||
| 60 | defer c.movedPane.mu.Unlock() | 20 | defer c.movedPane.mu.Unlock() | ||
| @@ -65,38 +25,34 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||||
| 65 | c.movedPane.stableID != string(c.req.SourcePaneID) || c.sourceTab.panes[c.movedPane.id] != c.movedPane || | 25 | c.movedPane.stableID != string(c.req.SourcePaneID) || c.sourceTab.panes[c.movedPane.id] != c.movedPane || | ||
| 66 | c.sourceTab.tree == nil || c.sourceTab.tree.Root == nil || !layout.ContainsLeaf(c.sourceTab.tree.Root, c.movedPane.id) || | 26 | c.sourceTab.tree == nil || c.sourceTab.tree.Root == nil || !layout.ContainsLeaf(c.sourceTab.tree.Root, c.movedPane.id) || | ||
| 67 | c.destinationTab.tree == nil || c.destinationTab.tree.Root == nil { | 27 | c.destinationTab.tree == nil || c.destinationTab.tree.Root == nil { | ||
| 68 | - | return false | 28 | + | return moveTopologyPublication{}, errMoveStaleTarget |
| 69 | } | 29 | } | ||
| 70 | owner := c.movedPane.ownerSnapshot() | 30 | owner := c.movedPane.ownerSnapshot() | ||
| 71 | if owner != nil && (owner.session != c.source || owner.tab != c.sourceTab) { | 31 | if owner != nil && (owner.session != c.source || owner.tab != c.sourceTab) { | ||
| 72 | - | return false | 32 | + | return moveTopologyPublication{}, errMoveStaleTarget |
| 73 | } | 33 | } | ||
| 74 | - | candidate, err = prepareMovePaneCandidate(c.sourceTab, c.destinationTab, c.movedPane) | 34 | + | candidate, err := prepareMovePaneCandidate(c.sourceTab, c.destinationTab, c.movedPane) |
| 75 | if err != nil { | 35 | if err != nil { | ||
| 76 | - | c.err = err | 36 | + | return moveTopologyPublication{}, err |
| 77 | - | return false | |||
| 78 | } | 37 | } | ||
| 79 | if candidate.sourceGeneration != c.sourceGeneration || candidate.destinationGeneration != c.destinationGeneration { | 38 | if candidate.sourceGeneration != c.sourceGeneration || candidate.destinationGeneration != c.destinationGeneration { | ||
| 80 | - | return false | 39 | + | return moveTopologyPublication{}, errMoveStaleTarget |
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1 | |||
| 84 | - | if sourceWillEmpty && c.source != c.destination && !sameMoveAttachmentsLocked(c.source, c.sourceAttachments) { | |||
| 85 | - | return false | |||
| 86 | } | 40 | } | ||
| 87 | - | 41 | + | idx := -1 | |
| 88 | - | // Everything above is fallible. Shared topology and owner publication are | |||
| 89 | - | // committed once; attachment views are repaired after these locks release. | |||
| 90 | if candidate.removeSourceTab { | 42 | if candidate.removeSourceTab { | ||
| 91 | - | idx := indexMoveTabLocked(c.source, c.sourceTab) | 43 | + | idx = indexMoveTabLocked(c.source, c.sourceTab) |
| 92 | if idx < 0 { | 44 | if idx < 0 { | ||
| 93 | - | return false | 45 | + | return moveTopologyPublication{}, errMoveStaleTarget |
| 94 | } | 46 | } | ||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Everything above is fallible. The remaining topology and owner writes form | |||
| 50 | + | // one in-memory publication and cannot turn into a Move rejection. | |||
| 51 | + | if candidate.removeSourceTab { | |||
| 95 | if len(c.source.tabs) > 1 { | 52 | if len(c.source.tabs) > 1 { | ||
| 96 | c.source.prepareAttachmentViewsForRemovedTabLocked(c.sourceTab, idx) | 53 | c.source.prepareAttachmentViewsForRemovedTabLocked(c.sourceTab, idx) | ||
| 97 | } | 54 | } | ||
| 98 | c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...) | 55 | c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...) | ||
| 99 | - | c.sourceTabRemoved = true | |||
| 100 | } else { | 56 | } else { | ||
| 101 | c.sourceTab.tree = candidate.sourceTree | 57 | c.sourceTab.tree = candidate.sourceTree | ||
| 102 | c.sourceTab.bumpLayoutGenerationLocked() | 58 | c.sourceTab.bumpLayoutGenerationLocked() | ||
| @@ -111,23 +67,15 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||||
| 111 | c.destinationTab.bumpLayoutGenerationLocked() | 67 | c.destinationTab.bumpLayoutGenerationLocked() | ||
| 112 | c.movedPane.id = candidate.destinationID | 68 | c.movedPane.id = candidate.destinationID | ||
| 113 | oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0) | 69 | oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0) | ||
| 114 | - | c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner)) | 70 | + | var syncCleanup syncTimerCleanup |
| 71 | + | syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner)) | |||
| 115 | 72 | ||||
| 116 | - | if sourceWillEmpty { | 73 | + | return moveTopologyPublication{ |
| 117 | - | d.unregisterSessionLocked(c.source) | 74 | + | sourceTab: c.sourceTab, |
| 118 | - | c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.sourceTransports) | 75 | + | destinationTab: c.destinationTab, |
| 119 | - | c.source.tabs = nil | 76 | + | movedPane: c.movedPane, |
| 120 | - | d.purgeParkingForSessionLocked(c.source) | 77 | + | syncCleanup: syncCleanup, |
| 121 | - | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | 78 | + | sourceTabRemoved: candidate.removeSourceTab, |
| 122 | - | } | 79 | + | sourceEmpty: len(c.source.tabs) == 0, |
| 123 | - | c.sourceEmpty = len(c.source.tabs) == 0 | 80 | + | }, nil |
| 124 | - | if !c.source.ephemeral { | |||
| 125 | - | c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate() | |||
| 126 | - | c.sourceMetadataValid = true | |||
| 127 | - | } | |||
| 128 | - | if !c.destination.ephemeral && c.destination != c.source { | |||
| 129 | - | c.destinationMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate() | |||
| 130 | - | c.destinationMetadataValid = true | |||
| 131 | - | } | |||
| 132 | - | return true | |||
| 133 | } | 81 | } | ||
R internal/usecase/daemon/move_pane_postcommit.go → internal/usecase/daemon/move_postcommit.go +12 -10 renamed, 90% similar
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/ports" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | // movePanePostcommitPlan is captured while the move's architecture and owner | |
| 12 | + | // movePostcommitPlan is captured while the move's architecture and owner | |
| 13 | 13 | // fences still protect publication. It contains only immutable pointers, | |
| 14 | 14 | // generations, and exact attachment tokens needed by fallible follow-up work. | |
| 15 | 15 | func firstMovePane(panes []*pane) *pane { | |
| @@ -19,9 +19,11 @@ func firstMovePane(panes []*pane) *pane { | |||
| 19 | 19 | return panes[0] | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | - | type movePanePostcommitPlan struct { | |
| 22 | + | type movePostcommitPlan struct { | |
| 23 | 23 | source *session | |
| 24 | 24 | destination *session | |
| 25 | + | sourceName string | |
| 26 | + | destinationName string | |
| 25 | 27 | sourceTab *tab | |
| 26 | 28 | destinationTab *tab | |
| 27 | 29 | movedPane *pane | |
| @@ -49,7 +51,7 @@ type movePanePostcommitPlan struct { | |||
| 49 | 51 | // execute runs only after every resize-owner fence and architecture lock is | |
| 50 | 52 | // released. Generation-bound tokens make stale effects drop rather than route | |
| 51 | 53 | // back to the retired source. | |
| 52 | - | func (p movePanePostcommitPlan) execute(d *Daemon) { | |
| 54 | + | func (p movePostcommitPlan) execute(d *Daemon) { | |
| 53 | 55 | if p.oldTabCancel != nil { | |
| 54 | 56 | p.oldTabCancel() | |
| 55 | 57 | } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil { | |
| @@ -82,10 +84,10 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 82 | 84 | p.reservation.Release() | |
| 83 | 85 | attrs := []any{ | |
| 84 | 86 | "operation", p.operation, | |
| 85 | - | "source_session", p.source.name, | |
| 87 | + | "source_session", p.sourceName, | |
| 86 | 88 | "source_session_id", p.source.id, | |
| 87 | 89 | "source_tab_id", p.sourceTab.stableID, | |
| 88 | - | "destination_session", p.destination.name, | |
| 90 | + | "destination_session", p.destinationName, | |
| 89 | 91 | "destination_session_id", p.destination.id, | |
| 90 | 92 | "source_retired", p.sourceEmpty, | |
| 91 | 93 | } | |
| @@ -134,7 +136,7 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 134 | 136 | if len(p.retiredAttachments) != 0 { | |
| 135 | 137 | d.log.Info("move detaching source attachments", | |
| 136 | 138 | "operation", p.operation, | |
| 137 | - | "source_session", p.source.name, | |
| 139 | + | "source_session", p.sourceName, | |
| 138 | 140 | "source_session_id", p.source.id, | |
| 139 | 141 | "reason", "session-killed", | |
| 140 | 142 | "attachments", len(p.retiredAttachments), | |
| @@ -144,7 +146,7 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 144 | 146 | d.unregisterPreview(attachment.ac) | |
| 145 | 147 | attachment.ac.clearCaptureFrames() | |
| 146 | 148 | if err := d.cleanupAttachmentOutput(attachment.ac); err != nil { | |
| 147 | - | d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.source.name) | |
| 149 | + | d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.sourceName) | |
| 148 | 150 | } | |
| 149 | 151 | d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled) | |
| 150 | 152 | } | |
| @@ -157,9 +159,9 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 157 | 159 | } | |
| 158 | 160 | d.finishParkedAttachmentRetirements(p.retiredParked) | |
| 159 | 161 | if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled { | |
| 160 | - | if err := d.beginSnapshotPurge(p.source.name, p.source.incarnation); err == nil { | |
| 161 | - | if err := d.finishSnapshotPurge(d.serveCtx, p.source.name, p.source.incarnation); err != nil { | |
| 162 | - | d.log.Warn("moving final pane source purge failed", "err", err, "session", p.source.name) | |
| 162 | + | if err := d.beginSnapshotPurge(p.sourceName, p.source.incarnation); err == nil { | |
| 163 | + | if err := d.finishSnapshotPurge(d.serveCtx, p.sourceName, p.source.incarnation); err != nil { | |
| 164 | + | d.log.Warn("moving final pane source purge failed", "err", err, "session", p.sourceName) | |
| 163 | 165 | } | |
| 164 | 166 | } | |
| 165 | 167 | } | |
| @@ -9,7 +9,7 @@ import ( | |||||
| 9 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | - | // movePanePostcommitPlan is captured while the move's architecture and owner | 12 | + | // movePostcommitPlan is captured while the move's architecture and owner |
| 13 | // fences still protect publication. It contains only immutable pointers, | 13 | // fences still protect publication. It contains only immutable pointers, | ||
| 14 | // generations, and exact attachment tokens needed by fallible follow-up work. | 14 | // generations, and exact attachment tokens needed by fallible follow-up work. | ||
| 15 | func firstMovePane(panes []*pane) *pane { | 15 | func firstMovePane(panes []*pane) *pane { | ||
| @@ -19,9 +19,11 @@ func firstMovePane(panes []*pane) *pane { | |||||
| 19 | return panes[0] | 19 | return panes[0] | ||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | - | type movePanePostcommitPlan struct { | 22 | + | type movePostcommitPlan struct { |
| 23 | source *session | 23 | source *session | ||
| 24 | destination *session | 24 | destination *session | ||
| 25 | + | sourceName string | |||
| 26 | + | destinationName string | |||
| 25 | sourceTab *tab | 27 | sourceTab *tab | ||
| 26 | destinationTab *tab | 28 | destinationTab *tab | ||
| 27 | movedPane *pane | 29 | movedPane *pane | ||
| @@ -49,7 +51,7 @@ type movePanePostcommitPlan struct { | |||||
| 49 | // execute runs only after every resize-owner fence and architecture lock is | 51 | // execute runs only after every resize-owner fence and architecture lock is | ||
| 50 | // released. Generation-bound tokens make stale effects drop rather than route | 52 | // released. Generation-bound tokens make stale effects drop rather than route | ||
| 51 | // back to the retired source. | 53 | // back to the retired source. | ||
| 52 | - | func (p movePanePostcommitPlan) execute(d *Daemon) { | 54 | + | func (p movePostcommitPlan) execute(d *Daemon) { |
| 53 | if p.oldTabCancel != nil { | 55 | if p.oldTabCancel != nil { | ||
| 54 | p.oldTabCancel() | 56 | p.oldTabCancel() | ||
| 55 | } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil { | 57 | } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil { | ||
| @@ -82,10 +84,10 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||||
| 82 | p.reservation.Release() | 84 | p.reservation.Release() | ||
| 83 | attrs := []any{ | 85 | attrs := []any{ | ||
| 84 | "operation", p.operation, | 86 | "operation", p.operation, | ||
| 85 | - | "source_session", p.source.name, | 87 | + | "source_session", p.sourceName, |
| 86 | "source_session_id", p.source.id, | 88 | "source_session_id", p.source.id, | ||
| 87 | "source_tab_id", p.sourceTab.stableID, | 89 | "source_tab_id", p.sourceTab.stableID, | ||
| 88 | - | "destination_session", p.destination.name, | 90 | + | "destination_session", p.destinationName, |
| 89 | "destination_session_id", p.destination.id, | 91 | "destination_session_id", p.destination.id, | ||
| 90 | "source_retired", p.sourceEmpty, | 92 | "source_retired", p.sourceEmpty, | ||
| 91 | } | 93 | } | ||
| @@ -134,7 +136,7 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||||
| 134 | if len(p.retiredAttachments) != 0 { | 136 | if len(p.retiredAttachments) != 0 { | ||
| 135 | d.log.Info("move detaching source attachments", | 137 | d.log.Info("move detaching source attachments", | ||
| 136 | "operation", p.operation, | 138 | "operation", p.operation, | ||
| 137 | - | "source_session", p.source.name, | 139 | + | "source_session", p.sourceName, |
| 138 | "source_session_id", p.source.id, | 140 | "source_session_id", p.source.id, | ||
| 139 | "reason", "session-killed", | 141 | "reason", "session-killed", | ||
| 140 | "attachments", len(p.retiredAttachments), | 142 | "attachments", len(p.retiredAttachments), | ||
| @@ -144,7 +146,7 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||||
| 144 | d.unregisterPreview(attachment.ac) | 146 | d.unregisterPreview(attachment.ac) | ||
| 145 | attachment.ac.clearCaptureFrames() | 147 | attachment.ac.clearCaptureFrames() | ||
| 146 | if err := d.cleanupAttachmentOutput(attachment.ac); err != nil { | 148 | if err := d.cleanupAttachmentOutput(attachment.ac); err != nil { | ||
| 147 | - | d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.source.name) | 149 | + | d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.sourceName) |
| 148 | } | 150 | } | ||
| 149 | d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled) | 151 | d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled) | ||
| 150 | } | 152 | } | ||
| @@ -157,9 +159,9 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||||
| 157 | } | 159 | } | ||
| 158 | d.finishParkedAttachmentRetirements(p.retiredParked) | 160 | d.finishParkedAttachmentRetirements(p.retiredParked) | ||
| 159 | if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled { | 161 | if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled { | ||
| 160 | - | if err := d.beginSnapshotPurge(p.source.name, p.source.incarnation); err == nil { | 162 | + | if err := d.beginSnapshotPurge(p.sourceName, p.source.incarnation); err == nil { |
| 161 | - | if err := d.finishSnapshotPurge(d.serveCtx, p.source.name, p.source.incarnation); err != nil { | 163 | + | if err := d.finishSnapshotPurge(d.serveCtx, p.sourceName, p.source.incarnation); err != nil { |
| 162 | - | d.log.Warn("moving final pane source purge failed", "err", err, "session", p.source.name) | 164 | + | d.log.Warn("moving final pane source purge failed", "err", err, "session", p.sourceName) |
| 163 | } | 165 | } | ||
| 164 | } | 166 | } | ||
| 165 | } | 167 | } | ||
M internal/usecase/daemon/move_tab.go +110 -241
| @@ -7,11 +7,17 @@ import ( | |||
| 7 | 7 | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | // moveTabTopology is the private whole-Tab topology Implementation consumed by | |
| 11 | + | // the shared moveTransaction Module. | |
| 12 | + | type moveTabTopology struct { | |
| 13 | + | req moveTabRequest | |
| 14 | + | admission moveTabAdmission | |
| 15 | + | fencedPanes []*pane | |
| 16 | + | } | |
| 17 | + | ||
| 10 | 18 | type moveTabAdmission struct { | |
| 11 | 19 | tab *tab | |
| 12 | 20 | sourceIndex int | |
| 13 | - | sourceAttachments []*attachedClient | |
| 14 | - | sourceTransports map[*attachedClient]transportSnapshot | |
| 15 | 21 | layoutGeneration uint64 | |
| 16 | 22 | floatingState floatingState | |
| 17 | 23 | floatingPane *pane | |
| @@ -23,270 +29,137 @@ type moveTabAdmission struct { | |||
| 23 | 29 | } | |
| 24 | 30 | ||
| 25 | 31 | type moveTabCommit struct { | |
| 26 | - | req moveTabRequest | |
| 27 | - | source *session | |
| 28 | - | destination *session | |
| 29 | - | admission moveTabAdmission | |
| 30 | - | sourceEffectsFrozen bool | |
| 31 | - | frozenEffects attachmentTransitionGuard | |
| 32 | - | syncCleanup syncTimerCleanup | |
| 33 | - | oldTabCancel context.CancelFunc | |
| 34 | - | sourceEmpty bool | |
| 35 | - | retiredParked []parkedAttachmentRetirement | |
| 36 | - | retiredAttachments []detachedAttachmentSnapshot | |
| 37 | - | sourceMetadata domain.CatalogueMetadataUpdate | |
| 38 | - | sourceMetadataValid bool | |
| 39 | - | destMetadata domain.CatalogueMetadataUpdate | |
| 40 | - | destMetadataValid bool | |
| 41 | - | err error | |
| 32 | + | req moveTabRequest | |
| 33 | + | source *session | |
| 34 | + | destination *session | |
| 35 | + | admission moveTabAdmission | |
| 42 | 36 | } | |
| 43 | 37 | ||
| 44 | - | func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |
| 45 | - | if d == nil { | |
| 46 | - | return errMovePaneInvalid | |
| 47 | - | } | |
| 48 | - | d.log.Info("tab move requested", | |
| 49 | - | "source_session_id", req.Source.ID, | |
| 50 | - | "source_tab_id", req.SourceTabID, | |
| 51 | - | "destination_session_id", req.Destination.ID, | |
| 52 | - | ) | |
| 53 | - | defer func() { | |
| 54 | - | result = normalizeMoveRejection(result) | |
| 55 | - | if result != nil { | |
| 56 | - | d.log.Warn("tab move rejected", | |
| 57 | - | "err", result, | |
| 58 | - | "source_session_id", req.Source.ID, | |
| 59 | - | "source_tab_id", req.SourceTabID, | |
| 60 | - | "destination_session_id", req.Destination.ID, | |
| 61 | - | ) | |
| 62 | - | } | |
| 63 | - | }() | |
| 64 | - | if req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | |
| 65 | - | return errMovePaneInvalid | |
| 66 | - | } | |
| 67 | - | d.mu.Lock() | |
| 68 | - | source := moveSessionForLocatorLocked(d, req.Source) | |
| 69 | - | destination := moveSessionForLocatorLocked(d, req.Destination) | |
| 70 | - | d.mu.Unlock() | |
| 71 | - | if source == nil || destination == nil { | |
| 72 | - | return errMoveStaleTarget | |
| 73 | - | } | |
| 74 | - | if source == destination { | |
| 75 | - | return errMovePaneInvalid | |
| 76 | - | } | |
| 77 | - | // Dispatch admission must precede lifecycle reservation. A final close holds | |
| 78 | - | // dispatchMu while it waits for teardown ownership; reserving first would let | |
| 79 | - | // that close wait on a move which is itself blocked on dispatchMu. | |
| 80 | - | if d.beforeMoveDispatch != nil { | |
| 81 | - | d.beforeMoveDispatch() | |
| 82 | - | } | |
| 83 | - | unlockDispatch := lockMoveDispatch(source, destination) | |
| 84 | - | dispatchHeld := true | |
| 85 | - | defer func() { | |
| 86 | - | if dispatchHeld { | |
| 87 | - | unlockDispatch() | |
| 88 | - | } | |
| 89 | - | }() | |
| 90 | - | ||
| 91 | - | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 92 | - | if err != nil { | |
| 93 | - | return errMoveStaleTarget | |
| 94 | - | } | |
| 95 | - | reservationHeld := true | |
| 96 | - | defer func() { | |
| 97 | - | if reservationHeld { | |
| 98 | - | reservation.Release() | |
| 99 | - | } | |
| 100 | - | }() | |
| 101 | - | if d.afterMoveLifecycleReserved != nil { | |
| 102 | - | d.afterMoveLifecycleReserved() | |
| 103 | - | } | |
| 38 | + | func (d *Daemon) moveTab(req moveTabRequest) error { | |
| 39 | + | return d.executeMove(&moveTabTopology{req: req}) | |
| 40 | + | } | |
| 104 | 41 | ||
| 105 | - | admission, err := d.snapshotMoveTabAdmission(req, source, destination) | |
| 106 | - | if err != nil { | |
| 107 | - | return err | |
| 42 | + | func (p *moveTabTopology) transactionRequest() moveTransactionRequest { | |
| 43 | + | req := p.req | |
| 44 | + | return moveTransactionRequest{ | |
| 45 | + | operation: "tab", | |
| 46 | + | attachment: req.Attachment, | |
| 47 | + | attachmentCapability: req.AttachmentCapability, | |
| 48 | + | source: req.Source, | |
| 49 | + | destination: req.Destination, | |
| 50 | + | logAttrs: []any{ | |
| 51 | + | "source_session_id", req.Source.ID, | |
| 52 | + | "source_tab_id", req.SourceTabID, | |
| 53 | + | "destination_session_id", req.Destination.ID, | |
| 54 | + | }, | |
| 108 | 55 | } | |
| 109 | - | if d.afterMoveTabSourceSnapshot != nil { | |
| 110 | - | d.afterMoveTabSourceSnapshot() | |
| 111 | - | } | |
| 112 | - | commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission} | |
| 113 | - | var frozen attachmentTransitionGuard | |
| 114 | - | if admission.finalSource && len(admission.sourceAttachments) != 0 { | |
| 115 | - | interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments)) | |
| 116 | - | for _, ac := range admission.sourceAttachments { | |
| 117 | - | if transport := admission.sourceTransports[ac]; transport.transport != nil { | |
| 118 | - | interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport}) | |
| 119 | - | } | |
| 120 | - | } | |
| 121 | - | frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) { | |
| 122 | - | if d.afterAttachmentEffectGateFrozen != nil { | |
| 123 | - | d.afterAttachmentEffectGateFrozen("move-tab", ac) | |
| 124 | - | } | |
| 125 | - | }}, admission.sourceAttachments...) | |
| 126 | - | if !frozen.acquired || !frozen.drained { | |
| 127 | - | frozen.unfreeze() | |
| 128 | - | return errMovePaneInvalid | |
| 129 | - | } | |
| 130 | - | commit.sourceEffectsFrozen = true | |
| 131 | - | } | |
| 132 | - | commit.frozenEffects = frozen | |
| 133 | - | effectsFrozen := commit.sourceEffectsFrozen | |
| 134 | - | defer func() { | |
| 135 | - | if effectsFrozen { | |
| 136 | - | frozen.unfreeze() | |
| 137 | - | } | |
| 138 | - | }() | |
| 56 | + | } | |
| 139 | 57 | ||
| 140 | - | fences := newMoveTabResizeFences(source, destination, admission.tab) | |
| 141 | - | if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) { | |
| 142 | - | if commit.err != nil { | |
| 143 | - | return commit.err | |
| 144 | - | } | |
| 145 | - | return errMoveStaleTarget | |
| 146 | - | } | |
| 147 | - | fences.Release() | |
| 148 | - | postcommit := movePanePostcommitPlan{ | |
| 149 | - | source: source, | |
| 150 | - | destination: destination, | |
| 151 | - | sourceTab: admission.tab, | |
| 152 | - | destinationTab: admission.tab, | |
| 153 | - | movedPane: firstMovePane(admission.panes), | |
| 154 | - | movedPanes: admission.panes, | |
| 155 | - | operation: "tab", | |
| 156 | - | sourceAttachments: admission.sourceAttachments, | |
| 157 | - | syncCleanup: commit.syncCleanup, | |
| 158 | - | frozenEffects: frozen, | |
| 159 | - | effectsFrozen: effectsFrozen, | |
| 160 | - | unlockDispatch: unlockDispatch, | |
| 161 | - | reservation: reservation, | |
| 162 | - | oldTabCancel: commit.oldTabCancel, | |
| 163 | - | sourceTabRemoved: true, | |
| 164 | - | sourceEmpty: commit.sourceEmpty, | |
| 165 | - | retiredParked: commit.retiredParked, | |
| 166 | - | retiredAttachments: commit.retiredAttachments, | |
| 167 | - | sourceMetadata: commit.sourceMetadata, | |
| 168 | - | sourceMetadataValid: commit.sourceMetadataValid, | |
| 169 | - | destinationMetadata: commit.destMetadata, | |
| 170 | - | destinationMetadataValid: commit.destMetadataValid, | |
| 171 | - | } | |
| 172 | - | effectsFrozen = false | |
| 173 | - | dispatchHeld = false | |
| 174 | - | reservationHeld = false | |
| 175 | - | postcommit.execute(d) | |
| 176 | - | return nil | |
| 58 | + | func (p *moveTabTopology) validRequest() bool { | |
| 59 | + | return p != nil && p.req.SourceTabID != "" && p.req.Source.ID != p.req.Destination.ID | |
| 177 | 60 | } | |
| 178 | 61 | ||
| 179 | - | func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destination *session) (*moveTabAdmission, error) { | |
| 180 | - | d.mu.Lock() | |
| 181 | - | defer d.mu.Unlock() | |
| 182 | - | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | |
| 183 | - | return nil, errMoveStaleTarget | |
| 184 | - | } | |
| 185 | - | unlockSessions := lockAttachmentSessions(source, destination) | |
| 186 | - | defer unlockSessions() | |
| 187 | - | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | |
| 188 | - | return nil, errMoveStaleTarget | |
| 189 | - | } | |
| 190 | - | if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) { | |
| 191 | - | return nil, errMoveStaleTarget | |
| 192 | - | } | |
| 193 | - | if req.AttachmentCapability.ac != nil && (req.AttachmentCapability.ac != req.Attachment || !req.AttachmentCapability.currentInSessionLocked(source)) { | |
| 194 | - | return nil, errMoveStaleTarget | |
| 62 | + | // admitLocked runs with daemon and ordered Session locks held. It snapshots | |
| 63 | + | // whole-Tab policy without publishing topology or Pane ownership. | |
| 64 | + | func (p *moveTabTopology) admitLocked(_ *Daemon, source, destination *session) error { | |
| 65 | + | if source == destination { | |
| 66 | + | return errMovePaneInvalid | |
| 195 | 67 | } | |
| 196 | - | moved := findMoveTabLocked(source, req.SourceTabID) | |
| 68 | + | moved := findMoveTabLocked(source, p.req.SourceTabID) | |
| 197 | 69 | if moved == nil || len(destination.tabs) == 0 { | |
| 198 | - | return nil, errMoveStaleTarget | |
| 70 | + | return errMoveStaleTarget | |
| 199 | 71 | } | |
| 200 | 72 | destinationActive := destination.tabs[0] | |
| 201 | 73 | destinationActive.mu.Lock() | |
| 202 | 74 | destinationSize := destinationActive.size | |
| 203 | 75 | destinationActive.mu.Unlock() | |
| 76 | + | ||
| 204 | 77 | moved.mu.Lock() | |
| 205 | 78 | defer moved.mu.Unlock() | |
| 206 | 79 | if !moved.floatingTransferableLocked() { | |
| 207 | - | return nil, errMoveFloatingWarming | |
| 80 | + | return errMoveFloatingWarming | |
| 208 | 81 | } | |
| 209 | 82 | if moved.tree == nil || moved.tree.Root == nil { | |
| 210 | - | return nil, errMovePaneInvalid | |
| 83 | + | return errMovePaneInvalid | |
| 211 | 84 | } | |
| 212 | 85 | if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok { | |
| 213 | - | return nil, errMoveTooSmall | |
| 86 | + | return errMoveTooSmall | |
| 214 | 87 | } | |
| 215 | 88 | panes := make([]*pane, 0, len(moved.panes)+1) | |
| 216 | - | for _, p := range moved.panes { | |
| 217 | - | panes = append(panes, p) | |
| 89 | + | for _, pane := range moved.panes { | |
| 90 | + | panes = append(panes, pane) | |
| 218 | 91 | } | |
| 219 | 92 | if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) { | |
| 220 | 93 | panes = append(panes, moved.floating.pane) | |
| 221 | 94 | } | |
| 222 | - | idx := indexMoveTabLocked(source, moved) | |
| 223 | - | attachments := source.snapshotAttachmentsLocked() | |
| 224 | - | transports := make(map[*attachedClient]transportSnapshot, len(attachments)) | |
| 225 | - | for _, ac := range attachments { | |
| 226 | - | transports[ac] = ac.transportSnapshot() | |
| 227 | - | } | |
| 228 | - | return &moveTabAdmission{ | |
| 229 | - | tab: moved, sourceIndex: idx, sourceAttachments: attachments, sourceTransports: transports, | |
| 95 | + | p.admission = moveTabAdmission{ | |
| 96 | + | tab: moved, sourceIndex: indexMoveTabLocked(source, moved), | |
| 230 | 97 | layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state, | |
| 231 | 98 | floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation, | |
| 232 | 99 | panes: panes, destinationActive: destinationActive, destinationSize: destinationSize, | |
| 233 | 100 | finalSource: len(source.tabs) == 1, | |
| 234 | - | }, nil | |
| 101 | + | } | |
| 102 | + | return nil | |
| 235 | 103 | } | |
| 236 | 104 | ||
| 237 | - | func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |
| 238 | - | d.mu.Lock() | |
| 239 | - | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | |
| 240 | - | d.mu.Unlock() | |
| 241 | - | return false | |
| 242 | - | } | |
| 243 | - | d.notices.routingMu.Lock() | |
| 244 | - | unlockSessions := lockAttachmentSessions(c.source, c.destination) | |
| 245 | - | defer unlockSessions() | |
| 246 | - | defer d.notices.routingMu.Unlock() | |
| 247 | - | defer d.mu.Unlock() | |
| 248 | - | if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) { | |
| 249 | - | return false | |
| 250 | - | } | |
| 251 | - | if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) { | |
| 252 | - | return false | |
| 253 | - | } | |
| 254 | - | if c.req.AttachmentCapability.ac != nil && (c.req.AttachmentCapability.ac != c.req.Attachment || !c.req.AttachmentCapability.currentInSessionLocked(c.source)) { | |
| 255 | - | return false | |
| 105 | + | func (p *moveTabTopology) afterAdmission(d *Daemon) { | |
| 106 | + | if d.afterMoveTabSourceSnapshot != nil { | |
| 107 | + | d.afterMoveTabSourceSnapshot() | |
| 256 | 108 | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (p *moveTabTopology) willRetireSource() bool { | |
| 112 | + | return p != nil && p.admission.finalSource | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (p *moveTabTopology) resizeFences(source, destination *session) *moveResizeFences { | |
| 116 | + | fences := newMoveTabResizeFences(source, destination, p.admission.tab) | |
| 117 | + | p.fencedPanes = fences.panes | |
| 118 | + | return fences | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (p *moveTabTopology) publishLocked(d *Daemon, source, destination *session) (moveTopologyPublication, error) { | |
| 122 | + | commit := moveTabCommit{req: p.req, source: source, destination: destination, admission: p.admission} | |
| 123 | + | return commit.publishTopologyLocked(d, p.fencedPanes) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // publishTopologyLocked performs every whole-Tab validation before changing | |
| 127 | + | // membership, Tab context, or Pane owners. The shared transaction owns common | |
| 128 | + | // Session retirement and persistence capture after this method succeeds. | |
| 129 | + | func (c *moveTabCommit) publishTopologyLocked(d *Daemon, fencedPanes []*pane) (moveTopologyPublication, error) { | |
| 257 | 130 | moved := c.admission.tab | |
| 258 | 131 | if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil || | |
| 259 | - | len(c.source.tabs) == 1 != c.admission.finalSource { | |
| 260 | - | return false | |
| 261 | - | } | |
| 262 | - | if c.admission.finalSource && !sameMoveAttachmentsLocked(c.source, c.admission.sourceAttachments) { | |
| 263 | - | return false | |
| 132 | + | (len(c.source.tabs) == 1) != c.admission.finalSource { | |
| 133 | + | return moveTopologyPublication{}, errMoveStaleTarget | |
| 264 | 134 | } | |
| 265 | 135 | if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive { | |
| 266 | - | return false | |
| 136 | + | return moveTopologyPublication{}, errMoveStaleTarget | |
| 267 | 137 | } | |
| 268 | 138 | c.admission.destinationActive.mu.Lock() | |
| 269 | 139 | destinationSizeCurrent := c.admission.destinationActive.size == c.admission.destinationSize | |
| 270 | 140 | c.admission.destinationActive.mu.Unlock() | |
| 271 | 141 | if !destinationSizeCurrent { | |
| 272 | - | return false | |
| 142 | + | return moveTopologyPublication{}, errMoveStaleTarget | |
| 273 | 143 | } | |
| 144 | + | ||
| 274 | 145 | moved.mu.Lock() | |
| 275 | 146 | defer moved.mu.Unlock() | |
| 276 | 147 | if moved.layoutGeneration != c.admission.layoutGeneration || !moved.floatingTransferableLocked() || | |
| 277 | 148 | moved.floating.state != c.admission.floatingState || moved.floating.pane != c.admission.floatingPane || | |
| 278 | 149 | moved.floating.generation != c.admission.floatingGeneration || !sameMoveTabPanesLocked(moved, c.admission.panes, fencedPanes) { | |
| 279 | - | return false | |
| 150 | + | return moveTopologyPublication{}, errMoveStaleTarget | |
| 280 | 151 | } | |
| 281 | - | for _, p := range fencedPanes { | |
| 282 | - | p.mu.Lock() | |
| 283 | - | defer p.mu.Unlock() | |
| 284 | - | owner := p.ownerSnapshot() | |
| 152 | + | for _, pane := range fencedPanes { | |
| 153 | + | pane.mu.Lock() | |
| 154 | + | defer pane.mu.Unlock() | |
| 155 | + | owner := pane.ownerSnapshot() | |
| 285 | 156 | if owner == nil || owner.session != c.source || owner.tab != moved { | |
| 286 | - | return false | |
| 157 | + | return moveTopologyPublication{}, errMoveStaleTarget | |
| 287 | 158 | } | |
| 288 | 159 | } | |
| 289 | 160 | ||
| 161 | + | // Everything above is fallible. Membership, context, and Pane owner writes | |
| 162 | + | // below form one in-memory publication and cannot become a Move rejection. | |
| 290 | 163 | idx := c.admission.sourceIndex | |
| 291 | 164 | if len(c.source.tabs) > 1 { | |
| 292 | 165 | c.source.prepareAttachmentViewsForRemovedTabLocked(moved, idx) | |
| @@ -295,55 +168,51 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 295 | 168 | c.destination.tabs = append(c.destination.tabs, moved) | |
| 296 | 169 | moved.size = c.admission.destinationSize | |
| 297 | 170 | moved.bumpLayoutGenerationLocked() | |
| 298 | - | c.oldTabCancel = moved.cancel | |
| 171 | + | oldTabCancel := moved.cancel | |
| 299 | 172 | parent := c.destination.ctx | |
| 300 | 173 | if parent == nil { | |
| 301 | 174 | parent = d.serveCtx | |
| 302 | 175 | } | |
| 303 | 176 | moved.ctx, moved.cancel = context.WithCancel(parent) | |
| 177 | + | ||
| 304 | 178 | type ownerChange struct { | |
| 305 | 179 | pane *pane | |
| 306 | 180 | oldOwner paneEffectLease | |
| 307 | 181 | newOwner paneEffectLease | |
| 308 | 182 | } | |
| 309 | 183 | ownerChanges := make([]ownerChange, 0, len(fencedPanes)) | |
| 310 | - | for _, p := range fencedPanes { | |
| 184 | + | for _, pane := range fencedPanes { | |
| 311 | 185 | floatingGeneration := uint64(0) | |
| 312 | - | if p == moved.floating.pane { | |
| 186 | + | if pane == moved.floating.pane { | |
| 313 | 187 | floatingGeneration = moved.floating.generation | |
| 314 | 188 | } | |
| 315 | - | oldOwner, newOwner := p.publishOwnerLocked(c.destination, moved, floatingGeneration) | |
| 316 | - | ownerChanges = append(ownerChanges, ownerChange{pane: p, oldOwner: oldOwner, newOwner: newOwner}) | |
| 189 | + | oldOwner, newOwner := pane.publishOwnerLocked(c.destination, moved, floatingGeneration) | |
| 190 | + | ownerChanges = append(ownerChanges, ownerChange{pane: pane, oldOwner: oldOwner, newOwner: newOwner}) | |
| 317 | 191 | } | |
| 318 | 192 | if d.beforeMoveTabCommit != nil { | |
| 319 | 193 | d.beforeMoveTabCommit() | |
| 320 | 194 | } | |
| 195 | + | var syncCleanup syncTimerCleanup | |
| 321 | 196 | for _, change := range ownerChanges { | |
| 322 | - | c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner)) | |
| 323 | - | } | |
| 324 | - | if len(c.source.tabs) == 0 { | |
| 325 | - | d.unregisterSessionLocked(c.source) | |
| 326 | - | c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.admission.sourceTransports) | |
| 327 | - | c.source.tabs = nil | |
| 328 | - | d.purgeParkingForSessionLocked(c.source) | |
| 329 | - | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |
| 330 | - | } | |
| 331 | - | c.sourceEmpty = len(c.source.tabs) == 0 | |
| 332 | - | if !c.source.ephemeral { | |
| 333 | - | c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate() | |
| 334 | - | c.sourceMetadataValid = true | |
| 197 | + | syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner)) | |
| 335 | 198 | } | |
| 336 | - | if !c.destination.ephemeral { | |
| 337 | - | c.destMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate() | |
| 338 | - | c.destMetadataValid = true | |
| 339 | - | } | |
| 340 | - | return true | |
| 199 | + | ||
| 200 | + | return moveTopologyPublication{ | |
| 201 | + | sourceTab: moved, | |
| 202 | + | destinationTab: moved, | |
| 203 | + | movedPane: firstMovePane(c.admission.panes), | |
| 204 | + | movedPanes: c.admission.panes, | |
| 205 | + | syncCleanup: syncCleanup, | |
| 206 | + | oldTabCancel: oldTabCancel, | |
| 207 | + | sourceTabRemoved: true, | |
| 208 | + | sourceEmpty: len(c.source.tabs) == 0, | |
| 209 | + | }, nil | |
| 341 | 210 | } | |
| 342 | 211 | ||
| 343 | 212 | func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool { | |
| 344 | 213 | current := make(map[*pane]struct{}, len(tb.panes)+1) | |
| 345 | - | for _, p := range tb.panes { | |
| 346 | - | current[p] = struct{}{} | |
| 214 | + | for _, pane := range tb.panes { | |
| 215 | + | current[pane] = struct{}{} | |
| 347 | 216 | } | |
| 348 | 217 | if tb.floating.pane != nil && (tb.floating.state == floatingHidden || tb.floating.state == floatingVisible) { | |
| 349 | 218 | current[tb.floating.pane] = struct{}{} | |
| @@ -351,13 +220,13 @@ func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool { | |||
| 351 | 220 | if len(current) != len(admitted) || len(current) != len(fenced) { | |
| 352 | 221 | return false | |
| 353 | 222 | } | |
| 354 | - | for _, p := range admitted { | |
| 355 | - | if _, ok := current[p]; !ok { | |
| 223 | + | for _, pane := range admitted { | |
| 224 | + | if _, ok := current[pane]; !ok { | |
| 356 | 225 | return false | |
| 357 | 226 | } | |
| 358 | 227 | } | |
| 359 | - | for _, p := range fenced { | |
| 360 | - | if _, ok := current[p]; !ok { | |
| 228 | + | for _, pane := range fenced { | |
| 229 | + | if _, ok := current[pane]; !ok { | |
| 361 | 230 | return false | |
| 362 | 231 | } | |
| 363 | 232 | } | |
| @@ -7,11 +7,17 @@ import ( | |||||
| 7 | "github.com/bnema/vev/internal/usecase/layout" | 7 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | // moveTabTopology is the private whole-Tab topology Implementation consumed by | |||
| 11 | + | // the shared moveTransaction Module. | |||
| 12 | + | type moveTabTopology struct { | |||
| 13 | + | req moveTabRequest | |||
| 14 | + | admission moveTabAdmission | |||
| 15 | + | fencedPanes []*pane | |||
| 16 | + | } | |||
| 17 | + | ||||
| 10 | type moveTabAdmission struct { | 18 | type moveTabAdmission struct { | ||
| 11 | tab *tab | 19 | tab *tab | ||
| 12 | sourceIndex int | 20 | sourceIndex int | ||
| 13 | - | sourceAttachments []*attachedClient | |||
| 14 | - | sourceTransports map[*attachedClient]transportSnapshot | |||
| 15 | layoutGeneration uint64 | 21 | layoutGeneration uint64 | ||
| 16 | floatingState floatingState | 22 | floatingState floatingState | ||
| 17 | floatingPane *pane | 23 | floatingPane *pane | ||
| @@ -23,270 +29,137 @@ type moveTabAdmission struct { | |||||
| 23 | } | 29 | } | ||
| 24 | 30 | ||||
| 25 | type moveTabCommit struct { | 31 | type moveTabCommit struct { | ||
| 26 | - | req moveTabRequest | 32 | + | req moveTabRequest |
| 27 | - | source *session | 33 | + | source *session |
| 28 | - | destination *session | 34 | + | destination *session |
| 29 | - | admission moveTabAdmission | 35 | + | admission moveTabAdmission |
| 30 | - | sourceEffectsFrozen bool | |||
| 31 | - | frozenEffects attachmentTransitionGuard | |||
| 32 | - | syncCleanup syncTimerCleanup | |||
| 33 | - | oldTabCancel context.CancelFunc | |||
| 34 | - | sourceEmpty bool | |||
| 35 | - | retiredParked []parkedAttachmentRetirement | |||
| 36 | - | retiredAttachments []detachedAttachmentSnapshot | |||
| 37 | - | sourceMetadata domain.CatalogueMetadataUpdate | |||
| 38 | - | sourceMetadataValid bool | |||
| 39 | - | destMetadata domain.CatalogueMetadataUpdate | |||
| 40 | - | destMetadataValid bool | |||
| 41 | - | err error | |||
| 42 | } | 36 | } | ||
| 43 | 37 | ||||
| 44 | - | func (d *Daemon) moveTab(req moveTabRequest) (result error) { | 38 | + | func (d *Daemon) moveTab(req moveTabRequest) error { |
| 45 | - | if d == nil { | 39 | + | return d.executeMove(&moveTabTopology{req: req}) |
| 46 | - | return errMovePaneInvalid | 40 | + | } |
| 47 | - | } | |||
| 48 | - | d.log.Info("tab move requested", | |||
| 49 | - | "source_session_id", req.Source.ID, | |||
| 50 | - | "source_tab_id", req.SourceTabID, | |||
| 51 | - | "destination_session_id", req.Destination.ID, | |||
| 52 | - | ) | |||
| 53 | - | defer func() { | |||
| 54 | - | result = normalizeMoveRejection(result) | |||
| 55 | - | if result != nil { | |||
| 56 | - | d.log.Warn("tab move rejected", | |||
| 57 | - | "err", result, | |||
| 58 | - | "source_session_id", req.Source.ID, | |||
| 59 | - | "source_tab_id", req.SourceTabID, | |||
| 60 | - | "destination_session_id", req.Destination.ID, | |||
| 61 | - | ) | |||
| 62 | - | } | |||
| 63 | - | }() | |||
| 64 | - | if req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | |||
| 65 | - | return errMovePaneInvalid | |||
| 66 | - | } | |||
| 67 | - | d.mu.Lock() | |||
| 68 | - | source := moveSessionForLocatorLocked(d, req.Source) | |||
| 69 | - | destination := moveSessionForLocatorLocked(d, req.Destination) | |||
| 70 | - | d.mu.Unlock() | |||
| 71 | - | if source == nil || destination == nil { | |||
| 72 | - | return errMoveStaleTarget | |||
| 73 | - | } | |||
| 74 | - | if source == destination { | |||
| 75 | - | return errMovePaneInvalid | |||
| 76 | - | } | |||
| 77 | - | // Dispatch admission must precede lifecycle reservation. A final close holds | |||
| 78 | - | // dispatchMu while it waits for teardown ownership; reserving first would let | |||
| 79 | - | // that close wait on a move which is itself blocked on dispatchMu. | |||
| 80 | - | if d.beforeMoveDispatch != nil { | |||
| 81 | - | d.beforeMoveDispatch() | |||
| 82 | - | } | |||
| 83 | - | unlockDispatch := lockMoveDispatch(source, destination) | |||
| 84 | - | dispatchHeld := true | |||
| 85 | - | defer func() { | |||
| 86 | - | if dispatchHeld { | |||
| 87 | - | unlockDispatch() | |||
| 88 | - | } | |||
| 89 | - | }() | |||
| 90 | - | ||||
| 91 | - | reservation, err := d.reserveMoveLifecycles(source, destination) | |||
| 92 | - | if err != nil { | |||
| 93 | - | return errMoveStaleTarget | |||
| 94 | - | } | |||
| 95 | - | reservationHeld := true | |||
| 96 | - | defer func() { | |||
| 97 | - | if reservationHeld { | |||
| 98 | - | reservation.Release() | |||
| 99 | - | } | |||
| 100 | - | }() | |||
| 101 | - | if d.afterMoveLifecycleReserved != nil { | |||
| 102 | - | d.afterMoveLifecycleReserved() | |||
| 103 | - | } | |||
| 104 | 41 | ||||
| 105 | - | admission, err := d.snapshotMoveTabAdmission(req, source, destination) | 42 | + | func (p *moveTabTopology) transactionRequest() moveTransactionRequest { |
| 106 | - | if err != nil { | 43 | + | req := p.req |
| 107 | - | return err | 44 | + | return moveTransactionRequest{ |
| 45 | + | operation: "tab", | |||
| 46 | + | attachment: req.Attachment, | |||
| 47 | + | attachmentCapability: req.AttachmentCapability, | |||
| 48 | + | source: req.Source, | |||
| 49 | + | destination: req.Destination, | |||
| 50 | + | logAttrs: []any{ | |||
| 51 | + | "source_session_id", req.Source.ID, | |||
| 52 | + | "source_tab_id", req.SourceTabID, | |||
| 53 | + | "destination_session_id", req.Destination.ID, | |||
| 54 | + | }, | |||
| 108 | } | 55 | } | ||
| 109 | - | if d.afterMoveTabSourceSnapshot != nil { | 56 | + | } |
| 110 | - | d.afterMoveTabSourceSnapshot() | |||
| 111 | - | } | |||
| 112 | - | commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission} | |||
| 113 | - | var frozen attachmentTransitionGuard | |||
| 114 | - | if admission.finalSource && len(admission.sourceAttachments) != 0 { | |||
| 115 | - | interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments)) | |||
| 116 | - | for _, ac := range admission.sourceAttachments { | |||
| 117 | - | if transport := admission.sourceTransports[ac]; transport.transport != nil { | |||
| 118 | - | interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport}) | |||
| 119 | - | } | |||
| 120 | - | } | |||
| 121 | - | frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) { | |||
| 122 | - | if d.afterAttachmentEffectGateFrozen != nil { | |||
| 123 | - | d.afterAttachmentEffectGateFrozen("move-tab", ac) | |||
| 124 | - | } | |||
| 125 | - | }}, admission.sourceAttachments...) | |||
| 126 | - | if !frozen.acquired || !frozen.drained { | |||
| 127 | - | frozen.unfreeze() | |||
| 128 | - | return errMovePaneInvalid | |||
| 129 | - | } | |||
| 130 | - | commit.sourceEffectsFrozen = true | |||
| 131 | - | } | |||
| 132 | - | commit.frozenEffects = frozen | |||
| 133 | - | effectsFrozen := commit.sourceEffectsFrozen | |||
| 134 | - | defer func() { | |||
| 135 | - | if effectsFrozen { | |||
| 136 | - | frozen.unfreeze() | |||
| 137 | - | } | |||
| 138 | - | }() | |||
| 139 | 57 | ||||
| 140 | - | fences := newMoveTabResizeFences(source, destination, admission.tab) | 58 | + | func (p *moveTabTopology) validRequest() bool { |
| 141 | - | if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) { | 59 | + | return p != nil && p.req.SourceTabID != "" && p.req.Source.ID != p.req.Destination.ID |
| 142 | - | if commit.err != nil { | |||
| 143 | - | return commit.err | |||
| 144 | - | } | |||
| 145 | - | return errMoveStaleTarget | |||
| 146 | - | } | |||
| 147 | - | fences.Release() | |||
| 148 | - | postcommit := movePanePostcommitPlan{ | |||
| 149 | - | source: source, | |||
| 150 | - | destination: destination, | |||
| 151 | - | sourceTab: admission.tab, | |||
| 152 | - | destinationTab: admission.tab, | |||
| 153 | - | movedPane: firstMovePane(admission.panes), | |||
| 154 | - | movedPanes: admission.panes, | |||
| 155 | - | operation: "tab", | |||
| 156 | - | sourceAttachments: admission.sourceAttachments, | |||
| 157 | - | syncCleanup: commit.syncCleanup, | |||
| 158 | - | frozenEffects: frozen, | |||
| 159 | - | effectsFrozen: effectsFrozen, | |||
| 160 | - | unlockDispatch: unlockDispatch, | |||
| 161 | - | reservation: reservation, | |||
| 162 | - | oldTabCancel: commit.oldTabCancel, | |||
| 163 | - | sourceTabRemoved: true, | |||
| 164 | - | sourceEmpty: commit.sourceEmpty, | |||
| 165 | - | retiredParked: commit.retiredParked, | |||
| 166 | - | retiredAttachments: commit.retiredAttachments, | |||
| 167 | - | sourceMetadata: commit.sourceMetadata, | |||
| 168 | - | sourceMetadataValid: commit.sourceMetadataValid, | |||
| 169 | - | destinationMetadata: commit.destMetadata, | |||
| 170 | - | destinationMetadataValid: commit.destMetadataValid, | |||
| 171 | - | } | |||
| 172 | - | effectsFrozen = false | |||
| 173 | - | dispatchHeld = false | |||
| 174 | - | reservationHeld = false | |||
| 175 | - | postcommit.execute(d) | |||
| 176 | - | return nil | |||
| 177 | } | 60 | } | ||
| 178 | 61 | ||||
| 179 | - | func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destination *session) (*moveTabAdmission, error) { | 62 | + | // admitLocked runs with daemon and ordered Session locks held. It snapshots |
| 180 | - | d.mu.Lock() | 63 | + | // whole-Tab policy without publishing topology or Pane ownership. |
| 181 | - | defer d.mu.Unlock() | 64 | + | func (p *moveTabTopology) admitLocked(_ *Daemon, source, destination *session) error { |
| 182 | - | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | 65 | + | if source == destination { |
| 183 | - | return nil, errMoveStaleTarget | 66 | + | return errMovePaneInvalid |
| 184 | - | } | |||
| 185 | - | unlockSessions := lockAttachmentSessions(source, destination) | |||
| 186 | - | defer unlockSessions() | |||
| 187 | - | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | |||
| 188 | - | return nil, errMoveStaleTarget | |||
| 189 | - | } | |||
| 190 | - | if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) { | |||
| 191 | - | return nil, errMoveStaleTarget | |||
| 192 | - | } | |||
| 193 | - | if req.AttachmentCapability.ac != nil && (req.AttachmentCapability.ac != req.Attachment || !req.AttachmentCapability.currentInSessionLocked(source)) { | |||
| 194 | - | return nil, errMoveStaleTarget | |||
| 195 | } | 67 | } | ||
| 196 | - | moved := findMoveTabLocked(source, req.SourceTabID) | 68 | + | moved := findMoveTabLocked(source, p.req.SourceTabID) |
| 197 | if moved == nil || len(destination.tabs) == 0 { | 69 | if moved == nil || len(destination.tabs) == 0 { | ||
| 198 | - | return nil, errMoveStaleTarget | 70 | + | return errMoveStaleTarget |
| 199 | } | 71 | } | ||
| 200 | destinationActive := destination.tabs[0] | 72 | destinationActive := destination.tabs[0] | ||
| 201 | destinationActive.mu.Lock() | 73 | destinationActive.mu.Lock() | ||
| 202 | destinationSize := destinationActive.size | 74 | destinationSize := destinationActive.size | ||
| 203 | destinationActive.mu.Unlock() | 75 | destinationActive.mu.Unlock() | ||
| 76 | + | ||||
| 204 | moved.mu.Lock() | 77 | moved.mu.Lock() | ||
| 205 | defer moved.mu.Unlock() | 78 | defer moved.mu.Unlock() | ||
| 206 | if !moved.floatingTransferableLocked() { | 79 | if !moved.floatingTransferableLocked() { | ||
| 207 | - | return nil, errMoveFloatingWarming | 80 | + | return errMoveFloatingWarming |
| 208 | } | 81 | } | ||
| 209 | if moved.tree == nil || moved.tree.Root == nil { | 82 | if moved.tree == nil || moved.tree.Root == nil { | ||
| 210 | - | return nil, errMovePaneInvalid | 83 | + | return errMovePaneInvalid |
| 211 | } | 84 | } | ||
| 212 | if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok { | 85 | if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok { | ||
| 213 | - | return nil, errMoveTooSmall | 86 | + | return errMoveTooSmall |
| 214 | } | 87 | } | ||
| 215 | panes := make([]*pane, 0, len(moved.panes)+1) | 88 | panes := make([]*pane, 0, len(moved.panes)+1) | ||
| 216 | - | for _, p := range moved.panes { | 89 | + | for _, pane := range moved.panes { |
| 217 | - | panes = append(panes, p) | 90 | + | panes = append(panes, pane) |
| 218 | } | 91 | } | ||
| 219 | if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) { | 92 | if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) { | ||
| 220 | panes = append(panes, moved.floating.pane) | 93 | panes = append(panes, moved.floating.pane) | ||
| 221 | } | 94 | } | ||
| 222 | - | idx := indexMoveTabLocked(source, moved) | 95 | + | p.admission = moveTabAdmission{ |
| 223 | - | attachments := source.snapshotAttachmentsLocked() | 96 | + | tab: moved, sourceIndex: indexMoveTabLocked(source, moved), |
| 224 | - | transports := make(map[*attachedClient]transportSnapshot, len(attachments)) | |||
| 225 | - | for _, ac := range attachments { | |||
| 226 | - | transports[ac] = ac.transportSnapshot() | |||
| 227 | - | } | |||
| 228 | - | return &moveTabAdmission{ | |||
| 229 | - | tab: moved, sourceIndex: idx, sourceAttachments: attachments, sourceTransports: transports, | |||
| 230 | layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state, | 97 | layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state, | ||
| 231 | floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation, | 98 | floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation, | ||
| 232 | panes: panes, destinationActive: destinationActive, destinationSize: destinationSize, | 99 | panes: panes, destinationActive: destinationActive, destinationSize: destinationSize, | ||
| 233 | finalSource: len(source.tabs) == 1, | 100 | finalSource: len(source.tabs) == 1, | ||
| 234 | - | }, nil | 101 | + | } |
| 102 | + | return nil | |||
| 235 | } | 103 | } | ||
| 236 | 104 | ||||
| 237 | - | func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | 105 | + | func (p *moveTabTopology) afterAdmission(d *Daemon) { |
| 238 | - | d.mu.Lock() | 106 | + | if d.afterMoveTabSourceSnapshot != nil { |
| 239 | - | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | 107 | + | d.afterMoveTabSourceSnapshot() |
| 240 | - | d.mu.Unlock() | |||
| 241 | - | return false | |||
| 242 | - | } | |||
| 243 | - | d.notices.routingMu.Lock() | |||
| 244 | - | unlockSessions := lockAttachmentSessions(c.source, c.destination) | |||
| 245 | - | defer unlockSessions() | |||
| 246 | - | defer d.notices.routingMu.Unlock() | |||
| 247 | - | defer d.mu.Unlock() | |||
| 248 | - | if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) { | |||
| 249 | - | return false | |||
| 250 | - | } | |||
| 251 | - | if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) { | |||
| 252 | - | return false | |||
| 253 | - | } | |||
| 254 | - | if c.req.AttachmentCapability.ac != nil && (c.req.AttachmentCapability.ac != c.req.Attachment || !c.req.AttachmentCapability.currentInSessionLocked(c.source)) { | |||
| 255 | - | return false | |||
| 256 | } | 108 | } | ||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (p *moveTabTopology) willRetireSource() bool { | |||
| 112 | + | return p != nil && p.admission.finalSource | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (p *moveTabTopology) resizeFences(source, destination *session) *moveResizeFences { | |||
| 116 | + | fences := newMoveTabResizeFences(source, destination, p.admission.tab) | |||
| 117 | + | p.fencedPanes = fences.panes | |||
| 118 | + | return fences | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func (p *moveTabTopology) publishLocked(d *Daemon, source, destination *session) (moveTopologyPublication, error) { | |||
| 122 | + | commit := moveTabCommit{req: p.req, source: source, destination: destination, admission: p.admission} | |||
| 123 | + | return commit.publishTopologyLocked(d, p.fencedPanes) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // publishTopologyLocked performs every whole-Tab validation before changing | |||
| 127 | + | // membership, Tab context, or Pane owners. The shared transaction owns common | |||
| 128 | + | // Session retirement and persistence capture after this method succeeds. | |||
| 129 | + | func (c *moveTabCommit) publishTopologyLocked(d *Daemon, fencedPanes []*pane) (moveTopologyPublication, error) { | |||
| 257 | moved := c.admission.tab | 130 | moved := c.admission.tab | ||
| 258 | if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil || | 131 | if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil || | ||
| 259 | - | len(c.source.tabs) == 1 != c.admission.finalSource { | 132 | + | (len(c.source.tabs) == 1) != c.admission.finalSource { |
| 260 | - | return false | 133 | + | return moveTopologyPublication{}, errMoveStaleTarget |
| 261 | - | } | |||
| 262 | - | if c.admission.finalSource && !sameMoveAttachmentsLocked(c.source, c.admission.sourceAttachments) { | |||
| 263 | - | return false | |||
| 264 | } | 134 | } | ||
| 265 | if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive { | 135 | if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive { | ||
| 266 | - | return false | 136 | + | return moveTopologyPublication{}, errMoveStaleTarget |
| 267 | } | 137 | } | ||
| 268 | c.admission.destinationActive.mu.Lock() | 138 | c.admission.destinationActive.mu.Lock() | ||
| 269 | destinationSizeCurrent := c.admission.destinationActive.size == c.admission.destinationSize | 139 | destinationSizeCurrent := c.admission.destinationActive.size == c.admission.destinationSize | ||
| 270 | c.admission.destinationActive.mu.Unlock() | 140 | c.admission.destinationActive.mu.Unlock() | ||
| 271 | if !destinationSizeCurrent { | 141 | if !destinationSizeCurrent { | ||
| 272 | - | return false | 142 | + | return moveTopologyPublication{}, errMoveStaleTarget |
| 273 | } | 143 | } | ||
| 144 | + | ||||
| 274 | moved.mu.Lock() | 145 | moved.mu.Lock() | ||
| 275 | defer moved.mu.Unlock() | 146 | defer moved.mu.Unlock() | ||
| 276 | if moved.layoutGeneration != c.admission.layoutGeneration || !moved.floatingTransferableLocked() || | 147 | if moved.layoutGeneration != c.admission.layoutGeneration || !moved.floatingTransferableLocked() || | ||
| 277 | moved.floating.state != c.admission.floatingState || moved.floating.pane != c.admission.floatingPane || | 148 | moved.floating.state != c.admission.floatingState || moved.floating.pane != c.admission.floatingPane || | ||
| 278 | moved.floating.generation != c.admission.floatingGeneration || !sameMoveTabPanesLocked(moved, c.admission.panes, fencedPanes) { | 149 | moved.floating.generation != c.admission.floatingGeneration || !sameMoveTabPanesLocked(moved, c.admission.panes, fencedPanes) { | ||
| 279 | - | return false | 150 | + | return moveTopologyPublication{}, errMoveStaleTarget |
| 280 | } | 151 | } | ||
| 281 | - | for _, p := range fencedPanes { | 152 | + | for _, pane := range fencedPanes { |
| 282 | - | p.mu.Lock() | 153 | + | pane.mu.Lock() |
| 283 | - | defer p.mu.Unlock() | 154 | + | defer pane.mu.Unlock() |
| 284 | - | owner := p.ownerSnapshot() | 155 | + | owner := pane.ownerSnapshot() |
| 285 | if owner == nil || owner.session != c.source || owner.tab != moved { | 156 | if owner == nil || owner.session != c.source || owner.tab != moved { | ||
| 286 | - | return false | 157 | + | return moveTopologyPublication{}, errMoveStaleTarget |
| 287 | } | 158 | } | ||
| 288 | } | 159 | } | ||
| 289 | 160 | ||||
| 161 | + | // Everything above is fallible. Membership, context, and Pane owner writes | |||
| 162 | + | // below form one in-memory publication and cannot become a Move rejection. | |||
| 290 | idx := c.admission.sourceIndex | 163 | idx := c.admission.sourceIndex | ||
| 291 | if len(c.source.tabs) > 1 { | 164 | if len(c.source.tabs) > 1 { | ||
| 292 | c.source.prepareAttachmentViewsForRemovedTabLocked(moved, idx) | 165 | c.source.prepareAttachmentViewsForRemovedTabLocked(moved, idx) | ||
| @@ -295,55 +168,51 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||||
| 295 | c.destination.tabs = append(c.destination.tabs, moved) | 168 | c.destination.tabs = append(c.destination.tabs, moved) | ||
| 296 | moved.size = c.admission.destinationSize | 169 | moved.size = c.admission.destinationSize | ||
| 297 | moved.bumpLayoutGenerationLocked() | 170 | moved.bumpLayoutGenerationLocked() | ||
| 298 | - | c.oldTabCancel = moved.cancel | 171 | + | oldTabCancel := moved.cancel |
| 299 | parent := c.destination.ctx | 172 | parent := c.destination.ctx | ||
| 300 | if parent == nil { | 173 | if parent == nil { | ||
| 301 | parent = d.serveCtx | 174 | parent = d.serveCtx | ||
| 302 | } | 175 | } | ||
| 303 | moved.ctx, moved.cancel = context.WithCancel(parent) | 176 | moved.ctx, moved.cancel = context.WithCancel(parent) | ||
| 177 | + | ||||
| 304 | type ownerChange struct { | 178 | type ownerChange struct { | ||
| 305 | pane *pane | 179 | pane *pane | ||
| 306 | oldOwner paneEffectLease | 180 | oldOwner paneEffectLease | ||
| 307 | newOwner paneEffectLease | 181 | newOwner paneEffectLease | ||
| 308 | } | 182 | } | ||
| 309 | ownerChanges := make([]ownerChange, 0, len(fencedPanes)) | 183 | ownerChanges := make([]ownerChange, 0, len(fencedPanes)) | ||
| 310 | - | for _, p := range fencedPanes { | 184 | + | for _, pane := range fencedPanes { |
| 311 | floatingGeneration := uint64(0) | 185 | floatingGeneration := uint64(0) | ||
| 312 | - | if p == moved.floating.pane { | 186 | + | if pane == moved.floating.pane { |
| 313 | floatingGeneration = moved.floating.generation | 187 | floatingGeneration = moved.floating.generation | ||
| 314 | } | 188 | } | ||
| 315 | - | oldOwner, newOwner := p.publishOwnerLocked(c.destination, moved, floatingGeneration) | 189 | + | oldOwner, newOwner := pane.publishOwnerLocked(c.destination, moved, floatingGeneration) |
| 316 | - | ownerChanges = append(ownerChanges, ownerChange{pane: p, oldOwner: oldOwner, newOwner: newOwner}) | 190 | + | ownerChanges = append(ownerChanges, ownerChange{pane: pane, oldOwner: oldOwner, newOwner: newOwner}) |
| 317 | } | 191 | } | ||
| 318 | if d.beforeMoveTabCommit != nil { | 192 | if d.beforeMoveTabCommit != nil { | ||
| 319 | d.beforeMoveTabCommit() | 193 | d.beforeMoveTabCommit() | ||
| 320 | } | 194 | } | ||
| 195 | + | var syncCleanup syncTimerCleanup | |||
| 321 | for _, change := range ownerChanges { | 196 | for _, change := range ownerChanges { | ||
| 322 | - | c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner)) | 197 | + | syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner)) |
| 323 | - | } | |||
| 324 | - | if len(c.source.tabs) == 0 { | |||
| 325 | - | d.unregisterSessionLocked(c.source) | |||
| 326 | - | c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.admission.sourceTransports) | |||
| 327 | - | c.source.tabs = nil | |||
| 328 | - | d.purgeParkingForSessionLocked(c.source) | |||
| 329 | - | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |||
| 330 | - | } | |||
| 331 | - | c.sourceEmpty = len(c.source.tabs) == 0 | |||
| 332 | - | if !c.source.ephemeral { | |||
| 333 | - | c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate() | |||
| 334 | - | c.sourceMetadataValid = true | |||
| 335 | } | 198 | } | ||
| 336 | - | if !c.destination.ephemeral { | 199 | + | |
| 337 | - | c.destMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate() | 200 | + | return moveTopologyPublication{ |
| 338 | - | c.destMetadataValid = true | 201 | + | sourceTab: moved, |
| 339 | - | } | 202 | + | destinationTab: moved, |
| 340 | - | return true | 203 | + | movedPane: firstMovePane(c.admission.panes), |
| 204 | + | movedPanes: c.admission.panes, | |||
| 205 | + | syncCleanup: syncCleanup, | |||
| 206 | + | oldTabCancel: oldTabCancel, | |||
| 207 | + | sourceTabRemoved: true, | |||
| 208 | + | sourceEmpty: len(c.source.tabs) == 0, | |||
| 209 | + | }, nil | |||
| 341 | } | 210 | } | ||
| 342 | 211 | ||||
| 343 | func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool { | 212 | func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool { | ||
| 344 | current := make(map[*pane]struct{}, len(tb.panes)+1) | 213 | current := make(map[*pane]struct{}, len(tb.panes)+1) | ||
| 345 | - | for _, p := range tb.panes { | 214 | + | for _, pane := range tb.panes { |
| 346 | - | current[p] = struct{}{} | 215 | + | current[pane] = struct{}{} |
| 347 | } | 216 | } | ||
| 348 | if tb.floating.pane != nil && (tb.floating.state == floatingHidden || tb.floating.state == floatingVisible) { | 217 | if tb.floating.pane != nil && (tb.floating.state == floatingHidden || tb.floating.state == floatingVisible) { | ||
| 349 | current[tb.floating.pane] = struct{}{} | 218 | current[tb.floating.pane] = struct{}{} | ||
| @@ -351,13 +220,13 @@ func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool { | |||||
| 351 | if len(current) != len(admitted) || len(current) != len(fenced) { | 220 | if len(current) != len(admitted) || len(current) != len(fenced) { | ||
| 352 | return false | 221 | return false | ||
| 353 | } | 222 | } | ||
| 354 | - | for _, p := range admitted { | 223 | + | for _, pane := range admitted { |
| 355 | - | if _, ok := current[p]; !ok { | 224 | + | if _, ok := current[pane]; !ok { |
| 356 | return false | 225 | return false | ||
| 357 | } | 226 | } | ||
| 358 | } | 227 | } | ||
| 359 | - | for _, p := range fenced { | 228 | + | for _, pane := range fenced { |
| 360 | - | if _, ok := current[p]; !ok { | 229 | + | if _, ok := current[pane]; !ok { |
| 361 | return false | 230 | return false | ||
| 362 | } | 231 | } | ||
| 363 | } | 232 | } | ||
A internal/usecase/daemon/move_transaction.go +316 -0
| @@ -0,0 +1,316 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // moveTopology is the private policy Interface consumed by moveTransaction. | |
| 10 | + | // Admission runs under daemon and ordered Session locks and must be read-only. | |
| 11 | + | // Publication runs under daemon, routing, ordered Session, and resize fences; | |
| 12 | + | // it must finish every fallible check before its first write. | |
| 13 | + | type moveTopology interface { | |
| 14 | + | transactionRequest() moveTransactionRequest | |
| 15 | + | validRequest() bool | |
| 16 | + | admitLocked(*Daemon, *session, *session) error | |
| 17 | + | afterAdmission(*Daemon) | |
| 18 | + | willRetireSource() bool | |
| 19 | + | resizeFences(*session, *session) *moveResizeFences | |
| 20 | + | publishLocked(*Daemon, *session, *session) (moveTopologyPublication, error) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | type moveTransactionRequest struct { | |
| 24 | + | operation string | |
| 25 | + | attachment *attachedClient | |
| 26 | + | attachmentCapability attachmentCapability | |
| 27 | + | source moveSessionLocator | |
| 28 | + | destination moveSessionLocator | |
| 29 | + | logAttrs []any | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // moveTopologyPublication contains immutable topology outputs needed after the | |
| 33 | + | // in-memory publication point. No field grants authority to perform additional | |
| 34 | + | // topology writes. | |
| 35 | + | type moveTopologyPublication struct { | |
| 36 | + | sourceTab, destinationTab *tab | |
| 37 | + | movedPane *pane | |
| 38 | + | movedPanes []*pane | |
| 39 | + | syncCleanup syncTimerCleanup | |
| 40 | + | oldTabCancel context.CancelFunc | |
| 41 | + | sourceTabRemoved bool | |
| 42 | + | sourceEmpty bool | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // moveTransaction owns the shared pane/tab Move phase protocol. Topology and | |
| 46 | + | // owner policy remain private Implementations behind moveTopology. | |
| 47 | + | type moveTransaction struct { | |
| 48 | + | daemon *Daemon | |
| 49 | + | topology moveTopology | |
| 50 | + | request moveTransactionRequest | |
| 51 | + | ||
| 52 | + | source *session | |
| 53 | + | destination *session | |
| 54 | + | ||
| 55 | + | sourceAttachments []*attachedClient | |
| 56 | + | sourceTransports map[*attachedClient]transportSnapshot | |
| 57 | + | frozenEffects attachmentTransitionGuard | |
| 58 | + | effectsFrozen bool | |
| 59 | + | ||
| 60 | + | publication moveTopologyPublication | |
| 61 | + | publishErr error | |
| 62 | + | ||
| 63 | + | retiredParked []parkedAttachmentRetirement | |
| 64 | + | retiredAttachments []detachedAttachmentSnapshot | |
| 65 | + | sourceMetadata domain.CatalogueMetadataUpdate | |
| 66 | + | sourceMetadataOK bool | |
| 67 | + | destMetadata domain.CatalogueMetadataUpdate | |
| 68 | + | destMetadataOK bool | |
| 69 | + | sourceName string | |
| 70 | + | destinationName string | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (d *Daemon) executeMove(topology moveTopology) (result error) { | |
| 74 | + | if d == nil || topology == nil { | |
| 75 | + | return errMovePaneInvalid | |
| 76 | + | } | |
| 77 | + | t := moveTransaction{daemon: d, topology: topology, request: topology.transactionRequest()} | |
| 78 | + | d.log.Info(t.request.operation+" move requested", t.request.logAttrs...) | |
| 79 | + | defer func() { | |
| 80 | + | result = normalizeMoveRejection(result) | |
| 81 | + | if result != nil { | |
| 82 | + | attrs := make([]any, 0, len(t.request.logAttrs)+2) | |
| 83 | + | attrs = append(attrs, "err", result) | |
| 84 | + | attrs = append(attrs, t.request.logAttrs...) | |
| 85 | + | d.log.Warn(t.request.operation+" move rejected", attrs...) | |
| 86 | + | } | |
| 87 | + | }() | |
| 88 | + | if !t.validRequest() { | |
| 89 | + | return errMovePaneInvalid | |
| 90 | + | } | |
| 91 | + | if err := t.resolveSessions(); err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | if d.beforeMoveDispatch != nil { | |
| 96 | + | d.beforeMoveDispatch() | |
| 97 | + | } | |
| 98 | + | unlockDispatch := lockMoveDispatch(t.source, t.destination) | |
| 99 | + | dispatchHeld := true | |
| 100 | + | defer func() { | |
| 101 | + | if dispatchHeld { | |
| 102 | + | unlockDispatch() | |
| 103 | + | } | |
| 104 | + | }() | |
| 105 | + | ||
| 106 | + | reservation, err := d.reserveMoveLifecycles(t.source, t.destination) | |
| 107 | + | if err != nil { | |
| 108 | + | return errMoveStaleTarget | |
| 109 | + | } | |
| 110 | + | reservationHeld := true | |
| 111 | + | defer func() { | |
| 112 | + | if reservationHeld { | |
| 113 | + | reservation.Release() | |
| 114 | + | } | |
| 115 | + | }() | |
| 116 | + | if d.afterMoveLifecycleReserved != nil { | |
| 117 | + | d.afterMoveLifecycleReserved() | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | if err := t.admit(); err != nil { | |
| 121 | + | return err | |
| 122 | + | } | |
| 123 | + | t.topology.afterAdmission(d) | |
| 124 | + | if err := t.freezeSourceAttachments(); err != nil { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | defer func() { | |
| 128 | + | if t.effectsFrozen { | |
| 129 | + | t.frozenEffects.unfreeze() | |
| 130 | + | } | |
| 131 | + | }() | |
| 132 | + | ||
| 133 | + | fences := t.topology.resizeFences(t.source, t.destination) | |
| 134 | + | if fences == nil || !fences.acquire(func() bool { | |
| 135 | + | t.publishErr = t.publishLocked() | |
| 136 | + | return t.publishErr == nil | |
| 137 | + | }) { | |
| 138 | + | if t.publishErr != nil { | |
| 139 | + | return t.publishErr | |
| 140 | + | } | |
| 141 | + | return errMoveStaleTarget | |
| 142 | + | } | |
| 143 | + | fences.Release() | |
| 144 | + | ||
| 145 | + | postcommit := t.postcommitPlan(unlockDispatch, reservation) | |
| 146 | + | t.effectsFrozen = false | |
| 147 | + | dispatchHeld = false | |
| 148 | + | reservationHeld = false | |
| 149 | + | postcommit.execute(d) | |
| 150 | + | return nil | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (t *moveTransaction) validRequest() bool { | |
| 154 | + | return t != nil && t.topology != nil && t.request.operation != "" && | |
| 155 | + | t.request.source.ID != "" && t.request.destination.ID != "" && t.topology.validRequest() | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (t *moveTransaction) resolveSessions() error { | |
| 159 | + | d := t.daemon | |
| 160 | + | d.mu.Lock() | |
| 161 | + | t.source = moveSessionForLocatorLocked(d, t.request.source) | |
| 162 | + | t.destination = moveSessionForLocatorLocked(d, t.request.destination) | |
| 163 | + | d.mu.Unlock() | |
| 164 | + | if t.source == nil || t.destination == nil { | |
| 165 | + | return errMoveStaleTarget | |
| 166 | + | } | |
| 167 | + | return nil | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func (t *moveTransaction) attachmentAuthorityCurrentLocked() bool { | |
| 171 | + | req := t.request | |
| 172 | + | if req.attachment != nil && !attachmentRegisteredLocked(t.source, req.attachment) { | |
| 173 | + | return false | |
| 174 | + | } | |
| 175 | + | if req.attachmentCapability.ac != nil && | |
| 176 | + | (req.attachmentCapability.ac != req.attachment || !req.attachmentCapability.currentInSessionLocked(t.source)) { | |
| 177 | + | return false | |
| 178 | + | } | |
| 179 | + | return true | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // admit snapshots exact common Attachment participants while the topology | |
| 183 | + | // Implementation snapshots only its private identity and layout state. | |
| 184 | + | func (t *moveTransaction) admit() error { | |
| 185 | + | d := t.daemon | |
| 186 | + | d.mu.Lock() | |
| 187 | + | defer d.mu.Unlock() | |
| 188 | + | if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination { | |
| 189 | + | return errMoveStaleTarget | |
| 190 | + | } | |
| 191 | + | unlockSessions := lockAttachmentSessions(t.source, t.destination) | |
| 192 | + | defer unlockSessions() | |
| 193 | + | if !moveSessionLocatorCurrentLocked(t.source, t.request.source) || | |
| 194 | + | !moveSessionLocatorCurrentLocked(t.destination, t.request.destination) || | |
| 195 | + | !t.attachmentAuthorityCurrentLocked() { | |
| 196 | + | return errMoveStaleTarget | |
| 197 | + | } | |
| 198 | + | if err := t.topology.admitLocked(d, t.source, t.destination); err != nil { | |
| 199 | + | return err | |
| 200 | + | } | |
| 201 | + | t.sourceAttachments = t.source.snapshotAttachmentsLocked() | |
| 202 | + | t.sourceTransports = make(map[*attachedClient]transportSnapshot, len(t.sourceAttachments)) | |
| 203 | + | for _, attachment := range t.sourceAttachments { | |
| 204 | + | t.sourceTransports[attachment] = attachment.transportSnapshot() | |
| 205 | + | } | |
| 206 | + | return nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // freezeSourceAttachments delegates effect admission and drain authority to the | |
| 210 | + | // attachment lifecycle Module. Move uses nonblocking acquisition to preserve | |
| 211 | + | // its ordering with final teardown. | |
| 212 | + | func (t *moveTransaction) freezeSourceAttachments() error { | |
| 213 | + | if !t.topology.willRetireSource() || len(t.sourceAttachments) == 0 { | |
| 214 | + | return nil | |
| 215 | + | } | |
| 216 | + | interrupts := make([]attachmentTransportInterrupt, 0, len(t.sourceAttachments)) | |
| 217 | + | for _, attachment := range t.sourceAttachments { | |
| 218 | + | if transport := t.sourceTransports[attachment]; transport.transport != nil { | |
| 219 | + | interrupts = append(interrupts, attachmentTransportInterrupt{ac: attachment, transport: transport}) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{ | |
| 223 | + | interrupts: interrupts, | |
| 224 | + | nonblocking: true, | |
| 225 | + | afterFrozen: func(attachment *attachedClient) { | |
| 226 | + | if t.daemon.afterAttachmentEffectGateFrozen != nil { | |
| 227 | + | t.daemon.afterAttachmentEffectGateFrozen("move-"+t.request.operation, attachment) | |
| 228 | + | } | |
| 229 | + | }, | |
| 230 | + | }, t.sourceAttachments...) | |
| 231 | + | if !frozen.acquired || !frozen.drained { | |
| 232 | + | frozen.unfreeze() | |
| 233 | + | return errMovePaneInvalid | |
| 234 | + | } | |
| 235 | + | t.frozenEffects = frozen | |
| 236 | + | t.effectsFrozen = true | |
| 237 | + | return nil | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // publishLocked is the only shared architecture-lock window. Topology | |
| 241 | + | // Implementations perform all fallible private validation before their first | |
| 242 | + | // write. Common retirement and metadata capture are non-failing in-memory work. | |
| 243 | + | func (t *moveTransaction) publishLocked() error { | |
| 244 | + | d := t.daemon | |
| 245 | + | d.mu.Lock() | |
| 246 | + | defer d.mu.Unlock() | |
| 247 | + | if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination { | |
| 248 | + | return errMoveStaleTarget | |
| 249 | + | } | |
| 250 | + | d.notices.routingMu.Lock() | |
| 251 | + | defer d.notices.routingMu.Unlock() | |
| 252 | + | unlockSessions := lockAttachmentSessions(t.source, t.destination) | |
| 253 | + | defer unlockSessions() | |
| 254 | + | if !moveSessionLocatorCurrentLocked(t.source, t.request.source) || | |
| 255 | + | !moveSessionLocatorCurrentLocked(t.destination, t.request.destination) || | |
| 256 | + | !t.attachmentAuthorityCurrentLocked() { | |
| 257 | + | return errMoveStaleTarget | |
| 258 | + | } | |
| 259 | + | if t.topology.willRetireSource() && !sameMoveAttachmentsLocked(t.source, t.sourceAttachments) { | |
| 260 | + | return errMoveStaleTarget | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | publication, err := t.topology.publishLocked(d, t.source, t.destination) | |
| 264 | + | if err != nil { | |
| 265 | + | return err | |
| 266 | + | } | |
| 267 | + | t.publication = publication | |
| 268 | + | if publication.sourceEmpty { | |
| 269 | + | d.unregisterSessionLocked(t.source) | |
| 270 | + | t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports) | |
| 271 | + | t.source.tabs = nil | |
| 272 | + | d.purgeParkingForSessionLocked(t.source) | |
| 273 | + | t.retiredParked = d.purgeParkedForSessionLocked(t.source) | |
| 274 | + | } | |
| 275 | + | t.sourceName = t.source.name | |
| 276 | + | t.destinationName = t.destination.name | |
| 277 | + | if !t.source.ephemeral { | |
| 278 | + | t.sourceMetadata = t.source.persistRecordLocked(max(d.nowUnixNano(), t.source.createdAt, int64(1))).MetadataUpdate() | |
| 279 | + | t.sourceMetadataOK = true | |
| 280 | + | } | |
| 281 | + | if !t.destination.ephemeral && t.destination != t.source { | |
| 282 | + | t.destMetadata = t.destination.persistRecordLocked(max(d.nowUnixNano(), t.destination.createdAt, int64(1))).MetadataUpdate() | |
| 283 | + | t.destMetadataOK = true | |
| 284 | + | } | |
| 285 | + | return nil | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *moveLifecycleReservation) movePostcommitPlan { | |
| 289 | + | publication := t.publication | |
| 290 | + | return movePostcommitPlan{ | |
| 291 | + | source: t.source, | |
| 292 | + | destination: t.destination, | |
| 293 | + | sourceName: t.sourceName, | |
| 294 | + | destinationName: t.destinationName, | |
| 295 | + | sourceTab: publication.sourceTab, | |
| 296 | + | destinationTab: publication.destinationTab, | |
| 297 | + | movedPane: publication.movedPane, | |
| 298 | + | movedPanes: publication.movedPanes, | |
| 299 | + | operation: t.request.operation, | |
| 300 | + | sourceAttachments: t.sourceAttachments, | |
| 301 | + | syncCleanup: publication.syncCleanup, | |
| 302 | + | frozenEffects: t.frozenEffects, | |
| 303 | + | effectsFrozen: t.effectsFrozen, | |
| 304 | + | unlockDispatch: unlockDispatch, | |
| 305 | + | reservation: reservation, | |
| 306 | + | oldTabCancel: publication.oldTabCancel, | |
| 307 | + | sourceTabRemoved: publication.sourceTabRemoved, | |
| 308 | + | sourceEmpty: publication.sourceEmpty, | |
| 309 | + | retiredParked: t.retiredParked, | |
| 310 | + | retiredAttachments: t.retiredAttachments, | |
| 311 | + | sourceMetadata: t.sourceMetadata, | |
| 312 | + | sourceMetadataValid: t.sourceMetadataOK, | |
| 313 | + | destinationMetadata: t.destMetadata, | |
| 314 | + | destinationMetadataValid: t.destMetadataOK, | |
| 315 | + | } | |
| 316 | + | } | |
| @@ -0,0 +1,316 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // moveTopology is the private policy Interface consumed by moveTransaction. | |||
| 10 | + | // Admission runs under daemon and ordered Session locks and must be read-only. | |||
| 11 | + | // Publication runs under daemon, routing, ordered Session, and resize fences; | |||
| 12 | + | // it must finish every fallible check before its first write. | |||
| 13 | + | type moveTopology interface { | |||
| 14 | + | transactionRequest() moveTransactionRequest | |||
| 15 | + | validRequest() bool | |||
| 16 | + | admitLocked(*Daemon, *session, *session) error | |||
| 17 | + | afterAdmission(*Daemon) | |||
| 18 | + | willRetireSource() bool | |||
| 19 | + | resizeFences(*session, *session) *moveResizeFences | |||
| 20 | + | publishLocked(*Daemon, *session, *session) (moveTopologyPublication, error) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | type moveTransactionRequest struct { | |||
| 24 | + | operation string | |||
| 25 | + | attachment *attachedClient | |||
| 26 | + | attachmentCapability attachmentCapability | |||
| 27 | + | source moveSessionLocator | |||
| 28 | + | destination moveSessionLocator | |||
| 29 | + | logAttrs []any | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // moveTopologyPublication contains immutable topology outputs needed after the | |||
| 33 | + | // in-memory publication point. No field grants authority to perform additional | |||
| 34 | + | // topology writes. | |||
| 35 | + | type moveTopologyPublication struct { | |||
| 36 | + | sourceTab, destinationTab *tab | |||
| 37 | + | movedPane *pane | |||
| 38 | + | movedPanes []*pane | |||
| 39 | + | syncCleanup syncTimerCleanup | |||
| 40 | + | oldTabCancel context.CancelFunc | |||
| 41 | + | sourceTabRemoved bool | |||
| 42 | + | sourceEmpty bool | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // moveTransaction owns the shared pane/tab Move phase protocol. Topology and | |||
| 46 | + | // owner policy remain private Implementations behind moveTopology. | |||
| 47 | + | type moveTransaction struct { | |||
| 48 | + | daemon *Daemon | |||
| 49 | + | topology moveTopology | |||
| 50 | + | request moveTransactionRequest | |||
| 51 | + | ||||
| 52 | + | source *session | |||
| 53 | + | destination *session | |||
| 54 | + | ||||
| 55 | + | sourceAttachments []*attachedClient | |||
| 56 | + | sourceTransports map[*attachedClient]transportSnapshot | |||
| 57 | + | frozenEffects attachmentTransitionGuard | |||
| 58 | + | effectsFrozen bool | |||
| 59 | + | ||||
| 60 | + | publication moveTopologyPublication | |||
| 61 | + | publishErr error | |||
| 62 | + | ||||
| 63 | + | retiredParked []parkedAttachmentRetirement | |||
| 64 | + | retiredAttachments []detachedAttachmentSnapshot | |||
| 65 | + | sourceMetadata domain.CatalogueMetadataUpdate | |||
| 66 | + | sourceMetadataOK bool | |||
| 67 | + | destMetadata domain.CatalogueMetadataUpdate | |||
| 68 | + | destMetadataOK bool | |||
| 69 | + | sourceName string | |||
| 70 | + | destinationName string | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (d *Daemon) executeMove(topology moveTopology) (result error) { | |||
| 74 | + | if d == nil || topology == nil { | |||
| 75 | + | return errMovePaneInvalid | |||
| 76 | + | } | |||
| 77 | + | t := moveTransaction{daemon: d, topology: topology, request: topology.transactionRequest()} | |||
| 78 | + | d.log.Info(t.request.operation+" move requested", t.request.logAttrs...) | |||
| 79 | + | defer func() { | |||
| 80 | + | result = normalizeMoveRejection(result) | |||
| 81 | + | if result != nil { | |||
| 82 | + | attrs := make([]any, 0, len(t.request.logAttrs)+2) | |||
| 83 | + | attrs = append(attrs, "err", result) | |||
| 84 | + | attrs = append(attrs, t.request.logAttrs...) | |||
| 85 | + | d.log.Warn(t.request.operation+" move rejected", attrs...) | |||
| 86 | + | } | |||
| 87 | + | }() | |||
| 88 | + | if !t.validRequest() { | |||
| 89 | + | return errMovePaneInvalid | |||
| 90 | + | } | |||
| 91 | + | if err := t.resolveSessions(); err != nil { | |||
| 92 | + | return err | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | if d.beforeMoveDispatch != nil { | |||
| 96 | + | d.beforeMoveDispatch() | |||
| 97 | + | } | |||
| 98 | + | unlockDispatch := lockMoveDispatch(t.source, t.destination) | |||
| 99 | + | dispatchHeld := true | |||
| 100 | + | defer func() { | |||
| 101 | + | if dispatchHeld { | |||
| 102 | + | unlockDispatch() | |||
| 103 | + | } | |||
| 104 | + | }() | |||
| 105 | + | ||||
| 106 | + | reservation, err := d.reserveMoveLifecycles(t.source, t.destination) | |||
| 107 | + | if err != nil { | |||
| 108 | + | return errMoveStaleTarget | |||
| 109 | + | } | |||
| 110 | + | reservationHeld := true | |||
| 111 | + | defer func() { | |||
| 112 | + | if reservationHeld { | |||
| 113 | + | reservation.Release() | |||
| 114 | + | } | |||
| 115 | + | }() | |||
| 116 | + | if d.afterMoveLifecycleReserved != nil { | |||
| 117 | + | d.afterMoveLifecycleReserved() | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | if err := t.admit(); err != nil { | |||
| 121 | + | return err | |||
| 122 | + | } | |||
| 123 | + | t.topology.afterAdmission(d) | |||
| 124 | + | if err := t.freezeSourceAttachments(); err != nil { | |||
| 125 | + | return err | |||
| 126 | + | } | |||
| 127 | + | defer func() { | |||
| 128 | + | if t.effectsFrozen { | |||
| 129 | + | t.frozenEffects.unfreeze() | |||
| 130 | + | } | |||
| 131 | + | }() | |||
| 132 | + | ||||
| 133 | + | fences := t.topology.resizeFences(t.source, t.destination) | |||
| 134 | + | if fences == nil || !fences.acquire(func() bool { | |||
| 135 | + | t.publishErr = t.publishLocked() | |||
| 136 | + | return t.publishErr == nil | |||
| 137 | + | }) { | |||
| 138 | + | if t.publishErr != nil { | |||
| 139 | + | return t.publishErr | |||
| 140 | + | } | |||
| 141 | + | return errMoveStaleTarget | |||
| 142 | + | } | |||
| 143 | + | fences.Release() | |||
| 144 | + | ||||
| 145 | + | postcommit := t.postcommitPlan(unlockDispatch, reservation) | |||
| 146 | + | t.effectsFrozen = false | |||
| 147 | + | dispatchHeld = false | |||
| 148 | + | reservationHeld = false | |||
| 149 | + | postcommit.execute(d) | |||
| 150 | + | return nil | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func (t *moveTransaction) validRequest() bool { | |||
| 154 | + | return t != nil && t.topology != nil && t.request.operation != "" && | |||
| 155 | + | t.request.source.ID != "" && t.request.destination.ID != "" && t.topology.validRequest() | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func (t *moveTransaction) resolveSessions() error { | |||
| 159 | + | d := t.daemon | |||
| 160 | + | d.mu.Lock() | |||
| 161 | + | t.source = moveSessionForLocatorLocked(d, t.request.source) | |||
| 162 | + | t.destination = moveSessionForLocatorLocked(d, t.request.destination) | |||
| 163 | + | d.mu.Unlock() | |||
| 164 | + | if t.source == nil || t.destination == nil { | |||
| 165 | + | return errMoveStaleTarget | |||
| 166 | + | } | |||
| 167 | + | return nil | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func (t *moveTransaction) attachmentAuthorityCurrentLocked() bool { | |||
| 171 | + | req := t.request | |||
| 172 | + | if req.attachment != nil && !attachmentRegisteredLocked(t.source, req.attachment) { | |||
| 173 | + | return false | |||
| 174 | + | } | |||
| 175 | + | if req.attachmentCapability.ac != nil && | |||
| 176 | + | (req.attachmentCapability.ac != req.attachment || !req.attachmentCapability.currentInSessionLocked(t.source)) { | |||
| 177 | + | return false | |||
| 178 | + | } | |||
| 179 | + | return true | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // admit snapshots exact common Attachment participants while the topology | |||
| 183 | + | // Implementation snapshots only its private identity and layout state. | |||
| 184 | + | func (t *moveTransaction) admit() error { | |||
| 185 | + | d := t.daemon | |||
| 186 | + | d.mu.Lock() | |||
| 187 | + | defer d.mu.Unlock() | |||
| 188 | + | if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination { | |||
| 189 | + | return errMoveStaleTarget | |||
| 190 | + | } | |||
| 191 | + | unlockSessions := lockAttachmentSessions(t.source, t.destination) | |||
| 192 | + | defer unlockSessions() | |||
| 193 | + | if !moveSessionLocatorCurrentLocked(t.source, t.request.source) || | |||
| 194 | + | !moveSessionLocatorCurrentLocked(t.destination, t.request.destination) || | |||
| 195 | + | !t.attachmentAuthorityCurrentLocked() { | |||
| 196 | + | return errMoveStaleTarget | |||
| 197 | + | } | |||
| 198 | + | if err := t.topology.admitLocked(d, t.source, t.destination); err != nil { | |||
| 199 | + | return err | |||
| 200 | + | } | |||
| 201 | + | t.sourceAttachments = t.source.snapshotAttachmentsLocked() | |||
| 202 | + | t.sourceTransports = make(map[*attachedClient]transportSnapshot, len(t.sourceAttachments)) | |||
| 203 | + | for _, attachment := range t.sourceAttachments { | |||
| 204 | + | t.sourceTransports[attachment] = attachment.transportSnapshot() | |||
| 205 | + | } | |||
| 206 | + | return nil | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // freezeSourceAttachments delegates effect admission and drain authority to the | |||
| 210 | + | // attachment lifecycle Module. Move uses nonblocking acquisition to preserve | |||
| 211 | + | // its ordering with final teardown. | |||
| 212 | + | func (t *moveTransaction) freezeSourceAttachments() error { | |||
| 213 | + | if !t.topology.willRetireSource() || len(t.sourceAttachments) == 0 { | |||
| 214 | + | return nil | |||
| 215 | + | } | |||
| 216 | + | interrupts := make([]attachmentTransportInterrupt, 0, len(t.sourceAttachments)) | |||
| 217 | + | for _, attachment := range t.sourceAttachments { | |||
| 218 | + | if transport := t.sourceTransports[attachment]; transport.transport != nil { | |||
| 219 | + | interrupts = append(interrupts, attachmentTransportInterrupt{ac: attachment, transport: transport}) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{ | |||
| 223 | + | interrupts: interrupts, | |||
| 224 | + | nonblocking: true, | |||
| 225 | + | afterFrozen: func(attachment *attachedClient) { | |||
| 226 | + | if t.daemon.afterAttachmentEffectGateFrozen != nil { | |||
| 227 | + | t.daemon.afterAttachmentEffectGateFrozen("move-"+t.request.operation, attachment) | |||
| 228 | + | } | |||
| 229 | + | }, | |||
| 230 | + | }, t.sourceAttachments...) | |||
| 231 | + | if !frozen.acquired || !frozen.drained { | |||
| 232 | + | frozen.unfreeze() | |||
| 233 | + | return errMovePaneInvalid | |||
| 234 | + | } | |||
| 235 | + | t.frozenEffects = frozen | |||
| 236 | + | t.effectsFrozen = true | |||
| 237 | + | return nil | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // publishLocked is the only shared architecture-lock window. Topology | |||
| 241 | + | // Implementations perform all fallible private validation before their first | |||
| 242 | + | // write. Common retirement and metadata capture are non-failing in-memory work. | |||
| 243 | + | func (t *moveTransaction) publishLocked() error { | |||
| 244 | + | d := t.daemon | |||
| 245 | + | d.mu.Lock() | |||
| 246 | + | defer d.mu.Unlock() | |||
| 247 | + | if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination { | |||
| 248 | + | return errMoveStaleTarget | |||
| 249 | + | } | |||
| 250 | + | d.notices.routingMu.Lock() | |||
| 251 | + | defer d.notices.routingMu.Unlock() | |||
| 252 | + | unlockSessions := lockAttachmentSessions(t.source, t.destination) | |||
| 253 | + | defer unlockSessions() | |||
| 254 | + | if !moveSessionLocatorCurrentLocked(t.source, t.request.source) || | |||
| 255 | + | !moveSessionLocatorCurrentLocked(t.destination, t.request.destination) || | |||
| 256 | + | !t.attachmentAuthorityCurrentLocked() { | |||
| 257 | + | return errMoveStaleTarget | |||
| 258 | + | } | |||
| 259 | + | if t.topology.willRetireSource() && !sameMoveAttachmentsLocked(t.source, t.sourceAttachments) { | |||
| 260 | + | return errMoveStaleTarget | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | publication, err := t.topology.publishLocked(d, t.source, t.destination) | |||
| 264 | + | if err != nil { | |||
| 265 | + | return err | |||
| 266 | + | } | |||
| 267 | + | t.publication = publication | |||
| 268 | + | if publication.sourceEmpty { | |||
| 269 | + | d.unregisterSessionLocked(t.source) | |||
| 270 | + | t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports) | |||
| 271 | + | t.source.tabs = nil | |||
| 272 | + | d.purgeParkingForSessionLocked(t.source) | |||
| 273 | + | t.retiredParked = d.purgeParkedForSessionLocked(t.source) | |||
| 274 | + | } | |||
| 275 | + | t.sourceName = t.source.name | |||
| 276 | + | t.destinationName = t.destination.name | |||
| 277 | + | if !t.source.ephemeral { | |||
| 278 | + | t.sourceMetadata = t.source.persistRecordLocked(max(d.nowUnixNano(), t.source.createdAt, int64(1))).MetadataUpdate() | |||
| 279 | + | t.sourceMetadataOK = true | |||
| 280 | + | } | |||
| 281 | + | if !t.destination.ephemeral && t.destination != t.source { | |||
| 282 | + | t.destMetadata = t.destination.persistRecordLocked(max(d.nowUnixNano(), t.destination.createdAt, int64(1))).MetadataUpdate() | |||
| 283 | + | t.destMetadataOK = true | |||
| 284 | + | } | |||
| 285 | + | return nil | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *moveLifecycleReservation) movePostcommitPlan { | |||
| 289 | + | publication := t.publication | |||
| 290 | + | return movePostcommitPlan{ | |||
| 291 | + | source: t.source, | |||
| 292 | + | destination: t.destination, | |||
| 293 | + | sourceName: t.sourceName, | |||
| 294 | + | destinationName: t.destinationName, | |||
| 295 | + | sourceTab: publication.sourceTab, | |||
| 296 | + | destinationTab: publication.destinationTab, | |||
| 297 | + | movedPane: publication.movedPane, | |||
| 298 | + | movedPanes: publication.movedPanes, | |||
| 299 | + | operation: t.request.operation, | |||
| 300 | + | sourceAttachments: t.sourceAttachments, | |||
| 301 | + | syncCleanup: publication.syncCleanup, | |||
| 302 | + | frozenEffects: t.frozenEffects, | |||
| 303 | + | effectsFrozen: t.effectsFrozen, | |||
| 304 | + | unlockDispatch: unlockDispatch, | |||
| 305 | + | reservation: reservation, | |||
| 306 | + | oldTabCancel: publication.oldTabCancel, | |||
| 307 | + | sourceTabRemoved: publication.sourceTabRemoved, | |||
| 308 | + | sourceEmpty: publication.sourceEmpty, | |||
| 309 | + | retiredParked: t.retiredParked, | |||
| 310 | + | retiredAttachments: t.retiredAttachments, | |||
| 311 | + | sourceMetadata: t.sourceMetadata, | |||
| 312 | + | sourceMetadataValid: t.sourceMetadataOK, | |||
| 313 | + | destinationMetadata: t.destMetadata, | |||
| 314 | + | destinationMetadataValid: t.destMetadataOK, | |||
| 315 | + | } | |||
| 316 | + | } | |||
A internal/usecase/daemon/move_transaction_test.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestMoveTransactionRejectsStalePublicationWithoutLeakingAuthority(t *testing.T) { | |
| 13 | + | tests := []struct { | |
| 14 | + | name string | |
| 15 | + | installRace func(*Daemon, *tab) | |
| 16 | + | move func(*Daemon, *session, *tab, *pane, *session, *tab) error | |
| 17 | + | }{ | |
| 18 | + | { | |
| 19 | + | name: "pane", | |
| 20 | + | installRace: func(d *Daemon, movedTab *tab) { | |
| 21 | + | d.afterMovePaneSourceSnapshot = func() { | |
| 22 | + | movedTab.mu.Lock() | |
| 23 | + | movedTab.layoutGeneration++ | |
| 24 | + | movedTab.mu.Unlock() | |
| 25 | + | } | |
| 26 | + | }, | |
| 27 | + | move: func(d *Daemon, source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) error { | |
| 28 | + | return d.movePane(movePaneRequest{ | |
| 29 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 30 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 31 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 32 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 33 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 34 | + | }) | |
| 35 | + | }, | |
| 36 | + | }, | |
| 37 | + | { | |
| 38 | + | name: "tab", | |
| 39 | + | installRace: func(d *Daemon, movedTab *tab) { | |
| 40 | + | d.afterMoveTabSourceSnapshot = func() { | |
| 41 | + | movedTab.mu.Lock() | |
| 42 | + | movedTab.layoutGeneration++ | |
| 43 | + | movedTab.mu.Unlock() | |
| 44 | + | } | |
| 45 | + | }, | |
| 46 | + | move: func(d *Daemon, source *session, sourceTab *tab, _ *pane, destination *session, _ *tab) error { | |
| 47 | + | return d.moveTab(moveTabRequest{ | |
| 48 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 49 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 50 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 51 | + | }) | |
| 52 | + | }, | |
| 53 | + | }, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | for _, tt := range tests { | |
| 57 | + | t.Run(tt.name, func(t *testing.T) { | |
| 58 | + | d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t) | |
| 59 | + | defer releasePTYs() | |
| 60 | + | ||
| 61 | + | source.mu.Lock() | |
| 62 | + | beforeSourceTabs := append([]*tab(nil), source.tabs...) | |
| 63 | + | sourceAttachments := source.snapshotAttachmentsLocked() | |
| 64 | + | source.mu.Unlock() | |
| 65 | + | destination.mu.Lock() | |
| 66 | + | beforeDestinationTabs := append([]*tab(nil), destination.tabs...) | |
| 67 | + | destination.mu.Unlock() | |
| 68 | + | beforeOwner := moved.ownerSnapshot() | |
| 69 | + | ||
| 70 | + | tt.installRace(d, sourceTab) | |
| 71 | + | err := tt.move(d, source, sourceTab, moved, destination, destinationTab) | |
| 72 | + | require.ErrorIs(t, err, errMoveStaleTarget) | |
| 73 | + | ||
| 74 | + | var rejection *moveRejection | |
| 75 | + | require.ErrorAs(t, err, &rejection) | |
| 76 | + | require.Equal(t, moveRejectionStaleTarget, rejection.Reason) | |
| 77 | + | ||
| 78 | + | source.mu.Lock() | |
| 79 | + | require.Equal(t, beforeSourceTabs, source.tabs) | |
| 80 | + | require.True(t, sameMoveAttachmentsLocked(source, sourceAttachments)) | |
| 81 | + | source.mu.Unlock() | |
| 82 | + | destination.mu.Lock() | |
| 83 | + | require.Equal(t, beforeDestinationTabs, destination.tabs) | |
| 84 | + | destination.mu.Unlock() | |
| 85 | + | require.Same(t, beforeOwner, moved.ownerSnapshot()) | |
| 86 | + | ||
| 87 | + | d.moveLifecycleMu.Lock() | |
| 88 | + | require.Zero(t, d.moveLifecycleActive) | |
| 89 | + | d.moveLifecycleMu.Unlock() | |
| 90 | + | for _, sess := range []*session{source, destination} { | |
| 91 | + | sess.teardownMu.Lock() | |
| 92 | + | require.Zero(t, sess.moveReservations) | |
| 93 | + | sess.teardownMu.Unlock() | |
| 94 | + | } | |
| 95 | + | for name, lock := range map[string]*sync.Mutex{ | |
| 96 | + | "source dispatch": &source.dispatchMu, | |
| 97 | + | "destination dispatch": &destination.dispatchMu, | |
| 98 | + | "source layout": &source.layoutApplyMu, | |
| 99 | + | "destination layout": &destination.layoutApplyMu, | |
| 100 | + | "moved tab layout": &sourceTab.layoutApplyMu, | |
| 101 | + | "destination tab layout": &destinationTab.layoutApplyMu, | |
| 102 | + | "moved pane resize": &moved.resizeMu, | |
| 103 | + | } { | |
| 104 | + | require.True(t, lock.TryLock(), "%s authority leaked after rejection", name) | |
| 105 | + | lock.Unlock() | |
| 106 | + | } | |
| 107 | + | for _, attachment := range sourceAttachments { | |
| 108 | + | requireAttachmentEffectGateRetired(t, attachment) | |
| 109 | + | } | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestMoveTransactionRejectsStalePublicationWithoutLeakingAuthority(t *testing.T) { | |||
| 13 | + | tests := []struct { | |||
| 14 | + | name string | |||
| 15 | + | installRace func(*Daemon, *tab) | |||
| 16 | + | move func(*Daemon, *session, *tab, *pane, *session, *tab) error | |||
| 17 | + | }{ | |||
| 18 | + | { | |||
| 19 | + | name: "pane", | |||
| 20 | + | installRace: func(d *Daemon, movedTab *tab) { | |||
| 21 | + | d.afterMovePaneSourceSnapshot = func() { | |||
| 22 | + | movedTab.mu.Lock() | |||
| 23 | + | movedTab.layoutGeneration++ | |||
| 24 | + | movedTab.mu.Unlock() | |||
| 25 | + | } | |||
| 26 | + | }, | |||
| 27 | + | move: func(d *Daemon, source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) error { | |||
| 28 | + | return d.movePane(movePaneRequest{ | |||
| 29 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |||
| 30 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |||
| 31 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |||
| 32 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 33 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |||
| 34 | + | }) | |||
| 35 | + | }, | |||
| 36 | + | }, | |||
| 37 | + | { | |||
| 38 | + | name: "tab", | |||
| 39 | + | installRace: func(d *Daemon, movedTab *tab) { | |||
| 40 | + | d.afterMoveTabSourceSnapshot = func() { | |||
| 41 | + | movedTab.mu.Lock() | |||
| 42 | + | movedTab.layoutGeneration++ | |||
| 43 | + | movedTab.mu.Unlock() | |||
| 44 | + | } | |||
| 45 | + | }, | |||
| 46 | + | move: func(d *Daemon, source *session, sourceTab *tab, _ *pane, destination *session, _ *tab) error { | |||
| 47 | + | return d.moveTab(moveTabRequest{ | |||
| 48 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |||
| 49 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |||
| 50 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 51 | + | }) | |||
| 52 | + | }, | |||
| 53 | + | }, | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | for _, tt := range tests { | |||
| 57 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 58 | + | d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t) | |||
| 59 | + | defer releasePTYs() | |||
| 60 | + | ||||
| 61 | + | source.mu.Lock() | |||
| 62 | + | beforeSourceTabs := append([]*tab(nil), source.tabs...) | |||
| 63 | + | sourceAttachments := source.snapshotAttachmentsLocked() | |||
| 64 | + | source.mu.Unlock() | |||
| 65 | + | destination.mu.Lock() | |||
| 66 | + | beforeDestinationTabs := append([]*tab(nil), destination.tabs...) | |||
| 67 | + | destination.mu.Unlock() | |||
| 68 | + | beforeOwner := moved.ownerSnapshot() | |||
| 69 | + | ||||
| 70 | + | tt.installRace(d, sourceTab) | |||
| 71 | + | err := tt.move(d, source, sourceTab, moved, destination, destinationTab) | |||
| 72 | + | require.ErrorIs(t, err, errMoveStaleTarget) | |||
| 73 | + | ||||
| 74 | + | var rejection *moveRejection | |||
| 75 | + | require.ErrorAs(t, err, &rejection) | |||
| 76 | + | require.Equal(t, moveRejectionStaleTarget, rejection.Reason) | |||
| 77 | + | ||||
| 78 | + | source.mu.Lock() | |||
| 79 | + | require.Equal(t, beforeSourceTabs, source.tabs) | |||
| 80 | + | require.True(t, sameMoveAttachmentsLocked(source, sourceAttachments)) | |||
| 81 | + | source.mu.Unlock() | |||
| 82 | + | destination.mu.Lock() | |||
| 83 | + | require.Equal(t, beforeDestinationTabs, destination.tabs) | |||
| 84 | + | destination.mu.Unlock() | |||
| 85 | + | require.Same(t, beforeOwner, moved.ownerSnapshot()) | |||
| 86 | + | ||||
| 87 | + | d.moveLifecycleMu.Lock() | |||
| 88 | + | require.Zero(t, d.moveLifecycleActive) | |||
| 89 | + | d.moveLifecycleMu.Unlock() | |||
| 90 | + | for _, sess := range []*session{source, destination} { | |||
| 91 | + | sess.teardownMu.Lock() | |||
| 92 | + | require.Zero(t, sess.moveReservations) | |||
| 93 | + | sess.teardownMu.Unlock() | |||
| 94 | + | } | |||
| 95 | + | for name, lock := range map[string]*sync.Mutex{ | |||
| 96 | + | "source dispatch": &source.dispatchMu, | |||
| 97 | + | "destination dispatch": &destination.dispatchMu, | |||
| 98 | + | "source layout": &source.layoutApplyMu, | |||
| 99 | + | "destination layout": &destination.layoutApplyMu, | |||
| 100 | + | "moved tab layout": &sourceTab.layoutApplyMu, | |||
| 101 | + | "destination tab layout": &destinationTab.layoutApplyMu, | |||
| 102 | + | "moved pane resize": &moved.resizeMu, | |||
| 103 | + | } { | |||
| 104 | + | require.True(t, lock.TryLock(), "%s authority leaked after rejection", name) | |||
| 105 | + | lock.Unlock() | |||
| 106 | + | } | |||
| 107 | + | for _, attachment := range sourceAttachments { | |||
| 108 | + | requireAttachmentEffectGateRetired(t, attachment) | |||
| 109 | + | } | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | } | |||