move_gate_race_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/domain"
11	"github.com/bnema/vev/internal/protocol"
12)
13
14func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *session, *tab, func()) {
15	t.Helper()
16	movedPTY, releaseMoved := newBlockingPTY(t)
17	destinationPTY, releaseDestination := newBlockingPTY(t)
18	d, source, client, _ := newManualSessionWithPTYs(t, movedPTY)
19	sourceTab := source.tabs[0]
20	sourceTab.stableID = "source-tab"
21	moved := sourceTab.focusedPane()
22	require.NotNil(t, d.attachCoordinator(source, nil, client, true))
23
24	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
25	destinationTab := destination.tabs[0]
26	publishTiledPaneOwners(destination, destinationTab)
27	d.mu.Lock()
28	d.sessions[destination.id] = destination
29	d.mu.Unlock()
30	return d, source, sourceTab, moved, destination, destinationTab, func() {
31		releaseMoved()
32		releaseDestination()
33	}
34}
35
36func moveGateRaceRequest(source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) movePaneRequest {
37	return movePaneRequest{
38		Source:           moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
39		SourceTabID:      domain.TabStableID(sourceTab.stableID),
40		SourcePaneID:     domain.PaneStableID(moved.stableID),
41		Destination:      moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
42		DestinationTabID: domain.TabStableID(destinationTab.stableID),
43	}
44}
45
46func waitMoveRace[T any](t *testing.T, ch <-chan T, name string) T {
47	t.Helper()
48	select {
49	case value := <-ch:
50		return value
51	case <-timeAfterMoveRace():
52		t.Fatalf("%s did not finish", name)
53		var zero T
54		return zero
55	}
56}
57
58// timeAfterMoveRace is kept in one helper so these barrier tests use a
59// deterministic bounded wait rather than sleeps.
60func timeAfterMoveRace() <-chan time.Time {
61	return time.After(testWaitTimeout)
62}
63
64func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
65	d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
66	defer releasePTYs()
67
68	moveReserved := make(chan struct{})
69	releaseMoveAdmission := make(chan struct{})
70	d.afterMoveLifecycleReserved = func() {
71		close(moveReserved)
72		<-releaseMoveAdmission
73	}
74	killFrozen := make(chan struct{})
75	releaseKill := make(chan struct{})
76	var killFrozenOnce sync.Once
77	d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
78		if action != "" {
79			return
80		}
81		killFrozenOnce.Do(func() { close(killFrozen) })
82		<-releaseKill
83	}
84	defer func() {
85		d.afterMoveLifecycleReserved = nil
86		d.afterAttachmentEffectGateFrozen = nil
87	}()
88
89	moveDone := make(chan error, 1)
90	go func() {
91		moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
92	}()
93	waitMoveRace(t, moveReserved, "move reservation")
94
95	killDone := make(chan error, 1)
96	go func() { killDone <- d.killSession(source, protocol.ReasonSessionKilled, false) }()
97	waitMoveRace(t, killFrozen, "kill gate freeze")
98	close(releaseMoveAdmission)
99	moveErr := waitMoveRace(t, moveDone, "move abort after kill gate acquisition")
100	close(releaseKill)
101	killErr := waitMoveRace(t, killDone, "kill teardown")
102
103	require.ErrorIs(t, moveErr, errMovePaneInvalid)
104	require.NoError(t, killErr)
105	d.mu.Lock()
106	_, sourceLive := d.sessions[source.id]
107	_, destinationLive := d.sessions[destination.id]
108	d.mu.Unlock()
109	require.False(t, sourceLive, "kill winner must retire the source")
110	require.True(t, destinationLive)
111	require.NotSame(t, moved, destination.tabs[0].panes[moved.id], "kill winner must not publish a partial destination move")
112}
113
114func TestFinalCloseDoesNotDeadlockWithMoveDispatchAdmission(t *testing.T) {
115	tests := []struct {
116		name  string
117		close func(*Daemon, *session, *tab, *pane) error
118		move  func(*Daemon, *session, *tab, *pane, *session, *tab) error
119	}{
120		{name: "pane", close: func(d *Daemon, source *session, sourceTab *tab, _ *pane) error {
121			return d.closeTab(source, sourceTab, true)
122		}, move: func(d *Daemon, source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) error {
123			return d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
124		}},
125		{name: "pane-reap", close: func(d *Daemon, _ *session, _ *tab, moved *pane) error {
126			d.reapPaneOwner(moved)
127			return nil
128		}, move: func(d *Daemon, source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) error {
129			return d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
130		}},
131		{name: "tab", close: func(d *Daemon, source *session, sourceTab *tab, _ *pane) error {
132			return d.closeTab(source, sourceTab, true)
133		}, move: func(d *Daemon, source *session, sourceTab *tab, _ *pane, destination *session, _ *tab) error {
134			return d.moveTab(moveTabRequest{
135				Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(sourceTab.stableID),
136				Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
137			})
138		}},
139	}
140	for _, test := range tests {
141		t.Run(test.name, func(t *testing.T) {
142			d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
143			defer releasePTYs()
144
145			closeReady := make(chan struct{})
146			releaseClose := make(chan struct{})
147			d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
148				if action != "" {
149					return
150				}
151				close(closeReady)
152				<-releaseClose
153			}
154			moveDispatchReady := make(chan struct{})
155			d.beforeMoveDispatch = func() { close(moveDispatchReady) }
156			defer func() {
157				d.afterAttachmentEffectParticipantsSnapshotted = nil
158				d.beforeMoveDispatch = nil
159			}()
160
161			closeDone := make(chan error, 1)
162			go func() { closeDone <- test.close(d, source, sourceTab, moved) }()
163			waitMoveRace(t, closeReady, "final close participant snapshot")
164
165			moveDone := make(chan error, 1)
166			go func() { moveDone <- test.move(d, source, sourceTab, moved, destination, destinationTab) }()
167			waitMoveRace(t, moveDispatchReady, "move dispatch admission")
168			close(releaseClose)
169
170			require.NoError(t, waitMoveRace(t, closeDone, "final close"))
171			require.ErrorIs(t, waitMoveRace(t, moveDone, "move after final close"), errMoveStaleTarget)
172			d.mu.Lock()
173			_, sourceLive := d.sessions[source.id]
174			_, destinationLive := d.sessions[destination.id]
175			d.mu.Unlock()
176			require.False(t, sourceLive, "final close must retire the source")
177			require.True(t, destinationLive)
178			if test.name == "pane" || test.name == "pane-reap" {
179				require.NotSame(t, moved, destinationTab.panes[moved.id], "a rejected move must not publish into the destination")
180			}
181		})
182	}
183}
184
185func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
186	d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY())
187	d.attachCoordinator(sess, nil, current, true)
188	otherTransport := &closeTrackingTransport{}
189	other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: current.size}
190	other.initOverlays()
191	other.setSession(sess)
192	sess.mu.Lock()
193	require.True(t, sess.registerAttachmentLocked(other))
194	sess.mu.Unlock()
195	d.attachCoordinator(sess, nil, other, true)
196
197	currentToken := sess.captureAttachmentCapability(current, current.transport())
198	currentEffect, admitted := current.beginAttachmentEffect(currentToken)
199	require.True(t, admitted)
200	t.Cleanup(currentEffect.End)
201	otherToken := sess.captureAttachmentCapability(other, otherTransport)
202	otherEffect, admitted := other.beginAttachmentEffect(otherToken)
203	require.True(t, admitted)
204	t.Cleanup(otherEffect.End)
205
206	currentEnded := make(chan struct{})
207	currentReleasedBeforePeerFreeze := make(chan struct{})
208	otherFrozen := make(chan struct{})
209	d.afterActionAttachmentEffectEnded = func(action string) {
210		if action == "close-tab" {
211			close(currentEnded)
212		}
213	}
214	d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) {
215		if action == "close-tab" && ac == other {
216			if currentEffect.ended.Load() {
217				close(currentReleasedBeforePeerFreeze)
218			}
219			close(otherFrozen)
220		}
221	}
222	defer func() {
223		d.afterActionAttachmentEffectEnded = nil
224		d.afterAttachmentEffectGateFrozen = nil
225	}()
226
227	closeDone := make(chan error, 1)
228	go func() {
229		sess.dispatchMu.Lock()
230		defer sess.dispatchMu.Unlock()
231		closeDone <- (paletteExec{d: d, sess: sess, ac: current, effect: currentEffect}).CloseTab()
232	}()
233	waitMoveRace(t, otherFrozen, "peer effect gate freeze")
234	waitMoveRace(t, currentReleasedBeforePeerFreeze, "current effect release before peer drain")
235	waitMoveRace(t, currentEnded, "current palette effect completion")
236	select {
237	case err := <-closeDone:
238		t.Fatalf("final close drained its own peer before release: %v", err)
239	default:
240	}
241	otherEffect.End()
242	require.NoError(t, waitMoveRace(t, closeDone, "palette final close"))
243	requireAttachmentEffectGateRetired(t, current)
244	requireAttachmentEffectGateRetired(t, other)
245}
246
247func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
248	d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
249	defer releasePTYs()
250
251	moveGateFrozen := make(chan struct{})
252	releaseMove := make(chan struct{})
253	var moveGateFrozenOnce sync.Once
254	d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
255		if action != "move-pane" {
256			return
257		}
258		moveGateFrozenOnce.Do(func() { close(moveGateFrozen) })
259		<-releaseMove
260	}
261	killSnapshotted := make(chan struct{})
262	var killSnapshottedOnce sync.Once
263	d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
264		if action == "" {
265			killSnapshottedOnce.Do(func() { close(killSnapshotted) })
266		}
267	}
268	defer func() {
269		d.afterAttachmentEffectGateFrozen = nil
270		d.afterAttachmentEffectParticipantsSnapshotted = nil
271	}()
272
273	moveDone := make(chan error, 1)
274	go func() {
275		moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
276	}()
277	waitMoveRace(t, moveGateFrozen, "move gate freeze")
278
279	killDone := make(chan error, 1)
280	go func() { killDone <- d.killSession(source, protocol.ReasonSessionKilled, false) }()
281	waitMoveRace(t, killSnapshotted, "kill participant snapshot")
282	close(releaseMove)
283
284	require.NoError(t, waitMoveRace(t, moveDone, "move commit"))
285	require.NoError(t, waitMoveRace(t, killDone, "kill loser"))
286	d.mu.Lock()
287	_, sourceLive := d.sessions[source.id]
288	_, destinationLive := d.sessions[destination.id]
289	d.mu.Unlock()
290	require.False(t, sourceLive)
291	require.True(t, destinationLive)
292	require.Same(t, moved, destinationTab.panes[moved.id])
293}

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