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 TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(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, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
38 require.NotNil(t, d.attachCoordinator(source, nil, client, true))
39 sourceTab := source.tabs[0]
40 sourceTab.stableID = "source-tab"
41 moved := sourceTab.focusedPane()
42 client.sendMu.Lock()
43 client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
44 client.sendMu.Unlock()
45
46 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
47 destinationTab := destination.tabs[0]
48 publishTiledPaneOwners(destination, destinationTab)
49 d.mu.Lock()
50 d.sessions[destination.id] = destination
51 d.mu.Unlock()
52
53 moved.mu.Lock()
54 owner := moved.effectLeaseLocked()
55 moved.mu.Unlock()
56 members := []resizeMember{{session: source, tab: sourceTab, pane: moved, owner: owner}}
57 rc := source.renderCoordinator()
58 require.NotNil(t, rc)
59 lease := rc.attachmentLease(client)
60 epoch := rc.recordResizeRequestForLease(client.size, client, lease)
61 ticket, admitted := beginAttachmentLeaseEffect(source, client, lease)
62 require.True(t, admitted)
63 defer ticket.End()
64
65 sendLocked := make(chan struct{})
66 allowResizeFence := make(chan struct{})
67 d.afterResizeCommitSendLocked = func() {
68 close(sendLocked)
69 <-allowResizeFence
70 }
71 defer func() { d.afterResizeCommitSendLocked = nil }()
72 resizeDone := make(chan bool, 1)
73 go func() {
74 resizeDone <- d.publishResizeCommit(members, source, client, lease, epoch, ticket, client.size)
75 }()
76 awaitTestCompletion(t, sendLocked, "resize commit did not acquire sendMu")
77
78 insideMoveCommit := make(chan struct{})
79 allowMoveCommit := make(chan struct{})
80 d.beforeMovePaneCommit = func() {
81 close(insideMoveCommit)
82 <-allowMoveCommit
83 }
84 defer func() { d.beforeMovePaneCommit = nil }()
85 moveDone := make(chan error, 1)
86 go func() {
87 moveDone <- d.movePane(movePaneRequest{
88 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
89 SourceTabID: domain.TabStableID(sourceTab.stableID),
90 SourcePaneID: domain.PaneStableID(moved.stableID),
91 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
92 DestinationTabID: domain.TabStableID(destinationTab.stableID),
93 })
94 }()
95 awaitTestCompletion(t, insideMoveCommit, "move did not reach its commit section")
96
97 // Resize now owns sendMu and waits for the moved pane fence. The move may
98 // finish only if it releases every resize fence before capture cleanup tries
99 // to acquire sendMu.
100 close(allowResizeFence)
101 close(allowMoveCommit)
102 require.NoError(t, awaitTestValue(t, moveDone, "move deadlocked against resize commit"))
103 require.False(t, awaitTestValue(t, resizeDone, "resize commit did not drop stale owner"))
104}
105
106func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
107 movedPTY, releaseMoved := newBlockingPTY(t)
108 remainingPTY, releaseRemaining := newBlockingPTY(t)
109 destinationPTY, releaseDestination := newBlockingPTY(t)
110 defer releaseMoved()
111 defer releaseRemaining()
112 defer releaseDestination()
113
114 d, source, displaced, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
115 sourceTab := source.tabs[0]
116 remainingTab := source.tabs[1]
117 sourceTab.stableID = "source-tab"
118 remainingTab.stableID = "remaining-tab"
119 moved := sourceTab.focusedPane()
120 require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))
121
122 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
123 destinationTab := destination.tabs[0]
124 publishTiledPaneOwners(destination, destinationTab)
125 d.mu.Lock()
126 d.sessions[destination.id] = destination
127 d.mu.Unlock()
128
129 // Both clients carry state tied specifically to the pane. The existing
130 // attachment and the concurrently registered attachment remain independent;
131 // moving one exact attachment must not clean the other.
132 displaced.overlays.copyMu.Lock()
133 displaced.overlays.copyPane = moved
134 displaced.overlays.copyMode = &scopy.Mode{}
135 displaced.overlays.copyMu.Unlock()
136 displaced.sendMu.Lock()
137 displaced.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
138 displaced.sendMu.Unlock()
139
140 replacementTransport, _ := newCapturingTransport(t)
141 replacement := &attachedClient{
142 tr: replacementTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24},
143 }
144 replacement.initOverlays()
145 replacement.setSession(source)
146 replacement.overlays.copyMu.Lock()
147 replacement.overlays.copyPane = moved
148 replacement.overlays.copyMode = &scopy.Mode{}
149 replacement.overlays.copyMu.Unlock()
150 replacement.sendMu.Lock()
151 replacement.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
152 replacement.sendMu.Unlock()
153
154 // Keep the source layout fence occupied while the move has captured its
155 // pre-fence source client. Replacing the client at this barrier reproduces
156 // the stale cleanup race without sleeps.
157 source.layoutApplyMu.Lock()
158 snapshot := make(chan struct{})
159 releaseSnapshot := make(chan struct{})
160 d.afterMovePaneSourceSnapshot = func() {
161 close(snapshot)
162 <-releaseSnapshot
163 }
164 defer func() { d.afterMovePaneSourceSnapshot = nil }()
165
166 moveDone := make(chan error, 1)
167 go func() {
168 moveDone <- d.movePane(movePaneRequest{
169 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
170 SourceTabID: domain.TabStableID(sourceTab.stableID),
171 SourcePaneID: domain.PaneStableID(moved.stableID),
172 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
173 DestinationTabID: domain.TabStableID(destinationTab.stableID),
174 })
175 }()
176 awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment")
177
178 source.mu.Lock()
179 source.registerAttachmentLocked(replacement)
180 source.mu.Unlock()
181 close(releaseSnapshot)
182 source.layoutApplyMu.Unlock()
183 require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release"))
184
185 displaced.overlays.copyMu.Lock()
186 require.Nil(t, displaced.overlays.copyMode, "moved attachment copy mode was not cleaned")
187 require.Nil(t, displaced.overlays.copyPane)
188 displaced.overlays.copyMu.Unlock()
189 displaced.sendMu.Lock()
190 _, displacedCaptured := displaced.captureFrames[moved]
191 displaced.sendMu.Unlock()
192 require.False(t, displacedCaptured, "moved attachment capture was not pruned")
193
194 replacement.overlays.copyMu.Lock()
195 require.NotNil(t, replacement.overlays.copyMode, "independent attachment copy mode was cleaned")
196 require.Same(t, moved, replacement.overlays.copyPane)
197 replacement.overlays.copyMu.Unlock()
198 replacement.sendMu.Lock()
199 _, replacementCaptured := replacement.captureFrames[moved]
200 replacement.sendMu.Unlock()
201 require.True(t, replacementCaptured, "independent attachment capture was pruned")
202}