move_resize_fences_test.go RawHistoryFolder

1package daemon
2
3import (
4	"sync"
5	"testing"
6	"time"
7
8	"github.com/stretchr/testify/require"
9
10	"github.com/bnema/vev/internal/usecase/layout"
11)
12
13func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) {
14	t.Helper()
15	require.Eventually(t, func() bool {
16		if !mu.TryLock() {
17			return true
18		}
19		mu.Unlock()
20		return false
21	}, time.Second, time.Millisecond, message)
22}
23
24func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) {
25	first := &session{sessionCore: sessionCore{id: "a-first"}}
26	second := &session{sessionCore: sessionCore{id: "z-second"}}
27	fences := newMoveResizeFences([]*session{second, first}, nil, nil)
28
29	first.layoutApplyMu.Lock()
30	acquired := make(chan bool, 1)
31	go func() {
32		acquired <- fences.acquire(func() bool { return true })
33	}()
34
35	require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later session before the lowest SessionID")
36	second.layoutApplyMu.Unlock()
37	first.layoutApplyMu.Unlock()
38
39	require.True(t, awaitTestValue(t, acquired, "ordered resize fences did not acquire"))
40	fences.Release()
41	require.True(t, first.layoutApplyMu.TryLock())
42	first.layoutApplyMu.Unlock()
43	require.True(t, second.layoutApplyMu.TryLock())
44	second.layoutApplyMu.Unlock()
45}
46
47func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) {
48	sess := &session{sessionCore: sessionCore{id: "session"}}
49	first := &tab{stableID: "a-first"}
50	second := &tab{stableID: "z-second"}
51	fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil)
52
53	first.layoutApplyMu.Lock()
54	acquired := make(chan bool, 1)
55	go func() {
56		acquired <- fences.acquire(func() bool { return true })
57	}()
58
59	requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before waiting on a tab fence")
60	require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later tab before the lowest stable tab ID")
61	second.layoutApplyMu.Unlock()
62	select {
63	case <-acquired:
64		t.Fatal("resize fences ignored the blocked first tab")
65	default:
66	}
67	first.layoutApplyMu.Unlock()
68
69	require.True(t, awaitTestValue(t, acquired, "ordered tab resize fences did not acquire"))
70	fences.Release()
71}
72
73func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) {
74	sess := &session{sessionCore: sessionCore{id: "session"}}
75	tb := &tab{stableID: "tab"}
76	first := &pane{stableID: "a-first"}
77	second := &pane{stableID: "z-second"}
78	fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{second, first})
79
80	first.resizeMu.Lock()
81	acquired := make(chan bool, 1)
82	go func() {
83		acquired <- fences.acquire(func() bool { return true })
84	}()
85
86	requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before pane wait")
87	requireMutexLocked(t, &tb.layoutApplyMu, "tab fence was not acquired before pane wait")
88	require.True(t, second.resizeMu.TryLock(), "acquisition reached the later pane before the lowest stable pane ID")
89	second.resizeMu.Unlock()
90	select {
91	case <-acquired:
92		t.Fatal("resize fences ignored the blocked first pane")
93	default:
94	}
95	first.resizeMu.Unlock()
96
97	require.True(t, awaitTestValue(t, acquired, "ordered pane resize fences did not acquire"))
98	fences.Release()
99}
100
101func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) {
102	sess := &session{sessionCore: sessionCore{id: "session"}}
103	tb := &tab{stableID: "tab"}
104	p := &pane{stableID: "pane"}
105
106	fences := newMoveResizeFences([]*session{sess, sess}, []*tab{tb, tb}, []*pane{p, p})
107
108	require.Len(t, fences.sessions, 1)
109	require.Len(t, fences.tabs, 1)
110	require.Len(t, fences.panes, 1)
111	require.True(t, fences.acquire(func() bool { return true }))
112	fences.Release()
113}
114
115func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) {
116	resolved := &session{sessionCore: sessionCore{id: "same"}}
117	collision := &session{sessionCore: sessionCore{id: "same"}}
118
119	fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil)
120
121	require.Len(t, fences.sessions, 1)
122	require.Same(t, resolved, fences.sessions[0])
123}
124
125func TestMoveResizeFencesDeduplicateTabIDWithoutReplacingResolvedOwner(t *testing.T) {
126	resolved := &tab{stableID: "same"}
127	collision := &tab{stableID: "same"}
128
129	fences := newMoveResizeFences(nil, []*tab{resolved, collision}, nil)
130
131	require.Len(t, fences.tabs, 1)
132	require.Same(t, resolved, fences.tabs[0])
133}
134
135func TestMoveResizeFencesDeduplicatePaneIDWithoutReplacingResolvedOwner(t *testing.T) {
136	resolved := &pane{stableID: "same"}
137	collision := &pane{stableID: "same"}
138
139	fences := newMoveResizeFences(nil, nil, []*pane{resolved, collision})
140
141	require.Len(t, fences.panes, 1)
142	require.Same(t, resolved, fences.panes[0])
143}
144
145func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) {
146	sess := &session{sessionCore: sessionCore{id: "session"}}
147	tb := &tab{stableID: "tab"}
148	p := &pane{stableID: "pane"}
149	fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
150
151	membershipCurrent := true
152	p.resizeMu.Lock()
153	acquired := make(chan bool, 1)
154	callbackCalled := make(chan struct{})
155	go func() {
156		acquired <- fences.acquire(func() bool {
157			close(callbackCalled)
158			return membershipCurrent
159		})
160	}()
161	requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane")
162	membershipCurrent = false
163	p.resizeMu.Unlock()
164
165	require.False(t, awaitTestValue(t, acquired, "stale resize fence acquisition did not return"))
166	awaitTestCompletion(t, callbackCalled, "membership was not revalidated after resize fence acquisition")
167	for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} {
168		require.True(t, lock.TryLock(), "rejected acquisition leaked a resize fence")
169		lock.Unlock()
170	}
171}
172
173func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) {
174	sess := &session{sessionCore: sessionCore{id: "session"}}
175	tb := &tab{stableID: "tab"}
176	p := &pane{stableID: "pane"}
177	fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
178
179	require.Panics(t, func() {
180		fences.acquire(func() bool { panic("publication failed") })
181	})
182	for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} {
183		require.True(t, lock.TryLock(), "panicked publication leaked a partial resize fence set")
184		lock.Unlock()
185	}
186}
187
188func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *testing.T) {
189	d := &Daemon{}
190	sess := &session{sessionCore: sessionCore{id: "session"}}
191	tb := &tab{stableID: "tab"}
192	p := &pane{stableID: "pane"}
193	members := []resizeMember{{session: sess, tab: tb, pane: p}}
194
195	var recovered any
196	func() {
197		defer func() { recovered = recover() }()
198		d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() {
199			panic("post effect failed")
200		})
201	}()
202	require.Equal(t, "post effect failed", recovered)
203
204	for _, fence := range []struct {
205		name string
206		mu   *sync.Mutex
207	}{
208		{name: "session", mu: &sess.layoutApplyMu},
209		{name: "tab", mu: &tb.layoutApplyMu},
210		{name: "pane", mu: &p.resizeMu},
211	} {
212		require.True(t, fence.mu.TryLock(), "%s fence leaked after panic recovery", fence.name)
213		fence.mu.Unlock()
214	}
215}
216
217func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) {
218	d := &Daemon{}
219	sess := &session{sessionCore: sessionCore{id: "session"}}
220	tb := &tab{stableID: "tab"}
221	p := &pane{stableID: "pane"}
222	fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
223
224	p.resizeMu.Lock()
225	acquired := make(chan bool, 1)
226	go func() {
227		acquired <- fences.acquire(func() bool { return true })
228	}()
229	requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane")
230
231	for name, lock := range map[string]*sync.Mutex{
232		"daemon":  &d.mu,
233		"session": &sess.mu,
234		"tab":     &tb.mu,
235	} {
236		require.True(t, lock.TryLock(), "%s architecture lock was held while waiting on pane resize", name)
237		lock.Unlock()
238	}
239	p.resizeMu.Unlock()
240
241	require.True(t, awaitTestValue(t, acquired, "resize fences did not acquire after pane resize released"))
242	fences.Release()
243}
244
245func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) {
246	source := &session{sessionCore: sessionCore{id: "source"}}
247	destination := &session{sessionCore: sessionCore{id: "destination"}}
248	sourceTab := &tab{stableID: "a-source-tab"}
249	destinationTab := &tab{stableID: "z-destination-tab"}
250	p := &pane{stableID: "pane"}
251	fences := newMoveResizeFences(
252		[]*session{source, destination},
253		[]*tab{sourceTab, destinationTab},
254		[]*pane{p},
255	)
256
257	require.True(t, fences.acquire(func() bool {
258		// This callback is the move's architecture-lock window: membership is
259		// revalidated before owner publication and both affected generations bump.
260		sourceTab.mu.Lock()
261		destinationTab.mu.Lock()
262		sourceTab.bumpLayoutGenerationLocked()
263		destinationTab.bumpLayoutGenerationLocked()
264		destinationTab.mu.Unlock()
265		sourceTab.mu.Unlock()
266		return true
267	}))
268	require.Equal(t, uint64(1), sourceTab.layoutGeneration)
269	require.Equal(t, uint64(1), destinationTab.layoutGeneration)
270	for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} {
271		require.False(t, lock.TryLock(), "publication returned without retaining every resize fence")
272	}
273
274	// Move callers release here, before applyTabLayout or any other PTY I/O.
275	fences.Release()
276	for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} {
277		require.True(t, lock.TryLock(), "publication fence did not release before layout application")
278		lock.Unlock()
279	}
280}
281
282func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) {
283	source := &session{sessionCore: sessionCore{id: "source"}}
284	destination := &session{sessionCore: sessionCore{id: "destination"}}
285	sourceTab := &tab{stableID: "source-tab"}
286	destinationTab := &tab{stableID: "destination-tab"}
287	moved := &pane{stableID: "moved-pane"}
288
289	fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, moved)
290
291	require.ElementsMatch(t, []*session{source, destination}, fences.sessions)
292	require.ElementsMatch(t, []*tab{sourceTab, destinationTab}, fences.tabs)
293	require.Equal(t, []*pane{moved}, fences.panes)
294}
295
296func TestMoveTabResizeFencesCoverEveryContainedPane(t *testing.T) {
297	for _, tc := range []struct {
298		name  string
299		state floatingState
300	}{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} {
301		t.Run(tc.name, func(t *testing.T) {
302			source := &session{sessionCore: sessionCore{id: "source"}}
303			destination := &session{sessionCore: sessionCore{id: "destination"}}
304			first := &pane{stableID: "first"}
305			second := &pane{stableID: "second"}
306			floating := &pane{stableID: "floating"}
307			movedTab := &tab{
308				stableID: "moved-tab",
309				panes: map[layout.PaneID]*pane{
310					"pane-1": first,
311					"pane-2": second,
312				},
313				floating: floatingSlot{state: tc.state, pane: floating, generation: 7},
314			}
315
316			fences := newMoveTabResizeFences(source, destination, movedTab)
317
318			require.ElementsMatch(t, []*session{source, destination}, fences.sessions)
319			require.Equal(t, []*tab{movedTab}, fences.tabs)
320			require.ElementsMatch(t, []*pane{first, second, floating}, fences.panes)
321		})
322	}
323}

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