1package capture
2
3import (
4 "context"
5 "errors"
6 "image"
7 "math"
8 "os"
9 "testing"
10 "time"
11
12 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13 "github.com/bnema/neferwl/internal/ports"
14 "github.com/stretchr/testify/mock"
15 "golang.org/x/sys/unix"
16)
17
18// writeRequest is a valid 2x2 request over a 16-byte file.
19func writeRequest(t *testing.T) ports.CaptureRequest {
20 t.Helper()
21 return pipelineRequest(t, 7)
22}
23
24// runWriteFrame calls writeFrame with a buffered reply channel and returns its
25// single reply.
26func runWriteFrame(t *testing.T, req ports.CaptureRequest, frame ports.CaptureFrame) ports.CaptureDone {
27 t.Helper()
28 replies := make(chan ports.CaptureDone, 1)
29 writeFrame(context.Background(), req, frame, time.Unix(5, 6), replies)
30 done := awaitCapture(t, replies)
31 if done.ID != req.ID {
32 t.Fatalf("reply for %d, want %d", done.ID, req.ID)
33 }
34 if len(replies) != 0 {
35 t.Fatal("more than one reply")
36 }
37 return done
38}
39
40func requireClosed(t *testing.T, f *os.File) {
41 t.Helper()
42 if f == nil {
43 return
44 }
45 if _, err := f.Stat(); err == nil {
46 t.Fatal("destination file left open")
47 }
48}
49
50func TestWriteFrameRejectsInvalidDestination(t *testing.T) {
51 for _, tc := range []struct {
52 name string
53 mutate func(t *testing.T, q *ports.CaptureRequest)
54 }{
55 {"nil file", func(_ *testing.T, q *ports.CaptureRequest) { q.Dst.File = nil }},
56 {"zero width", func(_ *testing.T, q *ports.CaptureRequest) { q.Width = 0 }},
57 {"negative width", func(_ *testing.T, q *ports.CaptureRequest) { q.Width = -2 }},
58 {"zero height", func(_ *testing.T, q *ports.CaptureRequest) { q.Height = 0 }},
59 {"negative height", func(_ *testing.T, q *ports.CaptureRequest) { q.Height = -2 }},
60 {"stride below row", func(_ *testing.T, q *ports.CaptureRequest) { q.Stride = 4 }},
61 {"region width mismatch", func(_ *testing.T, q *ports.CaptureRequest) { q.Region = image.Rect(0, 0, 3, 2) }},
62 {"region height mismatch", func(_ *testing.T, q *ports.CaptureRequest) { q.Region = image.Rect(0, 0, 2, 1) }},
63 {"negative offset", func(_ *testing.T, q *ports.CaptureRequest) { q.Dst.Offset = -4 }},
64 {"invalid format", func(_ *testing.T, q *ports.CaptureRequest) { q.Format = 0xdeadbeef }},
65 {"width overflows bound", func(_ *testing.T, q *ports.CaptureRequest) {
66 q.Width, q.Region = math.MaxInt32, image.Rect(0, 0, math.MaxInt32, 2)
67 q.Stride = q.Width * 4
68 }},
69 {"huge stride overflowing h-1", func(_ *testing.T, q *ports.CaptureRequest) {
70 q.Height, q.Region = 3, image.Rect(0, 0, 2, 3)
71 q.Stride = math.MaxInt64 / 2
72 }},
73 {"max stride", func(_ *testing.T, q *ports.CaptureRequest) { q.Stride = math.MaxInt }},
74 {"huge height", func(_ *testing.T, q *ports.CaptureRequest) {
75 q.Height, q.Region = math.MaxInt32, image.Rect(0, 0, 2, math.MaxInt32)
76 }},
77 {"offset past bound", func(_ *testing.T, q *ports.CaptureRequest) { q.Dst.Offset = maxMappedBytes }},
78 {"one GiB plus one byte", func(_ *testing.T, q *ports.CaptureRequest) {
79 // 1 row of 4 bytes at offset 1GiB-3 ends one byte past the bound.
80 q.Width, q.Height, q.Stride, q.Region = 1, 1, 4, image.Rect(0, 0, 1, 1)
81 q.Dst.Offset = maxMappedBytes - 3
82 }},
83 {"truncated file", func(t *testing.T, q *ports.CaptureRequest) {
84 if err := q.Dst.File.Truncate(15); err != nil {
85 t.Fatal(err)
86 }
87 }},
88 {"offset past end of file", func(_ *testing.T, q *ports.CaptureRequest) { q.Dst.Offset = 4 }},
89 } {
90 t.Run(tc.name, func(t *testing.T) {
91 req := writeRequest(t)
92 tc.mutate(t, &req)
93 // No Read expectation: any Read call fails the test.
94 frame := portsmocks.NewMockCaptureFrame(t)
95 done := runWriteFrame(t, req, frame)
96 if done.Err == nil {
97 t.Fatal("invalid request succeeded")
98 }
99 requireClosed(t, req.Dst.File)
100 })
101 }
102}
103
104func TestWriteFrameAcceptsExactFit(t *testing.T) {
105 req := writeRequest(t)
106 frame := portsmocks.NewMockCaptureFrame(t)
107 frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error {
108 if len(dst) < 8+2*4 {
109 t.Errorf("dst holds %d bytes", len(dst))
110 return nil
111 }
112 copy(dst, []byte{1, 2, 3, 255})
113 return nil
114 }).Once()
115 done := runWriteFrame(t, req, frame)
116 if done.Err != nil {
117 t.Fatal(done.Err)
118 }
119 if !done.Time.Equal(time.Unix(5, 6)) {
120 t.Fatalf("time %v", done.Time)
121 }
122 requireClosed(t, req.Dst.File)
123 // Mockery retains Read arguments, which point at an unmapped SHM slice.
124 frame.Calls = nil
125}
126
127func TestWriteFramePropagatesReadError(t *testing.T) {
128 req := writeRequest(t)
129 boom := errors.New("readback failed")
130 frame := portsmocks.NewMockCaptureFrame(t)
131 frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).Return(boom).Once()
132 done := runWriteFrame(t, req, frame)
133 if !errors.Is(done.Err, boom) {
134 t.Fatalf("err %v", done.Err)
135 }
136 requireClosed(t, req.Dst.File)
137 frame.Calls = nil
138}
139
140func TestWriteFrameRecoversFromReadPanic(t *testing.T) {
141 req := writeRequest(t)
142 frame := portsmocks.NewMockCaptureFrame(t)
143 frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).RunAndReturn(func(image.Rectangle, []byte, int) error {
144 panic("renderer bug")
145 }).Once()
146 done := runWriteFrame(t, req, frame)
147 if done.Err == nil {
148 t.Fatal("panic not reported")
149 }
150 requireClosed(t, req.Dst.File)
151 frame.Calls = nil
152}
153
154// A client can truncate its buffer after the size check while it is mapped;
155// touching the mapping then raises SIGBUS, which must fail this capture only.
156func TestWriteFrameRecoversFromTruncationFault(t *testing.T) {
157 req := writeRequest(t)
158 frame := portsmocks.NewMockCaptureFrame(t)
159 frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error {
160 if err := req.Dst.File.Truncate(0); err != nil {
161 return err
162 }
163 dst[0] = 1 // SIGBUS: the page is past the end of the file
164 return nil
165 }).Once()
166 done := runWriteFrame(t, req, frame)
167 if done.Err == nil {
168 t.Fatal("fault not reported")
169 }
170 requireClosed(t, req.Dst.File)
171 // The compositor survives and can serve the next capture.
172 next := writeRequest(t)
173 ok := portsmocks.NewMockCaptureFrame(t)
174 ok.EXPECT().Read(next.Region, mock.Anything, next.Stride).Return(nil).Once()
175 if done := runWriteFrame(t, next, ok); done.Err != nil {
176 t.Fatalf("capture after fault: %v", done.Err)
177 }
178 frame.Calls = nil
179 ok.Calls = nil
180}
181
182// mappedRequest is a valid 2x2 request that writes into a borrowed mapping of
183// size bytes.
184func mappedRequest(size int) ports.CaptureRequest {
185 return ports.CaptureRequest{ID: 9, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Mapped: make([]byte, size)}
186}
187
188func TestWriteFrameMappedWritesAtOffset(t *testing.T) {
189 req := mappedRequest(32)
190 req.Dst.Offset = 8
191 frame := portsmocks.NewMockCaptureFrame(t)
192 frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error {
193 if len(dst) != 24 {
194 t.Errorf("dst holds %d bytes, want 24 (mapping from offset 8)", len(dst))
195 return nil
196 }
197 copy(dst, []byte{1, 2, 3, 255})
198 return nil
199 }).Once()
200 done := runWriteFrame(t, req, frame)
201 if done.Err != nil {
202 t.Fatal(done.Err)
203 }
204 if got := req.Mapped[8:12]; got[0] != 1 || got[3] != 255 {
205 t.Fatalf("mapping not written at the offset: %v", req.Mapped[:16])
206 }
207 frame.Calls = nil
208}
209
210func TestWriteFrameMappedExactFit(t *testing.T) {
211 req := mappedRequest(8 + 2*4) // (h-1)*stride + w*4
212 frame := portsmocks.NewMockCaptureFrame(t)
213 frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).Return(nil).Once()
214 if done := runWriteFrame(t, req, frame); done.Err != nil {
215 t.Fatal(done.Err)
216 }
217 frame.Calls = nil
218}
219
220func TestWriteFrameMappedRejectsShortOrAmbiguous(t *testing.T) {
221 for _, tc := range []struct {
222 name string
223 mutate func(q *ports.CaptureRequest)
224 }{
225 {"one byte short", func(q *ports.CaptureRequest) { q.Mapped = q.Mapped[:15] }},
226 {"offset past mapping", func(q *ports.CaptureRequest) { q.Dst.Offset = 4 }},
227 {"stride bound", func(q *ports.CaptureRequest) { q.Stride = math.MaxInt }},
228 {"negative offset", func(q *ports.CaptureRequest) { q.Dst.Offset = -4 }},
229 {"invalid format", func(q *ports.CaptureRequest) { q.Format = 7 }},
230 {"file and mapping", func(q *ports.CaptureRequest) { q.Dst.File = os.Stdin }},
231 } {
232 t.Run(tc.name, func(t *testing.T) {
233 req := mappedRequest(16)
234 tc.mutate(&req)
235 frame := portsmocks.NewMockCaptureFrame(t) // any Read call fails
236 if done := runWriteFrame(t, req, frame); done.Err == nil {
237 t.Fatal("invalid request succeeded")
238 }
239 })
240 }
241}
242
243// A borrowed mapping is never closed or unmapped by the output: it stays
244// writable after the reply.
245func TestWriteFrameMappedStaysMapped(t *testing.T) {
246 m, err := unix.Mmap(-1, 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_ANON|unix.MAP_PRIVATE)
247 if err != nil {
248 t.Fatal(err)
249 }
250 defer func() { _ = unix.Munmap(m) }()
251 req := mappedRequest(0)
252 req.Mapped = m
253 frame := portsmocks.NewMockCaptureFrame(t)
254 frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).Return(nil).Once()
255 if done := runWriteFrame(t, req, frame); done.Err != nil {
256 t.Fatal(done.Err)
257 }
258 m[0], m[4095] = 1, 2 // would fault if unmapped
259 frame.Calls = nil
260}
261
262func TestHandedKeepsMappedRequests(t *testing.T) {
263 if Handed(ports.CaptureRequest{Mapped: make([]byte, 4)}) {
264 t.Fatal("a mapped request with ID 0 is not handed")
265 }
266 if !Handed(ports.CaptureRequest{}) {
267 t.Fatal("a zero request is handed")
268 }
269}
270
271// The measured closure holds the production steps of a mapped write without
272// the renderer's Read (a testify mock would record each call): destination
273// validation, the borrowed slice and the reply.
274func TestWriteMappedAllocations(t *testing.T) {
275 if raceEnabled {
276 t.Skip("the race detector allocates")
277 }
278 req := mappedRequest(16)
279 replies := make(chan ports.CaptureDone, 1)
280 ctx := context.Background()
281 at := time.Unix(1, 2)
282 var sink []byte
283 allocs := testing.AllocsPerRun(200, func() {
284 size, err := destSize(req)
285 if err != nil {
286 panic(err)
287 }
288 sink, err = mappedDst(req, size)
289 if err != nil {
290 panic(err)
291 }
292 replyAt(ctx, req, nil, at, replies)
293 <-replies
294 })
295 if allocs != 0 || len(sink) != 16 {
296 t.Fatalf("%v allocs per mapped write step, dst %d bytes", allocs, len(sink))
297 }
298}