move_lifecycle_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close