detached df4b807

feat(capture): write frames into a borrowed mapped destination

commit
df4b8071da4089bfdabe8e862944fdeaefc09fb6
author
brice · · 2026-10-14 18:42 UTC
committer
brice · · 2026-10-14 21:39 UTC
parent
ea46f39
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +179 -14
M Makefile +1 -1
@@ -69,7 +69,7 @@ adapter-imports:
6969 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7070 # Fast, mandatory guards for tiled content publications and SHM copies.
7171 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations)$$' -count=1
72+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations)$$' -count=1
7373 check: vet test arch fakes-check log-check perf-check
7474
7575 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -69,7 +69,7 @@ adapter-imports:
69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
70 # Fast, mandatory guards for tiled content publications and SHM copies.70 # Fast, mandatory guards for tiled content publications and SHM copies.
71 perf-check:71 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations)$$' -count=172+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations)$$' -count=1
73 check: vet test arch fakes-check log-check perf-check73 check: vet test arch fakes-check log-check perf-check
74 74
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless75 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M internal/adapters/outputkit/capture/capture.go +43 -12
@@ -68,24 +68,22 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu
6868 if p := recover(); p != nil {
6969 err = fmt.Errorf("capture read panic: %v\n%s", p, debug.Stack())
7070 }
71+ // data is only ever a mapping made here, never req.Mapped.
7172 if data != nil {
7273 err = errors.Join(err, unix.Munmap(data))
7374 }
7475 replyAt(ctx, req, err, at, replies)
7576 }()
76- if req.Dst.File == nil || req.Width <= 0 || req.Width > maxMappedBytes/4 || req.Height <= 0 || req.Stride < req.Width*4 || req.Region.Dx() != req.Width || req.Region.Dy() != req.Height || req.Dst.Offset < 0 || (req.Format != shmARGB8888 && req.Format != shmXRGB8888) {
77- err = fmt.Errorf("invalid capture destination")
78- return
79- }
80- rowBytes := int64(req.Width) * 4
81- // Validate the bound before multiplication: padding must not wrap size.
82- if int64(req.Dst.Offset) > maxMappedBytes-rowBytes || (req.Height > 1 && int64(req.Stride) > (maxMappedBytes-int64(req.Dst.Offset)-rowBytes)/int64(req.Height-1)) {
83- err = fmt.Errorf("capture too large")
77+ size, err := destSize(req)
78+ if err != nil {
8479 return
8580 }
86- size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + rowBytes
87- if size <= 0 || size > int64(^uint(0)>>1) || size > maxMappedBytes {
88- err = fmt.Errorf("capture too large")
81+ if req.Mapped != nil {
82+ var dst []byte
83+ if dst, err = mappedDst(req, size); err != nil {
84+ return
85+ }
86+ err = frame.Read(req.Region, dst, req.Stride)
8987 return
9088 }
9189 var st unix.Stat_t
@@ -94,7 +92,7 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu
9492 return
9593 }
9694 if st.Size < size {
97- err = fmt.Errorf("capture buffer truncated")
95+ err = errTruncated
9896 return
9997 }
10098 // Map only for the duration of this capture; the renderer copies BGRA rows
@@ -105,3 +103,36 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu
105103 }
106104 err = frame.Read(req.Region, data[req.Dst.Offset:], req.Stride)
107105 }
106+
107+// destSize validates the destination of req, a file or a borrowed mapping, and
108+// returns the bytes it must hold from offset 0 through the last pixel.
109+func destSize(req ports.CaptureRequest) (int64, error) {
110+ if (req.Dst.File == nil) == (req.Mapped == nil) || req.Width <= 0 || req.Width > maxMappedBytes/4 || req.Height <= 0 || req.Stride < req.Width*4 || req.Region.Dx() != req.Width || req.Region.Dy() != req.Height || req.Dst.Offset < 0 || (req.Format != shmARGB8888 && req.Format != shmXRGB8888) {
111+ return 0, errInvalidDest
112+ }
113+ rowBytes := int64(req.Width) * 4
114+ // Validate the bound before multiplication: padding must not wrap size.
115+ if int64(req.Dst.Offset) > maxMappedBytes-rowBytes || (req.Height > 1 && int64(req.Stride) > (maxMappedBytes-int64(req.Dst.Offset)-rowBytes)/int64(req.Height-1)) {
116+ return 0, errTooLarge
117+ }
118+ size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + rowBytes
119+ if size <= 0 || size > int64(^uint(0)>>1) || size > maxMappedBytes {
120+ return 0, errTooLarge
121+ }
122+ return size, nil
123+}
124+
125+// mappedDst returns the borrowed mapping of req from the destination offset.
126+// The output never maps, unmaps or closes it.
127+func mappedDst(req ports.CaptureRequest, size int64) ([]byte, error) {
128+ if int64(len(req.Mapped)) < size {
129+ return nil, errTruncated
130+ }
131+ return req.Mapped[req.Dst.Offset:], nil
132+}
133+
134+var (
135+ errInvalidDest = errors.New("invalid capture destination")
136+ errTooLarge = errors.New("capture too large")
137+ errTruncated = errors.New("capture buffer truncated")
138+)
@@ -68,24 +68,22 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu
68 if p := recover(); p != nil {68 if p := recover(); p != nil {
69 err = fmt.Errorf("capture read panic: %v\n%s", p, debug.Stack())69 err = fmt.Errorf("capture read panic: %v\n%s", p, debug.Stack())
70 }70 }
71+ // data is only ever a mapping made here, never req.Mapped.
71 if data != nil {72 if data != nil {
72 err = errors.Join(err, unix.Munmap(data))73 err = errors.Join(err, unix.Munmap(data))
73 }74 }
74 replyAt(ctx, req, err, at, replies)75 replyAt(ctx, req, err, at, replies)
75 }()76 }()
76- if req.Dst.File == nil || req.Width <= 0 || req.Width > maxMappedBytes/4 || req.Height <= 0 || req.Stride < req.Width*4 || req.Region.Dx() != req.Width || req.Region.Dy() != req.Height || req.Dst.Offset < 0 || (req.Format != shmARGB8888 && req.Format != shmXRGB8888) {77+ size, err := destSize(req)
77- err = fmt.Errorf("invalid capture destination")78+ if err != nil {
78- return
79- }
80- rowBytes := int64(req.Width) * 4
81- // Validate the bound before multiplication: padding must not wrap size.
82- if int64(req.Dst.Offset) > maxMappedBytes-rowBytes || (req.Height > 1 && int64(req.Stride) > (maxMappedBytes-int64(req.Dst.Offset)-rowBytes)/int64(req.Height-1)) {
83- err = fmt.Errorf("capture too large")
84 return79 return
85 }80 }
86- size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + rowBytes81+ if req.Mapped != nil {
87- if size <= 0 || size > int64(^uint(0)>>1) || size > maxMappedBytes {82+ var dst []byte
88- err = fmt.Errorf("capture too large")83+ if dst, err = mappedDst(req, size); err != nil {
84+ return
85+ }
86+ err = frame.Read(req.Region, dst, req.Stride)
89 return87 return
90 }88 }
91 var st unix.Stat_t89 var st unix.Stat_t
@@ -94,7 +92,7 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu
94 return92 return
95 }93 }
96 if st.Size < size {94 if st.Size < size {
97- err = fmt.Errorf("capture buffer truncated")95+ err = errTruncated
98 return96 return
99 }97 }
100 // Map only for the duration of this capture; the renderer copies BGRA rows98 // Map only for the duration of this capture; the renderer copies BGRA rows
@@ -105,3 +103,36 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu
105 }103 }
106 err = frame.Read(req.Region, data[req.Dst.Offset:], req.Stride)104 err = frame.Read(req.Region, data[req.Dst.Offset:], req.Stride)
107 }105 }
106+
107+// destSize validates the destination of req, a file or a borrowed mapping, and
108+// returns the bytes it must hold from offset 0 through the last pixel.
109+func destSize(req ports.CaptureRequest) (int64, error) {
110+ if (req.Dst.File == nil) == (req.Mapped == nil) || req.Width <= 0 || req.Width > maxMappedBytes/4 || req.Height <= 0 || req.Stride < req.Width*4 || req.Region.Dx() != req.Width || req.Region.Dy() != req.Height || req.Dst.Offset < 0 || (req.Format != shmARGB8888 && req.Format != shmXRGB8888) {
111+ return 0, errInvalidDest
112+ }
113+ rowBytes := int64(req.Width) * 4
114+ // Validate the bound before multiplication: padding must not wrap size.
115+ if int64(req.Dst.Offset) > maxMappedBytes-rowBytes || (req.Height > 1 && int64(req.Stride) > (maxMappedBytes-int64(req.Dst.Offset)-rowBytes)/int64(req.Height-1)) {
116+ return 0, errTooLarge
117+ }
118+ size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + rowBytes
119+ if size <= 0 || size > int64(^uint(0)>>1) || size > maxMappedBytes {
120+ return 0, errTooLarge
121+ }
122+ return size, nil
123+}
124+
125+// mappedDst returns the borrowed mapping of req from the destination offset.
126+// The output never maps, unmaps or closes it.
127+func mappedDst(req ports.CaptureRequest, size int64) ([]byte, error) {
128+ if int64(len(req.Mapped)) < size {
129+ return nil, errTruncated
130+ }
131+ return req.Mapped[req.Dst.Offset:], nil
132+}
133+
134+var (
135+ errInvalidDest = errors.New("invalid capture destination")
136+ errTooLarge = errors.New("capture too large")
137+ errTruncated = errors.New("capture buffer truncated")
138+)
M internal/adapters/outputkit/capture/excluded.go +1 -1
@@ -193,4 +193,4 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
193193
194194 // Handed reports a zeroed request: one Split failed, or one already given to
195195 // the worker. The owner never answers it again.
196-func Handed(q ports.CaptureRequest) bool { return q.ID == 0 && q.Dst.File == nil }
196+func Handed(q ports.CaptureRequest) bool { return q.ID == 0 && q.Dst.File == nil && q.Mapped == nil }
@@ -193,4 +193,4 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
193 193
194 // Handed reports a zeroed request: one Split failed, or one already given to194 // Handed reports a zeroed request: one Split failed, or one already given to
195 // the worker. The owner never answers it again.195 // the worker. The owner never answers it again.
196-func Handed(q ports.CaptureRequest) bool { return q.ID == 0 && q.Dst.File == nil }196+func Handed(q ports.CaptureRequest) bool { return q.ID == 0 && q.Dst.File == nil && q.Mapped == nil }
A internal/adapters/outputkit/capture/norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package capture
4+
5+const raceEnabled = false
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package capture
4+
5+const raceEnabled = false
A internal/adapters/outputkit/capture/race_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build race
2+
3+package capture
4+
5+const raceEnabled = true
@@ -0,0 +1,5 @@
1+//go:build race
2+
3+package capture
4+
5+const raceEnabled = true
M internal/adapters/outputkit/capture/write_test.go +119 -0
@@ -12,6 +12,7 @@ import (
1212 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1313 "github.com/bnema/neferwl/internal/ports"
1414 "github.com/stretchr/testify/mock"
15+ "golang.org/x/sys/unix"
1516 )
1617
1718 // writeRequest is a valid 2x2 request over a 16-byte file.
@@ -177,3 +178,121 @@ func TestWriteFrameRecoversFromTruncationFault(t *testing.T) {
177178 frame.Calls = nil
178179 ok.Calls = nil
179180 }
181+
182+// mappedRequest is a valid 2x2 request that writes into a borrowed mapping of
183+// size bytes.
184+func 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+
188+func 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+
210+func 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+
220+func 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.
245+func 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+
262+func 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.
274+func 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+}
@@ -12,6 +12,7 @@ import (
12 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"12 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13 "github.com/bnema/neferwl/internal/ports"13 "github.com/bnema/neferwl/internal/ports"
14 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
15+ "golang.org/x/sys/unix"
15 )16 )
16 17
17 // writeRequest is a valid 2x2 request over a 16-byte file.18 // writeRequest is a valid 2x2 request over a 16-byte file.
@@ -177,3 +178,121 @@ func TestWriteFrameRecoversFromTruncationFault(t *testing.T) {
177 frame.Calls = nil178 frame.Calls = nil
178 ok.Calls = nil179 ok.Calls = nil
179 }180 }
181+
182+// mappedRequest is a valid 2x2 request that writes into a borrowed mapping of
183+// size bytes.
184+func 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+
188+func 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+
210+func 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+
220+func 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.
245+func 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+
262+func 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.
274+func 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+}
M internal/ports/capture_messages.go +5 -0
@@ -14,6 +14,11 @@ type CaptureRequest struct {
1414 Dst SHMBuffer
1515 Width, Height, Stride int
1616 Format uint32
17+ // Mapped, when set, is the destination instead of Dst.File (nil then):
18+ // the image is written at Mapped[Dst.Offset:] with Stride. The sender
19+ // keeps the mapping alive until CaptureDone; the output never maps,
20+ // unmaps or closes it.
21+ Mapped []byte
1722 // Session is the registered capture session (CaptureSessionOpen) the
1823 // request belongs to, 0 for wlr-screencopy. It is informational, except
1924 // with Exclude.
@@ -14,6 +14,11 @@ type CaptureRequest struct {
14 Dst SHMBuffer14 Dst SHMBuffer
15 Width, Height, Stride int15 Width, Height, Stride int
16 Format uint3216 Format uint32
17+ // Mapped, when set, is the destination instead of Dst.File (nil then):
18+ // the image is written at Mapped[Dst.Offset:] with Stride. The sender
19+ // keeps the mapping alive until CaptureDone; the output never maps,
20+ // unmaps or closes it.
21+ Mapped []byte
17 // Session is the registered capture session (CaptureSessionOpen) the22 // Session is the registered capture session (CaptureSessionOpen) the
18 // request belongs to, 0 for wlr-screencopy. It is informational, except23 // request belongs to, 0 for wlr-screencopy. It is informational, except
19 // with Exclude.24 // with Exclude.

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