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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +179 -14
M Makefile +1 -1
| @@ -69,7 +69,7 @@ adapter-imports: | |||
| 69 | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | |
| 70 | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 71 | 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=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 | |
| 73 | 73 | check: vet test arch fakes-check log-check perf-check | |
| 74 | 74 | ||
| 75 | 75 | # 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=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 |
| 73 | check: vet test arch fakes-check log-check perf-check | 73 | check: vet test arch fakes-check log-check perf-check | ||
| 74 | 74 | ||||
| 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | 75 | # 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 | |||
| 68 | 68 | if p := recover(); p != nil { | |
| 69 | 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 | 72 | if data != nil { | |
| 72 | 73 | err = errors.Join(err, unix.Munmap(data)) | |
| 73 | 74 | } | |
| 74 | 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 | - | 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 { | |
| 84 | 79 | return | |
| 85 | 80 | } | |
| 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) | |
| 89 | 87 | return | |
| 90 | 88 | } | |
| 91 | 89 | var st unix.Stat_t | |
| @@ -94,7 +92,7 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu | |||
| 94 | 92 | return | |
| 95 | 93 | } | |
| 96 | 94 | if st.Size < size { | |
| 97 | - | err = fmt.Errorf("capture buffer truncated") | |
| 95 | + | err = errTruncated | |
| 98 | 96 | return | |
| 99 | 97 | } | |
| 100 | 98 | // 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 | 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 | + | ) | |
| @@ -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 | return | 79 | return | ||
| 85 | } | 80 | } | ||
| 86 | - | size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + rowBytes | 81 | + | 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 | return | 87 | return | ||
| 90 | } | 88 | } | ||
| 91 | var st unix.Stat_t | 89 | var st unix.Stat_t | ||
| @@ -94,7 +92,7 @@ func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.Captu | |||||
| 94 | return | 92 | return | ||
| 95 | } | 93 | } | ||
| 96 | if st.Size < size { | 94 | if st.Size < size { | ||
| 97 | - | err = fmt.Errorf("capture buffer truncated") | 95 | + | err = errTruncated |
| 98 | return | 96 | return | ||
| 99 | } | 97 | } | ||
| 100 | // Map only for the duration of this capture; the renderer copies BGRA rows | 98 | // 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 | |||
| 193 | 193 | ||
| 194 | 194 | // Handed reports a zeroed request: one Split failed, or one already given to | |
| 195 | 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 } | |
| @@ -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 to | 194 | // 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 ( | |||
| 12 | 12 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 13 | 13 | "github.com/bnema/neferwl/internal/ports" | |
| 14 | 14 | "github.com/stretchr/testify/mock" | |
| 15 | + | "golang.org/x/sys/unix" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | // writeRequest is a valid 2x2 request over a 16-byte file. | |
| @@ -177,3 +178,121 @@ func TestWriteFrameRecoversFromTruncationFault(t *testing.T) { | |||
| 177 | 178 | frame.Calls = nil | |
| 178 | 179 | 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 | + | } | |
| @@ -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 = nil | 178 | frame.Calls = nil | ||
| 178 | ok.Calls = nil | 179 | 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 { | |||
| 14 | 14 | Dst SHMBuffer | |
| 15 | 15 | Width, Height, Stride int | |
| 16 | 16 | 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 | 22 | // Session is the registered capture session (CaptureSessionOpen) the | |
| 18 | 23 | // request belongs to, 0 for wlr-screencopy. It is informational, except | |
| 19 | 24 | // with Exclude. | |
| @@ -14,6 +14,11 @@ type CaptureRequest struct { | |||||
| 14 | Dst SHMBuffer | 14 | Dst SHMBuffer | ||
| 15 | Width, Height, Stride int | 15 | Width, Height, Stride int | ||
| 16 | Format uint32 | 16 | 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) the | 22 | // Session is the registered capture session (CaptureSessionOpen) the | ||
| 18 | // request belongs to, 0 for wlr-screencopy. It is informational, except | 23 | // request belongs to, 0 for wlr-screencopy. It is informational, except | ||
| 19 | // with Exclude. | 24 | // with Exclude. | ||