1package daemon
2
3import (
4 "testing"
5 "time"
6
7 "github.com/bnema/vev/internal/protocol"
8 "github.com/stretchr/testify/require"
9)
10
11func awaitMoveTeardownState(t *testing.T, sess *session, reservations, waiters uint) {
12 t.Helper()
13 for {
14 sess.teardownMu.Lock()
15 gotReservations := sess.moveReservations
16 gotWaiters := sess.teardownWaiters
17 changed := sess.teardownChangeLocked()
18 sess.teardownMu.Unlock()
19 if gotReservations == reservations && gotWaiters == waiters {
20 return
21 }
22 select {
23 case <-changed:
24 case <-time.After(testWaitTimeout):
25 t.Fatalf("move reservations/waiters = %d/%d, want %d/%d", gotReservations, gotWaiters, reservations, waiters)
26 }
27 }
28}
29
30func awaitDaemonMoveActive(t *testing.T, d *Daemon, want uint) {
31 t.Helper()
32 for {
33 d.moveLifecycleMu.Lock()
34 active := d.moveLifecycleActive
35 changed := d.moveLifecycleChangeLocked()
36 d.moveLifecycleMu.Unlock()
37 if active == want {
38 return
39 }
40 select {
41 case <-changed:
42 case <-time.After(testWaitTimeout):
43 t.Fatalf("active move lifecycles = %d, want %d", active, want)
44 }
45 }
46}
47
48func awaitDaemonMoveClosing(t *testing.T, d *Daemon) {
49 t.Helper()
50 for {
51 d.moveLifecycleMu.Lock()
52 closing := d.moveLifecycleClosing
53 changed := d.moveLifecycleChangeLocked()
54 d.moveLifecycleMu.Unlock()
55 if closing {
56 return
57 }
58 select {
59 case <-changed:
60 case <-time.After(testWaitTimeout):
61 t.Fatal("daemon move lifecycle gate did not begin closing")
62 }
63 }
64}
65
66func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) {
67 d := newTestDaemon(t, nil, stubClock{})
68 sess := &session{sessionCore: sessionCore{id: "source"}}
69
70 reservation, err := d.reserveMoveLifecycles(sess, sess)
71 require.NoError(t, err)
72 awaitMoveTeardownState(t, sess, 1, 0)
73
74 teardown := make(chan bool, 1)
75 go func() { teardown <- sess.beginTeardown(nil) }()
76 awaitMoveTeardownState(t, sess, 1, 1)
77 select {
78 case <-teardown:
79 t.Fatal("teardown claimed ownership while the move lifecycle was reserved")
80 default:
81 }
82
83 reservation.Release()
84 require.True(t, awaitTestValue(t, teardown, "teardown did not wake after move release"))
85 sess.finishTeardown()
86}
87
88func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) {
89 d := newTestDaemon(t, nil, stubClock{})
90 first := &session{sessionCore: sessionCore{id: "a"}}
91 second := &session{sessionCore:
92
93 // Both opposite-direction reservations must block on a before either takes
94 // b. This proves order from immutable IDs rather than argument direction.
95 sessionCore{id: "b"}}
96
97 first.teardownMu.Lock()
98 beforeTeardownLocks := make(chan struct{}, 2)
99 d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} }
100 defer func() { d.afterMoveLifecycleGateBeforeTeardownLocks = nil }()
101 results := make(chan *moveLifecycleReservation, 2)
102 errs := make(chan error, 2)
103 for _, pair := range [][2]*session{{first, second}, {second, first}} {
104 go func() {
105 reservation, err := d.reserveMoveLifecycles(pair[0], pair[1])
106 results <- reservation
107 errs <- err
108 }()
109 }
110 awaitDaemonMoveActive(t, d, 2)
111 for range 2 {
112 awaitTestCompletion(t, beforeTeardownLocks, "move did not reach ordered teardown-lock admission")
113 }
114 require.True(t, second.teardownMu.TryLock(), "opposite move locked b before the shared lowest-ID session")
115 second.teardownMu.Unlock()
116 first.teardownMu.Unlock()
117
118 for range 2 {
119 require.NoError(t, awaitTestValue(t, errs, "opposite move reservation did not finish"))
120 reservation := awaitTestValue(t, results, "opposite move reservation token missing")
121 require.NotNil(t, reservation)
122 reservation.Release()
123 }
124 awaitDaemonMoveActive(t, d, 0)
125}
126
127func TestMoveLifecycleReservationRejectsTeardownWithoutLeakingCounts(t *testing.T) {
128 for _, teardownSession := range []string{"source", "destination"} {
129 t.Run(teardownSession, func(t *testing.T) {
130 d := newTestDaemon(t, nil, stubClock{})
131 source := &session{sessionCore: sessionCore{id: "source"}}
132 destination := &session{sessionCore: sessionCore{id: "destination"}}
133 tearingDown := source
134 if teardownSession == "destination" {
135 tearingDown = destination
136 }
137 require.True(t, tearingDown.beginTeardown(nil))
138
139 reservation, err := d.reserveMoveLifecycles(source, destination)
140 require.ErrorIs(t, err, errMoveLifecycleUnavailable)
141 require.Nil(t, reservation)
142 awaitMoveTeardownState(t, source, 0, 0)
143 awaitMoveTeardownState(t, destination, 0, 0)
144 awaitDaemonMoveActive(t, d, 0)
145 tearingDown.finishTeardown()
146 })
147 }
148}
149
150// TestMoveLifecycleReservationRejectedByPurgeAdmissionIsRetryable proves a
151// move that arrives while a KillAll purge owns the exact lifecycle set is
152// rejected with the transient purge-admission sentinel rather than the
153// permanent shutdown verdict, and that the rejection leaks no move or purge
154// admission counts.
155func TestMoveLifecycleReservationRejectedByPurgeAdmissionIsRetryable(t *testing.T) {
156 d := newTestDaemon(t, nil, stubClock{})
157 source := &session{sessionCore: sessionCore{id: "source"}}
158 destination := &session{sessionCore: sessionCore{id: "destination"}}
159
160 beginTestPurgeAdmission(t, d)
161 reservation, err := d.reserveMoveLifecycles(source, destination)
162 require.ErrorIs(t, err, errPurgeAdmissionClosed)
163 require.NotErrorIs(t, err, errMoveLifecycleUnavailable)
164 require.Nil(t, reservation)
165 awaitDaemonMoveActive(t, d, 0)
166 d.endPurgeAdmission()
167 requirePurgeAdmissionBalanced(t, d)
168}
169
170func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T) {
171 d := newTestDaemon(t, nil, stubClock{})
172 sess := &session{sessionCore: sessionCore{id: "same"}}
173 reservation, err := d.reserveMoveLifecycles(sess, sess)
174 require.NoError(t, err)
175 awaitMoveTeardownState(t, sess, 1, 0)
176
177 const releasers = 8
178 released := make(chan struct{}, releasers)
179 for range releasers {
180 go func() {
181 reservation.Release()
182 released <- struct{}{}
183 }()
184 }
185 for range releasers {
186 awaitTestCompletion(t, released, "concurrent reservation release did not return")
187 }
188 awaitMoveTeardownState(t, sess, 0, 0)
189 awaitDaemonMoveActive(t, d, 0)
190}
191
192func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *testing.T) {
193 d := newTestDaemon(t, nil, stubClock{})
194 sess := &session{sessionCore: sessionCore{id: "source"}}
195 reservation, err := d.reserveMoveLifecycles(sess, sess)
196 require.NoError(t, err)
197 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
198
199 teardown := make(chan bool, 1)
200 go func() { teardown <- sess.beginTeardown(deadline) }()
201 awaitMoveTeardownState(t, sess, 1, 1)
202 close(deadline.done)
203 require.False(t, awaitTestValue(t, teardown, "cancelled teardown waiter did not return"))
204 awaitMoveTeardownState(t, sess, 1, 0)
205 awaitDaemonMoveActive(t, d, 1)
206
207 reservation.Release()
208 awaitMoveTeardownState(t, sess, 0, 0)
209 awaitDaemonMoveActive(t, d, 0)
210}
211
212func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) {
213 d := newTestDaemon(t, nil, stubClock{})
214 sess := &session{sessionCore: sessionCore{id: "source"}}
215 reservation, err := d.reserveMoveLifecycles(sess, sess)
216 require.NoError(t, err)
217
218 claimed := make(chan struct{}, 2)
219 finish := make(chan struct{}, 2)
220 done := make(chan struct{}, 2)
221 for range 2 {
222 go func() {
223 if sess.beginTeardown(nil) {
224 claimed <- struct{}{}
225 <-finish
226 sess.finishTeardown()
227 }
228 done <- struct{}{}
229 }()
230 }
231 awaitMoveTeardownState(t, sess, 1, 2)
232 reservation.Release()
233 for range 2 {
234 awaitTestCompletion(t, claimed, "teardown waiter did not claim ownership")
235 finish <- struct{}{}
236 }
237 for range 2 {
238 awaitTestCompletion(t, done, "teardown waiter did not complete")
239 }
240 awaitMoveTeardownState(t, sess, 0, 0)
241}
242
243func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) {
244 d := newTestDaemon(t, nil, stubClock{})
245 source := &session{sessionCore: sessionCore{id: "source"}}
246 destination := &session{sessionCore: sessionCore{id: "destination"}}
247 reservation, err := d.reserveMoveLifecycles(source, destination)
248 require.NoError(t, err)
249 paneLifetime := d.paneProcessCtx
250 require.NotNil(t, paneLifetime)
251
252 shutdown := make(chan bool, 1)
253 go func() { shutdown <- d.shutdownAll(protocol.ReasonServerShutdown) }()
254 awaitDaemonMoveClosing(t, d)
255 select {
256 case <-paneLifetime.Done():
257 t.Fatal("shutdown cancelled transferable pane lifetime before the move released")
258 default:
259 }
260 select {
261 case <-shutdown:
262 t.Fatal("shutdown completed while a move lifecycle remained reserved")
263 default:
264 }
265
266 reservation.Release()
267 awaitTestCompletion(t, paneLifetime.Done(), "shutdown did not cancel pane lifetime after move drain")
268 require.False(t, awaitTestValue(t, shutdown, "shutdown did not continue after move drain"))
269
270 reservation, err = d.reserveMoveLifecycles(source, destination)
271 require.ErrorIs(t, err, errMoveLifecycleUnavailable)
272 require.Nil(t, reservation)
273 awaitDaemonMoveActive(t, d, 0)
274}