1package daemon
2
3import (
4 "fmt"
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 awaitMoveTeardownState(t *testing.T, sess *session, reservations, waiters uint) {
15 t.Helper()
16 for {
17 sess.teardownMu.Lock()
18 gotReservations := sess.moveReservations
19 gotWaiters := sess.teardownWaiters
20 changed := sess.teardownChangeLocked()
21 sess.teardownMu.Unlock()
22 if gotReservations == reservations && gotWaiters == waiters {
23 return
24 }
25 select {
26 case <-changed:
27 case <-time.After(testWaitTimeout):
28 t.Fatalf("move reservations/waiters = %d/%d, want %d/%d", gotReservations, gotWaiters, reservations, waiters)
29 }
30 }
31}
32
33func awaitDaemonMoveActive(t *testing.T, d *Daemon, want uint) {
34 t.Helper()
35 for {
36 d.moveLifecycleMu.Lock()
37 active := d.moveLifecycleActive
38 changed := d.moveLifecycleChangeLocked()
39 d.moveLifecycleMu.Unlock()
40 if active == want {
41 return
42 }
43 select {
44 case <-changed:
45 case <-time.After(testWaitTimeout):
46 t.Fatalf("active move lifecycles = %d, want %d", active, want)
47 }
48 }
49}
50
51func awaitDaemonMoveClosing(t *testing.T, d *Daemon) {
52 t.Helper()
53 for {
54 d.moveLifecycleMu.Lock()
55 closing := d.moveLifecycleClosing
56 changed := d.moveLifecycleChangeLocked()
57 d.moveLifecycleMu.Unlock()
58 if closing {
59 return
60 }
61 select {
62 case <-changed:
63 case <-time.After(testWaitTimeout):
64 t.Fatal("daemon move lifecycle gate did not begin closing")
65 }
66 }
67}
68
69func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) {
70 d := newTestDaemon(t, nil, stubClock{})
71 sess := &session{sessionCore: sessionCore{id: "source"}}
72
73 reservation, err := d.reserveMoveLifecycles(sess, sess)
74 require.NoError(t, err)
75 awaitMoveTeardownState(t, sess, 1, 0)
76
77 teardown := make(chan bool, 1)
78 go func() { teardown <- sess.beginTeardown(nil) }()
79 awaitMoveTeardownState(t, sess, 1, 1)
80 select {
81 case <-teardown:
82 t.Fatal("teardown claimed ownership while the move lifecycle was reserved")
83 default:
84 }
85
86 reservation.Release()
87 require.True(t, awaitTestValue(t, teardown, "teardown did not wake after move release"))
88 sess.finishTeardown()
89}
90
91func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) {
92 d := newTestDaemon(t, nil, stubClock{})
93 first := &session{sessionCore: sessionCore{id: "a"}}
94 second := &session{sessionCore:
95
96 // Both opposite-direction reservations must block on a before either takes
97 // b. This proves order from immutable IDs rather than argument direction.
98 sessionCore{id: "b"}}
99
100 first.teardownMu.Lock()
101 beforeTeardownLocks := make(chan struct{}, 2)
102 d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} }
103 defer func() { d.afterMoveLifecycleGateBeforeTeardownLocks = nil }()
104 results := make(chan *moveLifecycleReservation, 2)
105 errs := make(chan error, 2)
106 for _, pair := range [][2]*session{{first, second}, {second, first}} {
107 go func() {
108 reservation, err := d.reserveMoveLifecycles(pair[0], pair[1])
109 results <- reservation
110 errs <- err
111 }()
112 }
113 awaitDaemonMoveActive(t, d, 2)
114 for range 2 {
115 awaitTestCompletion(t, beforeTeardownLocks, "move did not reach ordered teardown-lock admission")
116 }
117 require.True(t, second.teardownMu.TryLock(), "opposite move locked b before the shared lowest-ID session")
118 second.teardownMu.Unlock()
119 first.teardownMu.Unlock()
120
121 for range 2 {
122 require.NoError(t, awaitTestValue(t, errs, "opposite move reservation did not finish"))
123 reservation := awaitTestValue(t, results, "opposite move reservation token missing")
124 require.NotNil(t, reservation)
125 reservation.Release()
126 }
127 awaitDaemonMoveActive(t, d, 0)
128}
129
130func TestMoveLifecycleReservationRejectsTeardownWithoutLeakingCounts(t *testing.T) {
131 for _, teardownSession := range []string{"source", "destination"} {
132 t.Run(teardownSession, func(t *testing.T) {
133 d := newTestDaemon(t, nil, stubClock{})
134 source := &session{sessionCore: sessionCore{id: "source"}}
135 destination := &session{sessionCore: sessionCore{id: "destination"}}
136 tearingDown := source
137 if teardownSession == "destination" {
138 tearingDown = destination
139 }
140 require.True(t, tearingDown.beginTeardown(nil))
141
142 reservation, err := d.reserveMoveLifecycles(source, destination)
143 require.ErrorIs(t, err, errMoveLifecycleUnavailable)
144 require.Nil(t, reservation)
145 awaitMoveTeardownState(t, source, 0, 0)
146 awaitMoveTeardownState(t, destination, 0, 0)
147 awaitDaemonMoveActive(t, d, 0)
148 tearingDown.finishTeardown()
149 })
150 }
151}
152
153// TestMoveLifecycleReservationRejectedByPurgeAdmissionIsRetryable proves a
154// move that arrives while a KillAll purge owns the exact lifecycle set is
155// rejected with the transient purge-admission sentinel rather than the
156// permanent shutdown verdict, and that the rejection leaks no move or purge
157// admission counts.
158func TestMoveLifecycleReservationRejectedByPurgeAdmissionIsRetryable(t *testing.T) {
159 d := newTestDaemon(t, nil, stubClock{})
160 source := &session{sessionCore: sessionCore{id: "source"}}
161 destination := &session{sessionCore: sessionCore{id: "destination"}}
162
163 beginTestPurgeAdmission(t, d)
164 reservation, err := d.reserveMoveLifecycles(source, destination)
165 require.ErrorIs(t, err, errPurgeAdmissionClosed)
166 require.NotErrorIs(t, err, errMoveLifecycleUnavailable)
167 require.Nil(t, reservation)
168 awaitDaemonMoveActive(t, d, 0)
169 d.endPurgeAdmission()
170 requirePurgeAdmissionBalanced(t, d)
171}
172
173func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T) {
174 d := newTestDaemon(t, nil, stubClock{})
175 sess := &session{sessionCore: sessionCore{id: "same"}}
176 reservation, err := d.reserveMoveLifecycles(sess, sess)
177 require.NoError(t, err)
178 awaitMoveTeardownState(t, sess, 1, 0)
179
180 const releasers = 8
181 released := make(chan struct{}, releasers)
182 for range releasers {
183 go func() {
184 reservation.Release()
185 released <- struct{}{}
186 }()
187 }
188 for range releasers {
189 awaitTestCompletion(t, released, "concurrent reservation release did not return")
190 }
191 awaitMoveTeardownState(t, sess, 0, 0)
192 awaitDaemonMoveActive(t, d, 0)
193}
194
195func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *testing.T) {
196 d := newTestDaemon(t, nil, stubClock{})
197 sess := &session{sessionCore: sessionCore{id: "source"}}
198 reservation, err := d.reserveMoveLifecycles(sess, sess)
199 require.NoError(t, err)
200 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
201
202 teardown := make(chan bool, 1)
203 go func() { teardown <- sess.beginTeardown(deadline) }()
204 awaitMoveTeardownState(t, sess, 1, 1)
205 close(deadline.done)
206 require.False(t, awaitTestValue(t, teardown, "cancelled teardown waiter did not return"))
207 awaitMoveTeardownState(t, sess, 1, 0)
208 awaitDaemonMoveActive(t, d, 1)
209
210 reservation.Release()
211 awaitMoveTeardownState(t, sess, 0, 0)
212 awaitDaemonMoveActive(t, d, 0)
213}
214
215func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) {
216 d := newTestDaemon(t, nil, stubClock{})
217 sess := &session{sessionCore: sessionCore{id: "source"}}
218 reservation, err := d.reserveMoveLifecycles(sess, sess)
219 require.NoError(t, err)
220
221 claimed := make(chan struct{}, 2)
222 finish := make(chan struct{}, 2)
223 done := make(chan struct{}, 2)
224 for range 2 {
225 go func() {
226 if sess.beginTeardown(nil) {
227 claimed <- struct{}{}
228 <-finish
229 sess.finishTeardown()
230 }
231 done <- struct{}{}
232 }()
233 }
234 awaitMoveTeardownState(t, sess, 1, 2)
235 reservation.Release()
236 for range 2 {
237 awaitTestCompletion(t, claimed, "teardown waiter did not claim ownership")
238 finish <- struct{}{}
239 }
240 for range 2 {
241 awaitTestCompletion(t, done, "teardown waiter did not complete")
242 }
243 awaitMoveTeardownState(t, sess, 0, 0)
244}
245
246func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) {
247 d := newTestDaemon(t, nil, stubClock{})
248 source := &session{sessionCore: sessionCore{id: "source"}}
249 destination := &session{sessionCore: sessionCore{id: "destination"}}
250 reservation, err := d.reserveMoveLifecycles(source, destination)
251 require.NoError(t, err)
252 paneLifetime := d.paneProcessCtx
253 require.NotNil(t, paneLifetime)
254
255 shutdown := make(chan bool, 1)
256 go func() { shutdown <- d.shutdownAll(protocol.ReasonServerShutdown) }()
257 awaitDaemonMoveClosing(t, d)
258 select {
259 case <-paneLifetime.Done():
260 t.Fatal("shutdown cancelled transferable pane lifetime before the move released")
261 default:
262 }
263 select {
264 case <-shutdown:
265 t.Fatal("shutdown completed while a move lifecycle remained reserved")
266 default:
267 }
268
269 reservation.Release()
270 awaitTestCompletion(t, paneLifetime.Done(), "shutdown did not cancel pane lifetime after move drain")
271 require.False(t, awaitTestValue(t, shutdown, "shutdown did not continue after move drain"))
272
273 reservation, err = d.reserveMoveLifecycles(source, destination)
274 require.ErrorIs(t, err, errMoveLifecycleUnavailable)
275 require.Nil(t, reservation)
276 awaitDaemonMoveActive(t, d, 0)
277}
278
279// TestShutdownReservesPreservationBeforeCancellingPaneProcesses guards the
280// daemon-stop ordering that keeps named sessions durable. Cancelling the
281// daemon-wide pane lifetime closes every PTY; a session whose reader sees that
282// EOF before its shutdown-preservation policy is reserved takes the natural
283// final-shell-exit path and purges its durable record instead of preserving it.
284// The test records each session's reservation at the exact moment the pane
285// lifetime is cancelled, so it fails if cancellation ever precedes it.
286func TestShutdownReservesPreservationBeforeCancellingPaneProcesses(t *testing.T) {
287 tests := []struct {
288 name string
289 sessions []string
290 }{
291 {name: "one named session", sessions: []string{"work"}},
292 {name: "several named sessions", sessions: []string{"alpha", "beta", "gamma"}},
293 }
294
295 for _, tt := range tests {
296 t.Run(tt.name, func(t *testing.T) {
297 d := newTestDaemon(t, nil, stubClock{})
298 sessions := make([]*session, 0, len(tt.sessions))
299 d.mu.Lock()
300 for i, name := range tt.sessions {
301 sess := &session{sessionCore: sessionCore{id: domain.SessionID(fmt.Sprintf("sess-%d", i)), name: name}}
302 require.True(t, d.registerSessionLocked(sess))
303 sessions = append(sessions, sess)
304 }
305 d.mu.Unlock()
306
307 var reservedAtCancel []bool
308 cancel := d.paneProcessCancel
309 d.paneProcessCancel = func() {
310 for _, sess := range sessions {
311 sess.teardownMu.Lock()
312 reservedAtCancel = append(reservedAtCancel, sess.shutdownTeardownRequested)
313 sess.teardownMu.Unlock()
314 }
315 cancel()
316 }
317
318 d.shutdownAll(protocol.ReasonServerShutdown)
319
320 require.Error(t, d.paneProcessCtx.Err(), "shutdown must cancel pane processes")
321 require.NotEmpty(t, reservedAtCancel, "pane process cancellation was not observed")
322 for i, reserved := range reservedAtCancel {
323 require.True(t, reserved, "session %q was not reserved for preservation before its PTY was cancelled", tt.sessions[i%len(tt.sessions)])
324 }
325 })
326 }
327}