detached ac63f74

feat(daemon): move tabs between live sessions

commit
ac63f7437dfb3885129a0c551a469385e1a1d264
author
brice · · 2026-07-28 10:29 UTC
parent
bf32e20
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +1434 -4
M internal/usecase/daemon/attachment_transition.go +6 -2
@@ -19,8 +19,12 @@ type attachmentTransitionRequest struct {
1919 // preserveRole commits navigation state without replacing attachment
2020 // ownership. It is used for same-session picker selections after the exact
2121 // initiating capability has been frozen and revalidated.
22- preserveRole bool
23- expectedSourceTab *tab
22+ preserveRole bool
23+ expectedSourceTab *tab
24+ // transferExpectedSourceTab permits an installed visible floating pane only
25+ // when the caller atomically transfers this exact tab under its own tab and
26+ // floating-generation fences. Ordinary navigation still rejects visibility.
27+ transferExpectedSourceTab bool
2428 createTargetLocked func() (*session, error)
2529 ready bool
2630 expectedTargetCurrent *attachedClient
@@ -19,8 +19,12 @@ type attachmentTransitionRequest struct {
19 // preserveRole commits navigation state without replacing attachment19 // preserveRole commits navigation state without replacing attachment
20 // ownership. It is used for same-session picker selections after the exact20 // ownership. It is used for same-session picker selections after the exact
21 // initiating capability has been frozen and revalidated.21 // initiating capability has been frozen and revalidated.
22- preserveRole bool22+ preserveRole bool
23- expectedSourceTab *tab23+ expectedSourceTab *tab
24+ // transferExpectedSourceTab permits an installed visible floating pane only
25+ // when the caller atomically transfers this exact tab under its own tab and
26+ // floating-generation fences. Ordinary navigation still rejects visibility.
27+ transferExpectedSourceTab bool
24 createTargetLocked func() (*session, error)28 createTargetLocked func() (*session, error)
25 ready bool29 ready bool
26 expectedTargetCurrent *attachedClient30 expectedTargetCurrent *attachedClient
M internal/usecase/daemon/attachment_transition_publish.go +6 -2
@@ -50,6 +50,10 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
5050 }
5151
5252 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
53+ return transitionSourceTabCurrentForRequestLocked(source, expected, false)
54+}
55+
56+func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool {
5357 if expected == nil {
5458 return true
5559 }
@@ -58,7 +62,7 @@ func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
5862 }
5963 expected.mu.Lock()
6064 defer expected.mu.Unlock()
61- return expected.floating.state != floatingVisible
65+ return transferExpected || expected.floating.state != floatingVisible
6266 }
6367
6468 // transitionAttachmentLocked is the publication half of transitionAttachment.
@@ -127,7 +131,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
127131 releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),
128132 }
129133 invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
130- !transitionSourceTabCurrentLocked(source, req.expectedSourceTab))
134+ !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab))
131135 invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
132136 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
133137 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
@@ -50,6 +50,10 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
50 }50 }
51 51
52 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {52 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
53+ return transitionSourceTabCurrentForRequestLocked(source, expected, false)
54+}
55+
56+func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool {
53 if expected == nil {57 if expected == nil {
54 return true58 return true
55 }59 }
@@ -58,7 +62,7 @@ func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
58 }62 }
59 expected.mu.Lock()63 expected.mu.Lock()
60 defer expected.mu.Unlock()64 defer expected.mu.Unlock()
61- return expected.floating.state != floatingVisible65+ return transferExpected || expected.floating.state != floatingVisible
62 }66 }
63 67
64 // transitionAttachmentLocked is the publication half of transitionAttachment.68 // transitionAttachmentLocked is the publication half of transitionAttachment.
@@ -127,7 +131,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
127 releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),131 releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),
128 }132 }
129 invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||133 invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
130- !transitionSourceTabCurrentLocked(source, req.expectedSourceTab))134+ !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab))
131 invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)135 invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
132 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&136 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
133 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)137 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
M internal/usecase/daemon/daemon.go +1 -0
@@ -163,6 +163,7 @@ type Daemon struct {
163163 afterMoveLifecycleReserved func()
164164 afterMovePaneSourceSnapshot func()
165165 beforeMovePaneCommit func()
166+ beforeMoveTabCommit func()
166167 // afterDetachRoleEffectsFrozen observes terminal detach after it wins the
167168 // attachment gate but before it checks session ownership.
168169 afterDetachRoleEffectsFrozen func()
@@ -163,6 +163,7 @@ type Daemon struct {
163 afterMoveLifecycleReserved func()163 afterMoveLifecycleReserved func()
164 afterMovePaneSourceSnapshot func()164 afterMovePaneSourceSnapshot func()
165 beforeMovePaneCommit func()165 beforeMovePaneCommit func()
166+ beforeMoveTabCommit func()
166 // afterDetachRoleEffectsFrozen observes terminal detach after it wins the167 // afterDetachRoleEffectsFrozen observes terminal detach after it wins the
167 // attachment gate but before it checks session ownership.168 // attachment gate but before it checks session ownership.
168 afterDetachRoleEffectsFrozen func()169 afterDetachRoleEffectsFrozen func()
M internal/usecase/daemon/move.go +6 -0
@@ -15,6 +15,12 @@ type movePaneRequest struct {
1515 DestinationTabID domain.TabStableID
1616 }
1717
18+type moveTabRequest struct {
19+ Source moveSessionLocator
20+ SourceTabID domain.TabStableID
21+ Destination moveSessionLocator
22+}
23+
1824 // movePane resolves, reserves, validates, and commits one pane relocation. All
1925 // fallible work happens before the membership publication section. Once the
2026 // section starts, the caller holds the ordered architecture locks and only
@@ -15,6 +15,12 @@ type movePaneRequest struct {
15 DestinationTabID domain.TabStableID15 DestinationTabID domain.TabStableID
16 }16 }
17 17
18+type moveTabRequest struct {
19+ Source moveSessionLocator
20+ SourceTabID domain.TabStableID
21+ Destination moveSessionLocator
22+}
23+
18 // movePane resolves, reserves, validates, and commits one pane relocation. All24 // movePane resolves, reserves, validates, and commits one pane relocation. All
19 // fallible work happens before the membership publication section. Once the25 // fallible work happens before the membership publication section. Once the
20 // section starts, the caller holds the ordered architecture locks and only26 // section starts, the caller holds the ordered architecture locks and only
M internal/usecase/daemon/move_handoff_failure_test.go +77 -0
@@ -251,3 +251,80 @@ func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
251251 after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
252252 require.Equal(t, before, after, "stale handoff validation must reject before any move publication")
253253 }
254+
255+type moveTabHandoffState struct {
256+ closing bool
257+ registry map[domain.SessionID]*session
258+ source moveSessionState
259+ destination moveSessionState
260+ movedTab moveTabState
261+ follower moveClientState
262+}
263+
264+func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *tab, follower *attachedClient) moveTabHandoffState {
265+ d.mu.Lock()
266+ d.notices.routingMu.Lock()
267+ unlockSessions := lockAttachmentSessions(source, destination)
268+ moved.mu.Lock()
269+
270+ registry := make(map[domain.SessionID]*session, len(d.sessions))
271+ maps.Copy(registry, d.sessions)
272+ state := moveTabHandoffState{
273+ closing: d.closing,
274+ registry: registry,
275+ source: captureMoveSessionStateLocked(source),
276+ destination: captureMoveSessionStateLocked(destination),
277+ movedTab: captureMoveTabStateLocked(moved),
278+ follower: captureMoveClientStateLocked(source, destination, follower),
279+ }
280+
281+ moved.mu.Unlock()
282+ unlockSessions()
283+ d.notices.routingMu.Unlock()
284+ d.mu.Unlock()
285+ return state
286+}
287+
288+func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
289+ d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)
290+ floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
291+ floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx)
292+ t.Cleanup(floating.cancel)
293+ moved.mu.Lock()
294+ moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 9}
295+ moved.mu.Unlock()
296+ publishPaneOwner(floating, source, moved, 9)
297+
298+ before := captureMoveTabHandoffState(d, source, destination, moved, follower)
299+ originalTransport := before.follower.transport
300+ staleTransport := &closeTrackingTransport{}
301+ staleInjected := make(chan struct{})
302+ d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) {
303+ if action != "move-tab" || client != follower {
304+ return
305+ }
306+ client.replaceTransport(staleTransport)
307+ close(staleInjected)
308+ }
309+
310+ moveDone := make(chan error, 1)
311+ go func() {
312+ moveDone <- d.moveTab(moveTabRequest{
313+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
314+ SourceTabID: domain.TabStableID(moved.stableID),
315+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
316+ })
317+ }()
318+ <-staleInjected
319+ err := <-moveDone
320+
321+ follower.linkMu.Lock()
322+ follower.tr = originalTransport.transport
323+ follower.transportIncarnation = originalTransport.incarnation
324+ follower.linkMu.Unlock()
325+ d.afterRoleEffectGateFrozen = nil
326+
327+ require.ErrorIs(t, err, errAttachmentTransition)
328+ after := captureMoveTabHandoffState(d, source, destination, moved, follower)
329+ require.Equal(t, before, after, "stale move-tab handoff validation must reject before any move publication")
330+}
@@ -251,3 +251,80 @@ func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
251 after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)251 after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
252 require.Equal(t, before, after, "stale handoff validation must reject before any move publication")252 require.Equal(t, before, after, "stale handoff validation must reject before any move publication")
253 }253 }
254+
255+type moveTabHandoffState struct {
256+ closing bool
257+ registry map[domain.SessionID]*session
258+ source moveSessionState
259+ destination moveSessionState
260+ movedTab moveTabState
261+ follower moveClientState
262+}
263+
264+func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *tab, follower *attachedClient) moveTabHandoffState {
265+ d.mu.Lock()
266+ d.notices.routingMu.Lock()
267+ unlockSessions := lockAttachmentSessions(source, destination)
268+ moved.mu.Lock()
269+
270+ registry := make(map[domain.SessionID]*session, len(d.sessions))
271+ maps.Copy(registry, d.sessions)
272+ state := moveTabHandoffState{
273+ closing: d.closing,
274+ registry: registry,
275+ source: captureMoveSessionStateLocked(source),
276+ destination: captureMoveSessionStateLocked(destination),
277+ movedTab: captureMoveTabStateLocked(moved),
278+ follower: captureMoveClientStateLocked(source, destination, follower),
279+ }
280+
281+ moved.mu.Unlock()
282+ unlockSessions()
283+ d.notices.routingMu.Unlock()
284+ d.mu.Unlock()
285+ return state
286+}
287+
288+func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
289+ d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)
290+ floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
291+ floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx)
292+ t.Cleanup(floating.cancel)
293+ moved.mu.Lock()
294+ moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 9}
295+ moved.mu.Unlock()
296+ publishPaneOwner(floating, source, moved, 9)
297+
298+ before := captureMoveTabHandoffState(d, source, destination, moved, follower)
299+ originalTransport := before.follower.transport
300+ staleTransport := &closeTrackingTransport{}
301+ staleInjected := make(chan struct{})
302+ d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) {
303+ if action != "move-tab" || client != follower {
304+ return
305+ }
306+ client.replaceTransport(staleTransport)
307+ close(staleInjected)
308+ }
309+
310+ moveDone := make(chan error, 1)
311+ go func() {
312+ moveDone <- d.moveTab(moveTabRequest{
313+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
314+ SourceTabID: domain.TabStableID(moved.stableID),
315+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
316+ })
317+ }()
318+ <-staleInjected
319+ err := <-moveDone
320+
321+ follower.linkMu.Lock()
322+ follower.tr = originalTransport.transport
323+ follower.transportIncarnation = originalTransport.incarnation
324+ follower.linkMu.Unlock()
325+ d.afterRoleEffectGateFrozen = nil
326+
327+ require.ErrorIs(t, err, errAttachmentTransition)
328+ after := captureMoveTabHandoffState(d, source, destination, moved, follower)
329+ require.Equal(t, before, after, "stale move-tab handoff validation must reject before any move publication")
330+}
A internal/usecase/daemon/move_tab.go +439 -0
@@ -0,0 +1,439 @@
1+package daemon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/layout"
9+)
10+
11+type moveTabAdmission struct {
12+ tab *tab
13+ sourceIndex int
14+ sourceClient *attachedClient
15+ sourceSnatched int
16+ layoutGeneration uint64
17+ floatingState floatingState
18+ floatingPane *pane
19+ floatingGeneration uint64
20+ panes []*pane
21+ destinationActive *tab
22+ destinationSize domain.Size
23+ finalSource bool
24+}
25+
26+type moveTabCommit struct {
27+ req moveTabRequest
28+ source *session
29+ destination *session
30+ admission moveTabAdmission
31+ handoffFrozen bool
32+ sourceRolesFrozen bool
33+ handoffReq attachmentTransitionRequest
34+ handoffPublication *attachmentPublication
35+ handoffResult attachmentTransitionResult
36+ syncCleanup syncTimerCleanup
37+ sourceCleanupToken attachmentRoleToken
38+ oldTabCancel context.CancelFunc
39+ sourceEmpty bool
40+ retiredParked []parkedAttachmentRetirement
41+ retiredAttachments []detachedAttachmentSnapshot
42+ sourceMetadata domain.CatalogueMetadataUpdate
43+ sourceMetadataValid bool
44+ destMetadata domain.CatalogueMetadataUpdate
45+ destMetadataValid bool
46+ err error
47+}
48+
49+func (d *Daemon) moveTab(req moveTabRequest) error {
50+ if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
51+ return errMovePaneInvalid
52+ }
53+ d.mu.Lock()
54+ source := moveSessionForLocatorLocked(d, req.Source)
55+ destination := moveSessionForLocatorLocked(d, req.Destination)
56+ d.mu.Unlock()
57+ if source == nil || destination == nil || source == destination {
58+ return errMovePaneInvalid
59+ }
60+ reservation, err := d.reserveMoveLifecycles(source, destination)
61+ if err != nil {
62+ return err
63+ }
64+ reservationHeld := true
65+ defer func() {
66+ if reservationHeld {
67+ reservation.Release()
68+ }
69+ }()
70+ if d.afterMoveLifecycleReserved != nil {
71+ d.afterMoveLifecycleReserved()
72+ }
73+ unlockDispatch := lockMoveDispatch(source, destination)
74+ dispatchHeld := true
75+ defer func() {
76+ if dispatchHeld {
77+ unlockDispatch()
78+ }
79+ }()
80+
81+ admission, err := d.snapshotMoveTabAdmission(req, source, destination)
82+ if err != nil {
83+ return err
84+ }
85+ commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}
86+ var frozen frozenRoleEffectGates
87+ if admission.finalSource && admission.sourceClient != nil {
88+ transport := admission.sourceClient.transportSnapshot()
89+ token := source.attachmentToken(admission.sourceClient, transport.transport)
90+ if token.role != attachmentActive || token.transport.transport == nil {
91+ return errMovePaneInvalid
92+ }
93+ commit.handoffReq = attachmentTransitionRequest{
94+ source: source, target: destination, next: admission.sourceClient,
95+ expectedRole: attachmentActive, targetRole: attachmentActive,
96+ expectedTransport: token.transport, sourceToken: &token,
97+ expectedSourceTab: admission.tab, transferExpectedSourceTab: true, ready: true, action: "move-tab",
98+ }
99+ var participants attachmentTransitionParticipants
100+ commit.handoffReq, participants, err = d.snapshotAttachmentTransition(commit.handoffReq)
101+ if err != nil {
102+ return err
103+ }
104+ d.mu.Lock()
105+ source.mu.Lock()
106+ for ac := range source.snatched {
107+ participants.clients = append(participants.clients, ac)
108+ if tr := ac.transportSnapshot(); tr.transport != nil {
109+ participants.interrupts = append(participants.interrupts, roleTransportInterrupt{ac: ac, transport: tr})
110+ }
111+ }
112+ source.mu.Unlock()
113+ d.mu.Unlock()
114+ frozen = tryFreezeRoleEffectGatesInterruptingObserved(participants.interrupts, func(ac *attachedClient) {
115+ if d.afterRoleEffectGateFrozen != nil {
116+ d.afterRoleEffectGateFrozen("move-tab", ac)
117+ }
118+ }, participants.clients...)
119+ if !frozen.acquired || !frozen.drained {
120+ frozen.unfreeze()
121+ return errMovePaneInvalid
122+ }
123+ commit.handoffFrozen = true
124+ commit.sourceRolesFrozen = true
125+ commit.handoffReq.roleEffectsFrozen = true
126+ } else if admission.finalSource && admission.sourceSnatched > 0 {
127+ d.mu.Lock()
128+ source.mu.Lock()
129+ clients := make([]*attachedClient, 0, len(source.snatched))
130+ interrupts := make([]roleTransportInterrupt, 0, len(source.snatched))
131+ for ac := range source.snatched {
132+ clients = append(clients, ac)
133+ if tr := ac.transportSnapshot(); tr.transport != nil {
134+ interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: tr})
135+ }
136+ }
137+ source.mu.Unlock()
138+ d.mu.Unlock()
139+ frozen = tryFreezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...)
140+ if !frozen.acquired || !frozen.drained {
141+ frozen.unfreeze()
142+ return errMovePaneInvalid
143+ }
144+ commit.sourceRolesFrozen = true
145+ }
146+ rolesFrozen := commit.sourceRolesFrozen
147+ defer func() {
148+ if rolesFrozen {
149+ frozen.unfreeze()
150+ }
151+ }()
152+
153+ fences := newMoveTabResizeFences(source, destination, admission.tab)
154+ if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) {
155+ commit.releasePublication()
156+ if commit.err != nil {
157+ return commit.err
158+ }
159+ return errMovePaneInvalid
160+ }
161+ fences.Release()
162+ if commit.oldTabCancel != nil {
163+ commit.oldTabCancel()
164+ }
165+ for _, p := range admission.panes {
166+ if commit.sourceCleanupToken.current() {
167+ commit.sourceCleanupToken.ac.overlays.clearCopyModeForPane(p)
168+ commit.sourceCleanupToken.ac.pruneCaptureFrames(p)
169+ }
170+ }
171+ markSnapshotDirty(destination)
172+ if !commit.sourceEmpty {
173+ markSnapshotDirty(source)
174+ }
175+ commit.syncCleanup.finish()
176+ if rolesFrozen {
177+ frozen.unfreeze()
178+ rolesFrozen = false
179+ }
180+ unlockDispatch()
181+ dispatchHeld = false
182+ reservation.Release()
183+ reservationHeld = false
184+
185+ if !commit.sourceEmpty {
186+ if sourceTab := source.activeTab(); sourceTab != nil {
187+ d.activateTab(source, sourceTab)
188+ d.applyTabLayout(source, sourceTab)
189+ }
190+ }
191+ d.applyTabLayout(destination, admission.tab)
192+ if commit.sourceMetadataValid && !commit.sourceEmpty {
193+ d.markCatalogueDirty(commit.sourceMetadata)
194+ }
195+ if commit.destMetadataValid {
196+ d.markCatalogueDirty(commit.destMetadata)
197+ }
198+ if commit.handoffResult.published.ac != nil {
199+ follower := commit.handoffResult.published.ac
200+ d.applyHostTheme(destination, follower, follower.getClientTheme(), false)
201+ follower.recordPreviousSession(source)
202+ d.deferAttachmentTransitionCleanups(commit.handoffResult)
203+ d.firstPaintForTransition(commit.handoffResult.published)
204+ }
205+ if commit.sourceEmpty {
206+ retireEmptySessionAfterMove(d, source)
207+ }
208+ for _, attachment := range commit.retiredAttachments {
209+ d.unregisterPreview(attachment.ac)
210+ attachment.ac.clearPreviousSession()
211+ attachment.ac.clearCaptureFrames()
212+ d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
213+ }
214+ for _, retirement := range commit.retiredParked {
215+ if retirement.parked != nil && retirement.parked.ac != nil {
216+ d.unregisterPreview(retirement.parked.ac)
217+ retirement.parked.ac.clearCaptureFrames()
218+ }
219+ }
220+ finishParkedAttachmentRetirements(commit.retiredParked)
221+ if commit.sourceEmpty && commit.sourceMetadataValid && d.persistEnabled {
222+ if err := d.beginSnapshotPurge(source.name, source.incarnation); err == nil {
223+ if err := d.finishSnapshotPurge(d.serveCtx, source.name, source.incarnation); err != nil {
224+ d.log.Warn("moving final tab source purge failed", "err", err, "session", source.name)
225+ }
226+ }
227+ }
228+ return nil
229+}
230+
231+func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destination *session) (*moveTabAdmission, error) {
232+ d.mu.Lock()
233+ defer d.mu.Unlock()
234+ if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination {
235+ return nil, errMovePaneInvalid
236+ }
237+ unlockSessions := lockAttachmentSessions(source, destination)
238+ defer unlockSessions()
239+ if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
240+ return nil, errMovePaneInvalid
241+ }
242+ moved := findMoveTabLocked(source, req.SourceTabID)
243+ if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) {
244+ return nil, errMovePaneInvalid
245+ }
246+ destinationActive := destination.tabs[destination.active]
247+ destinationActive.mu.Lock()
248+ destinationSize := destinationActive.size
249+ destinationActive.mu.Unlock()
250+ moved.mu.Lock()
251+ defer moved.mu.Unlock()
252+ if !moved.floatingTransferableLocked() {
253+ return nil, errMovePaneInvalid
254+ }
255+ if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok {
256+ return nil, errMovePaneInvalid
257+ }
258+ panes := make([]*pane, 0, len(moved.panes)+1)
259+ for _, p := range moved.panes {
260+ panes = append(panes, p)
261+ }
262+ if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) {
263+ panes = append(panes, moved.floating.pane)
264+ }
265+ idx := indexMoveTabLocked(source, moved)
266+ return &moveTabAdmission{
267+ tab: moved, sourceIndex: idx,
268+ sourceClient: source.client, sourceSnatched: len(source.snatched),
269+ layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,
270+ floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,
271+ panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,
272+ finalSource: len(source.tabs) == 1,
273+ }, nil
274+}
275+
276+func (c *moveTabCommit) releasePublication() {
277+ if c.handoffPublication != nil {
278+ c.handoffPublication.unlockCoordinators()
279+ c.handoffPublication = nil
280+ }
281+}
282+
283+func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
284+ d.mu.Lock()
285+ if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {
286+ d.mu.Unlock()
287+ return false
288+ }
289+ d.notices.routingMu.Lock()
290+ unlockSessions := lockAttachmentSessions(c.source, c.destination)
291+ defer unlockSessions()
292+ defer d.notices.routingMu.Unlock()
293+ defer d.mu.Unlock()
294+ if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
295+ return false
296+ }
297+ moved := c.admission.tab
298+ if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil ||
299+ len(c.source.tabs) == 1 != c.admission.finalSource {
300+ return false
301+ }
302+ // Attachment validation inspects the expected source tab and therefore must
303+ // precede moved.mu. The resize fences and ordered session locks keep topology
304+ // stable until the tab/pane checks below complete.
305+ if c.handoffFrozen {
306+ if c.source.client != c.admission.sourceClient || len(c.source.snatched) != c.admission.sourceSnatched {
307+ return false
308+ }
309+ publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq)
310+ if err != nil {
311+ c.err = err
312+ return false
313+ }
314+ c.handoffPublication = publication
315+ defer c.releasePublication()
316+ }
317+ if c.sourceRolesFrozen && len(c.source.snatched) != c.admission.sourceSnatched {
318+ return false
319+ }
320+ if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive {
321+ return false
322+ }
323+ c.admission.destinationActive.mu.Lock()
324+ destinationSizeCurrent := c.admission.destinationActive.size == c.admission.destinationSize
325+ c.admission.destinationActive.mu.Unlock()
326+ if !destinationSizeCurrent {
327+ return false
328+ }
329+ moved.mu.Lock()
330+ defer moved.mu.Unlock()
331+ if moved.layoutGeneration != c.admission.layoutGeneration || !moved.floatingTransferableLocked() ||
332+ moved.floating.state != c.admission.floatingState || moved.floating.pane != c.admission.floatingPane ||
333+ moved.floating.generation != c.admission.floatingGeneration || !sameMoveTabPanesLocked(moved, c.admission.panes, fencedPanes) {
334+ return false
335+ }
336+ for _, p := range fencedPanes {
337+ p.mu.Lock()
338+ defer p.mu.Unlock()
339+ owner := p.ownerSnapshot()
340+ if owner == nil || owner.session != c.source || owner.tab != moved {
341+ return false
342+ }
343+ }
344+
345+ idx := c.admission.sourceIndex
346+ c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
347+ if len(c.source.tabs) == 0 {
348+ c.source.active = -1
349+ } else if c.source.active > idx {
350+ c.source.active--
351+ } else if c.source.active == idx {
352+ if idx >= len(c.source.tabs) {
353+ idx = len(c.source.tabs) - 1
354+ }
355+ c.source.active = idx
356+ }
357+ c.destination.tabs = append(c.destination.tabs, moved)
358+ moved.size = c.admission.destinationSize
359+ moved.bumpLayoutGenerationLocked()
360+ c.oldTabCancel = moved.cancel
361+ parent := c.destination.ctx
362+ if parent == nil {
363+ parent = d.serveCtx
364+ }
365+ moved.ctx, moved.cancel = context.WithCancel(parent)
366+ type ownerChange struct {
367+ pane *pane
368+ oldOwner paneEffectLease
369+ newOwner paneEffectLease
370+ }
371+ ownerChanges := make([]ownerChange, 0, len(fencedPanes))
372+ for _, p := range fencedPanes {
373+ floatingGeneration := uint64(0)
374+ if p == moved.floating.pane {
375+ floatingGeneration = moved.floating.generation
376+ }
377+ oldOwner, newOwner := p.publishOwnerLocked(c.destination, moved, floatingGeneration)
378+ ownerChanges = append(ownerChanges, ownerChange{pane: p, oldOwner: oldOwner, newOwner: newOwner})
379+ }
380+ if d.beforeMoveTabCommit != nil {
381+ d.beforeMoveTabCommit()
382+ }
383+ if c.handoffPublication != nil {
384+ c.destination.active = len(c.destination.tabs) - 1
385+ c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication)
386+ c.handoffPublication.unlockCoordinators()
387+ c.handoffPublication = nil
388+ }
389+ // Attachment publication retains source/destination coordinator locks.
390+ // Release those first, then migrate synchronized-output batches exactly as
391+ // Move pane does, while pane parsing remains fenced.
392+ for _, change := range ownerChanges {
393+ c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner))
394+ }
395+ if c.handoffResult.published.ac != nil {
396+ c.sourceCleanupToken = c.handoffResult.published
397+ } else if c.source.client != nil {
398+ c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)
399+ }
400+ if len(c.source.tabs) == 0 {
401+ delete(d.sessions, c.source.id)
402+ c.retiredAttachments = retireEmptyMoveSessionLocked(c.source)
403+ c.retiredParked = d.purgeParkedForSessionLocked(c.source)
404+ }
405+ c.sourceEmpty = len(c.source.tabs) == 0
406+ if !c.source.ephemeral {
407+ c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate()
408+ c.sourceMetadataValid = true
409+ }
410+ if !c.destination.ephemeral {
411+ c.destMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate()
412+ c.destMetadataValid = true
413+ }
414+ return true
415+}
416+
417+func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool {
418+ current := make(map[*pane]struct{}, len(tb.panes)+1)
419+ for _, p := range tb.panes {
420+ current[p] = struct{}{}
421+ }
422+ if tb.floating.pane != nil && (tb.floating.state == floatingHidden || tb.floating.state == floatingVisible) {
423+ current[tb.floating.pane] = struct{}{}
424+ }
425+ if len(current) != len(admitted) || len(current) != len(fenced) {
426+ return false
427+ }
428+ for _, p := range admitted {
429+ if _, ok := current[p]; !ok {
430+ return false
431+ }
432+ }
433+ for _, p := range fenced {
434+ if _, ok := current[p]; !ok {
435+ return false
436+ }
437+ }
438+ return true
439+}
@@ -0,0 +1,439 @@
1+package daemon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/layout"
9+)
10+
11+type moveTabAdmission struct {
12+ tab *tab
13+ sourceIndex int
14+ sourceClient *attachedClient
15+ sourceSnatched int
16+ layoutGeneration uint64
17+ floatingState floatingState
18+ floatingPane *pane
19+ floatingGeneration uint64
20+ panes []*pane
21+ destinationActive *tab
22+ destinationSize domain.Size
23+ finalSource bool
24+}
25+
26+type moveTabCommit struct {
27+ req moveTabRequest
28+ source *session
29+ destination *session
30+ admission moveTabAdmission
31+ handoffFrozen bool
32+ sourceRolesFrozen bool
33+ handoffReq attachmentTransitionRequest
34+ handoffPublication *attachmentPublication
35+ handoffResult attachmentTransitionResult
36+ syncCleanup syncTimerCleanup
37+ sourceCleanupToken attachmentRoleToken
38+ oldTabCancel context.CancelFunc
39+ sourceEmpty bool
40+ retiredParked []parkedAttachmentRetirement
41+ retiredAttachments []detachedAttachmentSnapshot
42+ sourceMetadata domain.CatalogueMetadataUpdate
43+ sourceMetadataValid bool
44+ destMetadata domain.CatalogueMetadataUpdate
45+ destMetadataValid bool
46+ err error
47+}
48+
49+func (d *Daemon) moveTab(req moveTabRequest) error {
50+ if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
51+ return errMovePaneInvalid
52+ }
53+ d.mu.Lock()
54+ source := moveSessionForLocatorLocked(d, req.Source)
55+ destination := moveSessionForLocatorLocked(d, req.Destination)
56+ d.mu.Unlock()
57+ if source == nil || destination == nil || source == destination {
58+ return errMovePaneInvalid
59+ }
60+ reservation, err := d.reserveMoveLifecycles(source, destination)
61+ if err != nil {
62+ return err
63+ }
64+ reservationHeld := true
65+ defer func() {
66+ if reservationHeld {
67+ reservation.Release()
68+ }
69+ }()
70+ if d.afterMoveLifecycleReserved != nil {
71+ d.afterMoveLifecycleReserved()
72+ }
73+ unlockDispatch := lockMoveDispatch(source, destination)
74+ dispatchHeld := true
75+ defer func() {
76+ if dispatchHeld {
77+ unlockDispatch()
78+ }
79+ }()
80+
81+ admission, err := d.snapshotMoveTabAdmission(req, source, destination)
82+ if err != nil {
83+ return err
84+ }
85+ commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}
86+ var frozen frozenRoleEffectGates
87+ if admission.finalSource && admission.sourceClient != nil {
88+ transport := admission.sourceClient.transportSnapshot()
89+ token := source.attachmentToken(admission.sourceClient, transport.transport)
90+ if token.role != attachmentActive || token.transport.transport == nil {
91+ return errMovePaneInvalid
92+ }
93+ commit.handoffReq = attachmentTransitionRequest{
94+ source: source, target: destination, next: admission.sourceClient,
95+ expectedRole: attachmentActive, targetRole: attachmentActive,
96+ expectedTransport: token.transport, sourceToken: &token,
97+ expectedSourceTab: admission.tab, transferExpectedSourceTab: true, ready: true, action: "move-tab",
98+ }
99+ var participants attachmentTransitionParticipants
100+ commit.handoffReq, participants, err = d.snapshotAttachmentTransition(commit.handoffReq)
101+ if err != nil {
102+ return err
103+ }
104+ d.mu.Lock()
105+ source.mu.Lock()
106+ for ac := range source.snatched {
107+ participants.clients = append(participants.clients, ac)
108+ if tr := ac.transportSnapshot(); tr.transport != nil {
109+ participants.interrupts = append(participants.interrupts, roleTransportInterrupt{ac: ac, transport: tr})
110+ }
111+ }
112+ source.mu.Unlock()
113+ d.mu.Unlock()
114+ frozen = tryFreezeRoleEffectGatesInterruptingObserved(participants.interrupts, func(ac *attachedClient) {
115+ if d.afterRoleEffectGateFrozen != nil {
116+ d.afterRoleEffectGateFrozen("move-tab", ac)
117+ }
118+ }, participants.clients...)
119+ if !frozen.acquired || !frozen.drained {
120+ frozen.unfreeze()
121+ return errMovePaneInvalid
122+ }
123+ commit.handoffFrozen = true
124+ commit.sourceRolesFrozen = true
125+ commit.handoffReq.roleEffectsFrozen = true
126+ } else if admission.finalSource && admission.sourceSnatched > 0 {
127+ d.mu.Lock()
128+ source.mu.Lock()
129+ clients := make([]*attachedClient, 0, len(source.snatched))
130+ interrupts := make([]roleTransportInterrupt, 0, len(source.snatched))
131+ for ac := range source.snatched {
132+ clients = append(clients, ac)
133+ if tr := ac.transportSnapshot(); tr.transport != nil {
134+ interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: tr})
135+ }
136+ }
137+ source.mu.Unlock()
138+ d.mu.Unlock()
139+ frozen = tryFreezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...)
140+ if !frozen.acquired || !frozen.drained {
141+ frozen.unfreeze()
142+ return errMovePaneInvalid
143+ }
144+ commit.sourceRolesFrozen = true
145+ }
146+ rolesFrozen := commit.sourceRolesFrozen
147+ defer func() {
148+ if rolesFrozen {
149+ frozen.unfreeze()
150+ }
151+ }()
152+
153+ fences := newMoveTabResizeFences(source, destination, admission.tab)
154+ if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) {
155+ commit.releasePublication()
156+ if commit.err != nil {
157+ return commit.err
158+ }
159+ return errMovePaneInvalid
160+ }
161+ fences.Release()
162+ if commit.oldTabCancel != nil {
163+ commit.oldTabCancel()
164+ }
165+ for _, p := range admission.panes {
166+ if commit.sourceCleanupToken.current() {
167+ commit.sourceCleanupToken.ac.overlays.clearCopyModeForPane(p)
168+ commit.sourceCleanupToken.ac.pruneCaptureFrames(p)
169+ }
170+ }
171+ markSnapshotDirty(destination)
172+ if !commit.sourceEmpty {
173+ markSnapshotDirty(source)
174+ }
175+ commit.syncCleanup.finish()
176+ if rolesFrozen {
177+ frozen.unfreeze()
178+ rolesFrozen = false
179+ }
180+ unlockDispatch()
181+ dispatchHeld = false
182+ reservation.Release()
183+ reservationHeld = false
184+
185+ if !commit.sourceEmpty {
186+ if sourceTab := source.activeTab(); sourceTab != nil {
187+ d.activateTab(source, sourceTab)
188+ d.applyTabLayout(source, sourceTab)
189+ }
190+ }
191+ d.applyTabLayout(destination, admission.tab)
192+ if commit.sourceMetadataValid && !commit.sourceEmpty {
193+ d.markCatalogueDirty(commit.sourceMetadata)
194+ }
195+ if commit.destMetadataValid {
196+ d.markCatalogueDirty(commit.destMetadata)
197+ }
198+ if commit.handoffResult.published.ac != nil {
199+ follower := commit.handoffResult.published.ac
200+ d.applyHostTheme(destination, follower, follower.getClientTheme(), false)
201+ follower.recordPreviousSession(source)
202+ d.deferAttachmentTransitionCleanups(commit.handoffResult)
203+ d.firstPaintForTransition(commit.handoffResult.published)
204+ }
205+ if commit.sourceEmpty {
206+ retireEmptySessionAfterMove(d, source)
207+ }
208+ for _, attachment := range commit.retiredAttachments {
209+ d.unregisterPreview(attachment.ac)
210+ attachment.ac.clearPreviousSession()
211+ attachment.ac.clearCaptureFrames()
212+ d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
213+ }
214+ for _, retirement := range commit.retiredParked {
215+ if retirement.parked != nil && retirement.parked.ac != nil {
216+ d.unregisterPreview(retirement.parked.ac)
217+ retirement.parked.ac.clearCaptureFrames()
218+ }
219+ }
220+ finishParkedAttachmentRetirements(commit.retiredParked)
221+ if commit.sourceEmpty && commit.sourceMetadataValid && d.persistEnabled {
222+ if err := d.beginSnapshotPurge(source.name, source.incarnation); err == nil {
223+ if err := d.finishSnapshotPurge(d.serveCtx, source.name, source.incarnation); err != nil {
224+ d.log.Warn("moving final tab source purge failed", "err", err, "session", source.name)
225+ }
226+ }
227+ }
228+ return nil
229+}
230+
231+func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destination *session) (*moveTabAdmission, error) {
232+ d.mu.Lock()
233+ defer d.mu.Unlock()
234+ if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination {
235+ return nil, errMovePaneInvalid
236+ }
237+ unlockSessions := lockAttachmentSessions(source, destination)
238+ defer unlockSessions()
239+ if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
240+ return nil, errMovePaneInvalid
241+ }
242+ moved := findMoveTabLocked(source, req.SourceTabID)
243+ if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) {
244+ return nil, errMovePaneInvalid
245+ }
246+ destinationActive := destination.tabs[destination.active]
247+ destinationActive.mu.Lock()
248+ destinationSize := destinationActive.size
249+ destinationActive.mu.Unlock()
250+ moved.mu.Lock()
251+ defer moved.mu.Unlock()
252+ if !moved.floatingTransferableLocked() {
253+ return nil, errMovePaneInvalid
254+ }
255+ if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok {
256+ return nil, errMovePaneInvalid
257+ }
258+ panes := make([]*pane, 0, len(moved.panes)+1)
259+ for _, p := range moved.panes {
260+ panes = append(panes, p)
261+ }
262+ if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) {
263+ panes = append(panes, moved.floating.pane)
264+ }
265+ idx := indexMoveTabLocked(source, moved)
266+ return &moveTabAdmission{
267+ tab: moved, sourceIndex: idx,
268+ sourceClient: source.client, sourceSnatched: len(source.snatched),
269+ layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,
270+ floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,
271+ panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,
272+ finalSource: len(source.tabs) == 1,
273+ }, nil
274+}
275+
276+func (c *moveTabCommit) releasePublication() {
277+ if c.handoffPublication != nil {
278+ c.handoffPublication.unlockCoordinators()
279+ c.handoffPublication = nil
280+ }
281+}
282+
283+func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
284+ d.mu.Lock()
285+ if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {
286+ d.mu.Unlock()
287+ return false
288+ }
289+ d.notices.routingMu.Lock()
290+ unlockSessions := lockAttachmentSessions(c.source, c.destination)
291+ defer unlockSessions()
292+ defer d.notices.routingMu.Unlock()
293+ defer d.mu.Unlock()
294+ if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
295+ return false
296+ }
297+ moved := c.admission.tab
298+ if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil ||
299+ len(c.source.tabs) == 1 != c.admission.finalSource {
300+ return false
301+ }
302+ // Attachment validation inspects the expected source tab and therefore must
303+ // precede moved.mu. The resize fences and ordered session locks keep topology
304+ // stable until the tab/pane checks below complete.
305+ if c.handoffFrozen {
306+ if c.source.client != c.admission.sourceClient || len(c.source.snatched) != c.admission.sourceSnatched {
307+ return false
308+ }
309+ publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq)
310+ if err != nil {
311+ c.err = err
312+ return false
313+ }
314+ c.handoffPublication = publication
315+ defer c.releasePublication()
316+ }
317+ if c.sourceRolesFrozen && len(c.source.snatched) != c.admission.sourceSnatched {
318+ return false
319+ }
320+ if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive {
321+ return false
322+ }
323+ c.admission.destinationActive.mu.Lock()
324+ destinationSizeCurrent := c.admission.destinationActive.size == c.admission.destinationSize
325+ c.admission.destinationActive.mu.Unlock()
326+ if !destinationSizeCurrent {
327+ return false
328+ }
329+ moved.mu.Lock()
330+ defer moved.mu.Unlock()
331+ if moved.layoutGeneration != c.admission.layoutGeneration || !moved.floatingTransferableLocked() ||
332+ moved.floating.state != c.admission.floatingState || moved.floating.pane != c.admission.floatingPane ||
333+ moved.floating.generation != c.admission.floatingGeneration || !sameMoveTabPanesLocked(moved, c.admission.panes, fencedPanes) {
334+ return false
335+ }
336+ for _, p := range fencedPanes {
337+ p.mu.Lock()
338+ defer p.mu.Unlock()
339+ owner := p.ownerSnapshot()
340+ if owner == nil || owner.session != c.source || owner.tab != moved {
341+ return false
342+ }
343+ }
344+
345+ idx := c.admission.sourceIndex
346+ c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
347+ if len(c.source.tabs) == 0 {
348+ c.source.active = -1
349+ } else if c.source.active > idx {
350+ c.source.active--
351+ } else if c.source.active == idx {
352+ if idx >= len(c.source.tabs) {
353+ idx = len(c.source.tabs) - 1
354+ }
355+ c.source.active = idx
356+ }
357+ c.destination.tabs = append(c.destination.tabs, moved)
358+ moved.size = c.admission.destinationSize
359+ moved.bumpLayoutGenerationLocked()
360+ c.oldTabCancel = moved.cancel
361+ parent := c.destination.ctx
362+ if parent == nil {
363+ parent = d.serveCtx
364+ }
365+ moved.ctx, moved.cancel = context.WithCancel(parent)
366+ type ownerChange struct {
367+ pane *pane
368+ oldOwner paneEffectLease
369+ newOwner paneEffectLease
370+ }
371+ ownerChanges := make([]ownerChange, 0, len(fencedPanes))
372+ for _, p := range fencedPanes {
373+ floatingGeneration := uint64(0)
374+ if p == moved.floating.pane {
375+ floatingGeneration = moved.floating.generation
376+ }
377+ oldOwner, newOwner := p.publishOwnerLocked(c.destination, moved, floatingGeneration)
378+ ownerChanges = append(ownerChanges, ownerChange{pane: p, oldOwner: oldOwner, newOwner: newOwner})
379+ }
380+ if d.beforeMoveTabCommit != nil {
381+ d.beforeMoveTabCommit()
382+ }
383+ if c.handoffPublication != nil {
384+ c.destination.active = len(c.destination.tabs) - 1
385+ c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication)
386+ c.handoffPublication.unlockCoordinators()
387+ c.handoffPublication = nil
388+ }
389+ // Attachment publication retains source/destination coordinator locks.
390+ // Release those first, then migrate synchronized-output batches exactly as
391+ // Move pane does, while pane parsing remains fenced.
392+ for _, change := range ownerChanges {
393+ c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner))
394+ }
395+ if c.handoffResult.published.ac != nil {
396+ c.sourceCleanupToken = c.handoffResult.published
397+ } else if c.source.client != nil {
398+ c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)
399+ }
400+ if len(c.source.tabs) == 0 {
401+ delete(d.sessions, c.source.id)
402+ c.retiredAttachments = retireEmptyMoveSessionLocked(c.source)
403+ c.retiredParked = d.purgeParkedForSessionLocked(c.source)
404+ }
405+ c.sourceEmpty = len(c.source.tabs) == 0
406+ if !c.source.ephemeral {
407+ c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate()
408+ c.sourceMetadataValid = true
409+ }
410+ if !c.destination.ephemeral {
411+ c.destMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate()
412+ c.destMetadataValid = true
413+ }
414+ return true
415+}
416+
417+func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool {
418+ current := make(map[*pane]struct{}, len(tb.panes)+1)
419+ for _, p := range tb.panes {
420+ current[p] = struct{}{}
421+ }
422+ if tb.floating.pane != nil && (tb.floating.state == floatingHidden || tb.floating.state == floatingVisible) {
423+ current[tb.floating.pane] = struct{}{}
424+ }
425+ if len(current) != len(admitted) || len(current) != len(fenced) {
426+ return false
427+ }
428+ for _, p := range admitted {
429+ if _, ok := current[p]; !ok {
430+ return false
431+ }
432+ }
433+ for _, p := range fenced {
434+ if _, ok := current[p]; !ok {
435+ return false
436+ }
437+ }
438+ return true
439+}
A internal/usecase/daemon/move_tab_handoff_test.go +341 -0
@@ -0,0 +1,341 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+)
12+
13+func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedClient, *session, *attachedClient, *tab) {
14+ t.Helper()
15+ d, source, follower, _ := newManualSessionWithPTYs(t, newQuietPTY())
16+ follower.replaceTransport(&closeTrackingTransport{})
17+ moved := source.tabs[0]
18+ moved.stableID = "moved-tab"
19+ require.NotNil(t, d.attachCoordinator(source, nil, follower, true))
20+
21+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
22+ displacedTransport := &closeTrackingTransport{}
23+ displaced := &attachedClient{
24+ tr: displacedTransport, output: newOutputStateStream(), size: follower.size,
25+ }
26+ displaced.initOverlays()
27+ displaced.setSession(destination)
28+ destination.mu.Lock()
29+ destination.client = displaced
30+ destination.mu.Unlock()
31+ require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true))
32+
33+ return d, source, follower, destination, displaced, moved
34+}
35+
36+func moveTabFinalSource(t *testing.T, d *Daemon, source, destination *session, moved *tab) {
37+ t.Helper()
38+ require.NoError(t, d.moveTab(moveTabRequest{
39+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
40+ SourceTabID: domain.TabStableID(moved.stableID),
41+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
42+ }))
43+}
44+
45+func detachedWithReason(frames []ports.Frame, reason uint8) bool {
46+ for _, frame := range frames {
47+ if frame.Type != ports.MsgDetached {
48+ continue
49+ }
50+ detached, err := ports.UnmarshalDetached(frame.Payload)
51+ if err == nil && detached.Reason == reason {
52+ return true
53+ }
54+ }
55+ return false
56+}
57+
58+func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) {
59+ d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
60+ displacedTransport := displaced.tr.(*closeTrackingTransport)
61+
62+ moveTabFinalSource(t, d, source, destination, moved)
63+ d.attachmentCleanupWg.Wait()
64+
65+ destination.mu.Lock()
66+ require.Same(t, follower, destination.client)
67+ require.Contains(t, destination.snatched, displaced)
68+ require.Same(t, moved, destination.tabs[destination.active])
69+ destination.mu.Unlock()
70+ require.Same(t, destination, follower.currentSession())
71+ require.Equal(t, attachmentActive, destination.attachmentRole(follower))
72+ require.Equal(t, attachmentSnatched, destination.attachmentRole(displaced))
73+
74+ frames := displacedTransport.Sends()
75+ require.Len(t, frames, 1)
76+ require.Equal(t, ports.MsgOutput, frames[0].Type)
77+ output, err := ports.UnmarshalOutput(frames[0].Payload)
78+ require.NoError(t, err)
79+ require.Contains(t, string(output.Data), "Session snatched")
80+ require.False(t, detachedWithReason(frames, ports.ReasonReplaced))
81+ require.False(t, displacedTransport.Closed())
82+}
83+
84+func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) {
85+ d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
86+
87+ waitingTransport := &closeTrackingTransport{}
88+ waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size}
89+ waiting.initOverlays()
90+ waiting.setSession(destination)
91+ destination.mu.Lock()
92+ destination.addSnatchedLocked(waiting)
93+ destination.mu.Unlock()
94+
95+ moveTabFinalSource(t, d, source, destination, moved)
96+ d.attachmentCleanupWg.Wait()
97+
98+ destination.mu.Lock()
99+ require.Contains(t, destination.snatched, displaced)
100+ require.Contains(t, destination.snatched, waiting)
101+ require.Len(t, destination.snatched, 2)
102+ destination.mu.Unlock()
103+ requireSingleOwnerAndLease(t, destination, follower, displaced, waiting)
104+}
105+
106+func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) {
107+ d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)
108+
109+ parkedTransport := &closeTrackingTransport{}
110+ parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true}
111+ parked.initOverlays()
112+ require.True(t, d.parkAttachment(source, parked))
113+
114+ sourceSnatchedTransport := &closeTrackingTransport{}
115+ sourceSnatched := &attachedClient{tr: sourceSnatchedTransport, output: newOutputStateStream(), size: follower.size}
116+ sourceSnatched.initOverlays()
117+ sourceSnatched.setSession(source)
118+ source.mu.Lock()
119+ source.addSnatchedLocked(sourceSnatched)
120+ source.mu.Unlock()
121+
122+ moveTabFinalSource(t, d, source, destination, moved)
123+ d.attachmentCleanupWg.Wait()
124+ d.waitNotifies()
125+
126+ d.mu.Lock()
127+ require.Empty(t, d.parked)
128+ d.mu.Unlock()
129+ require.True(t, parkedTransport.Closed())
130+ require.True(t, sourceSnatchedTransport.Closed())
131+ require.Nil(t, sourceSnatched.currentSession())
132+}
133+
134+func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) {
135+ d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)
136+ movedPane := moved.focusedPane()
137+
138+ follower.overlays.copyMu.Lock()
139+ follower.overlays.copyPane = movedPane
140+ follower.overlays.copyMode = &scopy.Mode{}
141+ follower.overlays.copyMu.Unlock()
142+ follower.sendMu.Lock()
143+ follower.captureFrames = map[*pane]capturedPaneRenderState{movedPane: {title: "stale-source-capture"}}
144+ follower.sendMu.Unlock()
145+
146+ otherPane := destination.tabs[0].focusedPane()
147+ waiting := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: follower.size}
148+ waiting.initOverlays()
149+ waiting.setSession(destination)
150+ waiting.overlays.copyMu.Lock()
151+ waiting.overlays.copyPane = otherPane
152+ waiting.overlays.copyMode = &scopy.Mode{}
153+ waiting.overlays.copyMu.Unlock()
154+ waiting.sendMu.Lock()
155+ waiting.captureFrames = map[*pane]capturedPaneRenderState{otherPane: {}}
156+ waiting.sendMu.Unlock()
157+ destination.mu.Lock()
158+ destination.addSnatchedLocked(waiting)
159+ destination.mu.Unlock()
160+
161+ moveTabFinalSource(t, d, source, destination, moved)
162+
163+ follower.overlays.copyMu.Lock()
164+ require.Nil(t, follower.overlays.copyMode)
165+ require.Nil(t, follower.overlays.copyPane)
166+ follower.overlays.copyMu.Unlock()
167+ follower.sendMu.Lock()
168+ followerCapture, followerCaptured := follower.captureFrames[movedPane]
169+ follower.sendMu.Unlock()
170+ if followerCaptured {
171+ require.NotEqual(t, "stale-source-capture", followerCapture.title, "source capture survived destination first paint")
172+ }
173+
174+ waiting.overlays.copyMu.Lock()
175+ require.NotNil(t, waiting.overlays.copyMode)
176+ require.Same(t, otherPane, waiting.overlays.copyPane)
177+ waiting.overlays.copyMu.Unlock()
178+ waiting.sendMu.Lock()
179+ _, waitingCaptured := waiting.captureFrames[otherPane]
180+ waiting.sendMu.Unlock()
181+ require.True(t, waitingCaptured)
182+}
183+
184+func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) {
185+ d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
186+ followerRebased := make(chan struct{})
187+ follower.renderStages.handoffRebase = func() { close(followerRebased) }
188+
189+ displaced.sendMu.Lock()
190+ panelCleanupStarted := make(chan struct{})
191+ d.afterDisplacedCleanupStarted = func() { close(panelCleanupStarted) }
192+ defer func() { d.afterDisplacedCleanupStarted = nil }()
193+
194+ moveDone := make(chan struct{})
195+ go func() {
196+ moveTabFinalSource(t, d, source, destination, moved)
197+ close(moveDone)
198+ }()
199+
200+ awaitTestCompletion(t, panelCleanupStarted, "displaced panel cleanup did not start")
201+ awaitTestCompletion(t, followerRebased, "follower reset first paint waited for displaced panel cleanup")
202+ awaitTestCompletion(t, moveDone, "move-tab handoff did not finish after follower first paint")
203+
204+ displaced.sendMu.Unlock()
205+ d.attachmentCleanupWg.Wait()
206+}
207+
208+func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) {
209+ d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
210+
211+ paintAdmitted := make(chan struct{})
212+ releasePaint := make(chan struct{})
213+ d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
214+ if token.ac == follower && token.role == attachmentActive {
215+ close(paintAdmitted)
216+ <-releasePaint
217+ }
218+ }
219+ defer func() { d.afterRoleEffectAdmitted = nil }()
220+
221+ moveDone := make(chan error, 1)
222+ go func() {
223+ moveDone <- d.moveTab(moveTabRequest{
224+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
225+ SourceTabID: domain.TabStableID(moved.stableID),
226+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
227+ })
228+ }()
229+ awaitTestCompletion(t, paintAdmitted, "move-tab follower first paint was not admitted")
230+
231+ displacedToken := destination.attachmentToken(displaced, displaced.transportSnapshot().transport)
232+ reclaimDone := make(chan struct{})
233+ go func() {
234+ d.handleSnatchedClientFrame(displacedToken, ports.Frame{
235+ Type: ports.MsgInput,
236+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
237+ })
238+ close(reclaimDone)
239+ }()
240+
241+ close(releasePaint)
242+ require.NoError(t, <-moveDone)
243+ awaitTestCompletion(t, reclaimDone, "concurrent destination reclaim did not finish")
244+ d.attachmentCleanupWg.Wait()
245+
246+ requireSingleOwnerAndLease(t, destination, follower, displaced)
247+ destination.mu.Lock()
248+ require.Same(t, displaced, destination.client)
249+ destination.mu.Unlock()
250+ require.Equal(t, attachmentSnatched, destination.attachmentRole(follower))
251+ require.Equal(t, attachmentActive, destination.attachmentRole(displaced))
252+}
253+
254+func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) {
255+ d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)
256+
257+ d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
258+ if token.ac == follower {
259+ token.ac.roleGeneration.Add(1)
260+ }
261+ }
262+ defer func() { d.afterRoleEffectAdmitted = nil }()
263+
264+ beforeTabs := append([]*tab(nil), destination.tabs...)
265+ moveTabFinalSource(t, d, source, destination, moved)
266+
267+ require.Nil(t, source.tabs)
268+ require.Same(t, destination, follower.currentSession())
269+ require.Same(t, follower, destination.client)
270+ require.Equal(t, append(beforeTabs, moved), destination.tabs)
271+ require.Same(t, moved, destination.tabs[destination.active])
272+}
273+
274+func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) {
275+ d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
276+ _ = displaced
277+
278+ commitEntered := make(chan struct{})
279+ releaseCommit := make(chan struct{})
280+ d.beforeMoveTabCommit = func() {
281+ close(commitEntered)
282+ <-releaseCommit
283+ }
284+ defer func() { d.beforeMoveTabCommit = nil }()
285+
286+ moveDone := make(chan error, 1)
287+ go func() {
288+ moveDone <- d.moveTab(moveTabRequest{
289+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
290+ SourceTabID: domain.TabStableID(moved.stableID),
291+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
292+ })
293+ }()
294+ <-commitEntered
295+
296+ type observation struct {
297+ sourceRegistered bool
298+ movedInSource bool
299+ movedInDestination bool
300+ followerOnDestination bool
301+ displacedSnatched bool
302+ movedActive bool
303+ }
304+ read := func() observation {
305+ d.mu.Lock()
306+ _, sourceLive := d.sessions[source.id]
307+ d.mu.Unlock()
308+ source.mu.Lock()
309+ movedInSource := indexMoveTabLocked(source, moved) >= 0
310+ source.mu.Unlock()
311+ destination.mu.Lock()
312+ obs := observation{
313+ sourceRegistered: sourceLive,
314+ movedInSource: movedInSource,
315+ movedInDestination: indexMoveTabLocked(destination, moved) >= 0,
316+ followerOnDestination: destination.client == follower,
317+ displacedSnatched: false,
318+ movedActive: destination.active >= 0 && destination.tabs[destination.active] == moved,
319+ }
320+ if displaced != nil {
321+ _, obs.displacedSnatched = destination.snatched[displaced]
322+ }
323+ destination.mu.Unlock()
324+ return obs
325+ }
326+
327+ require.False(t, d.mu.TryLock(), "daemon observers must be excluded from partial move-tab publication")
328+ require.False(t, source.mu.TryLock(), "source observers must be excluded from partial move-tab publication")
329+ require.False(t, destination.mu.TryLock(), "destination observers must be excluded from partial move-tab publication")
330+
331+ close(releaseCommit)
332+ require.NoError(t, <-moveDone)
333+
334+ final := read()
335+ require.False(t, final.sourceRegistered)
336+ require.False(t, final.movedInSource)
337+ require.True(t, final.movedInDestination)
338+ require.True(t, final.followerOnDestination)
339+ require.True(t, final.displacedSnatched)
340+ require.True(t, final.movedActive)
341+}
@@ -0,0 +1,341 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+)
12+
13+func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedClient, *session, *attachedClient, *tab) {
14+ t.Helper()
15+ d, source, follower, _ := newManualSessionWithPTYs(t, newQuietPTY())
16+ follower.replaceTransport(&closeTrackingTransport{})
17+ moved := source.tabs[0]
18+ moved.stableID = "moved-tab"
19+ require.NotNil(t, d.attachCoordinator(source, nil, follower, true))
20+
21+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
22+ displacedTransport := &closeTrackingTransport{}
23+ displaced := &attachedClient{
24+ tr: displacedTransport, output: newOutputStateStream(), size: follower.size,
25+ }
26+ displaced.initOverlays()
27+ displaced.setSession(destination)
28+ destination.mu.Lock()
29+ destination.client = displaced
30+ destination.mu.Unlock()
31+ require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true))
32+
33+ return d, source, follower, destination, displaced, moved
34+}
35+
36+func moveTabFinalSource(t *testing.T, d *Daemon, source, destination *session, moved *tab) {
37+ t.Helper()
38+ require.NoError(t, d.moveTab(moveTabRequest{
39+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
40+ SourceTabID: domain.TabStableID(moved.stableID),
41+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
42+ }))
43+}
44+
45+func detachedWithReason(frames []ports.Frame, reason uint8) bool {
46+ for _, frame := range frames {
47+ if frame.Type != ports.MsgDetached {
48+ continue
49+ }
50+ detached, err := ports.UnmarshalDetached(frame.Payload)
51+ if err == nil && detached.Reason == reason {
52+ return true
53+ }
54+ }
55+ return false
56+}
57+
58+func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) {
59+ d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
60+ displacedTransport := displaced.tr.(*closeTrackingTransport)
61+
62+ moveTabFinalSource(t, d, source, destination, moved)
63+ d.attachmentCleanupWg.Wait()
64+
65+ destination.mu.Lock()
66+ require.Same(t, follower, destination.client)
67+ require.Contains(t, destination.snatched, displaced)
68+ require.Same(t, moved, destination.tabs[destination.active])
69+ destination.mu.Unlock()
70+ require.Same(t, destination, follower.currentSession())
71+ require.Equal(t, attachmentActive, destination.attachmentRole(follower))
72+ require.Equal(t, attachmentSnatched, destination.attachmentRole(displaced))
73+
74+ frames := displacedTransport.Sends()
75+ require.Len(t, frames, 1)
76+ require.Equal(t, ports.MsgOutput, frames[0].Type)
77+ output, err := ports.UnmarshalOutput(frames[0].Payload)
78+ require.NoError(t, err)
79+ require.Contains(t, string(output.Data), "Session snatched")
80+ require.False(t, detachedWithReason(frames, ports.ReasonReplaced))
81+ require.False(t, displacedTransport.Closed())
82+}
83+
84+func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) {
85+ d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
86+
87+ waitingTransport := &closeTrackingTransport{}
88+ waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size}
89+ waiting.initOverlays()
90+ waiting.setSession(destination)
91+ destination.mu.Lock()
92+ destination.addSnatchedLocked(waiting)
93+ destination.mu.Unlock()
94+
95+ moveTabFinalSource(t, d, source, destination, moved)
96+ d.attachmentCleanupWg.Wait()
97+
98+ destination.mu.Lock()
99+ require.Contains(t, destination.snatched, displaced)
100+ require.Contains(t, destination.snatched, waiting)
101+ require.Len(t, destination.snatched, 2)
102+ destination.mu.Unlock()
103+ requireSingleOwnerAndLease(t, destination, follower, displaced, waiting)
104+}
105+
106+func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) {
107+ d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)
108+
109+ parkedTransport := &closeTrackingTransport{}
110+ parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true}
111+ parked.initOverlays()
112+ require.True(t, d.parkAttachment(source, parked))
113+
114+ sourceSnatchedTransport := &closeTrackingTransport{}
115+ sourceSnatched := &attachedClient{tr: sourceSnatchedTransport, output: newOutputStateStream(), size: follower.size}
116+ sourceSnatched.initOverlays()
117+ sourceSnatched.setSession(source)
118+ source.mu.Lock()
119+ source.addSnatchedLocked(sourceSnatched)
120+ source.mu.Unlock()
121+
122+ moveTabFinalSource(t, d, source, destination, moved)
123+ d.attachmentCleanupWg.Wait()
124+ d.waitNotifies()
125+
126+ d.mu.Lock()
127+ require.Empty(t, d.parked)
128+ d.mu.Unlock()
129+ require.True(t, parkedTransport.Closed())
130+ require.True(t, sourceSnatchedTransport.Closed())
131+ require.Nil(t, sourceSnatched.currentSession())
132+}
133+
134+func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) {
135+ d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)
136+ movedPane := moved.focusedPane()
137+
138+ follower.overlays.copyMu.Lock()
139+ follower.overlays.copyPane = movedPane
140+ follower.overlays.copyMode = &scopy.Mode{}
141+ follower.overlays.copyMu.Unlock()
142+ follower.sendMu.Lock()
143+ follower.captureFrames = map[*pane]capturedPaneRenderState{movedPane: {title: "stale-source-capture"}}
144+ follower.sendMu.Unlock()
145+
146+ otherPane := destination.tabs[0].focusedPane()
147+ waiting := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: follower.size}
148+ waiting.initOverlays()
149+ waiting.setSession(destination)
150+ waiting.overlays.copyMu.Lock()
151+ waiting.overlays.copyPane = otherPane
152+ waiting.overlays.copyMode = &scopy.Mode{}
153+ waiting.overlays.copyMu.Unlock()
154+ waiting.sendMu.Lock()
155+ waiting.captureFrames = map[*pane]capturedPaneRenderState{otherPane: {}}
156+ waiting.sendMu.Unlock()
157+ destination.mu.Lock()
158+ destination.addSnatchedLocked(waiting)
159+ destination.mu.Unlock()
160+
161+ moveTabFinalSource(t, d, source, destination, moved)
162+
163+ follower.overlays.copyMu.Lock()
164+ require.Nil(t, follower.overlays.copyMode)
165+ require.Nil(t, follower.overlays.copyPane)
166+ follower.overlays.copyMu.Unlock()
167+ follower.sendMu.Lock()
168+ followerCapture, followerCaptured := follower.captureFrames[movedPane]
169+ follower.sendMu.Unlock()
170+ if followerCaptured {
171+ require.NotEqual(t, "stale-source-capture", followerCapture.title, "source capture survived destination first paint")
172+ }
173+
174+ waiting.overlays.copyMu.Lock()
175+ require.NotNil(t, waiting.overlays.copyMode)
176+ require.Same(t, otherPane, waiting.overlays.copyPane)
177+ waiting.overlays.copyMu.Unlock()
178+ waiting.sendMu.Lock()
179+ _, waitingCaptured := waiting.captureFrames[otherPane]
180+ waiting.sendMu.Unlock()
181+ require.True(t, waitingCaptured)
182+}
183+
184+func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) {
185+ d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
186+ followerRebased := make(chan struct{})
187+ follower.renderStages.handoffRebase = func() { close(followerRebased) }
188+
189+ displaced.sendMu.Lock()
190+ panelCleanupStarted := make(chan struct{})
191+ d.afterDisplacedCleanupStarted = func() { close(panelCleanupStarted) }
192+ defer func() { d.afterDisplacedCleanupStarted = nil }()
193+
194+ moveDone := make(chan struct{})
195+ go func() {
196+ moveTabFinalSource(t, d, source, destination, moved)
197+ close(moveDone)
198+ }()
199+
200+ awaitTestCompletion(t, panelCleanupStarted, "displaced panel cleanup did not start")
201+ awaitTestCompletion(t, followerRebased, "follower reset first paint waited for displaced panel cleanup")
202+ awaitTestCompletion(t, moveDone, "move-tab handoff did not finish after follower first paint")
203+
204+ displaced.sendMu.Unlock()
205+ d.attachmentCleanupWg.Wait()
206+}
207+
208+func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) {
209+ d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
210+
211+ paintAdmitted := make(chan struct{})
212+ releasePaint := make(chan struct{})
213+ d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
214+ if token.ac == follower && token.role == attachmentActive {
215+ close(paintAdmitted)
216+ <-releasePaint
217+ }
218+ }
219+ defer func() { d.afterRoleEffectAdmitted = nil }()
220+
221+ moveDone := make(chan error, 1)
222+ go func() {
223+ moveDone <- d.moveTab(moveTabRequest{
224+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
225+ SourceTabID: domain.TabStableID(moved.stableID),
226+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
227+ })
228+ }()
229+ awaitTestCompletion(t, paintAdmitted, "move-tab follower first paint was not admitted")
230+
231+ displacedToken := destination.attachmentToken(displaced, displaced.transportSnapshot().transport)
232+ reclaimDone := make(chan struct{})
233+ go func() {
234+ d.handleSnatchedClientFrame(displacedToken, ports.Frame{
235+ Type: ports.MsgInput,
236+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
237+ })
238+ close(reclaimDone)
239+ }()
240+
241+ close(releasePaint)
242+ require.NoError(t, <-moveDone)
243+ awaitTestCompletion(t, reclaimDone, "concurrent destination reclaim did not finish")
244+ d.attachmentCleanupWg.Wait()
245+
246+ requireSingleOwnerAndLease(t, destination, follower, displaced)
247+ destination.mu.Lock()
248+ require.Same(t, displaced, destination.client)
249+ destination.mu.Unlock()
250+ require.Equal(t, attachmentSnatched, destination.attachmentRole(follower))
251+ require.Equal(t, attachmentActive, destination.attachmentRole(displaced))
252+}
253+
254+func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) {
255+ d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)
256+
257+ d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
258+ if token.ac == follower {
259+ token.ac.roleGeneration.Add(1)
260+ }
261+ }
262+ defer func() { d.afterRoleEffectAdmitted = nil }()
263+
264+ beforeTabs := append([]*tab(nil), destination.tabs...)
265+ moveTabFinalSource(t, d, source, destination, moved)
266+
267+ require.Nil(t, source.tabs)
268+ require.Same(t, destination, follower.currentSession())
269+ require.Same(t, follower, destination.client)
270+ require.Equal(t, append(beforeTabs, moved), destination.tabs)
271+ require.Same(t, moved, destination.tabs[destination.active])
272+}
273+
274+func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) {
275+ d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
276+ _ = displaced
277+
278+ commitEntered := make(chan struct{})
279+ releaseCommit := make(chan struct{})
280+ d.beforeMoveTabCommit = func() {
281+ close(commitEntered)
282+ <-releaseCommit
283+ }
284+ defer func() { d.beforeMoveTabCommit = nil }()
285+
286+ moveDone := make(chan error, 1)
287+ go func() {
288+ moveDone <- d.moveTab(moveTabRequest{
289+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
290+ SourceTabID: domain.TabStableID(moved.stableID),
291+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
292+ })
293+ }()
294+ <-commitEntered
295+
296+ type observation struct {
297+ sourceRegistered bool
298+ movedInSource bool
299+ movedInDestination bool
300+ followerOnDestination bool
301+ displacedSnatched bool
302+ movedActive bool
303+ }
304+ read := func() observation {
305+ d.mu.Lock()
306+ _, sourceLive := d.sessions[source.id]
307+ d.mu.Unlock()
308+ source.mu.Lock()
309+ movedInSource := indexMoveTabLocked(source, moved) >= 0
310+ source.mu.Unlock()
311+ destination.mu.Lock()
312+ obs := observation{
313+ sourceRegistered: sourceLive,
314+ movedInSource: movedInSource,
315+ movedInDestination: indexMoveTabLocked(destination, moved) >= 0,
316+ followerOnDestination: destination.client == follower,
317+ displacedSnatched: false,
318+ movedActive: destination.active >= 0 && destination.tabs[destination.active] == moved,
319+ }
320+ if displaced != nil {
321+ _, obs.displacedSnatched = destination.snatched[displaced]
322+ }
323+ destination.mu.Unlock()
324+ return obs
325+ }
326+
327+ require.False(t, d.mu.TryLock(), "daemon observers must be excluded from partial move-tab publication")
328+ require.False(t, source.mu.TryLock(), "source observers must be excluded from partial move-tab publication")
329+ require.False(t, destination.mu.TryLock(), "destination observers must be excluded from partial move-tab publication")
330+
331+ close(releaseCommit)
332+ require.NoError(t, <-moveDone)
333+
334+ final := read()
335+ require.False(t, final.sourceRegistered)
336+ require.False(t, final.movedInSource)
337+ require.True(t, final.movedInDestination)
338+ require.True(t, final.followerOnDestination)
339+ require.True(t, final.displacedSnatched)
340+ require.True(t, final.movedActive)
341+}
A internal/usecase/daemon/move_tab_test.go +558 -0
@@ -0,0 +1,558 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
15+ "github.com/bnema/vev/pkg/renderer"
16+)
17+
18+func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
19+ for _, destinationEphemeral := range []bool{false, true} {
20+ t.Run(map[bool]string{false: "named", true: "ephemeral"}[destinationEphemeral], func(t *testing.T) {
21+ movedPTY := newQuietPTY()
22+ remainingPTY := newQuietPTY()
23+ destinationPTY := newQuietPTY()
24+ floatingPTY := newQuietPTY()
25+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
26+ moved := source.tabs[0]
27+ moved.stableID = "moved-tab"
28+ moved.name = "preserved-name"
29+ remaining := source.tabs[1]
30+ remaining.stableID = "remaining-tab"
31+ source.active = 0
32+
33+ destinationCtx, destinationCancel := context.WithCancel(d.serveCtx)
34+ t.Cleanup(destinationCancel)
35+ destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30})
36+ destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)
37+ destination := &session{
38+ id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral,
39+ ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0,
40+ }
41+ publishTiledPaneOwners(destination, destinationTab)
42+
43+ floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
44+ moved.mu.Lock()
45+ moved.floating = floatingSlot{state: floatingHidden, pane: floating, generation: 7}
46+ moved.mu.Unlock()
47+ publishPaneOwner(floating, source, moved, 7)
48+
49+ d.mu.Lock()
50+ d.sessions[destination.id] = destination
51+ d.mu.Unlock()
52+
53+ tiled := moved.focusedPane()
54+ tiledScreen := tiled.screen
55+ tiledOwnerGeneration := tiled.ownerGeneration
56+ floatingOwnerGeneration := floating.ownerGeneration
57+ oldWorkerCtx := moved.ctx
58+ oldTree := moved.tree
59+ oldPanes := moved.panes
60+ beforeDestinationActive := destination.tabs[destination.active]
61+
62+ err := d.moveTab(moveTabRequest{
63+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
64+ SourceTabID: domain.TabStableID(moved.stableID),
65+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
66+ })
67+ require.NoError(t, err)
68+
69+ require.Equal(t, []*tab{remaining}, source.tabs)
70+ require.Zero(t, source.active)
71+ require.Same(t, remaining, source.tabs[source.active])
72+ require.Equal(t, []*tab{destinationTab, moved}, destination.tabs)
73+ require.Same(t, beforeDestinationActive, destination.tabs[destination.active], "moved tab stays in the background")
74+ require.Equal(t, destinationTab.size, moved.size, "destination geometry becomes authoritative")
75+ require.Equal(t, "preserved-name", moved.name)
76+ require.Same(t, oldTree, moved.tree)
77+ require.Equal(t, oldPanes, moved.panes)
78+ require.Same(t, movedPTY, tiled.pty)
79+ require.Same(t, tiledScreen, tiled.screen)
80+ require.Same(t, floating, moved.floating.pane)
81+ require.Same(t, floatingPTY, floating.pty)
82+ require.Equal(t, floatingState(floatingHidden), moved.floating.state)
83+ require.Equal(t, uint64(7), moved.floating.generation)
84+ require.Equal(t, tiledOwnerGeneration+1, tiled.ownerGeneration)
85+ require.Equal(t, floatingOwnerGeneration+1, floating.ownerGeneration)
86+ require.Same(t, destination, tiled.ownerSnapshot().session)
87+ require.Same(t, destination, floating.ownerSnapshot().session)
88+ require.Error(t, oldWorkerCtx.Err(), "source-derived tab worker context is cancelled")
89+ require.NoError(t, moved.ctx.Err(), "replacement tab worker context belongs to live destination")
90+ })
91+ }
92+}
93+
94+func TestMoveTabFinalSourceVisibleFloatingPreservesRuntimeAndFollowsClient(t *testing.T) {
95+ d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
96+ moved := source.tabs[0]
97+ moved.stableID = "moved-tab"
98+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
99+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
100+
101+ floatingPTY := newQuietPTY()
102+ floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
103+ floatingCtx, floatingCancel := context.WithCancel(d.paneProcessCtx)
104+ t.Cleanup(floatingCancel)
105+ floating.ctx, floating.cancel = floatingCtx, floatingCancel
106+ moved.mu.Lock()
107+ moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7}
108+ moved.mu.Unlock()
109+ publishPaneOwner(floating, source, moved, 7)
110+ d.applyTabLayout(source, moved)
111+
112+ beforeScreen := floating.screen
113+ beforeHistory := floating.history
114+ beforeContext := floating.ctx
115+ beforeOwnerGeneration := floating.ownerGeneration
116+
117+ require.NoError(t, d.moveTab(moveTabRequest{
118+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
119+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
120+ }))
121+
122+ require.Same(t, moved, destination.tabs[destination.active])
123+ require.Same(t, client, destination.client)
124+ require.Same(t, destination, client.currentSession())
125+ moved.mu.Lock()
126+ require.Equal(t, floatingVisible, moved.floating.state)
127+ require.True(t, moved.floating.desiredVisible)
128+ require.Equal(t, uint64(7), moved.floating.generation)
129+ require.Same(t, floating, moved.floating.pane)
130+ moved.mu.Unlock()
131+ expectedGeometry := calculateContentFloatingGeometry(moved.size, d.currentFloatingConfig())
132+ floating.mu.Lock()
133+ require.Same(t, floatingPTY, floating.pty)
134+ require.Same(t, beforeScreen, floating.screen)
135+ require.Same(t, beforeHistory, floating.history)
136+ require.Equal(t, beforeContext, floating.ctx)
137+ require.Equal(t, expectedGeometry, floating.popupGeometry)
138+ require.Equal(t, expectedGeometry.Inner, floating.rect)
139+ require.Equal(t, beforeOwnerGeneration+1, floating.ownerGeneration)
140+ floating.mu.Unlock()
141+ owner := floating.ownerSnapshot()
142+ require.Same(t, destination, owner.session)
143+ require.Same(t, moved, owner.tab)
144+ require.Equal(t, floating.ownerGeneration, owner.generation)
145+ require.Equal(t, uint64(7), owner.floatingSlotGeneration)
146+ require.NoError(t, floating.ctx.Err(), "floating process context remains live")
147+}
148+
149+func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) {
150+ d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
151+ moved := source.tabs[0]
152+ moved.stableID = "moved-tab"
153+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
154+ rebased := false
155+ client.renderStages.handoffRebase = func() { rebased = true }
156+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
157+ oldDestinationActive := destination.tabs[0]
158+
159+ require.NoError(t, d.moveTab(moveTabRequest{
160+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
161+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
162+ }))
163+
164+ require.Nil(t, source.tabs)
165+ require.Same(t, destination, client.currentSession())
166+ require.Same(t, client, destination.client)
167+ require.Equal(t, attachmentActive, destination.attachmentRole(client))
168+ require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs)
169+ require.Same(t, moved, destination.tabs[destination.active], "final-source follower activates the moved tab")
170+ require.True(t, rebased)
171+}
172+
173+func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) {
174+ tests := []struct {
175+ name string
176+ mutate func(source, destination *session, moved *tab, req *moveTabRequest)
177+ }{
178+ {name: "same session", mutate: func(source, _ *session, _ *tab, req *moveTabRequest) {
179+ req.Destination = req.Source
180+ }},
181+ {name: "stale incarnation", mutate: func(_, _ *session, _ *tab, req *moveTabRequest) {
182+ req.Destination.Incarnation[0]++
183+ }},
184+ {name: "missing membership", mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) {
185+ source.tabs = source.tabs[1:]
186+ _ = moved
187+ }},
188+ {name: "warming floating", mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) {
189+ moved.floating.state = floatingWarming
190+ moved.floating.generation = 3
191+ }},
192+ {name: "destination teardown", mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) {
193+ destination.teardownMu.Lock()
194+ destination.teardownActive = true
195+ destination.teardownMu.Unlock()
196+ }},
197+ }
198+ for _, tt := range tests {
199+ t.Run(tt.name, func(t *testing.T) {
200+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
201+ moved := source.tabs[0]
202+ moved.stableID = "moved-tab"
203+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
204+ req := moveTabRequest{
205+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
206+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
207+ }
208+ tt.mutate(source, destination, moved, &req)
209+ beforeDestination := append([]*tab(nil), destination.tabs...)
210+ beforeOwner := moved.focusedPane().ownerSnapshot()
211+
212+ err := d.moveTab(req)
213+ destination.teardownMu.Lock()
214+ destination.teardownActive = false
215+ destination.teardownMu.Unlock()
216+ require.Error(t, err)
217+ require.Equal(t, beforeDestination, destination.tabs)
218+ require.Same(t, beforeOwner, moved.focusedPane().ownerSnapshot())
219+ })
220+ }
221+}
222+
223+func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing.T) {
224+ for _, tt := range []struct {
225+ name string
226+ mutate func(*tab)
227+ }{
228+ {name: "generation", mutate: func(moved *tab) { moved.floating.generation++ }},
229+ {name: "warming", mutate: func(moved *tab) { moved.floating.state = floatingWarming }},
230+ } {
231+ t.Run(tt.name, func(t *testing.T) {
232+ d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
233+ moved := source.tabs[0]
234+ moved.stableID = "moved-tab"
235+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
236+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
237+ floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
238+ moved.mu.Lock()
239+ moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7}
240+ moved.mu.Unlock()
241+ publishPaneOwner(floating, source, moved, 7)
242+
243+ source.layoutApplyMu.Lock()
244+ admitted := make(chan struct{})
245+ d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {
246+ if action == "move-tab" && ac == client {
247+ close(admitted)
248+ }
249+ }
250+ moveDone := make(chan error, 1)
251+ go func() {
252+ moveDone <- d.moveTab(moveTabRequest{
253+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
254+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
255+ })
256+ }()
257+ awaitTestCompletion(t, admitted, "move-tab did not finish admission")
258+ moved.mu.Lock()
259+ tt.mutate(moved)
260+ moved.mu.Unlock()
261+ source.layoutApplyMu.Unlock()
262+
263+ require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return"))
264+ require.NotContains(t, destination.tabs, moved)
265+ require.Same(t, source, floating.ownerSnapshot().session)
266+ d.afterRoleEffectGateFrozen = nil
267+ })
268+ }
269+}
270+
271+func TestMoveTabWaitsForSourceResizeAndAppliesDestinationGeometryLast(t *testing.T) {
272+ pty := &transactionalResizePTY{}
273+ d, source, _, _ := newManualSessionWithPTYs(t, pty, newQuietPTY())
274+ moved := source.tabs[0]
275+ moved.stableID = "moved-tab"
276+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
277+ destination.tabs[0].mu.Lock()
278+ destination.tabs[0].size = domain.Size{Cols: 100, Rows: 30}
279+ destination.tabs[0].mu.Unlock()
280+ moved.mu.Lock()
281+ moved.size = domain.Size{Cols: 90, Rows: 20}
282+ moved.bumpLayoutGenerationLocked()
283+ moved.mu.Unlock()
284+
285+ resizeEntered := make(chan struct{})
286+ releaseResize := make(chan struct{})
287+ var once sync.Once
288+ pty.onResize = func() {
289+ once.Do(func() {
290+ close(resizeEntered)
291+ <-releaseResize
292+ })
293+ }
294+ layoutDone := make(chan bool, 1)
295+ go func() { layoutDone <- d.applyTabLayout(source, moved) }()
296+ <-resizeEntered
297+ reserved := make(chan struct{})
298+ d.afterMoveLifecycleReserved = func() { close(reserved) }
299+ defer func() { d.afterMoveLifecycleReserved = nil }()
300+ moveDone := make(chan error, 1)
301+ go func() {
302+ moveDone <- d.moveTab(moveTabRequest{
303+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
304+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
305+ })
306+ }()
307+ <-reserved
308+ select {
309+ case err := <-moveDone:
310+ t.Fatalf("move crossed blocked source resize: %v", err)
311+ default:
312+ }
313+ close(releaseResize)
314+ <-layoutDone // the old transaction may lose owner publication after its PTY call
315+ require.NoError(t, <-moveDone)
316+
317+ pty.mu.Lock()
318+ sizes := append([]domain.Size(nil), pty.sizes...)
319+ pty.mu.Unlock()
320+ require.GreaterOrEqual(t, len(sizes), 2)
321+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, sizes[len(sizes)-1], "destination geometry must be the final external resize")
322+}
323+
324+func TestMoveTabMigratesSyncAndInvalidatesSourceRetryOwner(t *testing.T) {
325+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
326+ moved := source.tabs[0]
327+ moved.stableID = "moved-tab"
328+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
329+ p := moved.focusedPane()
330+ sourceCoordinator := d.ensureRenderCoordinator(source)
331+ destinationCoordinator := d.ensureRenderCoordinator(destination)
332+
333+ p.mu.Lock()
334+ p.screen.Write([]byte(renderer.SyncStartCSI))
335+ generation := source.syncGen.Add(1)
336+ p.syncGen = generation
337+ oldLease := p.effectLeaseLocked()
338+ p.resizeRetry = true
339+ p.mu.Unlock()
340+ sourceCoordinator.noteSyncBeginWithRenderability(p, generation, oldLease.Current, nil)
341+ oldRetry := newAcceptedTabLayoutRetryToken(source, moved, []resizeMember{{session: source, tab: moved, pane: p, owner: oldLease}})
342+
343+ require.NoError(t, d.moveTab(moveTabRequest{
344+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
345+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
346+ }))
347+
348+ require.False(t, oldLease.Current(), "source effects are revoked at owner publication")
349+ require.False(t, oldRetry.current(), "source delayed retry cannot run after transfer")
350+ require.Same(t, destination, p.ownerSnapshot().session)
351+ sourceCoordinator.mu.Lock()
352+ require.NotContains(t, sourceCoordinator.syncBatches, p)
353+ sourceCoordinator.mu.Unlock()
354+ destinationCoordinator.mu.Lock()
355+ require.Contains(t, destinationCoordinator.syncBatches, p)
356+ destinationCoordinator.mu.Unlock()
357+ require.Equal(t, destination.syncGen.Load(), p.syncGen)
358+ p.mu.Lock()
359+ require.False(t, p.resizeRetry, "required resize work is completed under the destination owner")
360+ p.mu.Unlock()
361+}
362+
363+func TestMoveTabExitReapsOnlyDestination(t *testing.T) {
364+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
365+ moved := source.tabs[0]
366+ moved.stableID = "moved-tab"
367+ remaining := source.tabs[1]
368+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
369+ p := moved.focusedPane()
370+
371+ require.NoError(t, d.moveTab(moveTabRequest{
372+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
373+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
374+ }))
375+ d.reapPaneOwner(p)
376+
377+ require.Equal(t, []*tab{remaining}, source.tabs)
378+ require.NotContains(t, destination.tabs, moved, "exit removes the tab from its destination owner")
379+ require.Nil(t, p.ownerSnapshot())
380+}
381+
382+func TestMoveTabOppositeDirectionsDoNotDeadlock(t *testing.T) {
383+ d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
384+ left.tabs[0].stableID = "left-moved"
385+ right := addMoveTabTestSession(d, "right", "right-stays")
386+ rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
387+ rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
388+ publishTiledPaneOwners(right, rightMoved)
389+ right.tabs = append(right.tabs, rightMoved)
390+
391+ start := make(chan struct{})
392+ done := make(chan error, 2)
393+ go func() {
394+ <-start
395+ done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}, SourceTabID: domain.TabStableID("left-moved"), Destination: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}})
396+ }()
397+ go func() {
398+ <-start
399+ done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}, SourceTabID: domain.TabStableID("right-moved"), Destination: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}})
400+ }()
401+ close(start)
402+ for range 2 {
403+ select {
404+ case err := <-done:
405+ require.NoError(t, err)
406+ case <-time.After(5 * time.Second):
407+ t.Fatal("opposite-direction move tabs deadlocked")
408+ }
409+ }
410+}
411+
412+type moveTabPurgeCatalogue struct {
413+ ports.Catalogue
414+ mu sync.Mutex
415+ records map[string]domain.CatalogueRecord
416+ events []string
417+}
418+
419+func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
420+ c.mu.Lock()
421+ defer c.mu.Unlock()
422+ record, ok := c.records[name]
423+ return record, ok, nil
424+}
425+
426+func (c *moveTabPurgeCatalogue) Delete(name string) error {
427+ c.mu.Lock()
428+ defer c.mu.Unlock()
429+ delete(c.records, name)
430+ c.events = append(c.events, "catalogue-delete:"+name)
431+ return nil
432+}
433+
434+func (c *moveTabPurgeCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
435+ c.mu.Lock()
436+ defer c.mu.Unlock()
437+ c.events = append(c.events, "metadata:"+update.Name)
438+ return nil
439+}
440+
441+type moveTabPurgeRepository struct {
442+ noOpSnapshotRepository
443+ d *Daemon
444+ source *session
445+ destination *session
446+ moved *tab
447+ pane *pane
448+ client *attachedClient
449+ catalogue *moveTabPurgeCatalogue
450+ err error
451+ deleted []domain.IncarnationID
452+ outside bool
453+ dirty bool
454+}
455+
456+func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error {
457+ r.deleted = append(r.deleted, id)
458+ daemonUnlocked := r.d.mu.TryLock()
459+ routingUnlocked := r.d.notices.routingMu.TryLock()
460+ sourceUnlocked := r.source.mu.TryLock()
461+ destinationUnlocked := r.destination.mu.TryLock()
462+ tabUnlocked := r.moved.mu.TryLock()
463+ paneUnlocked := r.pane.mu.TryLock()
464+ sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()
465+ destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()
466+ tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()
467+ roleUnlocked := r.client.roleEffects.mu.TryLock()
468+ roleStable := roleUnlocked && r.client.roleEffects.phase == roleEffectsStable
469+ r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&
470+ sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable
471+ if roleUnlocked {
472+ r.client.roleEffects.mu.Unlock()
473+ }
474+ if tabFenceUnlocked {
475+ r.moved.layoutApplyMu.Unlock()
476+ }
477+ if destinationFenceUnlocked {
478+ r.destination.layoutApplyMu.Unlock()
479+ }
480+ if sourceFenceUnlocked {
481+ r.source.layoutApplyMu.Unlock()
482+ }
483+ if paneUnlocked {
484+ r.pane.mu.Unlock()
485+ }
486+ if tabUnlocked {
487+ r.moved.mu.Unlock()
488+ }
489+ if destinationUnlocked {
490+ r.destination.mu.Unlock()
491+ }
492+ if sourceUnlocked {
493+ r.source.mu.Unlock()
494+ }
495+ if routingUnlocked {
496+ r.d.notices.routingMu.Unlock()
497+ }
498+ if daemonUnlocked {
499+ r.d.mu.Unlock()
500+ }
501+ r.destination.snapshotMu.Lock()
502+ r.dirty = r.destination.snapDirty.Load() && r.destination.snapshotGeneration > 0
503+ r.destination.snapshotMu.Unlock()
504+ r.catalogue.mu.Lock()
505+ r.catalogue.events = append(r.catalogue.events, "repository-delete")
506+ r.catalogue.mu.Unlock()
507+ return r.err
508+}
509+
510+func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
511+ d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
512+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
513+ source.incarnation = domain.IncarnationID{4}
514+ moved := source.tabs[0]
515+ moved.stableID = "moved-tab"
516+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
517+ destination.ephemeral = false
518+ destination.snapEligible.Store(true)
519+
520+ catalogue := &moveTabPurgeCatalogue{records: map[string]domain.CatalogueRecord{
521+ source.name: {Name: source.name, IncarnationID: source.incarnation},
522+ destination.name: {Name: destination.name, IncarnationID: destination.incarnation},
523+ }}
524+ failure := errors.New("repository delete failed")
525+ repository := &moveTabPurgeRepository{
526+ d: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(), client: client,
527+ catalogue: catalogue, err: failure,
528+ }
529+ WithCatalogue(catalogue, nil)(d)
530+ WithSnapshotRepository(repository)(d)
531+ WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
532+
533+ require.NoError(t, d.moveTab(moveTabRequest{
534+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
535+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
536+ }))
537+
538+ require.Equal(t, []domain.IncarnationID{source.incarnation}, repository.deleted)
539+ require.True(t, repository.outside, "repository purge must run outside architecture and owner locks")
540+ require.True(t, repository.dirty, "destination snapshot dirtiness must be admitted before purge")
541+ catalogue.mu.Lock()
542+ require.Equal(t, []string{"metadata:destination", "catalogue-delete:work", "repository-delete"}, catalogue.events)
543+ catalogue.mu.Unlock()
544+ require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "best-effort purge failure cannot roll back committed membership")
545+ require.Same(t, destination, moved.focusedPane().ownerSnapshot().session)
546+}
547+
548+func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *session {
549+ ctx, cancel := context.WithCancel(d.serveCtx)
550+ tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
551+ tb.ctx, tb.cancel = context.WithCancel(ctx)
552+ sess := &session{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0}
553+ publishTiledPaneOwners(sess, tb)
554+ d.mu.Lock()
555+ d.sessions[sess.id] = sess
556+ d.mu.Unlock()
557+ return sess
558+}
@@ -0,0 +1,558 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
15+ "github.com/bnema/vev/pkg/renderer"
16+)
17+
18+func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
19+ for _, destinationEphemeral := range []bool{false, true} {
20+ t.Run(map[bool]string{false: "named", true: "ephemeral"}[destinationEphemeral], func(t *testing.T) {
21+ movedPTY := newQuietPTY()
22+ remainingPTY := newQuietPTY()
23+ destinationPTY := newQuietPTY()
24+ floatingPTY := newQuietPTY()
25+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
26+ moved := source.tabs[0]
27+ moved.stableID = "moved-tab"
28+ moved.name = "preserved-name"
29+ remaining := source.tabs[1]
30+ remaining.stableID = "remaining-tab"
31+ source.active = 0
32+
33+ destinationCtx, destinationCancel := context.WithCancel(d.serveCtx)
34+ t.Cleanup(destinationCancel)
35+ destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30})
36+ destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)
37+ destination := &session{
38+ id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral,
39+ ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0,
40+ }
41+ publishTiledPaneOwners(destination, destinationTab)
42+
43+ floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
44+ moved.mu.Lock()
45+ moved.floating = floatingSlot{state: floatingHidden, pane: floating, generation: 7}
46+ moved.mu.Unlock()
47+ publishPaneOwner(floating, source, moved, 7)
48+
49+ d.mu.Lock()
50+ d.sessions[destination.id] = destination
51+ d.mu.Unlock()
52+
53+ tiled := moved.focusedPane()
54+ tiledScreen := tiled.screen
55+ tiledOwnerGeneration := tiled.ownerGeneration
56+ floatingOwnerGeneration := floating.ownerGeneration
57+ oldWorkerCtx := moved.ctx
58+ oldTree := moved.tree
59+ oldPanes := moved.panes
60+ beforeDestinationActive := destination.tabs[destination.active]
61+
62+ err := d.moveTab(moveTabRequest{
63+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
64+ SourceTabID: domain.TabStableID(moved.stableID),
65+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
66+ })
67+ require.NoError(t, err)
68+
69+ require.Equal(t, []*tab{remaining}, source.tabs)
70+ require.Zero(t, source.active)
71+ require.Same(t, remaining, source.tabs[source.active])
72+ require.Equal(t, []*tab{destinationTab, moved}, destination.tabs)
73+ require.Same(t, beforeDestinationActive, destination.tabs[destination.active], "moved tab stays in the background")
74+ require.Equal(t, destinationTab.size, moved.size, "destination geometry becomes authoritative")
75+ require.Equal(t, "preserved-name", moved.name)
76+ require.Same(t, oldTree, moved.tree)
77+ require.Equal(t, oldPanes, moved.panes)
78+ require.Same(t, movedPTY, tiled.pty)
79+ require.Same(t, tiledScreen, tiled.screen)
80+ require.Same(t, floating, moved.floating.pane)
81+ require.Same(t, floatingPTY, floating.pty)
82+ require.Equal(t, floatingState(floatingHidden), moved.floating.state)
83+ require.Equal(t, uint64(7), moved.floating.generation)
84+ require.Equal(t, tiledOwnerGeneration+1, tiled.ownerGeneration)
85+ require.Equal(t, floatingOwnerGeneration+1, floating.ownerGeneration)
86+ require.Same(t, destination, tiled.ownerSnapshot().session)
87+ require.Same(t, destination, floating.ownerSnapshot().session)
88+ require.Error(t, oldWorkerCtx.Err(), "source-derived tab worker context is cancelled")
89+ require.NoError(t, moved.ctx.Err(), "replacement tab worker context belongs to live destination")
90+ })
91+ }
92+}
93+
94+func TestMoveTabFinalSourceVisibleFloatingPreservesRuntimeAndFollowsClient(t *testing.T) {
95+ d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
96+ moved := source.tabs[0]
97+ moved.stableID = "moved-tab"
98+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
99+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
100+
101+ floatingPTY := newQuietPTY()
102+ floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
103+ floatingCtx, floatingCancel := context.WithCancel(d.paneProcessCtx)
104+ t.Cleanup(floatingCancel)
105+ floating.ctx, floating.cancel = floatingCtx, floatingCancel
106+ moved.mu.Lock()
107+ moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7}
108+ moved.mu.Unlock()
109+ publishPaneOwner(floating, source, moved, 7)
110+ d.applyTabLayout(source, moved)
111+
112+ beforeScreen := floating.screen
113+ beforeHistory := floating.history
114+ beforeContext := floating.ctx
115+ beforeOwnerGeneration := floating.ownerGeneration
116+
117+ require.NoError(t, d.moveTab(moveTabRequest{
118+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
119+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
120+ }))
121+
122+ require.Same(t, moved, destination.tabs[destination.active])
123+ require.Same(t, client, destination.client)
124+ require.Same(t, destination, client.currentSession())
125+ moved.mu.Lock()
126+ require.Equal(t, floatingVisible, moved.floating.state)
127+ require.True(t, moved.floating.desiredVisible)
128+ require.Equal(t, uint64(7), moved.floating.generation)
129+ require.Same(t, floating, moved.floating.pane)
130+ moved.mu.Unlock()
131+ expectedGeometry := calculateContentFloatingGeometry(moved.size, d.currentFloatingConfig())
132+ floating.mu.Lock()
133+ require.Same(t, floatingPTY, floating.pty)
134+ require.Same(t, beforeScreen, floating.screen)
135+ require.Same(t, beforeHistory, floating.history)
136+ require.Equal(t, beforeContext, floating.ctx)
137+ require.Equal(t, expectedGeometry, floating.popupGeometry)
138+ require.Equal(t, expectedGeometry.Inner, floating.rect)
139+ require.Equal(t, beforeOwnerGeneration+1, floating.ownerGeneration)
140+ floating.mu.Unlock()
141+ owner := floating.ownerSnapshot()
142+ require.Same(t, destination, owner.session)
143+ require.Same(t, moved, owner.tab)
144+ require.Equal(t, floating.ownerGeneration, owner.generation)
145+ require.Equal(t, uint64(7), owner.floatingSlotGeneration)
146+ require.NoError(t, floating.ctx.Err(), "floating process context remains live")
147+}
148+
149+func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) {
150+ d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
151+ moved := source.tabs[0]
152+ moved.stableID = "moved-tab"
153+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
154+ rebased := false
155+ client.renderStages.handoffRebase = func() { rebased = true }
156+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
157+ oldDestinationActive := destination.tabs[0]
158+
159+ require.NoError(t, d.moveTab(moveTabRequest{
160+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
161+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
162+ }))
163+
164+ require.Nil(t, source.tabs)
165+ require.Same(t, destination, client.currentSession())
166+ require.Same(t, client, destination.client)
167+ require.Equal(t, attachmentActive, destination.attachmentRole(client))
168+ require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs)
169+ require.Same(t, moved, destination.tabs[destination.active], "final-source follower activates the moved tab")
170+ require.True(t, rebased)
171+}
172+
173+func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) {
174+ tests := []struct {
175+ name string
176+ mutate func(source, destination *session, moved *tab, req *moveTabRequest)
177+ }{
178+ {name: "same session", mutate: func(source, _ *session, _ *tab, req *moveTabRequest) {
179+ req.Destination = req.Source
180+ }},
181+ {name: "stale incarnation", mutate: func(_, _ *session, _ *tab, req *moveTabRequest) {
182+ req.Destination.Incarnation[0]++
183+ }},
184+ {name: "missing membership", mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) {
185+ source.tabs = source.tabs[1:]
186+ _ = moved
187+ }},
188+ {name: "warming floating", mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) {
189+ moved.floating.state = floatingWarming
190+ moved.floating.generation = 3
191+ }},
192+ {name: "destination teardown", mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) {
193+ destination.teardownMu.Lock()
194+ destination.teardownActive = true
195+ destination.teardownMu.Unlock()
196+ }},
197+ }
198+ for _, tt := range tests {
199+ t.Run(tt.name, func(t *testing.T) {
200+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
201+ moved := source.tabs[0]
202+ moved.stableID = "moved-tab"
203+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
204+ req := moveTabRequest{
205+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
206+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
207+ }
208+ tt.mutate(source, destination, moved, &req)
209+ beforeDestination := append([]*tab(nil), destination.tabs...)
210+ beforeOwner := moved.focusedPane().ownerSnapshot()
211+
212+ err := d.moveTab(req)
213+ destination.teardownMu.Lock()
214+ destination.teardownActive = false
215+ destination.teardownMu.Unlock()
216+ require.Error(t, err)
217+ require.Equal(t, beforeDestination, destination.tabs)
218+ require.Same(t, beforeOwner, moved.focusedPane().ownerSnapshot())
219+ })
220+ }
221+}
222+
223+func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing.T) {
224+ for _, tt := range []struct {
225+ name string
226+ mutate func(*tab)
227+ }{
228+ {name: "generation", mutate: func(moved *tab) { moved.floating.generation++ }},
229+ {name: "warming", mutate: func(moved *tab) { moved.floating.state = floatingWarming }},
230+ } {
231+ t.Run(tt.name, func(t *testing.T) {
232+ d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
233+ moved := source.tabs[0]
234+ moved.stableID = "moved-tab"
235+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
236+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
237+ floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
238+ moved.mu.Lock()
239+ moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7}
240+ moved.mu.Unlock()
241+ publishPaneOwner(floating, source, moved, 7)
242+
243+ source.layoutApplyMu.Lock()
244+ admitted := make(chan struct{})
245+ d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {
246+ if action == "move-tab" && ac == client {
247+ close(admitted)
248+ }
249+ }
250+ moveDone := make(chan error, 1)
251+ go func() {
252+ moveDone <- d.moveTab(moveTabRequest{
253+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
254+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
255+ })
256+ }()
257+ awaitTestCompletion(t, admitted, "move-tab did not finish admission")
258+ moved.mu.Lock()
259+ tt.mutate(moved)
260+ moved.mu.Unlock()
261+ source.layoutApplyMu.Unlock()
262+
263+ require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return"))
264+ require.NotContains(t, destination.tabs, moved)
265+ require.Same(t, source, floating.ownerSnapshot().session)
266+ d.afterRoleEffectGateFrozen = nil
267+ })
268+ }
269+}
270+
271+func TestMoveTabWaitsForSourceResizeAndAppliesDestinationGeometryLast(t *testing.T) {
272+ pty := &transactionalResizePTY{}
273+ d, source, _, _ := newManualSessionWithPTYs(t, pty, newQuietPTY())
274+ moved := source.tabs[0]
275+ moved.stableID = "moved-tab"
276+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
277+ destination.tabs[0].mu.Lock()
278+ destination.tabs[0].size = domain.Size{Cols: 100, Rows: 30}
279+ destination.tabs[0].mu.Unlock()
280+ moved.mu.Lock()
281+ moved.size = domain.Size{Cols: 90, Rows: 20}
282+ moved.bumpLayoutGenerationLocked()
283+ moved.mu.Unlock()
284+
285+ resizeEntered := make(chan struct{})
286+ releaseResize := make(chan struct{})
287+ var once sync.Once
288+ pty.onResize = func() {
289+ once.Do(func() {
290+ close(resizeEntered)
291+ <-releaseResize
292+ })
293+ }
294+ layoutDone := make(chan bool, 1)
295+ go func() { layoutDone <- d.applyTabLayout(source, moved) }()
296+ <-resizeEntered
297+ reserved := make(chan struct{})
298+ d.afterMoveLifecycleReserved = func() { close(reserved) }
299+ defer func() { d.afterMoveLifecycleReserved = nil }()
300+ moveDone := make(chan error, 1)
301+ go func() {
302+ moveDone <- d.moveTab(moveTabRequest{
303+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
304+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
305+ })
306+ }()
307+ <-reserved
308+ select {
309+ case err := <-moveDone:
310+ t.Fatalf("move crossed blocked source resize: %v", err)
311+ default:
312+ }
313+ close(releaseResize)
314+ <-layoutDone // the old transaction may lose owner publication after its PTY call
315+ require.NoError(t, <-moveDone)
316+
317+ pty.mu.Lock()
318+ sizes := append([]domain.Size(nil), pty.sizes...)
319+ pty.mu.Unlock()
320+ require.GreaterOrEqual(t, len(sizes), 2)
321+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, sizes[len(sizes)-1], "destination geometry must be the final external resize")
322+}
323+
324+func TestMoveTabMigratesSyncAndInvalidatesSourceRetryOwner(t *testing.T) {
325+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
326+ moved := source.tabs[0]
327+ moved.stableID = "moved-tab"
328+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
329+ p := moved.focusedPane()
330+ sourceCoordinator := d.ensureRenderCoordinator(source)
331+ destinationCoordinator := d.ensureRenderCoordinator(destination)
332+
333+ p.mu.Lock()
334+ p.screen.Write([]byte(renderer.SyncStartCSI))
335+ generation := source.syncGen.Add(1)
336+ p.syncGen = generation
337+ oldLease := p.effectLeaseLocked()
338+ p.resizeRetry = true
339+ p.mu.Unlock()
340+ sourceCoordinator.noteSyncBeginWithRenderability(p, generation, oldLease.Current, nil)
341+ oldRetry := newAcceptedTabLayoutRetryToken(source, moved, []resizeMember{{session: source, tab: moved, pane: p, owner: oldLease}})
342+
343+ require.NoError(t, d.moveTab(moveTabRequest{
344+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
345+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
346+ }))
347+
348+ require.False(t, oldLease.Current(), "source effects are revoked at owner publication")
349+ require.False(t, oldRetry.current(), "source delayed retry cannot run after transfer")
350+ require.Same(t, destination, p.ownerSnapshot().session)
351+ sourceCoordinator.mu.Lock()
352+ require.NotContains(t, sourceCoordinator.syncBatches, p)
353+ sourceCoordinator.mu.Unlock()
354+ destinationCoordinator.mu.Lock()
355+ require.Contains(t, destinationCoordinator.syncBatches, p)
356+ destinationCoordinator.mu.Unlock()
357+ require.Equal(t, destination.syncGen.Load(), p.syncGen)
358+ p.mu.Lock()
359+ require.False(t, p.resizeRetry, "required resize work is completed under the destination owner")
360+ p.mu.Unlock()
361+}
362+
363+func TestMoveTabExitReapsOnlyDestination(t *testing.T) {
364+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
365+ moved := source.tabs[0]
366+ moved.stableID = "moved-tab"
367+ remaining := source.tabs[1]
368+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
369+ p := moved.focusedPane()
370+
371+ require.NoError(t, d.moveTab(moveTabRequest{
372+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
373+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
374+ }))
375+ d.reapPaneOwner(p)
376+
377+ require.Equal(t, []*tab{remaining}, source.tabs)
378+ require.NotContains(t, destination.tabs, moved, "exit removes the tab from its destination owner")
379+ require.Nil(t, p.ownerSnapshot())
380+}
381+
382+func TestMoveTabOppositeDirectionsDoNotDeadlock(t *testing.T) {
383+ d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
384+ left.tabs[0].stableID = "left-moved"
385+ right := addMoveTabTestSession(d, "right", "right-stays")
386+ rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
387+ rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
388+ publishTiledPaneOwners(right, rightMoved)
389+ right.tabs = append(right.tabs, rightMoved)
390+
391+ start := make(chan struct{})
392+ done := make(chan error, 2)
393+ go func() {
394+ <-start
395+ done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}, SourceTabID: domain.TabStableID("left-moved"), Destination: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}})
396+ }()
397+ go func() {
398+ <-start
399+ done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}, SourceTabID: domain.TabStableID("right-moved"), Destination: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}})
400+ }()
401+ close(start)
402+ for range 2 {
403+ select {
404+ case err := <-done:
405+ require.NoError(t, err)
406+ case <-time.After(5 * time.Second):
407+ t.Fatal("opposite-direction move tabs deadlocked")
408+ }
409+ }
410+}
411+
412+type moveTabPurgeCatalogue struct {
413+ ports.Catalogue
414+ mu sync.Mutex
415+ records map[string]domain.CatalogueRecord
416+ events []string
417+}
418+
419+func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
420+ c.mu.Lock()
421+ defer c.mu.Unlock()
422+ record, ok := c.records[name]
423+ return record, ok, nil
424+}
425+
426+func (c *moveTabPurgeCatalogue) Delete(name string) error {
427+ c.mu.Lock()
428+ defer c.mu.Unlock()
429+ delete(c.records, name)
430+ c.events = append(c.events, "catalogue-delete:"+name)
431+ return nil
432+}
433+
434+func (c *moveTabPurgeCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
435+ c.mu.Lock()
436+ defer c.mu.Unlock()
437+ c.events = append(c.events, "metadata:"+update.Name)
438+ return nil
439+}
440+
441+type moveTabPurgeRepository struct {
442+ noOpSnapshotRepository
443+ d *Daemon
444+ source *session
445+ destination *session
446+ moved *tab
447+ pane *pane
448+ client *attachedClient
449+ catalogue *moveTabPurgeCatalogue
450+ err error
451+ deleted []domain.IncarnationID
452+ outside bool
453+ dirty bool
454+}
455+
456+func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error {
457+ r.deleted = append(r.deleted, id)
458+ daemonUnlocked := r.d.mu.TryLock()
459+ routingUnlocked := r.d.notices.routingMu.TryLock()
460+ sourceUnlocked := r.source.mu.TryLock()
461+ destinationUnlocked := r.destination.mu.TryLock()
462+ tabUnlocked := r.moved.mu.TryLock()
463+ paneUnlocked := r.pane.mu.TryLock()
464+ sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()
465+ destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()
466+ tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()
467+ roleUnlocked := r.client.roleEffects.mu.TryLock()
468+ roleStable := roleUnlocked && r.client.roleEffects.phase == roleEffectsStable
469+ r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&
470+ sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable
471+ if roleUnlocked {
472+ r.client.roleEffects.mu.Unlock()
473+ }
474+ if tabFenceUnlocked {
475+ r.moved.layoutApplyMu.Unlock()
476+ }
477+ if destinationFenceUnlocked {
478+ r.destination.layoutApplyMu.Unlock()
479+ }
480+ if sourceFenceUnlocked {
481+ r.source.layoutApplyMu.Unlock()
482+ }
483+ if paneUnlocked {
484+ r.pane.mu.Unlock()
485+ }
486+ if tabUnlocked {
487+ r.moved.mu.Unlock()
488+ }
489+ if destinationUnlocked {
490+ r.destination.mu.Unlock()
491+ }
492+ if sourceUnlocked {
493+ r.source.mu.Unlock()
494+ }
495+ if routingUnlocked {
496+ r.d.notices.routingMu.Unlock()
497+ }
498+ if daemonUnlocked {
499+ r.d.mu.Unlock()
500+ }
501+ r.destination.snapshotMu.Lock()
502+ r.dirty = r.destination.snapDirty.Load() && r.destination.snapshotGeneration > 0
503+ r.destination.snapshotMu.Unlock()
504+ r.catalogue.mu.Lock()
505+ r.catalogue.events = append(r.catalogue.events, "repository-delete")
506+ r.catalogue.mu.Unlock()
507+ return r.err
508+}
509+
510+func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
511+ d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
512+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
513+ source.incarnation = domain.IncarnationID{4}
514+ moved := source.tabs[0]
515+ moved.stableID = "moved-tab"
516+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
517+ destination.ephemeral = false
518+ destination.snapEligible.Store(true)
519+
520+ catalogue := &moveTabPurgeCatalogue{records: map[string]domain.CatalogueRecord{
521+ source.name: {Name: source.name, IncarnationID: source.incarnation},
522+ destination.name: {Name: destination.name, IncarnationID: destination.incarnation},
523+ }}
524+ failure := errors.New("repository delete failed")
525+ repository := &moveTabPurgeRepository{
526+ d: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(), client: client,
527+ catalogue: catalogue, err: failure,
528+ }
529+ WithCatalogue(catalogue, nil)(d)
530+ WithSnapshotRepository(repository)(d)
531+ WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
532+
533+ require.NoError(t, d.moveTab(moveTabRequest{
534+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
535+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
536+ }))
537+
538+ require.Equal(t, []domain.IncarnationID{source.incarnation}, repository.deleted)
539+ require.True(t, repository.outside, "repository purge must run outside architecture and owner locks")
540+ require.True(t, repository.dirty, "destination snapshot dirtiness must be admitted before purge")
541+ catalogue.mu.Lock()
542+ require.Equal(t, []string{"metadata:destination", "catalogue-delete:work", "repository-delete"}, catalogue.events)
543+ catalogue.mu.Unlock()
544+ require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "best-effort purge failure cannot roll back committed membership")
545+ require.Same(t, destination, moved.focusedPane().ownerSnapshot().session)
546+}
547+
548+func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *session {
549+ ctx, cancel := context.WithCancel(d.serveCtx)
550+ tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
551+ tb.ctx, tb.cancel = context.WithCancel(ctx)
552+ sess := &session{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0}
553+ publishTiledPaneOwners(sess, tb)
554+ d.mu.Lock()
555+ d.sessions[sess.id] = sess
556+ d.mu.Unlock()
557+ return sess
558+}

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