fix(daemon): harden final move retirement
- commit
- 96740f3172e4ebedcbb0a6ee0da106e9add89e71
- author
- brice · · 2026-07-28 12:29 UTC
- parent
- 35e7583
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +321 -37
M internal/usecase/daemon/daemon.go +1 -0
| @@ -162,6 +162,7 @@ type Daemon struct { | |||
| 162 | 162 | // perform external work while locks are held. | |
| 163 | 163 | afterMoveLifecycleReserved func() | |
| 164 | 164 | afterMovePaneSourceSnapshot func() | |
| 165 | + | afterMoveTabSourceSnapshot func() | |
| 165 | 166 | beforeMovePaneCommit func() | |
| 166 | 167 | beforeMoveTabCommit func() | |
| 167 | 168 | // afterDetachRoleEffectsFrozen observes terminal detach after it wins the | |
| @@ -162,6 +162,7 @@ type Daemon struct { | |||||
| 162 | // perform external work while locks are held. | 162 | // perform external work while locks are held. | ||
| 163 | afterMoveLifecycleReserved func() | 163 | afterMoveLifecycleReserved func() | ||
| 164 | afterMovePaneSourceSnapshot func() | 164 | afterMovePaneSourceSnapshot func() | ||
| 165 | + | afterMoveTabSourceSnapshot func() | |||
| 165 | beforeMovePaneCommit func() | 166 | beforeMovePaneCommit func() | ||
| 166 | beforeMoveTabCommit func() | 167 | beforeMoveTabCommit func() | ||
| 167 | // afterDetachRoleEffectsFrozen observes terminal detach after it wins the | 168 | // afterDetachRoleEffectsFrozen observes terminal detach after it wins the | ||
M internal/usecase/daemon/move.go +8 -9
| @@ -73,7 +73,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 73 | 73 | movedPane := admission.movedPane | |
| 74 | 74 | sourceClient := admission.sourceClient | |
| 75 | 75 | destinationClient := admission.destinationClient | |
| 76 | - | sourceSnatchedCount := admission.sourceSnatched | |
| 76 | + | sourceSnatched := admission.sourceSnatched | |
| 77 | 77 | sourceTabWasActive := admission.sourceTabWasActive | |
| 78 | 78 | sourceTabInitialGeneration := admission.sourceGeneration | |
| 79 | 79 | destinationTabInitialGeneration := admission.destinationGeneration | |
| @@ -117,7 +117,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 117 | 117 | // the follower. Their exact membership is checked again in the commit. | |
| 118 | 118 | d.mu.Lock() | |
| 119 | 119 | source.mu.Lock() | |
| 120 | - | for ac := range source.snatched { | |
| 120 | + | for _, ac := range sourceSnatched { | |
| 121 | 121 | handoffParticipants.clients = append(handoffParticipants.clients, ac) | |
| 122 | 122 | tr := ac.transportSnapshot() | |
| 123 | 123 | if tr.transport != nil { | |
| @@ -138,15 +138,14 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 138 | 138 | handoffFrozen = true | |
| 139 | 139 | sourceRolesFrozen = true | |
| 140 | 140 | handoffReq.roleEffectsFrozen = true | |
| 141 | - | } else if finalSourceTab && source != destination && sourceSnatchedCount > 0 { | |
| 141 | + | } else if finalSourceTab && source != destination && len(sourceSnatched) > 0 { | |
| 142 | 142 | // A headless source may still have persistent snatched clients. Freeze | |
| 143 | 143 | // their exact role gates before retiring the source registry. | |
| 144 | 144 | d.mu.Lock() | |
| 145 | 145 | source.mu.Lock() | |
| 146 | - | participants := make([]*attachedClient, 0, len(source.snatched)) | |
| 147 | - | interrupts := make([]roleTransportInterrupt, 0, len(source.snatched)) | |
| 148 | - | for ac := range source.snatched { | |
| 149 | - | participants = append(participants, ac) | |
| 146 | + | participants := append([]*attachedClient(nil), sourceSnatched...) | |
| 147 | + | interrupts := make([]roleTransportInterrupt, 0, len(sourceSnatched)) | |
| 148 | + | for _, ac := range sourceSnatched { | |
| 150 | 149 | if transport := ac.transportSnapshot(); transport.transport != nil { | |
| 151 | 150 | interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport}) | |
| 152 | 151 | } | |
| @@ -176,11 +175,11 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 176 | 175 | destinationTab: destinationTab, | |
| 177 | 176 | movedPane: movedPane, | |
| 178 | 177 | sourceClient: sourceClient, | |
| 179 | - | sourceSnatchedCount: sourceSnatchedCount, | |
| 178 | + | sourceSnatched: sourceSnatched, | |
| 180 | 179 | sourceGeneration: sourceTabInitialGeneration, | |
| 181 | 180 | destinationGeneration: destinationTabInitialGeneration, | |
| 182 | 181 | handoffFrozen: handoffFrozen, | |
| 183 | - | sourceRolesFrozen: sourceRolesFrozen, | |
| 182 | + | frozenRoles: frozen, | |
| 184 | 183 | handoffReq: handoffReq, | |
| 185 | 184 | } | |
| 186 | 185 | fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane) | |
| @@ -73,7 +73,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||||
| 73 | movedPane := admission.movedPane | 73 | movedPane := admission.movedPane | ||
| 74 | sourceClient := admission.sourceClient | 74 | sourceClient := admission.sourceClient | ||
| 75 | destinationClient := admission.destinationClient | 75 | destinationClient := admission.destinationClient | ||
| 76 | - | sourceSnatchedCount := admission.sourceSnatched | 76 | + | sourceSnatched := admission.sourceSnatched |
| 77 | sourceTabWasActive := admission.sourceTabWasActive | 77 | sourceTabWasActive := admission.sourceTabWasActive | ||
| 78 | sourceTabInitialGeneration := admission.sourceGeneration | 78 | sourceTabInitialGeneration := admission.sourceGeneration | ||
| 79 | destinationTabInitialGeneration := admission.destinationGeneration | 79 | destinationTabInitialGeneration := admission.destinationGeneration | ||
| @@ -117,7 +117,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||||
| 117 | // the follower. Their exact membership is checked again in the commit. | 117 | // the follower. Their exact membership is checked again in the commit. | ||
| 118 | d.mu.Lock() | 118 | d.mu.Lock() | ||
| 119 | source.mu.Lock() | 119 | source.mu.Lock() | ||
| 120 | - | for ac := range source.snatched { | 120 | + | for _, ac := range sourceSnatched { |
| 121 | handoffParticipants.clients = append(handoffParticipants.clients, ac) | 121 | handoffParticipants.clients = append(handoffParticipants.clients, ac) | ||
| 122 | tr := ac.transportSnapshot() | 122 | tr := ac.transportSnapshot() | ||
| 123 | if tr.transport != nil { | 123 | if tr.transport != nil { | ||
| @@ -138,15 +138,14 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||||
| 138 | handoffFrozen = true | 138 | handoffFrozen = true | ||
| 139 | sourceRolesFrozen = true | 139 | sourceRolesFrozen = true | ||
| 140 | handoffReq.roleEffectsFrozen = true | 140 | handoffReq.roleEffectsFrozen = true | ||
| 141 | - | } else if finalSourceTab && source != destination && sourceSnatchedCount > 0 { | 141 | + | } else if finalSourceTab && source != destination && len(sourceSnatched) > 0 { |
| 142 | // A headless source may still have persistent snatched clients. Freeze | 142 | // A headless source may still have persistent snatched clients. Freeze | ||
| 143 | // their exact role gates before retiring the source registry. | 143 | // their exact role gates before retiring the source registry. | ||
| 144 | d.mu.Lock() | 144 | d.mu.Lock() | ||
| 145 | source.mu.Lock() | 145 | source.mu.Lock() | ||
| 146 | - | participants := make([]*attachedClient, 0, len(source.snatched)) | 146 | + | participants := append([]*attachedClient(nil), sourceSnatched...) |
| 147 | - | interrupts := make([]roleTransportInterrupt, 0, len(source.snatched)) | 147 | + | interrupts := make([]roleTransportInterrupt, 0, len(sourceSnatched)) |
| 148 | - | for ac := range source.snatched { | 148 | + | for _, ac := range sourceSnatched { |
| 149 | - | participants = append(participants, ac) | |||
| 150 | if transport := ac.transportSnapshot(); transport.transport != nil { | 149 | if transport := ac.transportSnapshot(); transport.transport != nil { | ||
| 151 | interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport}) | 150 | interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport}) | ||
| 152 | } | 151 | } | ||
| @@ -176,11 +175,11 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||||
| 176 | destinationTab: destinationTab, | 175 | destinationTab: destinationTab, | ||
| 177 | movedPane: movedPane, | 176 | movedPane: movedPane, | ||
| 178 | sourceClient: sourceClient, | 177 | sourceClient: sourceClient, | ||
| 179 | - | sourceSnatchedCount: sourceSnatchedCount, | 178 | + | sourceSnatched: sourceSnatched, |
| 180 | sourceGeneration: sourceTabInitialGeneration, | 179 | sourceGeneration: sourceTabInitialGeneration, | ||
| 181 | destinationGeneration: destinationTabInitialGeneration, | 180 | destinationGeneration: destinationTabInitialGeneration, | ||
| 182 | handoffFrozen: handoffFrozen, | 181 | handoffFrozen: handoffFrozen, | ||
| 183 | - | sourceRolesFrozen: sourceRolesFrozen, | 182 | + | frozenRoles: frozen, |
| 184 | handoffReq: handoffReq, | 183 | handoffReq: handoffReq, | ||
| 185 | } | 184 | } | ||
| 186 | fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane) | 185 | fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane) | ||
A internal/usecase/daemon/move_attachments.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | // frozenMoveAttachmentRetirement is proof that every source snatched client | |
| 4 | + | // retired by a final move has had its exact role-effect gate frozen and drained. | |
| 5 | + | // It is prepared while the source session lock is held, before topology writes. | |
| 6 | + | type frozenMoveAttachmentRetirement struct { | |
| 7 | + | clients []*attachedClient | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | func snapshotMoveSnatchedLocked(sess *session) []*attachedClient { | |
| 11 | + | clients := make([]*attachedClient, 0, len(sess.snatched)) | |
| 12 | + | for ac := range sess.snatched { | |
| 13 | + | clients = append(clients, ac) | |
| 14 | + | } | |
| 15 | + | return clients | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func sameMoveSnatchedLocked(sess *session, admitted []*attachedClient) bool { | |
| 19 | + | if sess == nil || len(sess.snatched) != len(admitted) { | |
| 20 | + | return false | |
| 21 | + | } | |
| 22 | + | for _, ac := range admitted { | |
| 23 | + | if _, ok := sess.snatched[ac]; !ok { | |
| 24 | + | return false | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | return true | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func prepareFrozenMoveAttachmentRetirementLocked(sess *session, admitted []*attachedClient, frozen frozenRoleEffectGates) (frozenMoveAttachmentRetirement, bool) { | |
| 31 | + | if !sameMoveSnatchedLocked(sess, admitted) { | |
| 32 | + | return frozenMoveAttachmentRetirement{}, false | |
| 33 | + | } | |
| 34 | + | if len(admitted) == 0 { | |
| 35 | + | return frozenMoveAttachmentRetirement{}, true | |
| 36 | + | } | |
| 37 | + | if !frozen.acquired || !frozen.drained { | |
| 38 | + | return frozenMoveAttachmentRetirement{}, false | |
| 39 | + | } | |
| 40 | + | for _, ac := range admitted { | |
| 41 | + | if !frozen.contains(ac) { | |
| 42 | + | return frozenMoveAttachmentRetirement{}, false | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | return frozenMoveAttachmentRetirement{clients: append([]*attachedClient(nil), admitted...)}, true | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | // frozenMoveAttachmentRetirement is proof that every source snatched client | |||
| 4 | + | // retired by a final move has had its exact role-effect gate frozen and drained. | |||
| 5 | + | // It is prepared while the source session lock is held, before topology writes. | |||
| 6 | + | type frozenMoveAttachmentRetirement struct { | |||
| 7 | + | clients []*attachedClient | |||
| 8 | + | } | |||
| 9 | + | ||||
| 10 | + | func snapshotMoveSnatchedLocked(sess *session) []*attachedClient { | |||
| 11 | + | clients := make([]*attachedClient, 0, len(sess.snatched)) | |||
| 12 | + | for ac := range sess.snatched { | |||
| 13 | + | clients = append(clients, ac) | |||
| 14 | + | } | |||
| 15 | + | return clients | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func sameMoveSnatchedLocked(sess *session, admitted []*attachedClient) bool { | |||
| 19 | + | if sess == nil || len(sess.snatched) != len(admitted) { | |||
| 20 | + | return false | |||
| 21 | + | } | |||
| 22 | + | for _, ac := range admitted { | |||
| 23 | + | if _, ok := sess.snatched[ac]; !ok { | |||
| 24 | + | return false | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | return true | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func prepareFrozenMoveAttachmentRetirementLocked(sess *session, admitted []*attachedClient, frozen frozenRoleEffectGates) (frozenMoveAttachmentRetirement, bool) { | |||
| 31 | + | if !sameMoveSnatchedLocked(sess, admitted) { | |||
| 32 | + | return frozenMoveAttachmentRetirement{}, false | |||
| 33 | + | } | |||
| 34 | + | if len(admitted) == 0 { | |||
| 35 | + | return frozenMoveAttachmentRetirement{}, true | |||
| 36 | + | } | |||
| 37 | + | if !frozen.acquired || !frozen.drained { | |||
| 38 | + | return frozenMoveAttachmentRetirement{}, false | |||
| 39 | + | } | |||
| 40 | + | for _, ac := range admitted { | |||
| 41 | + | if !frozen.contains(ac) { | |||
| 42 | + | return frozenMoveAttachmentRetirement{}, false | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | return frozenMoveAttachmentRetirement{clients: append([]*attachedClient(nil), admitted...)}, true | |||
| 46 | + | } | |||
A internal/usecase/daemon/move_final_source_attachment_race_test.go +211 -0
| @@ -0,0 +1,211 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type finalMoveAttachmentMutation int | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | installActiveAfterFinalMoveAdmission finalMoveAttachmentMutation = iota | |
| 15 | + | installSnatchedAfterFinalMoveAdmission | |
| 16 | + | replaceSnatchedAfterFinalMoveAdmission | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func newFinalMoveRaceClient(sess *session) *attachedClient { | |
| 20 | + | ac := &attachedClient{ | |
| 21 | + | tr: &closeTrackingTransport{}, | |
| 22 | + | output: newOutputStateStream(), | |
| 23 | + | size: domain.Size{Cols: 80, Rows: 24}, | |
| 24 | + | } | |
| 25 | + | ac.initOverlays() | |
| 26 | + | ac.setSession(sess) | |
| 27 | + | return ac | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func makeFinalMoveSourceHeadless(source *session, original *attachedClient) { | |
| 31 | + | source.mu.Lock() | |
| 32 | + | source.client = nil | |
| 33 | + | source.mu.Unlock() | |
| 34 | + | original.setSession(nil) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func mutateFinalMoveAttachments(source *session, mutation finalMoveAttachmentMutation) (current, replaced *attachedClient) { | |
| 38 | + | current = newFinalMoveRaceClient(source) | |
| 39 | + | source.mu.Lock() | |
| 40 | + | switch mutation { | |
| 41 | + | case installActiveAfterFinalMoveAdmission: | |
| 42 | + | source.client = current | |
| 43 | + | case installSnatchedAfterFinalMoveAdmission: | |
| 44 | + | source.addSnatchedLocked(current) | |
| 45 | + | case replaceSnatchedAfterFinalMoveAdmission: | |
| 46 | + | for ac := range source.snatched { | |
| 47 | + | replaced = ac | |
| 48 | + | delete(source.snatched, ac) | |
| 49 | + | ac.setSession(nil) | |
| 50 | + | break | |
| 51 | + | } | |
| 52 | + | source.addSnatchedLocked(current) | |
| 53 | + | } | |
| 54 | + | source.mu.Unlock() | |
| 55 | + | return current, replaced | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func assertRejectedFinalMoveAttachmentPreserved(t *testing.T, source *session, current, replaced *attachedClient, mutation finalMoveAttachmentMutation) { | |
| 59 | + | t.Helper() | |
| 60 | + | require.Same(t, source, current.currentSession(), "the attachment admitted during the race must not be orphaned") | |
| 61 | + | switch mutation { | |
| 62 | + | case installActiveAfterFinalMoveAdmission: | |
| 63 | + | require.Equal(t, attachmentActive, source.attachmentRole(current)) | |
| 64 | + | case installSnatchedAfterFinalMoveAdmission, replaceSnatchedAfterFinalMoveAdmission: | |
| 65 | + | require.Equal(t, attachmentSnatched, source.attachmentRole(current)) | |
| 66 | + | } | |
| 67 | + | if replaced != nil { | |
| 68 | + | require.Nil(t, replaced.currentSession()) | |
| 69 | + | require.Equal(t, attachmentDetached, source.attachmentRole(replaced)) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestMovePaneFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) { | |
| 74 | + | for _, tc := range []struct { | |
| 75 | + | name string | |
| 76 | + | mutation finalMoveAttachmentMutation | |
| 77 | + | existing bool | |
| 78 | + | }{ | |
| 79 | + | {name: "new active", mutation: installActiveAfterFinalMoveAdmission}, | |
| 80 | + | {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission}, | |
| 81 | + | {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true}, | |
| 82 | + | } { | |
| 83 | + | t.Run(tc.name, func(t *testing.T) { | |
| 84 | + | d, source, sourceTab, moved, destination, destinationTab, release := newMoveGateRaceFixture(t) | |
| 85 | + | defer release() | |
| 86 | + | original := source.client | |
| 87 | + | makeFinalMoveSourceHeadless(source, original) | |
| 88 | + | if tc.existing { | |
| 89 | + | existing := newFinalMoveRaceClient(source) | |
| 90 | + | source.mu.Lock() | |
| 91 | + | source.addSnatchedLocked(existing) | |
| 92 | + | source.mu.Unlock() | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | sourceGeneration := sourceTab.layoutGeneration | |
| 96 | + | destinationGeneration := destinationTab.layoutGeneration | |
| 97 | + | destinationPaneAtMovedID := destinationTab.panes[moved.id] | |
| 98 | + | owner := moved.ownerSnapshot() | |
| 99 | + | barrier := make(chan struct{}) | |
| 100 | + | releaseMove := make(chan struct{}) | |
| 101 | + | d.afterMovePaneSourceSnapshot = func() { | |
| 102 | + | close(barrier) | |
| 103 | + | <-releaseMove | |
| 104 | + | } | |
| 105 | + | defer func() { d.afterMovePaneSourceSnapshot = nil }() | |
| 106 | + | ||
| 107 | + | moveDone := make(chan error, 1) | |
| 108 | + | go func() { | |
| 109 | + | moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab)) | |
| 110 | + | }() | |
| 111 | + | waitMoveRace(t, barrier, "move pane admission") | |
| 112 | + | current, replaced := mutateFinalMoveAttachments(source, tc.mutation) | |
| 113 | + | currentGeneration := current.roleGeneration.Load() | |
| 114 | + | close(releaseMove) | |
| 115 | + | err := waitMoveRace(t, moveDone, "move pane rejection") | |
| 116 | + | ||
| 117 | + | require.ErrorIs(t, err, errMoveStaleTarget) | |
| 118 | + | d.mu.Lock() | |
| 119 | + | require.Same(t, source, d.sessions[source.id]) | |
| 120 | + | require.Same(t, destination, d.sessions[destination.id]) | |
| 121 | + | d.mu.Unlock() | |
| 122 | + | source.mu.Lock() | |
| 123 | + | require.Equal(t, []*tab{sourceTab}, source.tabs) | |
| 124 | + | source.mu.Unlock() | |
| 125 | + | sourceTab.mu.Lock() | |
| 126 | + | require.Same(t, moved, sourceTab.panes[moved.id]) | |
| 127 | + | require.Equal(t, sourceGeneration, sourceTab.layoutGeneration) | |
| 128 | + | sourceTab.mu.Unlock() | |
| 129 | + | destinationTab.mu.Lock() | |
| 130 | + | require.Same(t, destinationPaneAtMovedID, destinationTab.panes[moved.id]) | |
| 131 | + | require.Equal(t, destinationGeneration, destinationTab.layoutGeneration) | |
| 132 | + | destinationTab.mu.Unlock() | |
| 133 | + | require.Equal(t, owner, moved.ownerSnapshot()) | |
| 134 | + | require.Equal(t, currentGeneration, current.roleGeneration.Load()) | |
| 135 | + | assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation) | |
| 136 | + | }) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestMoveTabFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) { | |
| 141 | + | for _, tc := range []struct { | |
| 142 | + | name string | |
| 143 | + | mutation finalMoveAttachmentMutation | |
| 144 | + | existing bool | |
| 145 | + | }{ | |
| 146 | + | {name: "new active", mutation: installActiveAfterFinalMoveAdmission}, | |
| 147 | + | {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission}, | |
| 148 | + | {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true}, | |
| 149 | + | } { | |
| 150 | + | t.Run(tc.name, func(t *testing.T) { | |
| 151 | + | d, source, original, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 152 | + | moved := source.tabs[0] | |
| 153 | + | moved.stableID = "moved-tab" | |
| 154 | + | makeFinalMoveSourceHeadless(source, original) | |
| 155 | + | if tc.existing { | |
| 156 | + | existing := newFinalMoveRaceClient(source) | |
| 157 | + | source.mu.Lock() | |
| 158 | + | source.addSnatchedLocked(existing) | |
| 159 | + | source.mu.Unlock() | |
| 160 | + | } | |
| 161 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 162 | + | destinationTabs := append([]*tab(nil), destination.tabs...) | |
| 163 | + | sourceGeneration := moved.layoutGeneration | |
| 164 | + | owners := make(map[*pane]*paneOwner, len(moved.panes)) | |
| 165 | + | for _, p := range moved.panes { | |
| 166 | + | owners[p] = p.ownerSnapshot() | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | barrier := make(chan struct{}) | |
| 170 | + | releaseMove := make(chan struct{}) | |
| 171 | + | d.afterMoveTabSourceSnapshot = func() { | |
| 172 | + | close(barrier) | |
| 173 | + | <-releaseMove | |
| 174 | + | } | |
| 175 | + | defer func() { d.afterMoveTabSourceSnapshot = nil }() | |
| 176 | + | moveDone := make(chan error, 1) | |
| 177 | + | go func() { | |
| 178 | + | moveDone <- d.moveTab(moveTabRequest{ | |
| 179 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 180 | + | SourceTabID: domain.TabStableID(moved.stableID), | |
| 181 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 182 | + | }) | |
| 183 | + | }() | |
| 184 | + | waitMoveRace(t, barrier, "move tab admission") | |
| 185 | + | current, replaced := mutateFinalMoveAttachments(source, tc.mutation) | |
| 186 | + | currentGeneration := current.roleGeneration.Load() | |
| 187 | + | close(releaseMove) | |
| 188 | + | err := waitMoveRace(t, moveDone, "move tab rejection") | |
| 189 | + | ||
| 190 | + | require.ErrorIs(t, err, errMoveStaleTarget) | |
| 191 | + | d.mu.Lock() | |
| 192 | + | require.Same(t, source, d.sessions[source.id]) | |
| 193 | + | require.Same(t, destination, d.sessions[destination.id]) | |
| 194 | + | d.mu.Unlock() | |
| 195 | + | source.mu.Lock() | |
| 196 | + | require.Equal(t, []*tab{moved}, source.tabs) | |
| 197 | + | source.mu.Unlock() | |
| 198 | + | destination.mu.Lock() | |
| 199 | + | require.Equal(t, destinationTabs, destination.tabs) | |
| 200 | + | destination.mu.Unlock() | |
| 201 | + | moved.mu.Lock() | |
| 202 | + | require.Equal(t, sourceGeneration, moved.layoutGeneration) | |
| 203 | + | moved.mu.Unlock() | |
| 204 | + | for p, owner := range owners { | |
| 205 | + | require.Equal(t, owner, p.ownerSnapshot()) | |
| 206 | + | } | |
| 207 | + | require.Equal(t, currentGeneration, current.roleGeneration.Load()) | |
| 208 | + | assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation) | |
| 209 | + | }) | |
| 210 | + | } | |
| 211 | + | } | |
| @@ -0,0 +1,211 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type finalMoveAttachmentMutation int | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | installActiveAfterFinalMoveAdmission finalMoveAttachmentMutation = iota | |||
| 15 | + | installSnatchedAfterFinalMoveAdmission | |||
| 16 | + | replaceSnatchedAfterFinalMoveAdmission | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func newFinalMoveRaceClient(sess *session) *attachedClient { | |||
| 20 | + | ac := &attachedClient{ | |||
| 21 | + | tr: &closeTrackingTransport{}, | |||
| 22 | + | output: newOutputStateStream(), | |||
| 23 | + | size: domain.Size{Cols: 80, Rows: 24}, | |||
| 24 | + | } | |||
| 25 | + | ac.initOverlays() | |||
| 26 | + | ac.setSession(sess) | |||
| 27 | + | return ac | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func makeFinalMoveSourceHeadless(source *session, original *attachedClient) { | |||
| 31 | + | source.mu.Lock() | |||
| 32 | + | source.client = nil | |||
| 33 | + | source.mu.Unlock() | |||
| 34 | + | original.setSession(nil) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func mutateFinalMoveAttachments(source *session, mutation finalMoveAttachmentMutation) (current, replaced *attachedClient) { | |||
| 38 | + | current = newFinalMoveRaceClient(source) | |||
| 39 | + | source.mu.Lock() | |||
| 40 | + | switch mutation { | |||
| 41 | + | case installActiveAfterFinalMoveAdmission: | |||
| 42 | + | source.client = current | |||
| 43 | + | case installSnatchedAfterFinalMoveAdmission: | |||
| 44 | + | source.addSnatchedLocked(current) | |||
| 45 | + | case replaceSnatchedAfterFinalMoveAdmission: | |||
| 46 | + | for ac := range source.snatched { | |||
| 47 | + | replaced = ac | |||
| 48 | + | delete(source.snatched, ac) | |||
| 49 | + | ac.setSession(nil) | |||
| 50 | + | break | |||
| 51 | + | } | |||
| 52 | + | source.addSnatchedLocked(current) | |||
| 53 | + | } | |||
| 54 | + | source.mu.Unlock() | |||
| 55 | + | return current, replaced | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func assertRejectedFinalMoveAttachmentPreserved(t *testing.T, source *session, current, replaced *attachedClient, mutation finalMoveAttachmentMutation) { | |||
| 59 | + | t.Helper() | |||
| 60 | + | require.Same(t, source, current.currentSession(), "the attachment admitted during the race must not be orphaned") | |||
| 61 | + | switch mutation { | |||
| 62 | + | case installActiveAfterFinalMoveAdmission: | |||
| 63 | + | require.Equal(t, attachmentActive, source.attachmentRole(current)) | |||
| 64 | + | case installSnatchedAfterFinalMoveAdmission, replaceSnatchedAfterFinalMoveAdmission: | |||
| 65 | + | require.Equal(t, attachmentSnatched, source.attachmentRole(current)) | |||
| 66 | + | } | |||
| 67 | + | if replaced != nil { | |||
| 68 | + | require.Nil(t, replaced.currentSession()) | |||
| 69 | + | require.Equal(t, attachmentDetached, source.attachmentRole(replaced)) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestMovePaneFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) { | |||
| 74 | + | for _, tc := range []struct { | |||
| 75 | + | name string | |||
| 76 | + | mutation finalMoveAttachmentMutation | |||
| 77 | + | existing bool | |||
| 78 | + | }{ | |||
| 79 | + | {name: "new active", mutation: installActiveAfterFinalMoveAdmission}, | |||
| 80 | + | {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission}, | |||
| 81 | + | {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true}, | |||
| 82 | + | } { | |||
| 83 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 84 | + | d, source, sourceTab, moved, destination, destinationTab, release := newMoveGateRaceFixture(t) | |||
| 85 | + | defer release() | |||
| 86 | + | original := source.client | |||
| 87 | + | makeFinalMoveSourceHeadless(source, original) | |||
| 88 | + | if tc.existing { | |||
| 89 | + | existing := newFinalMoveRaceClient(source) | |||
| 90 | + | source.mu.Lock() | |||
| 91 | + | source.addSnatchedLocked(existing) | |||
| 92 | + | source.mu.Unlock() | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | sourceGeneration := sourceTab.layoutGeneration | |||
| 96 | + | destinationGeneration := destinationTab.layoutGeneration | |||
| 97 | + | destinationPaneAtMovedID := destinationTab.panes[moved.id] | |||
| 98 | + | owner := moved.ownerSnapshot() | |||
| 99 | + | barrier := make(chan struct{}) | |||
| 100 | + | releaseMove := make(chan struct{}) | |||
| 101 | + | d.afterMovePaneSourceSnapshot = func() { | |||
| 102 | + | close(barrier) | |||
| 103 | + | <-releaseMove | |||
| 104 | + | } | |||
| 105 | + | defer func() { d.afterMovePaneSourceSnapshot = nil }() | |||
| 106 | + | ||||
| 107 | + | moveDone := make(chan error, 1) | |||
| 108 | + | go func() { | |||
| 109 | + | moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab)) | |||
| 110 | + | }() | |||
| 111 | + | waitMoveRace(t, barrier, "move pane admission") | |||
| 112 | + | current, replaced := mutateFinalMoveAttachments(source, tc.mutation) | |||
| 113 | + | currentGeneration := current.roleGeneration.Load() | |||
| 114 | + | close(releaseMove) | |||
| 115 | + | err := waitMoveRace(t, moveDone, "move pane rejection") | |||
| 116 | + | ||||
| 117 | + | require.ErrorIs(t, err, errMoveStaleTarget) | |||
| 118 | + | d.mu.Lock() | |||
| 119 | + | require.Same(t, source, d.sessions[source.id]) | |||
| 120 | + | require.Same(t, destination, d.sessions[destination.id]) | |||
| 121 | + | d.mu.Unlock() | |||
| 122 | + | source.mu.Lock() | |||
| 123 | + | require.Equal(t, []*tab{sourceTab}, source.tabs) | |||
| 124 | + | source.mu.Unlock() | |||
| 125 | + | sourceTab.mu.Lock() | |||
| 126 | + | require.Same(t, moved, sourceTab.panes[moved.id]) | |||
| 127 | + | require.Equal(t, sourceGeneration, sourceTab.layoutGeneration) | |||
| 128 | + | sourceTab.mu.Unlock() | |||
| 129 | + | destinationTab.mu.Lock() | |||
| 130 | + | require.Same(t, destinationPaneAtMovedID, destinationTab.panes[moved.id]) | |||
| 131 | + | require.Equal(t, destinationGeneration, destinationTab.layoutGeneration) | |||
| 132 | + | destinationTab.mu.Unlock() | |||
| 133 | + | require.Equal(t, owner, moved.ownerSnapshot()) | |||
| 134 | + | require.Equal(t, currentGeneration, current.roleGeneration.Load()) | |||
| 135 | + | assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation) | |||
| 136 | + | }) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestMoveTabFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) { | |||
| 141 | + | for _, tc := range []struct { | |||
| 142 | + | name string | |||
| 143 | + | mutation finalMoveAttachmentMutation | |||
| 144 | + | existing bool | |||
| 145 | + | }{ | |||
| 146 | + | {name: "new active", mutation: installActiveAfterFinalMoveAdmission}, | |||
| 147 | + | {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission}, | |||
| 148 | + | {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true}, | |||
| 149 | + | } { | |||
| 150 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 151 | + | d, source, original, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 152 | + | moved := source.tabs[0] | |||
| 153 | + | moved.stableID = "moved-tab" | |||
| 154 | + | makeFinalMoveSourceHeadless(source, original) | |||
| 155 | + | if tc.existing { | |||
| 156 | + | existing := newFinalMoveRaceClient(source) | |||
| 157 | + | source.mu.Lock() | |||
| 158 | + | source.addSnatchedLocked(existing) | |||
| 159 | + | source.mu.Unlock() | |||
| 160 | + | } | |||
| 161 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |||
| 162 | + | destinationTabs := append([]*tab(nil), destination.tabs...) | |||
| 163 | + | sourceGeneration := moved.layoutGeneration | |||
| 164 | + | owners := make(map[*pane]*paneOwner, len(moved.panes)) | |||
| 165 | + | for _, p := range moved.panes { | |||
| 166 | + | owners[p] = p.ownerSnapshot() | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | barrier := make(chan struct{}) | |||
| 170 | + | releaseMove := make(chan struct{}) | |||
| 171 | + | d.afterMoveTabSourceSnapshot = func() { | |||
| 172 | + | close(barrier) | |||
| 173 | + | <-releaseMove | |||
| 174 | + | } | |||
| 175 | + | defer func() { d.afterMoveTabSourceSnapshot = nil }() | |||
| 176 | + | moveDone := make(chan error, 1) | |||
| 177 | + | go func() { | |||
| 178 | + | moveDone <- d.moveTab(moveTabRequest{ | |||
| 179 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |||
| 180 | + | SourceTabID: domain.TabStableID(moved.stableID), | |||
| 181 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 182 | + | }) | |||
| 183 | + | }() | |||
| 184 | + | waitMoveRace(t, barrier, "move tab admission") | |||
| 185 | + | current, replaced := mutateFinalMoveAttachments(source, tc.mutation) | |||
| 186 | + | currentGeneration := current.roleGeneration.Load() | |||
| 187 | + | close(releaseMove) | |||
| 188 | + | err := waitMoveRace(t, moveDone, "move tab rejection") | |||
| 189 | + | ||||
| 190 | + | require.ErrorIs(t, err, errMoveStaleTarget) | |||
| 191 | + | d.mu.Lock() | |||
| 192 | + | require.Same(t, source, d.sessions[source.id]) | |||
| 193 | + | require.Same(t, destination, d.sessions[destination.id]) | |||
| 194 | + | d.mu.Unlock() | |||
| 195 | + | source.mu.Lock() | |||
| 196 | + | require.Equal(t, []*tab{moved}, source.tabs) | |||
| 197 | + | source.mu.Unlock() | |||
| 198 | + | destination.mu.Lock() | |||
| 199 | + | require.Equal(t, destinationTabs, destination.tabs) | |||
| 200 | + | destination.mu.Unlock() | |||
| 201 | + | moved.mu.Lock() | |||
| 202 | + | require.Equal(t, sourceGeneration, moved.layoutGeneration) | |||
| 203 | + | moved.mu.Unlock() | |||
| 204 | + | for p, owner := range owners { | |||
| 205 | + | require.Equal(t, owner, p.ownerSnapshot()) | |||
| 206 | + | } | |||
| 207 | + | require.Equal(t, currentGeneration, current.roleGeneration.Load()) | |||
| 208 | + | assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation) | |||
| 209 | + | }) | |||
| 210 | + | } | |||
| 211 | + | } | |||
M internal/usecase/daemon/move_pane_admission.go +2 -2
| @@ -15,7 +15,7 @@ type movePaneAdmission struct { | |||
| 15 | 15 | movedPane *pane | |
| 16 | 16 | sourceClient *attachedClient | |
| 17 | 17 | destinationClient *attachedClient | |
| 18 | - | sourceSnatched int | |
| 18 | + | sourceSnatched []*attachedClient | |
| 19 | 19 | sourceGeneration uint64 | |
| 20 | 20 | destinationGeneration uint64 | |
| 21 | 21 | finalSourceTab bool | |
| @@ -64,7 +64,7 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat | |||
| 64 | 64 | movedPane: movedPane, | |
| 65 | 65 | sourceClient: source.client, | |
| 66 | 66 | destinationClient: destination.client, | |
| 67 | - | sourceSnatched: len(source.snatched), | |
| 67 | + | sourceSnatched: snapshotMoveSnatchedLocked(source), | |
| 68 | 68 | sourceGeneration: sourceTab.layoutGeneration, | |
| 69 | 69 | destinationGeneration: destinationTab.layoutGeneration, | |
| 70 | 70 | finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab && | |
| @@ -15,7 +15,7 @@ type movePaneAdmission struct { | |||||
| 15 | movedPane *pane | 15 | movedPane *pane | ||
| 16 | sourceClient *attachedClient | 16 | sourceClient *attachedClient | ||
| 17 | destinationClient *attachedClient | 17 | destinationClient *attachedClient | ||
| 18 | - | sourceSnatched int | 18 | + | sourceSnatched []*attachedClient |
| 19 | sourceGeneration uint64 | 19 | sourceGeneration uint64 | ||
| 20 | destinationGeneration uint64 | 20 | destinationGeneration uint64 | ||
| 21 | finalSourceTab bool | 21 | finalSourceTab bool | ||
| @@ -64,7 +64,7 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat | |||||
| 64 | movedPane: movedPane, | 64 | movedPane: movedPane, | ||
| 65 | sourceClient: source.client, | 65 | sourceClient: source.client, | ||
| 66 | destinationClient: destination.client, | 66 | destinationClient: destination.client, | ||
| 67 | - | sourceSnatched: len(source.snatched), | 67 | + | sourceSnatched: snapshotMoveSnatchedLocked(source), |
| 68 | sourceGeneration: sourceTab.layoutGeneration, | 68 | sourceGeneration: sourceTab.layoutGeneration, | ||
| 69 | destinationGeneration: destinationTab.layoutGeneration, | 69 | destinationGeneration: destinationTab.layoutGeneration, | ||
| 70 | finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab && | 70 | finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab && | ||
M internal/usecase/daemon/move_pane_commit.go +15 -10
| @@ -14,11 +14,11 @@ type movePaneCommit struct { | |||
| 14 | 14 | sourceTab, destinationTab *tab | |
| 15 | 15 | movedPane *pane | |
| 16 | 16 | sourceClient *attachedClient | |
| 17 | - | sourceSnatchedCount int | |
| 17 | + | sourceSnatched []*attachedClient | |
| 18 | 18 | sourceGeneration uint64 | |
| 19 | 19 | destinationGeneration uint64 | |
| 20 | 20 | handoffFrozen bool | |
| 21 | - | sourceRolesFrozen bool | |
| 21 | + | frozenRoles frozenRoleEffectGates | |
| 22 | 22 | handoffReq attachmentTransitionRequest | |
| 23 | 23 | handoffPublication *attachmentPublication | |
| 24 | 24 | err error | |
| @@ -45,6 +45,7 @@ func (c *movePaneCommit) releasePublication() { | |||
| 45 | 45 | ||
| 46 | 46 | func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |
| 47 | 47 | var candidate *movePaneCandidate | |
| 48 | + | var retirement frozenMoveAttachmentRetirement | |
| 48 | 49 | var err error | |
| 49 | 50 | d.mu.Lock() | |
| 50 | 51 | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | |
| @@ -61,7 +62,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 61 | 62 | return false | |
| 62 | 63 | } | |
| 63 | 64 | if c.handoffFrozen { | |
| 64 | - | if c.source.client != c.sourceClient || len(c.source.snatched) != c.sourceSnatchedCount || | |
| 65 | + | if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) || | |
| 65 | 66 | c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) || | |
| 66 | 67 | c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab { | |
| 67 | 68 | return false | |
| @@ -100,12 +101,16 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 100 | 101 | } | |
| 101 | 102 | ||
| 102 | 103 | sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1 | |
| 103 | - | needsHandoff := sourceWillEmpty && c.source != c.destination && c.sourceClient != nil | |
| 104 | - | if needsHandoff != c.handoffFrozen || sourceWillEmpty && c.source != c.destination && c.source.client != nil && !needsHandoff { | |
| 105 | - | return false | |
| 106 | - | } | |
| 107 | - | if c.sourceRolesFrozen && len(c.source.snatched) != c.sourceSnatchedCount { | |
| 108 | - | return false | |
| 104 | + | if sourceWillEmpty && c.source != c.destination { | |
| 105 | + | if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) || | |
| 106 | + | (c.sourceClient != nil) != c.handoffFrozen { | |
| 107 | + | return false | |
| 108 | + | } | |
| 109 | + | var retirementOK bool | |
| 110 | + | retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.sourceSnatched, c.frozenRoles) | |
| 111 | + | if !retirementOK { | |
| 112 | + | return false | |
| 113 | + | } | |
| 109 | 114 | } | |
| 110 | 115 | ||
| 111 | 116 | // Everything above is fallible. Topology, owner, lifecycle registry, and | |
| @@ -152,7 +157,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 152 | 157 | ||
| 153 | 158 | if candidate.removeSourceTab && len(c.source.tabs) == 0 { | |
| 154 | 159 | delete(d.sessions, c.source.id) | |
| 155 | - | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source) | |
| 160 | + | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) | |
| 156 | 161 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |
| 157 | 162 | } | |
| 158 | 163 | c.sourceEmpty = len(c.source.tabs) == 0 | |
| @@ -14,11 +14,11 @@ type movePaneCommit struct { | |||||
| 14 | sourceTab, destinationTab *tab | 14 | sourceTab, destinationTab *tab | ||
| 15 | movedPane *pane | 15 | movedPane *pane | ||
| 16 | sourceClient *attachedClient | 16 | sourceClient *attachedClient | ||
| 17 | - | sourceSnatchedCount int | 17 | + | sourceSnatched []*attachedClient |
| 18 | sourceGeneration uint64 | 18 | sourceGeneration uint64 | ||
| 19 | destinationGeneration uint64 | 19 | destinationGeneration uint64 | ||
| 20 | handoffFrozen bool | 20 | handoffFrozen bool | ||
| 21 | - | sourceRolesFrozen bool | 21 | + | frozenRoles frozenRoleEffectGates |
| 22 | handoffReq attachmentTransitionRequest | 22 | handoffReq attachmentTransitionRequest | ||
| 23 | handoffPublication *attachmentPublication | 23 | handoffPublication *attachmentPublication | ||
| 24 | err error | 24 | err error | ||
| @@ -45,6 +45,7 @@ func (c *movePaneCommit) releasePublication() { | |||||
| 45 | 45 | ||||
| 46 | func (c *movePaneCommit) publishLocked(d *Daemon) bool { | 46 | func (c *movePaneCommit) publishLocked(d *Daemon) bool { | ||
| 47 | var candidate *movePaneCandidate | 47 | var candidate *movePaneCandidate | ||
| 48 | + | var retirement frozenMoveAttachmentRetirement | |||
| 48 | var err error | 49 | var err error | ||
| 49 | d.mu.Lock() | 50 | d.mu.Lock() | ||
| 50 | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | 51 | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | ||
| @@ -61,7 +62,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||||
| 61 | return false | 62 | return false | ||
| 62 | } | 63 | } | ||
| 63 | if c.handoffFrozen { | 64 | if c.handoffFrozen { | ||
| 64 | - | if c.source.client != c.sourceClient || len(c.source.snatched) != c.sourceSnatchedCount || | 65 | + | if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) || |
| 65 | c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) || | 66 | c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) || | ||
| 66 | c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab { | 67 | c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab { | ||
| 67 | return false | 68 | return false | ||
| @@ -100,12 +101,16 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||||
| 100 | } | 101 | } | ||
| 101 | 102 | ||||
| 102 | sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1 | 103 | sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1 | ||
| 103 | - | needsHandoff := sourceWillEmpty && c.source != c.destination && c.sourceClient != nil | 104 | + | if sourceWillEmpty && c.source != c.destination { |
| 104 | - | if needsHandoff != c.handoffFrozen || sourceWillEmpty && c.source != c.destination && c.source.client != nil && !needsHandoff { | 105 | + | if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) || |
| 105 | - | return false | 106 | + | (c.sourceClient != nil) != c.handoffFrozen { |
| 106 | - | } | 107 | + | return false |
| 107 | - | if c.sourceRolesFrozen && len(c.source.snatched) != c.sourceSnatchedCount { | 108 | + | } |
| 108 | - | return false | 109 | + | var retirementOK bool |
| 110 | + | retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.sourceSnatched, c.frozenRoles) | |||
| 111 | + | if !retirementOK { | |||
| 112 | + | return false | |||
| 113 | + | } | |||
| 109 | } | 114 | } | ||
| 110 | 115 | ||||
| 111 | // Everything above is fallible. Topology, owner, lifecycle registry, and | 116 | // Everything above is fallible. Topology, owner, lifecycle registry, and | ||
| @@ -152,7 +157,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||||
| 152 | 157 | ||||
| 153 | if candidate.removeSourceTab && len(c.source.tabs) == 0 { | 158 | if candidate.removeSourceTab && len(c.source.tabs) == 0 { | ||
| 154 | delete(d.sessions, c.source.id) | 159 | delete(d.sessions, c.source.id) | ||
| 155 | - | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source) | 160 | + | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) |
| 156 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | 161 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | ||
| 157 | } | 162 | } | ||
| 158 | c.sourceEmpty = len(c.source.tabs) == 0 | 163 | c.sourceEmpty = len(c.source.tabs) == 0 | ||
M internal/usecase/daemon/move_pane_postcommit.go +4 -4
| @@ -127,18 +127,18 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 127 | 127 | // retireEmptyMoveSessionLocked removes all source-owned attachment roles that | |
| 128 | 128 | // were not transferred. It runs inside the move commit, after the moved pane | |
| 129 | 129 | // has already acquired its destination owner, and performs no external work. | |
| 130 | - | func retireEmptyMoveSessionLocked(sess *session) []detachedAttachmentSnapshot { | |
| 130 | + | func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot { | |
| 131 | 131 | if sess == nil || sess.client != nil { | |
| 132 | 132 | return nil | |
| 133 | 133 | } | |
| 134 | - | retired := make([]detachedAttachmentSnapshot, 0, len(sess.snatched)) | |
| 135 | - | for ac := range sess.snatched { | |
| 134 | + | retired := make([]detachedAttachmentSnapshot, 0, len(retirement.clients)) | |
| 135 | + | for _, ac := range retirement.clients { | |
| 136 | 136 | retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()}) | |
| 137 | + | delete(sess.snatched, ac) | |
| 137 | 138 | ac.roleGeneration.Add(1) | |
| 138 | 139 | ac.setSession(nil) | |
| 139 | 140 | ac.invalidateFrozenRoleCapability() | |
| 140 | 141 | } | |
| 141 | - | clear(sess.snatched) | |
| 142 | 142 | sess.tabs = nil | |
| 143 | 143 | sess.active = -1 | |
| 144 | 144 | return retired | |
| @@ -127,18 +127,18 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||||
| 127 | // retireEmptyMoveSessionLocked removes all source-owned attachment roles that | 127 | // retireEmptyMoveSessionLocked removes all source-owned attachment roles that | ||
| 128 | // were not transferred. It runs inside the move commit, after the moved pane | 128 | // were not transferred. It runs inside the move commit, after the moved pane | ||
| 129 | // has already acquired its destination owner, and performs no external work. | 129 | // has already acquired its destination owner, and performs no external work. | ||
| 130 | - | func retireEmptyMoveSessionLocked(sess *session) []detachedAttachmentSnapshot { | 130 | + | func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot { |
| 131 | if sess == nil || sess.client != nil { | 131 | if sess == nil || sess.client != nil { | ||
| 132 | return nil | 132 | return nil | ||
| 133 | } | 133 | } | ||
| 134 | - | retired := make([]detachedAttachmentSnapshot, 0, len(sess.snatched)) | 134 | + | retired := make([]detachedAttachmentSnapshot, 0, len(retirement.clients)) |
| 135 | - | for ac := range sess.snatched { | 135 | + | for _, ac := range retirement.clients { |
| 136 | retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()}) | 136 | retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()}) | ||
| 137 | + | delete(sess.snatched, ac) | |||
| 137 | ac.roleGeneration.Add(1) | 138 | ac.roleGeneration.Add(1) | ||
| 138 | ac.setSession(nil) | 139 | ac.setSession(nil) | ||
| 139 | ac.invalidateFrozenRoleCapability() | 140 | ac.invalidateFrozenRoleCapability() | ||
| 140 | } | 141 | } | ||
| 141 | - | clear(sess.snatched) | |||
| 142 | sess.tabs = nil | 142 | sess.tabs = nil | ||
| 143 | sess.active = -1 | 143 | sess.active = -1 | ||
| 144 | return retired | 144 | return retired | ||
M internal/usecase/daemon/move_tab.go +25 -12
| @@ -12,7 +12,7 @@ type moveTabAdmission struct { | |||
| 12 | 12 | tab *tab | |
| 13 | 13 | sourceIndex int | |
| 14 | 14 | sourceClient *attachedClient | |
| 15 | - | sourceSnatched int | |
| 15 | + | sourceSnatched []*attachedClient | |
| 16 | 16 | layoutGeneration uint64 | |
| 17 | 17 | floatingState floatingState | |
| 18 | 18 | floatingPane *pane | |
| @@ -30,6 +30,7 @@ type moveTabCommit struct { | |||
| 30 | 30 | admission moveTabAdmission | |
| 31 | 31 | handoffFrozen bool | |
| 32 | 32 | sourceRolesFrozen bool | |
| 33 | + | frozenRoles frozenRoleEffectGates | |
| 33 | 34 | handoffReq attachmentTransitionRequest | |
| 34 | 35 | handoffPublication *attachmentPublication | |
| 35 | 36 | handoffResult attachmentTransitionResult | |
| @@ -86,6 +87,9 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 86 | 87 | if err != nil { | |
| 87 | 88 | return err | |
| 88 | 89 | } | |
| 90 | + | if d.afterMoveTabSourceSnapshot != nil { | |
| 91 | + | d.afterMoveTabSourceSnapshot() | |
| 92 | + | } | |
| 89 | 93 | commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission} | |
| 90 | 94 | var frozen frozenRoleEffectGates | |
| 91 | 95 | if admission.finalSource && admission.sourceClient != nil { | |
| @@ -107,7 +111,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 107 | 111 | } | |
| 108 | 112 | d.mu.Lock() | |
| 109 | 113 | source.mu.Lock() | |
| 110 | - | for ac := range source.snatched { | |
| 114 | + | for _, ac := range admission.sourceSnatched { | |
| 111 | 115 | participants.clients = append(participants.clients, ac) | |
| 112 | 116 | if tr := ac.transportSnapshot(); tr.transport != nil { | |
| 113 | 117 | participants.interrupts = append(participants.interrupts, roleTransportInterrupt{ac: ac, transport: tr}) | |
| @@ -127,13 +131,12 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 127 | 131 | commit.handoffFrozen = true | |
| 128 | 132 | commit.sourceRolesFrozen = true | |
| 129 | 133 | commit.handoffReq.roleEffectsFrozen = true | |
| 130 | - | } else if admission.finalSource && admission.sourceSnatched > 0 { | |
| 134 | + | } else if admission.finalSource && len(admission.sourceSnatched) > 0 { | |
| 131 | 135 | d.mu.Lock() | |
| 132 | 136 | source.mu.Lock() | |
| 133 | - | clients := make([]*attachedClient, 0, len(source.snatched)) | |
| 134 | - | interrupts := make([]roleTransportInterrupt, 0, len(source.snatched)) | |
| 135 | - | for ac := range source.snatched { | |
| 136 | - | clients = append(clients, ac) | |
| 137 | + | clients := append([]*attachedClient(nil), admission.sourceSnatched...) | |
| 138 | + | interrupts := make([]roleTransportInterrupt, 0, len(admission.sourceSnatched)) | |
| 139 | + | for _, ac := range admission.sourceSnatched { | |
| 137 | 140 | if tr := ac.transportSnapshot(); tr.transport != nil { | |
| 138 | 141 | interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: tr}) | |
| 139 | 142 | } | |
| @@ -147,6 +150,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 147 | 150 | } | |
| 148 | 151 | commit.sourceRolesFrozen = true | |
| 149 | 152 | } | |
| 153 | + | commit.frozenRoles = frozen | |
| 150 | 154 | rolesFrozen := commit.sourceRolesFrozen | |
| 151 | 155 | defer func() { | |
| 152 | 156 | if rolesFrozen { | |
| @@ -269,7 +273,7 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||
| 269 | 273 | idx := indexMoveTabLocked(source, moved) | |
| 270 | 274 | return &moveTabAdmission{ | |
| 271 | 275 | tab: moved, sourceIndex: idx, | |
| 272 | - | sourceClient: source.client, sourceSnatched: len(source.snatched), | |
| 276 | + | sourceClient: source.client, sourceSnatched: snapshotMoveSnatchedLocked(source), | |
| 273 | 277 | layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state, | |
| 274 | 278 | floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation, | |
| 275 | 279 | panes: panes, destinationActive: destinationActive, destinationSize: destinationSize, | |
| @@ -307,7 +311,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 307 | 311 | // precede moved.mu. The resize fences and ordered session locks keep topology | |
| 308 | 312 | // stable until the tab/pane checks below complete. | |
| 309 | 313 | if c.handoffFrozen { | |
| 310 | - | if c.source.client != c.admission.sourceClient || len(c.source.snatched) != c.admission.sourceSnatched { | |
| 314 | + | if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) { | |
| 311 | 315 | return false | |
| 312 | 316 | } | |
| 313 | 317 | publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq) | |
| @@ -318,8 +322,17 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 318 | 322 | c.handoffPublication = publication | |
| 319 | 323 | defer c.releasePublication() | |
| 320 | 324 | } | |
| 321 | - | if c.sourceRolesFrozen && len(c.source.snatched) != c.admission.sourceSnatched { | |
| 322 | - | return false | |
| 325 | + | var retirement frozenMoveAttachmentRetirement | |
| 326 | + | if c.admission.finalSource { | |
| 327 | + | if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) || | |
| 328 | + | (c.admission.sourceClient != nil) != c.handoffFrozen { | |
| 329 | + | return false | |
| 330 | + | } | |
| 331 | + | var retirementOK bool | |
| 332 | + | retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.admission.sourceSnatched, c.frozenRoles) | |
| 333 | + | if !retirementOK { | |
| 334 | + | return false | |
| 335 | + | } | |
| 323 | 336 | } | |
| 324 | 337 | if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive { | |
| 325 | 338 | return false | |
| @@ -403,7 +416,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 403 | 416 | } | |
| 404 | 417 | if len(c.source.tabs) == 0 { | |
| 405 | 418 | delete(d.sessions, c.source.id) | |
| 406 | - | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source) | |
| 419 | + | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) | |
| 407 | 420 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |
| 408 | 421 | } | |
| 409 | 422 | c.sourceEmpty = len(c.source.tabs) == 0 | |
| @@ -12,7 +12,7 @@ type moveTabAdmission struct { | |||||
| 12 | tab *tab | 12 | tab *tab | ||
| 13 | sourceIndex int | 13 | sourceIndex int | ||
| 14 | sourceClient *attachedClient | 14 | sourceClient *attachedClient | ||
| 15 | - | sourceSnatched int | 15 | + | sourceSnatched []*attachedClient |
| 16 | layoutGeneration uint64 | 16 | layoutGeneration uint64 | ||
| 17 | floatingState floatingState | 17 | floatingState floatingState | ||
| 18 | floatingPane *pane | 18 | floatingPane *pane | ||
| @@ -30,6 +30,7 @@ type moveTabCommit struct { | |||||
| 30 | admission moveTabAdmission | 30 | admission moveTabAdmission | ||
| 31 | handoffFrozen bool | 31 | handoffFrozen bool | ||
| 32 | sourceRolesFrozen bool | 32 | sourceRolesFrozen bool | ||
| 33 | + | frozenRoles frozenRoleEffectGates | |||
| 33 | handoffReq attachmentTransitionRequest | 34 | handoffReq attachmentTransitionRequest | ||
| 34 | handoffPublication *attachmentPublication | 35 | handoffPublication *attachmentPublication | ||
| 35 | handoffResult attachmentTransitionResult | 36 | handoffResult attachmentTransitionResult | ||
| @@ -86,6 +87,9 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||||
| 86 | if err != nil { | 87 | if err != nil { | ||
| 87 | return err | 88 | return err | ||
| 88 | } | 89 | } | ||
| 90 | + | if d.afterMoveTabSourceSnapshot != nil { | |||
| 91 | + | d.afterMoveTabSourceSnapshot() | |||
| 92 | + | } | |||
| 89 | commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission} | 93 | commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission} | ||
| 90 | var frozen frozenRoleEffectGates | 94 | var frozen frozenRoleEffectGates | ||
| 91 | if admission.finalSource && admission.sourceClient != nil { | 95 | if admission.finalSource && admission.sourceClient != nil { | ||
| @@ -107,7 +111,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||||
| 107 | } | 111 | } | ||
| 108 | d.mu.Lock() | 112 | d.mu.Lock() | ||
| 109 | source.mu.Lock() | 113 | source.mu.Lock() | ||
| 110 | - | for ac := range source.snatched { | 114 | + | for _, ac := range admission.sourceSnatched { |
| 111 | participants.clients = append(participants.clients, ac) | 115 | participants.clients = append(participants.clients, ac) | ||
| 112 | if tr := ac.transportSnapshot(); tr.transport != nil { | 116 | if tr := ac.transportSnapshot(); tr.transport != nil { | ||
| 113 | participants.interrupts = append(participants.interrupts, roleTransportInterrupt{ac: ac, transport: tr}) | 117 | participants.interrupts = append(participants.interrupts, roleTransportInterrupt{ac: ac, transport: tr}) | ||
| @@ -127,13 +131,12 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||||
| 127 | commit.handoffFrozen = true | 131 | commit.handoffFrozen = true | ||
| 128 | commit.sourceRolesFrozen = true | 132 | commit.sourceRolesFrozen = true | ||
| 129 | commit.handoffReq.roleEffectsFrozen = true | 133 | commit.handoffReq.roleEffectsFrozen = true | ||
| 130 | - | } else if admission.finalSource && admission.sourceSnatched > 0 { | 134 | + | } else if admission.finalSource && len(admission.sourceSnatched) > 0 { |
| 131 | d.mu.Lock() | 135 | d.mu.Lock() | ||
| 132 | source.mu.Lock() | 136 | source.mu.Lock() | ||
| 133 | - | clients := make([]*attachedClient, 0, len(source.snatched)) | 137 | + | clients := append([]*attachedClient(nil), admission.sourceSnatched...) |
| 134 | - | interrupts := make([]roleTransportInterrupt, 0, len(source.snatched)) | 138 | + | interrupts := make([]roleTransportInterrupt, 0, len(admission.sourceSnatched)) |
| 135 | - | for ac := range source.snatched { | 139 | + | for _, ac := range admission.sourceSnatched { |
| 136 | - | clients = append(clients, ac) | |||
| 137 | if tr := ac.transportSnapshot(); tr.transport != nil { | 140 | if tr := ac.transportSnapshot(); tr.transport != nil { | ||
| 138 | interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: tr}) | 141 | interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: tr}) | ||
| 139 | } | 142 | } | ||
| @@ -147,6 +150,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||||
| 147 | } | 150 | } | ||
| 148 | commit.sourceRolesFrozen = true | 151 | commit.sourceRolesFrozen = true | ||
| 149 | } | 152 | } | ||
| 153 | + | commit.frozenRoles = frozen | |||
| 150 | rolesFrozen := commit.sourceRolesFrozen | 154 | rolesFrozen := commit.sourceRolesFrozen | ||
| 151 | defer func() { | 155 | defer func() { | ||
| 152 | if rolesFrozen { | 156 | if rolesFrozen { | ||
| @@ -269,7 +273,7 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||||
| 269 | idx := indexMoveTabLocked(source, moved) | 273 | idx := indexMoveTabLocked(source, moved) | ||
| 270 | return &moveTabAdmission{ | 274 | return &moveTabAdmission{ | ||
| 271 | tab: moved, sourceIndex: idx, | 275 | tab: moved, sourceIndex: idx, | ||
| 272 | - | sourceClient: source.client, sourceSnatched: len(source.snatched), | 276 | + | sourceClient: source.client, sourceSnatched: snapshotMoveSnatchedLocked(source), |
| 273 | layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state, | 277 | layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state, | ||
| 274 | floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation, | 278 | floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation, | ||
| 275 | panes: panes, destinationActive: destinationActive, destinationSize: destinationSize, | 279 | panes: panes, destinationActive: destinationActive, destinationSize: destinationSize, | ||
| @@ -307,7 +311,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||||
| 307 | // precede moved.mu. The resize fences and ordered session locks keep topology | 311 | // precede moved.mu. The resize fences and ordered session locks keep topology | ||
| 308 | // stable until the tab/pane checks below complete. | 312 | // stable until the tab/pane checks below complete. | ||
| 309 | if c.handoffFrozen { | 313 | if c.handoffFrozen { | ||
| 310 | - | if c.source.client != c.admission.sourceClient || len(c.source.snatched) != c.admission.sourceSnatched { | 314 | + | if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) { |
| 311 | return false | 315 | return false | ||
| 312 | } | 316 | } | ||
| 313 | publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq) | 317 | publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq) | ||
| @@ -318,8 +322,17 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||||
| 318 | c.handoffPublication = publication | 322 | c.handoffPublication = publication | ||
| 319 | defer c.releasePublication() | 323 | defer c.releasePublication() | ||
| 320 | } | 324 | } | ||
| 321 | - | if c.sourceRolesFrozen && len(c.source.snatched) != c.admission.sourceSnatched { | 325 | + | var retirement frozenMoveAttachmentRetirement |
| 322 | - | return false | 326 | + | if c.admission.finalSource { |
| 327 | + | if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) || | |||
| 328 | + | (c.admission.sourceClient != nil) != c.handoffFrozen { | |||
| 329 | + | return false | |||
| 330 | + | } | |||
| 331 | + | var retirementOK bool | |||
| 332 | + | retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.admission.sourceSnatched, c.frozenRoles) | |||
| 333 | + | if !retirementOK { | |||
| 334 | + | return false | |||
| 335 | + | } | |||
| 323 | } | 336 | } | ||
| 324 | if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive { | 337 | if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive { | ||
| 325 | return false | 338 | return false | ||
| @@ -403,7 +416,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||||
| 403 | } | 416 | } | ||
| 404 | if len(c.source.tabs) == 0 { | 417 | if len(c.source.tabs) == 0 { | ||
| 405 | delete(d.sessions, c.source.id) | 418 | delete(d.sessions, c.source.id) | ||
| 406 | - | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source) | 419 | + | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) |
| 407 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | 420 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | ||
| 408 | } | 421 | } | ||
| 409 | c.sourceEmpty = len(c.source.tabs) == 0 | 422 | c.sourceEmpty = len(c.source.tabs) == 0 | ||
M internal/usecase/daemon/role_effect_freeze.go +9 -0
| @@ -72,6 +72,15 @@ func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transpor | |||
| 72 | 72 | return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | + | func (f frozenRoleEffectGates) contains(target *attachedClient) bool { | |
| 76 | + | for _, ac := range f.clients { | |
| 77 | + | if ac == target { | |
| 78 | + | return true | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | return false | |
| 82 | + | } | |
| 83 | + | ||
| 75 | 84 | type roleTransportInterrupt struct { | |
| 76 | 85 | ac *attachedClient | |
| 77 | 86 | transport transportSnapshot | |
| @@ -72,6 +72,15 @@ func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transpor | |||||
| 72 | return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation | 72 | return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation | ||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | + | func (f frozenRoleEffectGates) contains(target *attachedClient) bool { | |||
| 76 | + | for _, ac := range f.clients { | |||
| 77 | + | if ac == target { | |||
| 78 | + | return true | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | return false | |||
| 82 | + | } | |||
| 83 | + | ||||
| 75 | type roleTransportInterrupt struct { | 84 | type roleTransportInterrupt struct { | ||
| 76 | ac *attachedClient | 85 | ac *attachedClient | ||
| 77 | transport transportSnapshot | 86 | transport transportSnapshot | ||