detached 96740f3

fix(daemon): harden final move retirement

commit
96740f3172e4ebedcbb0a6ee0da106e9add89e71
author
brice · · 2026-07-28 12:29 UTC
parent
35e7583
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
162162 // perform external work while locks are held.
163163 afterMoveLifecycleReserved func()
164164 afterMovePaneSourceSnapshot func()
165+ afterMoveTabSourceSnapshot func()
165166 beforeMovePaneCommit func()
166167 beforeMoveTabCommit func()
167168 // 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 the168 // 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) {
7373 movedPane := admission.movedPane
7474 sourceClient := admission.sourceClient
7575 destinationClient := admission.destinationClient
76- sourceSnatchedCount := admission.sourceSnatched
76+ sourceSnatched := admission.sourceSnatched
7777 sourceTabWasActive := admission.sourceTabWasActive
7878 sourceTabInitialGeneration := admission.sourceGeneration
7979 destinationTabInitialGeneration := admission.destinationGeneration
@@ -117,7 +117,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
117117 // the follower. Their exact membership is checked again in the commit.
118118 d.mu.Lock()
119119 source.mu.Lock()
120- for ac := range source.snatched {
120+ for _, ac := range sourceSnatched {
121121 handoffParticipants.clients = append(handoffParticipants.clients, ac)
122122 tr := ac.transportSnapshot()
123123 if tr.transport != nil {
@@ -138,15 +138,14 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
138138 handoffFrozen = true
139139 sourceRolesFrozen = true
140140 handoffReq.roleEffectsFrozen = true
141- } else if finalSourceTab && source != destination && sourceSnatchedCount > 0 {
141+ } else if finalSourceTab && source != destination && len(sourceSnatched) > 0 {
142142 // A headless source may still have persistent snatched clients. Freeze
143143 // their exact role gates before retiring the source registry.
144144 d.mu.Lock()
145145 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 {
150149 if transport := ac.transportSnapshot(); transport.transport != nil {
151150 interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport})
152151 }
@@ -176,11 +175,11 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
176175 destinationTab: destinationTab,
177176 movedPane: movedPane,
178177 sourceClient: sourceClient,
179- sourceSnatchedCount: sourceSnatchedCount,
178+ sourceSnatched: sourceSnatched,
180179 sourceGeneration: sourceTabInitialGeneration,
181180 destinationGeneration: destinationTabInitialGeneration,
182181 handoffFrozen: handoffFrozen,
183- sourceRolesFrozen: sourceRolesFrozen,
182+ frozenRoles: frozen,
184183 handoffReq: handoffReq,
185184 }
186185 fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane)
@@ -73,7 +73,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
73 movedPane := admission.movedPane73 movedPane := admission.movedPane
74 sourceClient := admission.sourceClient74 sourceClient := admission.sourceClient
75 destinationClient := admission.destinationClient75 destinationClient := admission.destinationClient
76- sourceSnatchedCount := admission.sourceSnatched76+ sourceSnatched := admission.sourceSnatched
77 sourceTabWasActive := admission.sourceTabWasActive77 sourceTabWasActive := admission.sourceTabWasActive
78 sourceTabInitialGeneration := admission.sourceGeneration78 sourceTabInitialGeneration := admission.sourceGeneration
79 destinationTabInitialGeneration := admission.destinationGeneration79 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 = true138 handoffFrozen = true
139 sourceRolesFrozen = true139 sourceRolesFrozen = true
140 handoffReq.roleEffectsFrozen = true140 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. Freeze142 // 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 {
1515 movedPane *pane
1616 sourceClient *attachedClient
1717 destinationClient *attachedClient
18- sourceSnatched int
18+ sourceSnatched []*attachedClient
1919 sourceGeneration uint64
2020 destinationGeneration uint64
2121 finalSourceTab bool
@@ -64,7 +64,7 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
6464 movedPane: movedPane,
6565 sourceClient: source.client,
6666 destinationClient: destination.client,
67- sourceSnatched: len(source.snatched),
67+ sourceSnatched: snapshotMoveSnatchedLocked(source),
6868 sourceGeneration: sourceTab.layoutGeneration,
6969 destinationGeneration: destinationTab.layoutGeneration,
7070 finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&
@@ -15,7 +15,7 @@ type movePaneAdmission struct {
15 movedPane *pane15 movedPane *pane
16 sourceClient *attachedClient16 sourceClient *attachedClient
17 destinationClient *attachedClient17 destinationClient *attachedClient
18- sourceSnatched int18+ sourceSnatched []*attachedClient
19 sourceGeneration uint6419 sourceGeneration uint64
20 destinationGeneration uint6420 destinationGeneration uint64
21 finalSourceTab bool21 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 {
1414 sourceTab, destinationTab *tab
1515 movedPane *pane
1616 sourceClient *attachedClient
17- sourceSnatchedCount int
17+ sourceSnatched []*attachedClient
1818 sourceGeneration uint64
1919 destinationGeneration uint64
2020 handoffFrozen bool
21- sourceRolesFrozen bool
21+ frozenRoles frozenRoleEffectGates
2222 handoffReq attachmentTransitionRequest
2323 handoffPublication *attachmentPublication
2424 err error
@@ -45,6 +45,7 @@ func (c *movePaneCommit) releasePublication() {
4545
4646 func (c *movePaneCommit) publishLocked(d *Daemon) bool {
4747 var candidate *movePaneCandidate
48+ var retirement frozenMoveAttachmentRetirement
4849 var err error
4950 d.mu.Lock()
5051 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 {
6162 return false
6263 }
6364 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) ||
6566 c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) ||
6667 c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab {
6768 return false
@@ -100,12 +101,16 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
100101 }
101102
102103 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+ }
109114 }
110115
111116 // Everything above is fallible. Topology, owner, lifecycle registry, and
@@ -152,7 +157,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
152157
153158 if candidate.removeSourceTab && len(c.source.tabs) == 0 {
154159 delete(d.sessions, c.source.id)
155- c.retiredAttachments = retireEmptyMoveSessionLocked(c.source)
160+ c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
156161 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
157162 }
158163 c.sourceEmpty = len(c.source.tabs) == 0
@@ -14,11 +14,11 @@ type movePaneCommit struct {
14 sourceTab, destinationTab *tab14 sourceTab, destinationTab *tab
15 movedPane *pane15 movedPane *pane
16 sourceClient *attachedClient16 sourceClient *attachedClient
17- sourceSnatchedCount int17+ sourceSnatched []*attachedClient
18 sourceGeneration uint6418 sourceGeneration uint64
19 destinationGeneration uint6419 destinationGeneration uint64
20 handoffFrozen bool20 handoffFrozen bool
21- sourceRolesFrozen bool21+ frozenRoles frozenRoleEffectGates
22 handoffReq attachmentTransitionRequest22 handoffReq attachmentTransitionRequest
23 handoffPublication *attachmentPublication23 handoffPublication *attachmentPublication
24 err error24 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 *movePaneCandidate47 var candidate *movePaneCandidate
48+ var retirement frozenMoveAttachmentRetirement
48 var err error49 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 false62 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 false68 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) == 1103 sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1
103- needsHandoff := sourceWillEmpty && c.source != c.destination && c.sourceClient != nil104+ 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 false106+ (c.sourceClient != nil) != c.handoffFrozen {
106- }107+ return false
107- if c.sourceRolesFrozen && len(c.source.snatched) != c.sourceSnatchedCount {108+ }
108- return false109+ 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, and116 // 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) == 0163 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) {
127127 // retireEmptyMoveSessionLocked removes all source-owned attachment roles that
128128 // were not transferred. It runs inside the move commit, after the moved pane
129129 // 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 {
131131 if sess == nil || sess.client != nil {
132132 return nil
133133 }
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 {
136136 retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()})
137+ delete(sess.snatched, ac)
137138 ac.roleGeneration.Add(1)
138139 ac.setSession(nil)
139140 ac.invalidateFrozenRoleCapability()
140141 }
141- clear(sess.snatched)
142142 sess.tabs = nil
143143 sess.active = -1
144144 return retired
@@ -127,18 +127,18 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
127 // retireEmptyMoveSessionLocked removes all source-owned attachment roles that127 // retireEmptyMoveSessionLocked removes all source-owned attachment roles that
128 // were not transferred. It runs inside the move commit, after the moved pane128 // 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 nil132 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 = nil142 sess.tabs = nil
143 sess.active = -1143 sess.active = -1
144 return retired144 return retired
M internal/usecase/daemon/move_tab.go +25 -12
@@ -12,7 +12,7 @@ type moveTabAdmission struct {
1212 tab *tab
1313 sourceIndex int
1414 sourceClient *attachedClient
15- sourceSnatched int
15+ sourceSnatched []*attachedClient
1616 layoutGeneration uint64
1717 floatingState floatingState
1818 floatingPane *pane
@@ -30,6 +30,7 @@ type moveTabCommit struct {
3030 admission moveTabAdmission
3131 handoffFrozen bool
3232 sourceRolesFrozen bool
33+ frozenRoles frozenRoleEffectGates
3334 handoffReq attachmentTransitionRequest
3435 handoffPublication *attachmentPublication
3536 handoffResult attachmentTransitionResult
@@ -86,6 +87,9 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
8687 if err != nil {
8788 return err
8889 }
90+ if d.afterMoveTabSourceSnapshot != nil {
91+ d.afterMoveTabSourceSnapshot()
92+ }
8993 commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}
9094 var frozen frozenRoleEffectGates
9195 if admission.finalSource && admission.sourceClient != nil {
@@ -107,7 +111,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
107111 }
108112 d.mu.Lock()
109113 source.mu.Lock()
110- for ac := range source.snatched {
114+ for _, ac := range admission.sourceSnatched {
111115 participants.clients = append(participants.clients, ac)
112116 if tr := ac.transportSnapshot(); tr.transport != nil {
113117 participants.interrupts = append(participants.interrupts, roleTransportInterrupt{ac: ac, transport: tr})
@@ -127,13 +131,12 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
127131 commit.handoffFrozen = true
128132 commit.sourceRolesFrozen = true
129133 commit.handoffReq.roleEffectsFrozen = true
130- } else if admission.finalSource && admission.sourceSnatched > 0 {
134+ } else if admission.finalSource && len(admission.sourceSnatched) > 0 {
131135 d.mu.Lock()
132136 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 {
137140 if tr := ac.transportSnapshot(); tr.transport != nil {
138141 interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: tr})
139142 }
@@ -147,6 +150,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
147150 }
148151 commit.sourceRolesFrozen = true
149152 }
153+ commit.frozenRoles = frozen
150154 rolesFrozen := commit.sourceRolesFrozen
151155 defer func() {
152156 if rolesFrozen {
@@ -269,7 +273,7 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
269273 idx := indexMoveTabLocked(source, moved)
270274 return &moveTabAdmission{
271275 tab: moved, sourceIndex: idx,
272- sourceClient: source.client, sourceSnatched: len(source.snatched),
276+ sourceClient: source.client, sourceSnatched: snapshotMoveSnatchedLocked(source),
273277 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,
274278 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,
275279 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,
@@ -307,7 +311,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
307311 // precede moved.mu. The resize fences and ordered session locks keep topology
308312 // stable until the tab/pane checks below complete.
309313 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) {
311315 return false
312316 }
313317 publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq)
@@ -318,8 +322,17 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
318322 c.handoffPublication = publication
319323 defer c.releasePublication()
320324 }
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+ }
323336 }
324337 if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive {
325338 return false
@@ -403,7 +416,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
403416 }
404417 if len(c.source.tabs) == 0 {
405418 delete(d.sessions, c.source.id)
406- c.retiredAttachments = retireEmptyMoveSessionLocked(c.source)
419+ c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
407420 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
408421 }
409422 c.sourceEmpty = len(c.source.tabs) == 0
@@ -12,7 +12,7 @@ type moveTabAdmission struct {
12 tab *tab12 tab *tab
13 sourceIndex int13 sourceIndex int
14 sourceClient *attachedClient14 sourceClient *attachedClient
15- sourceSnatched int15+ sourceSnatched []*attachedClient
16 layoutGeneration uint6416 layoutGeneration uint64
17 floatingState floatingState17 floatingState floatingState
18 floatingPane *pane18 floatingPane *pane
@@ -30,6 +30,7 @@ type moveTabCommit struct {
30 admission moveTabAdmission30 admission moveTabAdmission
31 handoffFrozen bool31 handoffFrozen bool
32 sourceRolesFrozen bool32 sourceRolesFrozen bool
33+ frozenRoles frozenRoleEffectGates
33 handoffReq attachmentTransitionRequest34 handoffReq attachmentTransitionRequest
34 handoffPublication *attachmentPublication35 handoffPublication *attachmentPublication
35 handoffResult attachmentTransitionResult36 handoffResult attachmentTransitionResult
@@ -86,6 +87,9 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
86 if err != nil {87 if err != nil {
87 return err88 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 frozenRoleEffectGates94 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 = true131 commit.handoffFrozen = true
128 commit.sourceRolesFrozen = true132 commit.sourceRolesFrozen = true
129 commit.handoffReq.roleEffectsFrozen = true133 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 = true151 commit.sourceRolesFrozen = true
149 }152 }
153+ commit.frozenRoles = frozen
150 rolesFrozen := commit.sourceRolesFrozen154 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 topology311 // 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 false315 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 = publication322 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 false326+ 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 false338 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) == 0422 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
7272 return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation
7373 }
7474
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+
7584 type roleTransportInterrupt struct {
7685 ac *attachedClient
7786 transport transportSnapshot
@@ -72,6 +72,15 @@ func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transpor
72 return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation72 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 *attachedClient85 ac *attachedClient
77 transport transportSnapshot86 transport transportSnapshot

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close