1package daemon
2
3import (
4 "testing"
5
6 "github.com/stretchr/testify/require"
7
8 "github.com/bnema/vev/internal/domain"
9 scopy "github.com/bnema/vev/internal/usecase/copy"
10)
11
12func TestDetachMoveAttachmentsUsesStableSnapshotOrder(t *testing.T) {
13 first := &attachedClient{clientID: [16]byte{1}}
14 second := &attachedClient{clientID: [16]byte{2}}
15 sess := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{second: {}, first: {}}}}
16 frozen := freezeAttachmentEffectGates(first, second)
17 require.True(t, frozen.acquired)
18 defer frozen.unfreeze()
19
20 sess.mu.Lock()
21 retired := detachMoveAttachmentsLocked(sess, nil)
22 sess.mu.Unlock()
23
24 require.Len(t, retired, 2)
25 require.Same(t, first, retired[0].ac)
26 require.Same(t, second, retired[1].ac)
27}
28
29func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
30 movedPTY, releaseMoved := newBlockingPTY(t)
31 remainingPTY, releaseRemaining := newBlockingPTY(t)
32 destinationPTY, releaseDestination := newBlockingPTY(t)
33 defer releaseMoved()
34 defer releaseRemaining()
35 defer releaseDestination()
36
37 d, source, displaced, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
38 sourceTab := source.tabs[0]
39 remainingTab := source.tabs[1]
40 sourceTab.stableID = "source-tab"
41 remainingTab.stableID = "remaining-tab"
42 moved := sourceTab.focusedPane()
43 require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))
44
45 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
46 destinationTab := destination.tabs[0]
47 publishTiledPaneOwners(destination, destinationTab)
48 d.mu.Lock()
49 d.sessions[destination.id] = destination
50 d.mu.Unlock()
51
52 // Both clients carry state tied specifically to the pane. The existing
53 // attachment and the concurrently registered attachment remain independent;
54 // moving one exact attachment must not clean the other.
55 displaced.overlays.copyMu.Lock()
56 displaced.overlays.copyPane = moved
57 displaced.overlays.copyMode = &scopy.Mode{}
58 displaced.overlays.copyMu.Unlock()
59 displaced.sendMu.Lock()
60 displaced.render.panes = map[*pane]capturedPaneRenderState{moved: {}}
61 displaced.sendMu.Unlock()
62
63 replacementTransport, _ := newCapturingTransport(t)
64 replacement := &attachedClient{
65 tr: replacementTransport, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}},
66 }
67 replacement.initOverlays()
68 replacement.setSession(source)
69 replacement.overlays.copyMu.Lock()
70 replacement.overlays.copyPane = moved
71 replacement.overlays.copyMode = &scopy.Mode{}
72 replacement.overlays.copyMu.Unlock()
73 replacement.sendMu.Lock()
74 replacement.render.panes = map[*pane]capturedPaneRenderState{moved: {}}
75 replacement.sendMu.Unlock()
76
77 // Keep the source layout fence occupied while the move has captured its
78 // pre-fence source client. Replacing the client at this barrier reproduces
79 // the stale cleanup race without sleeps.
80 source.layoutApplyMu.Lock()
81 snapshot := make(chan struct{})
82 releaseSnapshot := make(chan struct{})
83 d.afterMovePaneSourceSnapshot = func() {
84 close(snapshot)
85 <-releaseSnapshot
86 }
87 defer func() { d.afterMovePaneSourceSnapshot = nil }()
88
89 moveDone := make(chan error, 1)
90 go func() {
91 moveDone <- d.movePane(movePaneRequest{
92 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
93 SourceTabID: domain.TabStableID(sourceTab.stableID),
94 SourcePaneID: domain.PaneStableID(moved.stableID),
95 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
96 DestinationTabID: domain.TabStableID(destinationTab.stableID),
97 })
98 }()
99 awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment")
100
101 source.mu.Lock()
102 source.registerAttachmentLocked(replacement)
103 source.mu.Unlock()
104 close(releaseSnapshot)
105 source.layoutApplyMu.Unlock()
106 require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release"))
107
108 displaced.overlays.copyMu.Lock()
109 require.Nil(t, displaced.overlays.copyMode, "moved attachment copy mode was not cleaned")
110 require.Nil(t, displaced.overlays.copyPane)
111 displaced.overlays.copyMu.Unlock()
112 displaced.sendMu.Lock()
113 _, displacedCaptured := displaced.render.panes[moved]
114 displaced.sendMu.Unlock()
115 require.False(t, displacedCaptured, "moved attachment capture was not pruned")
116
117 replacement.overlays.copyMu.Lock()
118 require.NotNil(t, replacement.overlays.copyMode, "independent attachment copy mode was cleaned")
119 require.Same(t, moved, replacement.overlays.copyPane)
120 replacement.overlays.copyMu.Unlock()
121 replacement.sendMu.Lock()
122 _, replacementCaptured := replacement.render.panes[moved]
123 replacement.sendMu.Unlock()
124 require.True(t, replacementCaptured, "independent attachment capture was pruned")
125}