move_lifecycle_test.go RawHistoryFolder

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}

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