offscreen_fence_test.go RawHistoryFolder

1package capture
2
3import (
4	"context"
5	"image"
6	"os"
7	"testing"
8
9	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10	"github.com/bnema/neferwl/internal/ports"
11	"github.com/stretchr/testify/mock"
12	"github.com/stretchr/testify/require"
13	"golang.org/x/sys/unix"
14)
15
16// fencePipe is a sync file stand-in from the kernel: readable once written.
17// fencePipe comes from pipeline_fence_test.go.
18
19func signalFence(t *testing.T, w *os.File) {
20	t.Helper()
21	_, err := w.Write([]byte{1})
22	require.NoError(t, err)
23}
24
25func fenceClosed(f *os.File) bool {
26	_, err := f.Stat()
27	return err != nil
28}
29
30// hiddenPipeline returns a pipeline whose child renders return the given
31// fences in order, and the scene/surfaces to submit.
32func hiddenPipeline(t *testing.T, fences ...*os.File) (*Pipeline, chan ports.CaptureDone, *portsmocks.MockRenderer) {
33	t.Helper()
34	replies := make(chan ports.CaptureDone, 8)
35	p := NewPipeline(context.Background(), replies)
36	child := portsmocks.NewMockRenderer(t)
37	frame := portsmocks.NewMockCaptureFrame(t)
38	frame.EXPECT().Done().Return(nil).Maybe()
39	frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
40	child.EXPECT().EndCapture(frame).Return().Maybe()
41	child.EXPECT().BeginCapture().Return(frame, nil).Maybe()
42	child.EXPECT().Close().Return().Maybe()
43	for _, f := range fences {
44		child.EXPECT().Render(mock.Anything, mock.Anything).Return(f, nil).Once()
45	}
46	p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
47	return p, replies, child
48}
49
50func hiddenSceneWindows(ids ...ports.WindowID) ports.Scene {
51	s := hiddenScene()
52	s.CaptureScene.Windows, s.CaptureScene.Layers = nil, nil
53	for _, id := range ids {
54		s.CaptureScene.Windows = append(s.CaptureScene.Windows, ports.SceneWindow{ID: id})
55	}
56	return s
57}
58
59func submitHiddenOne(t *testing.T, p *Pipeline, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, id uint64) {
60	t.Helper()
61	p.SubmitHidden(s, surfaces, []ports.CaptureRequest{hiddenRequest(t, id, image.Rect(0, 0, 200, 100))})
62}
63
64// GO001: a failing or cancelled wait still closes every fence, the ones not
65// waited for included.
66func TestWaitHiddenClosesEveryFenceOnCancel(t *testing.T) {
67	f1, _ := fencePipe(t)
68	f2, _ := fencePipe(t)
69	p, replies, _ := hiddenPipeline(t, f1, f2)
70	defer p.Close(nil)
71	s := hiddenSceneWindows(5)
72	submitHiddenOne(t, p, s, nil, 1)
73	submitHiddenOne(t, p, s, nil, 2)
74	require.Equal(t, 2, p.off.nf)
75	ctx, cancel := context.WithCancel(context.Background())
76	cancel()
77	require.Error(t, p.WaitHidden(ctx))
78	require.Zero(t, p.off.nf)
79	require.True(t, fenceClosed(f1), "first fence closed")
80	require.True(t, fenceClosed(f2), "second fence, never waited for, closed too")
81	for range 2 {
82		awaitCapture(t, replies)
83	}
84}
85
86// A terminal fence error must cancel and join readback before closing the
87// device. Holds remain in place until Close has made GPU reads impossible.
88func TestTerminalChildFenceClosesPipelineBeforeClearingHolds(t *testing.T) {
89	read, failed, err := os.Pipe()
90	require.NoError(t, err)
91	defer failed.Close()
92	pending, _ := fencePipe(t)
93	p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 2))
94	defer p.Close(nil)
95	child := portsmocks.NewMockRenderer(t)
96	frame := portsmocks.NewMockCaptureFrame(t)
97	frame.EXPECT().Done().Return(pending).Maybe()
98	child.EXPECT().Render(mock.Anything, mock.Anything).Return(failed, nil).Once()
99	child.EXPECT().BeginCapture().Return(frame, nil).Once()
100	ended := false
101	child.EXPECT().EndCapture(frame).Run(func(ports.CaptureFrame) { ended = true }).Once()
102	child.EXPECT().Close().Run(func() {
103		require.True(t, ended, "worker joined and lease returned before device closes")
104		require.Equal(t, 1, p.off.nf, "hold retained until device closes")
105	}).Once()
106	p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
107	submitHiddenOne(t, p, hiddenSceneWindows(5), map[ports.WindowID]ports.SurfaceContent{5: {Seq: 2}}, 1)
108	_, reads, busy := p.CapHiddenSeen(map[ports.WindowID]uint64{5: 7})
109	require.True(t, busy)
110	require.Equal(t, map[ports.WindowID]uint64{5: 2}, reads)
111	require.NoError(t, read.Close()) // write end now polls POLLERR
112	p.Retire(ports.Scene{})          // session end must not wait forever on the error
113	require.Nil(t, p.off.r, "failed device closed")
114	require.Nil(t, p.HiddenCompleted(), "failed worker joined")
115	seen := map[ports.WindowID]uint64{5: 7}
116	got, reads, busy := p.CapHiddenSeen(seen)
117	require.False(t, busy)
118	require.Empty(t, reads, "closed device lifts non-expiring holds")
119	require.Equal(t, seen, got)
120	require.True(t, fenceClosed(failed))
121}
122
123func TestWaitHiddenWaitsForSignalledFences(t *testing.T) {
124	f1, w1 := fencePipe(t)
125	p, replies, _ := hiddenPipeline(t, f1)
126	defer p.Close(nil)
127	submitHiddenOne(t, p, hiddenSceneWindows(5), nil, 1)
128	signalFence(t, w1)
129	require.NoError(t, p.WaitHidden(context.Background()))
130	require.True(t, fenceClosed(f1))
131	awaitCapture(t, replies)
132}
133
134// GO002: the ceiling of a fence covers only the windows its render drew, per
135// fence, and a signalled fence lifts it.
136func TestCapHiddenSeenPerFenceCeilings(t *testing.T) {
137	f1, w1 := fencePipe(t)
138	f2, w2 := fencePipe(t)
139	p, replies, _ := hiddenPipeline(t, f1, f2)
140	defer p.Close(nil)
141
142	// Render 1 drew window 5 (content Seq 2); render 2 drew 5 (Seq 6) and 6 (Seq 3).
143	submitHiddenOne(t, p, hiddenSceneWindows(5), map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 2}}, 1)
144	submitHiddenOne(t, p, hiddenSceneWindows(5, 6), map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 6}, 6: {ID: 6, Seq: 3}}, 2)
145
146	// 5 is capped by the lowest ceiling among the unfinished renders that drew
147	// it; 6 by the second; the display's window 1 and an unrelated 9 are free.
148	seen := map[ports.WindowID]uint64{1: 40, 5: 8, 6: 8, 9: 50}
149	got, reads, capped := p.CapHiddenSeen(seen)
150	require.Equal(t, map[ports.WindowID]uint64{5: 2, 6: 3}, reads)
151	require.True(t, capped)
152	require.Equal(t, map[ports.WindowID]uint64{1: 40, 5: 2, 6: 3, 9: 50}, got)
153	require.Equal(t, uint64(8), seen[5], "input untouched")
154
155	// Render 1 finishes: window 5 is now limited by render 2 only.
156	signalFence(t, w1)
157	got, reads, capped = p.CapHiddenSeen(seen)
158	require.Equal(t, map[ports.WindowID]uint64{5: 6, 6: 3}, reads)
159	require.True(t, capped)
160	require.Equal(t, map[ports.WindowID]uint64{1: 40, 5: 6, 6: 3, 9: 50}, got)
161
162	// A window the earlier render never saw is not capped by it: a display
163	// window that is new to the child releases at once.
164	signalFence(t, w2)
165	got, reads, capped = p.CapHiddenSeen(seen)
166	require.Empty(t, reads)
167	require.False(t, capped)
168	require.Equal(t, seen, got)
169	for range 2 {
170		awaitCapture(t, replies)
171	}
172}
173
174// A window the child drew with no content yet is capped at 0; one it never
175// drew is not capped at all.
176func TestCapHiddenSeenMissingContentCapsToZero(t *testing.T) {
177	f1, _ := fencePipe(t)
178	p, replies, _ := hiddenPipeline(t, f1)
179	defer p.Close(nil)
180	submitHiddenOne(t, p, hiddenSceneWindows(5), map[ports.WindowID]ports.SurfaceContent{}, 1)
181	got, reads, capped := p.CapHiddenSeen(map[ports.WindowID]uint64{5: 4, 7: 4})
182	require.Equal(t, map[ports.WindowID]uint64{5: 0}, reads)
183	require.True(t, capped)
184	require.Equal(t, map[ports.WindowID]uint64{5: 0, 7: 4}, got)
185	awaitCapture(t, replies)
186}
187
188func TestCapHiddenSeenNoAllocationInSteadyState(t *testing.T) {
189	f1, _ := fencePipe(t)
190	p, replies, _ := hiddenPipeline(t, f1)
191	defer p.Close(nil)
192	submitHiddenOne(t, p, hiddenSceneWindows(5), map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 2}}, 1)
193	seen := map[ports.WindowID]uint64{1: 40, 5: 8}
194	p.CapHiddenSeen(seen) // warm the scratch map
195	allocs := testing.AllocsPerRun(100, func() { p.CapHiddenSeen(seen) })
196	require.LessOrEqual(t, allocs, float64(1), "only the poll descriptor may allocate")
197	t.Logf("outstanding child report: %.1f allocs", allocs)
198	awaitCapture(t, replies)
199	var none *Pipeline
200	got, reads, capped := none.CapHiddenSeen(seen)
201	require.Nil(t, reads)
202	require.False(t, capped)
203	require.Equal(t, seen, got)
204}
205
206// GO003: a third render is refused before the renderer is asked to draw it.
207func TestSubmitHiddenThirdRenderRefusedBeforeRender(t *testing.T) {
208	f1, _ := fencePipe(t)
209	f2, _ := fencePipe(t)
210	p, replies, _ := hiddenPipeline(t, f1, f2) // only two Render expectations
211	defer p.Close(nil)
212	s := hiddenSceneWindows(5)
213	submitHiddenOne(t, p, s, nil, 1)
214	submitHiddenOne(t, p, s, nil, 2)
215	submitHiddenOne(t, p, s, nil, 3)
216	byID := map[uint64]error{}
217	for range 3 {
218		d := awaitCapture(t, replies)
219		byID[d.ID] = d.Err
220	}
221	require.NoError(t, byID[1])
222	require.NoError(t, byID[2])
223	require.ErrorIs(t, byID[3], ErrOffscreenBusy)
224}
225
226// GO005: an idle child of an ended session is closed once, whether its lease
227// or its fence came back last.
228func TestRetireClosesIdleChildOnce(t *testing.T) {
229	f1, w1 := fencePipe(t)
230	replies := make(chan ports.CaptureDone, 4)
231	p := NewPipeline(context.Background(), replies)
232	child := portsmocks.NewMockRenderer(t)
233	frame := portsmocks.NewMockCaptureFrame(t)
234	frame.EXPECT().Done().Return(nil).Maybe()
235	frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
236	child.EXPECT().Render(mock.Anything, mock.Anything).Return(f1, nil).Once()
237	child.EXPECT().BeginCapture().Return(frame, nil).Once()
238	child.EXPECT().EndCapture(frame).Return().Once()
239	child.EXPECT().Close().Return().Once() // exactly once
240	p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
241
242	submitHiddenOne(t, p, hiddenSceneWindows(5), nil, 1)
243	awaitCapture(t, replies)
244	p.RecycleHidden(<-p.HiddenCompleted())
245
246	// The fence is still pending: the child stays.
247	p.Retire(ports.Scene{})
248	require.NotNil(t, p.off.r)
249	signalFence(t, w1)
250	p.Retire(ports.Scene{})
251	require.Nil(t, p.off.r, "closed once idle")
252	require.Nil(t, p.HiddenCompleted(), "no child, no channel")
253	p.Retire(ports.Scene{}) // nothing left to close
254	p.Close(nil)
255}
256
257// GO006: a readable fence (or an invalid descriptor) is done; POLLERR and poll
258// failures are terminal; timeouts and interrupts stay pending.
259func TestPollResult(t *testing.T) {
260	for _, tc := range []struct {
261		name    string
262		n       int
263		err     error
264		revents int16
265		want    fenceState
266	}{
267		{"readable", 1, nil, unix.POLLIN, fenceDone},
268		{"timeout", 0, nil, 0, fencePending},
269		{"interrupted", -1, unix.EINTR, 0, fencePending},
270		{"other poll error", -1, unix.EIO, 0, fenceFailed},
271		{"invalid poll descriptor", -1, unix.EBADF, 0, fenceDone},
272		{"invalid descriptor event", 1, nil, unix.POLLNVAL, fenceDone},
273		{"gpu error status", 1, nil, unix.POLLERR, fenceFailed},
274		{"error with data", 1, nil, unix.POLLIN | unix.POLLERR, fenceFailed},
275		{"hangup only", 1, nil, unix.POLLHUP, fencePending},
276	} {
277		t.Run(tc.name, func(t *testing.T) {
278			require.Equal(t, tc.want, pollResult(tc.n, tc.err, tc.revents))
279		})
280	}
281}
282
283func TestPollFenceOnRealDescriptors(t *testing.T) {
284	r, w := fencePipe(t)
285	require.Equal(t, fencePending, pollFence(r), "nothing written yet")
286	signalFence(t, w)
287	require.Equal(t, fenceDone, pollFence(r))
288	closed, _ := fencePipe(t)
289	require.NoError(t, closed.Close())
290	require.Equal(t, fenceDone, pollFence(closed), "a closed descriptor has nothing to wait for")
291	allocs := testing.AllocsPerRun(100, func() { pollFence(r) })
292	require.Zero(t, allocs, "polling a fence does not allocate")
293	t.Logf("pollFence: %.1f allocs", allocs)
294}

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 25

Tags 10

↑↓ moveenter openesc close