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)
16
17// writeRequest is a valid 2x2 request over a 16-byte file.
18func writeRequest(t *testing.T) ports.CaptureRequest {
19 t.Helper()
20 return pipelineRequest(t, 7)
21}
22
23// runWriteFrame calls writeFrame with a buffered reply channel and returns its
24// single reply.
25func runWriteFrame(t *testing.T, req ports.CaptureRequest, frame ports.CaptureFrame) ports.CaptureDone {
26 t.Helper()
27 replies := make(chan ports.CaptureDone, 1)
28 writeFrame(context.Background(), req, frame, time.Unix(5, 6), replies)
29 done := awaitCapture(t, replies)
30 if done.ID != req.ID {
31 t.Fatalf("reply for %d, want %d", done.ID, req.ID)
32 }
33 if len(replies) != 0 {
34 t.Fatal("more than one reply")
35 }
36 return done
37}
38
39func requireClosed(t *testing.T, f *os.File) {
40 t.Helper()
41 if f == nil {
42 return
43 }
44 if _, err := f.Stat(); err == nil {
45 t.Fatal("destination file left open")
46 }
47}
48
49func TestWriteFrameRejectsInvalidDestination(t *testing.T) {
50 for _, tc := range []struct {
51 name string
52 mutate func(t *testing.T, q *ports.CaptureRequest)
53 }{
54 {"nil file", func(_ *testing.T, q *ports.CaptureRequest) { q.Dst.File = nil }},
55 {"zero width", func(_ *testing.T, q *ports.CaptureRequest) { q.Width = 0 }},
56 {"negative width", func(_ *testing.T, q *ports.CaptureRequest) { q.Width = -2 }},
57 {"zero height", func(_ *testing.T, q *ports.CaptureRequest) { q.Height = 0 }},
58 {"negative height", func(_ *testing.T, q *ports.CaptureRequest) { q.Height = -2 }},
59 {"stride below row", func(_ *testing.T, q *ports.CaptureRequest) { q.Stride = 4 }},
60 {"region width mismatch", func(_ *testing.T, q *ports.CaptureRequest) { q.Region = image.Rect(0, 0, 3, 2) }},
61 {"region height mismatch", func(_ *testing.T, q *ports.CaptureRequest) { q.Region = image.Rect(0, 0, 2, 1) }},
62 {"negative offset", func(_ *testing.T, q *ports.CaptureRequest) { q.Dst.Offset = -4 }},
63 {"invalid format", func(_ *testing.T, q *ports.CaptureRequest) { q.Format = 0xdeadbeef }},
64 {"width overflows bound", func(_ *testing.T, q *ports.CaptureRequest) {
65 q.Width, q.Region = math.MaxInt32, image.Rect(0, 0, math.MaxInt32, 2)
66 q.Stride = q.Width * 4
67 }},
68 {"huge stride overflowing h-1", func(_ *testing.T, q *ports.CaptureRequest) {
69 q.Height, q.Region = 3, image.Rect(0, 0, 2, 3)
70 q.Stride = math.MaxInt64 / 2
71 }},
72 {"max stride", func(_ *testing.T, q *ports.CaptureRequest) { q.Stride = math.MaxInt }},
73 {"huge height", func(_ *testing.T, q *ports.CaptureRequest) {
74 q.Height, q.Region = math.MaxInt32, image.Rect(0, 0, 2, math.MaxInt32)
75 }},
76 {"offset past bound", func(_ *testing.T, q *ports.CaptureRequest) { q.Dst.Offset = maxMappedBytes }},
77 {"one GiB plus one byte", func(_ *testing.T, q *ports.CaptureRequest) {
78 // 1 row of 4 bytes at offset 1GiB-3 ends one byte past the bound.
79 q.Width, q.Height, q.Stride, q.Region = 1, 1, 4, image.Rect(0, 0, 1, 1)
80 q.Dst.Offset = maxMappedBytes - 3
81 }},
82 {"truncated file", func(t *testing.T, q *ports.CaptureRequest) {
83 if err := q.Dst.File.Truncate(15); err != nil {
84 t.Fatal(err)
85 }
86 }},
87 {"offset past end of file", func(_ *testing.T, q *ports.CaptureRequest) { q.Dst.Offset = 4 }},
88 } {
89 t.Run(tc.name, func(t *testing.T) {
90 req := writeRequest(t)
91 tc.mutate(t, &req)
92 // No Read expectation: any Read call fails the test.
93 frame := portsmocks.NewMockCaptureFrame(t)
94 done := runWriteFrame(t, req, frame)
95 if done.Err == nil {
96 t.Fatal("invalid request succeeded")
97 }
98 requireClosed(t, req.Dst.File)
99 })
100 }
101}
102
103func TestWriteFrameAcceptsExactFit(t *testing.T) {
104 req := writeRequest(t)
105 frame := portsmocks.NewMockCaptureFrame(t)
106 frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error {
107 if len(dst) < 8+2*4 {
108 t.Errorf("dst holds %d bytes", len(dst))
109 return nil
110 }
111 copy(dst, []byte{1, 2, 3, 255})
112 return nil
113 }).Once()
114 done := runWriteFrame(t, req, frame)
115 if done.Err != nil {
116 t.Fatal(done.Err)
117 }
118 if !done.Time.Equal(time.Unix(5, 6)) {
119 t.Fatalf("time %v", done.Time)
120 }
121 requireClosed(t, req.Dst.File)
122 // Mockery retains Read arguments, which point at an unmapped SHM slice.
123 frame.Calls = nil
124}
125
126func TestWriteFramePropagatesReadError(t *testing.T) {
127 req := writeRequest(t)
128 boom := errors.New("readback failed")
129 frame := portsmocks.NewMockCaptureFrame(t)
130 frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).Return(boom).Once()
131 done := runWriteFrame(t, req, frame)
132 if !errors.Is(done.Err, boom) {
133 t.Fatalf("err %v", done.Err)
134 }
135 requireClosed(t, req.Dst.File)
136 frame.Calls = nil
137}
138
139func TestWriteFrameRecoversFromReadPanic(t *testing.T) {
140 req := writeRequest(t)
141 frame := portsmocks.NewMockCaptureFrame(t)
142 frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).RunAndReturn(func(image.Rectangle, []byte, int) error {
143 panic("renderer bug")
144 }).Once()
145 done := runWriteFrame(t, req, frame)
146 if done.Err == nil {
147 t.Fatal("panic not reported")
148 }
149 requireClosed(t, req.Dst.File)
150 frame.Calls = nil
151}
152
153// A client can truncate its buffer after the size check while it is mapped;
154// touching the mapping then raises SIGBUS, which must fail this capture only.
155func TestWriteFrameRecoversFromTruncationFault(t *testing.T) {
156 req := writeRequest(t)
157 frame := portsmocks.NewMockCaptureFrame(t)
158 frame.EXPECT().Read(req.Region, mock.Anything, req.Stride).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error {
159 if err := req.Dst.File.Truncate(0); err != nil {
160 return err
161 }
162 dst[0] = 1 // SIGBUS: the page is past the end of the file
163 return nil
164 }).Once()
165 done := runWriteFrame(t, req, frame)
166 if done.Err == nil {
167 t.Fatal("fault not reported")
168 }
169 requireClosed(t, req.Dst.File)
170 // The compositor survives and can serve the next capture.
171 next := writeRequest(t)
172 ok := portsmocks.NewMockCaptureFrame(t)
173 ok.EXPECT().Read(next.Region, mock.Anything, next.Stride).Return(nil).Once()
174 if done := runWriteFrame(t, next, ok); done.Err != nil {
175 t.Fatalf("capture after fault: %v", done.Err)
176 }
177 frame.Calls = nil
178 ok.Calls = nil
179}