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