1package daemon
2
3import (
4 "sync"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/protocol"
12)
13
14func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *session, *tab, func()) {
15 t.Helper()
16 movedPTY, releaseMoved := newBlockingPTY(t)
17 destinationPTY, releaseDestination := newBlockingPTY(t)
18 d, source, client, _ := newManualSessionWithPTYs(t, movedPTY)
19 sourceTab := source.tabs[0]
20 sourceTab.stableID = "source-tab"
21 moved := sourceTab.focusedPane()
22 require.NotNil(t, d.attachCoordinator(source, nil, client, true))
23
24 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
25 destinationTab := destination.tabs[0]
26 publishTiledPaneOwners(destination, destinationTab)
27 d.mu.Lock()
28 d.sessions[destination.id] = destination
29 d.mu.Unlock()
30 return d, source, sourceTab, moved, destination, destinationTab, func() {
31 releaseMoved()
32 releaseDestination()
33 }
34}
35
36func moveGateRaceRequest(source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) movePaneRequest {
37 return movePaneRequest{
38 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
39 SourceTabID: domain.TabStableID(sourceTab.stableID),
40 SourcePaneID: domain.PaneStableID(moved.stableID),
41 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
42 DestinationTabID: domain.TabStableID(destinationTab.stableID),
43 }
44}
45
46func waitMoveRace[T any](t *testing.T, ch <-chan T, name string) T {
47 t.Helper()
48 select {
49 case value := <-ch:
50 return value
51 case <-timeAfterMoveRace():
52 t.Fatalf("%s did not finish", name)
53 var zero T
54 return zero
55 }
56}
57
58// timeAfterMoveRace is kept in one helper so these barrier tests use a
59// deterministic bounded wait rather than sleeps.
60func timeAfterMoveRace() <-chan time.Time {
61 return time.After(testWaitTimeout)
62}
63
64func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
65 d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
66 defer releasePTYs()
67
68 moveReserved := make(chan struct{})
69 releaseMoveAdmission := make(chan struct{})
70 d.afterMoveLifecycleReserved = func() {
71 close(moveReserved)
72 <-releaseMoveAdmission
73 }
74 killFrozen := make(chan struct{})
75 releaseKill := make(chan struct{})
76 var killFrozenOnce sync.Once
77 d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
78 if action != "" {
79 return
80 }
81 killFrozenOnce.Do(func() { close(killFrozen) })
82 <-releaseKill
83 }
84 defer func() {
85 d.afterMoveLifecycleReserved = nil
86 d.afterAttachmentEffectGateFrozen = nil
87 }()
88
89 moveDone := make(chan error, 1)
90 go func() {
91 moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
92 }()
93 waitMoveRace(t, moveReserved, "move reservation")
94
95 killDone := make(chan error, 1)
96 go func() { killDone <- d.killSession(source, protocol.ReasonSessionKilled, false) }()
97 waitMoveRace(t, killFrozen, "kill gate freeze")
98 close(releaseMoveAdmission)
99 moveErr := waitMoveRace(t, moveDone, "move abort after kill gate acquisition")
100 close(releaseKill)
101 killErr := waitMoveRace(t, killDone, "kill teardown")
102
103 require.ErrorIs(t, moveErr, errMovePaneInvalid)
104 require.NoError(t, killErr)
105 d.mu.Lock()
106 _, sourceLive := d.sessions[source.id]
107 _, destinationLive := d.sessions[destination.id]
108 d.mu.Unlock()
109 require.False(t, sourceLive, "kill winner must retire the source")
110 require.True(t, destinationLive)
111 require.NotSame(t, moved, destination.tabs[0].panes[moved.id], "kill winner must not publish a partial destination move")
112}
113
114func TestFinalCloseDoesNotDeadlockWithMoveDispatchAdmission(t *testing.T) {
115 tests := []struct {
116 name string
117 close func(*Daemon, *session, *tab, *pane) error
118 move func(*Daemon, *session, *tab, *pane, *session, *tab) error
119 }{
120 {name: "pane", close: func(d *Daemon, source *session, sourceTab *tab, _ *pane) error {
121 return d.closeTab(source, sourceTab, true)
122 }, move: func(d *Daemon, source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) error {
123 return d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
124 }},
125 {name: "pane-reap", close: func(d *Daemon, _ *session, _ *tab, moved *pane) error {
126 d.reapPaneOwner(moved)
127 return nil
128 }, move: func(d *Daemon, source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) error {
129 return d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
130 }},
131 {name: "tab", close: func(d *Daemon, source *session, sourceTab *tab, _ *pane) error {
132 return d.closeTab(source, sourceTab, true)
133 }, move: func(d *Daemon, source *session, sourceTab *tab, _ *pane, destination *session, _ *tab) error {
134 return d.moveTab(moveTabRequest{
135 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(sourceTab.stableID),
136 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
137 })
138 }},
139 }
140 for _, test := range tests {
141 t.Run(test.name, func(t *testing.T) {
142 d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
143 defer releasePTYs()
144
145 closeReady := make(chan struct{})
146 releaseClose := make(chan struct{})
147 d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
148 if action != "" {
149 return
150 }
151 close(closeReady)
152 <-releaseClose
153 }
154 moveDispatchReady := make(chan struct{})
155 d.beforeMoveDispatch = func() { close(moveDispatchReady) }
156 defer func() {
157 d.afterAttachmentEffectParticipantsSnapshotted = nil
158 d.beforeMoveDispatch = nil
159 }()
160
161 closeDone := make(chan error, 1)
162 go func() { closeDone <- test.close(d, source, sourceTab, moved) }()
163 waitMoveRace(t, closeReady, "final close participant snapshot")
164
165 moveDone := make(chan error, 1)
166 go func() { moveDone <- test.move(d, source, sourceTab, moved, destination, destinationTab) }()
167 waitMoveRace(t, moveDispatchReady, "move dispatch admission")
168 close(releaseClose)
169
170 require.NoError(t, waitMoveRace(t, closeDone, "final close"))
171 require.ErrorIs(t, waitMoveRace(t, moveDone, "move after final close"), errMoveStaleTarget)
172 d.mu.Lock()
173 _, sourceLive := d.sessions[source.id]
174 _, destinationLive := d.sessions[destination.id]
175 d.mu.Unlock()
176 require.False(t, sourceLive, "final close must retire the source")
177 require.True(t, destinationLive)
178 if test.name == "pane" || test.name == "pane-reap" {
179 require.NotSame(t, moved, destinationTab.panes[moved.id], "a rejected move must not publish into the destination")
180 }
181 })
182 }
183}
184
185func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
186 d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY())
187 d.attachCoordinator(sess, nil, current, true)
188 otherTransport := &closeTrackingTransport{}
189 other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: current.size}
190 other.initOverlays()
191 other.setSession(sess)
192 sess.mu.Lock()
193 require.True(t, sess.registerAttachmentLocked(other))
194 sess.mu.Unlock()
195 d.attachCoordinator(sess, nil, other, true)
196
197 currentToken := sess.captureAttachmentCapability(current, current.transport())
198 currentEffect, admitted := current.beginAttachmentEffect(currentToken)
199 require.True(t, admitted)
200 t.Cleanup(currentEffect.End)
201 otherToken := sess.captureAttachmentCapability(other, otherTransport)
202 otherEffect, admitted := other.beginAttachmentEffect(otherToken)
203 require.True(t, admitted)
204 t.Cleanup(otherEffect.End)
205
206 currentEnded := make(chan struct{})
207 currentReleasedBeforePeerFreeze := make(chan struct{})
208 otherFrozen := make(chan struct{})
209 d.afterActionAttachmentEffectEnded = func(action string) {
210 if action == "close-tab" {
211 close(currentEnded)
212 }
213 }
214 d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) {
215 if action == "close-tab" && ac == other {
216 if currentEffect.ended.Load() {
217 close(currentReleasedBeforePeerFreeze)
218 }
219 close(otherFrozen)
220 }
221 }
222 defer func() {
223 d.afterActionAttachmentEffectEnded = nil
224 d.afterAttachmentEffectGateFrozen = nil
225 }()
226
227 closeDone := make(chan error, 1)
228 go func() {
229 sess.dispatchMu.Lock()
230 defer sess.dispatchMu.Unlock()
231 closeDone <- (paletteExec{d: d, sess: sess, ac: current, effect: currentEffect}).CloseTab()
232 }()
233 waitMoveRace(t, otherFrozen, "peer effect gate freeze")
234 waitMoveRace(t, currentReleasedBeforePeerFreeze, "current effect release before peer drain")
235 waitMoveRace(t, currentEnded, "current palette effect completion")
236 select {
237 case err := <-closeDone:
238 t.Fatalf("final close drained its own peer before release: %v", err)
239 default:
240 }
241 otherEffect.End()
242 require.NoError(t, waitMoveRace(t, closeDone, "palette final close"))
243 requireAttachmentEffectGateRetired(t, current)
244 requireAttachmentEffectGateRetired(t, other)
245}
246
247func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
248 d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
249 defer releasePTYs()
250
251 moveGateFrozen := make(chan struct{})
252 releaseMove := make(chan struct{})
253 var moveGateFrozenOnce sync.Once
254 d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
255 if action != "move-pane" {
256 return
257 }
258 moveGateFrozenOnce.Do(func() { close(moveGateFrozen) })
259 <-releaseMove
260 }
261 killSnapshotted := make(chan struct{})
262 var killSnapshottedOnce sync.Once
263 d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
264 if action == "" {
265 killSnapshottedOnce.Do(func() { close(killSnapshotted) })
266 }
267 }
268 defer func() {
269 d.afterAttachmentEffectGateFrozen = nil
270 d.afterAttachmentEffectParticipantsSnapshotted = nil
271 }()
272
273 moveDone := make(chan error, 1)
274 go func() {
275 moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
276 }()
277 waitMoveRace(t, moveGateFrozen, "move gate freeze")
278
279 killDone := make(chan error, 1)
280 go func() { killDone <- d.killSession(source, protocol.ReasonSessionKilled, false) }()
281 waitMoveRace(t, killSnapshotted, "kill participant snapshot")
282 close(releaseMove)
283
284 require.NoError(t, waitMoveRace(t, moveDone, "move commit"))
285 require.NoError(t, waitMoveRace(t, killDone, "kill loser"))
286 d.mu.Lock()
287 _, sourceLive := d.sessions[source.id]
288 _, destinationLive := d.sessions[destination.id]
289 d.mu.Unlock()
290 require.False(t, sourceLive)
291 require.True(t, destinationLive)
292 require.Same(t, moved, destinationTab.panes[moved.id])
293}