detached 36cc89e

feat(capture): bounded asynchronous SDR capture with GPU HDR mapping (#67)

* feat(capture): add bounded asynchronous SDR readback and GPU HDR mapping

* perf(capture): avoid allocating file metadata for SHM copies

* test(capture): handle Vulkan devices without sync-file export
commit
36cc89ee9566b81c9a60cabce386c01ffe231517
author
bnema · · 2026-09-30 06:02 UTC
committer
GitHub · · 2026-09-30 06:02 UTC
parent
5117e75
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
48 files · +3351 -490
M .mockery.yml +8 -0
@@ -13,6 +13,7 @@ packages:
13 pkgname: portsmocks13 pkgname: portsmocks
14 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'14 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'
15 interfaces:15 interfaces:
16+ CaptureFrame:
16 Clock:17 Clock:
17 Notifier:18 Notifier:
18 OutputScaleStore:19 OutputScaleStore:
@@ -28,6 +29,13 @@ packages:
28 dir: internal/adapters/drm29 dir: internal/adapters/drm
29 pkgname: drm30 pkgname: drm
30 filename: kms_mock_test.go31 filename: kms_mock_test.go
32+ github.com/bnema/neferwl/internal/adapters/vulkan:
33+ interfaces:
34+ captureSync:
35+ config:
36+ dir: internal/adapters/vulkan
37+ pkgname: vulkan
38+ filename: capture_sync_mock_test.go
31 github.com/bnema/neferwl/internal/adapters/wayland:39 github.com/bnema/neferwl/internal/adapters/wayland:
32 interfaces:40 interfaces:
33 procEnv:41 procEnv:
M docs/config.md +1 -1
@@ -77,7 +77,7 @@ Hardware-decoded video can use NV12 (8-bit) or P010 (10-bit) DMA-BUF subsurfaces
77 77
78 - Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged.78 - Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged.
79 - Windowed and otherwise composed HDR content keeps its full range in a linear fp16 image before PQ output; the display tone-maps it according to its EDID metadata. SDR surfaces are blended in linear light on HDR outputs, so antialiased edges may look slightly different than sRGB-space blending. Dim veils (`floating.dim`, `stash.dim`) darken SDR white as much as on an SDR output; brighter HDR highlights stay a little brighter.79 - Windowed and otherwise composed HDR content keeps its full range in a linear fp16 image before PQ output; the display tone-maps it according to its EDID metadata. SDR surfaces are blended in linear light on HDR outputs, so antialiased edges may look slightly different than sRGB-space blending. Dim veils (`floating.dim`, `stash.dim`) darken SDR white as much as on an SDR output; brighter HDR highlights stay a little brighter.
80-- Screenshots and capture of HDR outputs are converted to 8-bit sRGB, clipping highlights at SDR white (only the capture is clipped).80+- Screenshots and capture use opaque 8-bit sRGB. HDR capture applies GPU tone mapping and reduces out-of-sRGB gamut: values below 90% of SDR reference white stay unchanged, while brighter values are compressed to preserve highlight differences (SDR reference white maps to about 245/255 when capturing an HDR output, versus 255/255 on an SDR output). This capture-only transform does not change HDR display output.
81 - The display gets the metadata from its own EDID, not the client's MaxCLL/MaxFALL.81 - The display gets the metadata from its own EDID, not the client's MaxCLL/MaxFALL.
82 82
83 HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 composition and 10-bit export formats/features. Unsupported capabilities fall back to SDR. NVIDIA is not supported yet (tested status).83 HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 composition and 10-bit export formats/features. Unsupported capabilities fall back to SDR. NVIDIA is not supported yet (tested status).
M docs/headless.md +1 -1
@@ -7,7 +7,7 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots
7 WAYLAND_DISPLAY=<name from the log> foot7 WAYLAND_DISPLAY=<name from the log> foot
8 ```8 ```
9 9
10-Screenshots through `grim` are verified with `zwlr_screencopy_v1` and `ext_image_copy_capture_v1`. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor.10+Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG.
11 11
12 `/tmp/neferwl-shots/latest.png` shows the current frame. `--timeout 5s` stops NeferWL after 5 seconds.12 `/tmp/neferwl-shots/latest.png` shows the current frame. `--timeout 5s` stops NeferWL after 5 seconds.
13 13
M docs/performance.md +2 -1
@@ -5,9 +5,10 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
5 ## Design5 ## Design
6 6
7 - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.7 - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.
8-- **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.8+- **Allocation-free steady-state presentation.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
9 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.9 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
10 - **Game frames keep a short gap after each VRR flip.** On amdgpu, a flip committed right after the previous flip event can miss the early refresh and wait for the panel's slowest rate (~21 ms at 48 Hz); the next frame then lands at the same point, and the screen stays at 48 Hz while the game renders at 150 fps. Game frames under VRR therefore commit at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. `internal/adapters/drm/vrr_flip_gap.go` has the measurements.10 - **Game frames keep a short gap after each VRR flip.** On amdgpu, a flip committed right after the previous flip event can miss the early refresh and wait for the panel's slowest rate (~21 ms at 48 Hz); the next frame then lands at the same point, and the screen stays at 48 Hz while the game renders at 150 fps. Game frames under VRR therefore commit at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. `internal/adapters/drm/vrr_flip_gap.go` has the measurements.
11+- **Bounded capture.** Protocol requests and sync-file handles still need small allocations; pixel storage and worker batches are reused. At most 32 protocol requests are outstanding globally, with up to eight per output frame, one SHM-copy worker and two reusable GPU staging slots per output. Saturation fails requests instead of waiting or growing a queue. Two 4K slots use about 63 MiB of host-visible memory, capped at 128 MiB per output; HDR adds one GPU-local 8-bit conversion image (about 32 MiB at 4K, up to 64 MiB), outside the host-memory cap. GPU resources are allocated on first capture and retained until the output stops. Copies have a separate completion fence but still cost GPU time and bandwidth; capture forces composition instead of scanout. There is no per-client reservation of the shared request limit.
11 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.12 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
12 13
13 Output and input threads request real-time scheduling, and the Vulkan queue requests elevated priority. Both require CAP_SYS_NICE, which the Arch packages set on `/usr/bin/neferwl` at install. Apps launched by NeferWL inherit neither. Set `performance.realtime = false` to opt out. A binary built with `make bin` runs at normal priority unless you grant it: `sudo setcap cap_sys_nice+ep bin/neferwl`.14 Output and input threads request real-time scheduling, and the Vulkan queue requests elevated priority. Both require CAP_SYS_NICE, which the Arch packages set on `/usr/bin/neferwl` at install. Apps launched by NeferWL inherit neither. Set `performance.realtime = false` to opt out. A binary built with `make bin` runs at normal priority unless you grant it: `sudo setcap cap_sys_nice+ep bin/neferwl`.
M internal/adapters/capture/capture.go +46 -14
@@ -7,20 +7,41 @@ import (
7 "context"7 "context"
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10+ "runtime/debug"
10 "time"11 "time"
11 12
12 "github.com/bnema/neferwl/internal/ports"13 "github.com/bnema/neferwl/internal/ports"
13 "golang.org/x/sys/unix"14 "golang.org/x/sys/unix"
14 )15 )
15 16
17+const maxMappedBytes = 1 << 30
18+
19+// Wayland SHM format identifiers; all capture pixels are opaque BGRA.
20+const (
21+ shmARGB8888 = 0
22+ shmXRGB8888 = 1
23+)
24+
16 func reply(ctx context.Context, req ports.CaptureRequest, err error, replies chan<- ports.CaptureDone) {25 func reply(ctx context.Context, req ports.CaptureRequest, err error, replies chan<- ports.CaptureDone) {
26+ replyAt(ctx, req, err, monotonicNow(), replies)
27+}
28+
29+func replyAt(ctx context.Context, req ports.CaptureRequest, err error, at time.Time, replies chan<- ports.CaptureDone) {
17 if req.Dst.File != nil {30 if req.Dst.File != nil {
18 err = errors.Join(err, req.Dst.File.Close())31 err = errors.Join(err, req.Dst.File.Close())
19 }32 }
33+ done := ports.CaptureDone{ID: req.ID, Output: req.Output, Err: err, Time: at}
34+ // Prefer a ready reply even after output cancellation: Wayland needs the
35+ // completion to release admission credit while the session is still alive.
36+ select {
37+ case replies <- done:
38+ return
39+ default:
40+ }
20 // A nil channel is allowed: cancellation still releases the sender.41 // A nil channel is allowed: cancellation still releases the sender.
21 select {42 select {
22 case <-ctx.Done():43 case <-ctx.Done():
23- case replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Err: err, Time: monotonicNow()}:44+ case replies <- done:
24 }45 }
25 }46 }
26 47
@@ -37,39 +58,50 @@ func Fail(ctx context.Context, req ports.CaptureRequest, err error, replies chan
37 reply(ctx, req, err, replies)58 reply(ctx, req, err, replies)
38 }59 }
39 60
40-func Write(ctx context.Context, req ports.CaptureRequest, r ports.Renderer, replies chan<- ports.CaptureDone) {61+func writeFrame(ctx context.Context, req ports.CaptureRequest, frame ports.CaptureFrame, at time.Time, replies chan<- ports.CaptureDone) {
41 var err error62 var err error
42 var data []byte63 var data []byte
64+ // A client can truncate its file after Stat while it is still mapped.
65+ // Turn SIGBUS into an error on this worker, not a compositor crash.
66+ defer debug.SetPanicOnFault(debug.SetPanicOnFault(true))
43 defer func() {67 defer func() {
68+ if p := recover(); p != nil {
69+ err = fmt.Errorf("capture read panic: %v\n%s", p, debug.Stack())
70+ }
44 if data != nil {71 if data != nil {
45 err = errors.Join(err, unix.Munmap(data))72 err = errors.Join(err, unix.Munmap(data))
46 }73 }
47- reply(ctx, req, err, replies)74+ replyAt(ctx, req, err, at, replies)
48 }()75 }()
49- if req.Dst.File == nil || req.Width <= 0 || req.Height <= 0 || req.Stride < req.Width*4 || req.Region.Dx() != req.Width || req.Region.Dy() != req.Height || req.Dst.Offset < 0 {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) {
50 err = fmt.Errorf("invalid capture destination")77 err = fmt.Errorf("invalid capture destination")
51 return78 return
52 }79 }
53- size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + int64(req.Width)*480+ rowBytes := int64(req.Width) * 4
54- if size <= 0 || size > int64(^uint(0)>>1) || size > 1<<30 {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
85+ }
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 {
55 err = fmt.Errorf("capture too large")88 err = fmt.Errorf("capture too large")
56 return89 return
57 }90 }
58- st, e := req.Dst.File.Stat()91+ var st unix.Stat_t
59- if e != nil {92+ if e := unix.Fstat(int(req.Dst.File.Fd()), &st); e != nil {
60- err = e93+ err = fmt.Errorf("fstat capture destination: %w", e)
61 return94 return
62 }95 }
63- if st.Size() < size {96+ if st.Size < size {
64 err = fmt.Errorf("capture buffer truncated")97 err = fmt.Errorf("capture buffer truncated")
65 return98 return
66 }99 }
67 // Map only for the duration of this capture; the renderer copies BGRA rows100 // Map only for the duration of this capture; the renderer copies BGRA rows
68 // directly from its readback buffer into the selected shm region.101 // directly from its readback buffer into the selected shm region.
69- data, e = unix.Mmap(int(req.Dst.File.Fd()), 0, int(size), unix.PROT_WRITE, unix.MAP_SHARED)102+ data, err = unix.Mmap(int(req.Dst.File.Fd()), 0, int(size), unix.PROT_WRITE, unix.MAP_SHARED)
70- if e != nil {103+ if err != nil {
71- err = e
72 return104 return
73 }105 }
74- err = r.Capture(req.Region, data[req.Dst.Offset:], req.Stride)106+ err = frame.Read(req.Region, data[req.Dst.Offset:], req.Stride)
75 }107 }
M internal/adapters/capture/capture_test.go +24 -0
@@ -30,6 +30,30 @@ func TestFailClosesBeforeReplyAndUsesMonotonic(t *testing.T) {
30 }30 }
31 }31 }
32 32
33+func TestFailCancelledStillDeliversReadyReply(t *testing.T) {
34+ ctx, cancel := context.WithCancel(context.Background())
35+ cancel()
36+ replies := make(chan ports.CaptureDone, 1)
37+ for id := range uint64(100) {
38+ f, err := os.CreateTemp(t.TempDir(), "capture")
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ Fail(ctx, ports.CaptureRequest{ID: id, Dst: ports.SHMBuffer{File: f}}, nil, replies)
43+ select {
44+ case result := <-replies:
45+ if result.ID != id {
46+ t.Fatalf("reply ID %d, want %d", result.ID, id)
47+ }
48+ default:
49+ t.Fatal("cancelled output lost ready completion")
50+ }
51+ if _, err := f.Stat(); err == nil {
52+ t.Fatal("descriptor open at reply")
53+ }
54+ }
55+}
56+
33 func TestFailCancelledWithFullReplies(t *testing.T) {57 func TestFailCancelledWithFullReplies(t *testing.T) {
34 ctx, cancel := context.WithCancel(context.Background())58 ctx, cancel := context.WithCancel(context.Background())
35 cancel()59 cancel()
A internal/adapters/capture/pipeline.go +149 -0
@@ -0,0 +1,149 @@
1+package capture
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/syncfile"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// MaxRequests bounds requests collected for one output frame.
14+const MaxRequests = 8
15+
16+const pipelineSlots = 2
17+
18+// batch remains owned by the worker until it is returned through completed.
19+// Both batches and their request storage are reused for the output's lifetime.
20+type batch struct {
21+ frame ports.CaptureFrame
22+ requests [MaxRequests]ports.CaptureRequest
23+ count int
24+ at time.Time
25+}
26+
27+// Pipeline moves fence waiting and SHM copies off the renderer's owner.
28+// Submit, Recycle and Close belong to that owner; one worker handles all jobs.
29+type Pipeline struct {
30+ ctx context.Context
31+ cancel context.CancelFunc
32+ replies chan<- ports.CaptureDone
33+ jobs chan *batch
34+ completed chan *batch
35+ batches [pipelineSlots]batch
36+ worker sync.WaitGroup
37+}
38+
39+func NewPipeline(ctx context.Context, replies chan<- ports.CaptureDone) *Pipeline {
40+ ctx, cancel := context.WithCancel(ctx)
41+ p := &Pipeline{ctx: ctx, cancel: cancel, replies: replies, jobs: make(chan *batch, pipelineSlots), completed: make(chan *batch, pipelineSlots)}
42+ p.worker.Go(p.run)
43+ return p
44+}
45+
46+// Completed wakes the owner even when the output is otherwise idle. Its opaque
47+// token is passed directly to Recycle; callers never access batch storage.
48+func (p *Pipeline) Completed() <-chan *batch { return p.completed }
49+
50+// Recycle returns a completed GPU readback lease on the renderer's owner.
51+func (p *Pipeline) Recycle(b *batch, r ports.Renderer) {
52+ r.EndCapture(b.frame)
53+ *b = batch{}
54+}
55+
56+// Submit consumes all requests. Saturation fails explicitly instead of
57+// waiting, growing a queue, or starting another worker.
58+func (p *Pipeline) Submit(r ports.Renderer, requests []ports.CaptureRequest) {
59+ if len(requests) == 0 {
60+ return
61+ }
62+ // Reclaim returned leases before declaring the small ring saturated.
63+ p.reclaim(r)
64+ var b *batch
65+ if len(requests) <= MaxRequests && p.ctx.Err() == nil {
66+ for i := range p.batches {
67+ if p.batches[i].frame == nil {
68+ b = &p.batches[i]
69+ break
70+ }
71+ }
72+ }
73+ var err error
74+ if b == nil {
75+ err = fmt.Errorf("capture pipeline full or stopped")
76+ } else {
77+ b.frame, err = r.BeginCapture()
78+ if err == nil && b.frame == nil {
79+ err = fmt.Errorf("renderer returned no capture frame")
80+ }
81+ }
82+ if err != nil {
83+ for _, q := range requests {
84+ Fail(p.ctx, q, err, p.replies)
85+ }
86+ return
87+ }
88+ b.count = copy(b.requests[:], requests)
89+ b.at = monotonicNow()
90+ // There are exactly two reusable batches and two queue slots, so this
91+ // send cannot wait: no batch can be queued twice before recycling.
92+ p.jobs <- b
93+}
94+
95+func (p *Pipeline) reclaim(r ports.Renderer) {
96+ for {
97+ select {
98+ case b := <-p.completed:
99+ p.Recycle(b, r)
100+ default:
101+ return
102+ }
103+ }
104+}
105+
106+func (p *Pipeline) run() {
107+ for {
108+ select {
109+ case b := <-p.jobs:
110+ p.process(b)
111+ case <-p.ctx.Done():
112+ for {
113+ select {
114+ case b := <-p.jobs:
115+ p.process(b)
116+ default:
117+ return
118+ }
119+ }
120+ }
121+ }
122+}
123+
124+func (p *Pipeline) process(b *batch) {
125+ err := p.ctx.Err()
126+ if err == nil && b.frame.Done() != nil {
127+ err = syncfile.Wait(p.ctx, b.frame.Done())
128+ }
129+ for i := range b.count {
130+ q := b.requests[i]
131+ if err != nil {
132+ Fail(p.ctx, q, err, p.replies)
133+ } else if e := p.ctx.Err(); e != nil {
134+ Fail(p.ctx, q, e, p.replies)
135+ } else {
136+ writeFrame(p.ctx, q, b.frame, b.at, p.replies)
137+ }
138+ }
139+ p.completed <- b
140+}
141+
142+// Close joins the worker before any renderer memory may be unmapped. A
143+// cancelled GPU operation is returned without reading its staging buffer;
144+// EndCapture must retain GPU-pending storage until completion.
145+func (p *Pipeline) Close(r ports.Renderer) {
146+ p.cancel()
147+ p.worker.Wait()
148+ p.reclaim(r)
149+}
A internal/adapters/capture/pipeline_fence_test.go +93 -0
@@ -0,0 +1,93 @@
1+package capture
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "os"
8+ "testing"
9+ "time"
10+
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
14+)
15+
16+// fencePipe returns a pipe standing in for a sync file: readable is signalled.
17+func fencePipe(t *testing.T) (read, write *os.File) {
18+ t.Helper()
19+ read, write, err := os.Pipe()
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ t.Cleanup(func() { _ = read.Close(); _ = write.Close() })
24+ return read, write
25+}
26+
27+func TestPipelineWaitsForFenceBeforeRead(t *testing.T) {
28+ r := portsmocks.NewMockRenderer(t)
29+ frame := portsmocks.NewMockCaptureFrame(t)
30+ fence, signal := fencePipe(t)
31+ frame.EXPECT().Done().Return(fence)
32+ entered := make(chan struct{})
33+ frame.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.Anything, 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error {
34+ close(entered)
35+ copy(dst, []byte{1, 2, 3, 255})
36+ return nil
37+ }).Once()
38+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
39+ r.EXPECT().EndCapture(frame).Return().Once()
40+ replies := make(chan ports.CaptureDone, 1)
41+ p := NewPipeline(context.Background(), replies)
42+ q := pipelineRequest(t, 1)
43+ p.Submit(r, []ports.CaptureRequest{q})
44+ // Longer than one syncfile poll slice: the worker has polled at least once.
45+ select {
46+ case <-entered:
47+ t.Fatal("Read before the fence signalled")
48+ case <-replies:
49+ t.Fatal("reply before the fence signalled")
50+ case <-time.After(250 * time.Millisecond):
51+ }
52+ if _, err := signal.Write([]byte{1}); err != nil {
53+ t.Fatal(err)
54+ }
55+ done := awaitCapture(t, replies)
56+ if done.Err != nil || done.ID != q.ID {
57+ t.Fatalf("result %+v", done)
58+ }
59+ select {
60+ case <-entered:
61+ default:
62+ t.Fatal("Read never ran")
63+ }
64+ if _, err := q.Dst.File.Stat(); err == nil {
65+ t.Fatal("descriptor open at completion")
66+ }
67+ p.Close(r)
68+ frame.Calls = nil
69+}
70+
71+func TestPipelinePropagatesReadError(t *testing.T) {
72+ r := portsmocks.NewMockRenderer(t)
73+ frame := portsmocks.NewMockCaptureFrame(t)
74+ frame.EXPECT().Done().Return(nil)
75+ boom := errors.New("readback failed")
76+ frame.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.Anything, 8).Return(boom).Once()
77+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
78+ r.EXPECT().EndCapture(frame).Return().Once()
79+ replies := make(chan ports.CaptureDone, 1)
80+ p := NewPipeline(context.Background(), replies)
81+ q := pipelineRequest(t, 1)
82+ p.Submit(r, []ports.CaptureRequest{q})
83+ done := awaitCapture(t, replies)
84+ if !errors.Is(done.Err, boom) || done.ID != q.ID {
85+ t.Fatalf("result %+v", done)
86+ }
87+ if _, err := q.Dst.File.Stat(); err == nil {
88+ t.Fatal("descriptor open at completion")
89+ }
90+ // The lease returns even though the read failed.
91+ p.Close(r)
92+ frame.Calls = nil
93+}
A internal/adapters/capture/pipeline_test.go +148 -0
@@ -0,0 +1,148 @@
1+package capture
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "os"
8+ "testing"
9+ "time"
10+
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
14+)
15+
16+func pipelineRequest(t *testing.T, id uint64) ports.CaptureRequest {
17+ t.Helper()
18+ f, err := os.CreateTemp(t.TempDir(), "capture")
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ t.Cleanup(func() { _ = f.Close() })
23+ if err := f.Truncate(16); err != nil {
24+ t.Fatal(err)
25+ }
26+ return ports.CaptureRequest{ID: id, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: ports.SHMBuffer{File: f}}
27+}
28+
29+func awaitCapture(t *testing.T, replies <-chan ports.CaptureDone) ports.CaptureDone {
30+ t.Helper()
31+ select {
32+ case done := <-replies:
33+ return done
34+ case <-time.After(2 * time.Second):
35+ t.Fatal("no capture reply")
36+ return ports.CaptureDone{}
37+ }
38+}
39+
40+func TestPipelineCopiesOffOwnerAndRecycles(t *testing.T) {
41+ r := portsmocks.NewMockRenderer(t)
42+ frame := portsmocks.NewMockCaptureFrame(t)
43+ frame.EXPECT().Done().Return(nil)
44+ entered, release := make(chan struct{}), make(chan struct{})
45+ frame.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.Anything, 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error {
46+ close(entered)
47+ <-release
48+ copy(dst, []byte{1, 2, 3, 255})
49+ return nil
50+ }).Once()
51+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
52+ r.EXPECT().EndCapture(frame).Return().Once()
53+ replies := make(chan ports.CaptureDone, 1)
54+ p := NewPipeline(context.Background(), replies)
55+ q := pipelineRequest(t, 1)
56+ p.Submit(r, []ports.CaptureRequest{q})
57+ select {
58+ case <-entered:
59+ case <-time.After(time.Second):
60+ t.Fatal("copy worker did not run")
61+ }
62+ // Submit returned while the worker is still inside Read.
63+ select {
64+ case <-replies:
65+ t.Fatal("reply before copy completed")
66+ default:
67+ }
68+ close(release)
69+ done := awaitCapture(t, replies)
70+ if done.Err != nil || done.ID != q.ID {
71+ t.Fatalf("result %+v", done)
72+ }
73+ if _, err := q.Dst.File.Stat(); err == nil {
74+ t.Fatal("descriptor open at completion")
75+ }
76+ p.Close(r)
77+ // Mockery retains Read arguments, which point at an unmapped SHM slice.
78+ frame.Calls = nil
79+}
80+
81+func TestPipelineSaturationAndShutdownWithoutGPUCompletion(t *testing.T) {
82+ r := portsmocks.NewMockRenderer(t)
83+ replies := make(chan ports.CaptureDone, 4)
84+ p := NewPipeline(context.Background(), replies)
85+ for id := range uint64(2) {
86+ read, write, err := os.Pipe()
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ t.Cleanup(func() { _ = read.Close(); _ = write.Close() })
91+ frame := portsmocks.NewMockCaptureFrame(t)
92+ frame.EXPECT().Done().Return(read).Maybe()
93+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
94+ r.EXPECT().EndCapture(frame).Return().Once()
95+ p.Submit(r, []ports.CaptureRequest{pipelineRequest(t, id)})
96+ }
97+ q := pipelineRequest(t, 3)
98+ p.Submit(r, []ports.CaptureRequest{q})
99+ if done := awaitCapture(t, replies); done.ID != 3 || done.Err == nil {
100+ t.Fatalf("saturation result %+v", done)
101+ }
102+ closed := make(chan struct{})
103+ go func() { p.Close(r); close(closed) }()
104+ select {
105+ case <-closed:
106+ case <-time.After(time.Second):
107+ t.Fatal("shutdown waited for GPU fence")
108+ }
109+ for range 2 {
110+ if done := awaitCapture(t, replies); done.Err == nil {
111+ t.Fatalf("cancelled result %+v", done)
112+ }
113+ }
114+}
115+
116+func TestPipelineBeginFailureClosesRequest(t *testing.T) {
117+ r := portsmocks.NewMockRenderer(t)
118+ boom := errors.New("submission failed")
119+ r.EXPECT().BeginCapture().Return(nil, boom).Once()
120+ replies := make(chan ports.CaptureDone, 1)
121+ p := NewPipeline(context.Background(), replies)
122+ q := pipelineRequest(t, 1)
123+ p.Submit(r, []ports.CaptureRequest{q})
124+ if done := awaitCapture(t, replies); !errors.Is(done.Err, boom) {
125+ t.Fatalf("result %+v", done)
126+ }
127+ if _, err := q.Dst.File.Stat(); err == nil {
128+ t.Fatal("descriptor open")
129+ }
130+ p.Close(r)
131+}
132+
133+func TestPipelineRejectsOversizedBatch(t *testing.T) {
134+ r := portsmocks.NewMockRenderer(t)
135+ replies := make(chan ports.CaptureDone, MaxRequests+1)
136+ p := NewPipeline(context.Background(), replies)
137+ requests := make([]ports.CaptureRequest, MaxRequests+1)
138+ for i := range requests {
139+ requests[i] = pipelineRequest(t, uint64(i))
140+ }
141+ p.Submit(r, requests)
142+ for range requests {
143+ if done := awaitCapture(t, replies); done.Err == nil {
144+ t.Fatal("oversized batch accepted")
145+ }
146+ }
147+ p.Close(r)
148+}
A internal/adapters/capture/write_test.go +179 -0
@@ -0,0 +1,179 @@
1+package capture
2+
3+import (
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.
18+func 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.
25+func 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+
39+func 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+
49+func 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+
103+func 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+
126+func 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+
139+func 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.
155+func 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+}
A internal/adapters/drm/capture_ownership_test.go +63 -0
@@ -0,0 +1,63 @@
1+package drm
2+
3+import (
4+ "context"
5+ "image"
6+ "os"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/adapters/capture"
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/mock"
13+)
14+
15+func TestCaptureOwnershipTransferredBeforeCommitPanic(t *testing.T) {
16+ o, _, _ := testOutput(t)
17+ o.cursor = nil
18+ k := newMockkms(t)
19+ o.k = k
20+ k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(*atomicReq, uint32, uint64) error { panic("commit panic") }).Once()
21+ r := portsmocks.NewMockRenderer(t)
22+ r.EXPECT().UseTarget(0).Return().Once()
23+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
24+ frame := portsmocks.NewMockCaptureFrame(t)
25+ frame.EXPECT().Done().Return(nil).Maybe()
26+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
27+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
28+ r.EXPECT().EndCapture(frame).Return().Once()
29+ f, err := os.CreateTemp(t.TempDir(), "capture")
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := f.Truncate(16); err != nil {
34+ t.Fatal(err)
35+ }
36+ requests := []ports.CaptureRequest{{ID: 1, Dst: ports.SHMBuffer{File: f}, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1}}
37+ replies := make(chan ports.CaptureDone, 2)
38+ pipeline := capture.NewPipeline(context.Background(), replies)
39+ func() {
40+ defer func() {
41+ if p := recover(); p != "commit panic" {
42+ t.Fatalf("panic %v", p)
43+ }
44+ }()
45+ _, _ = o.submitFrame(context.Background(), r, ports.Scene{Seq: 1}, nil, nil, requests, pipeline)
46+ }()
47+ pipeline.Close(r)
48+ if requests[0].ID != 0 || requests[0].Dst.File != nil {
49+ t.Fatal("owner retained handed-off request")
50+ }
51+ if result := <-replies; result.ID != 1 {
52+ t.Fatalf("reply %+v", result)
53+ }
54+ select {
55+ case result := <-replies:
56+ t.Fatalf("duplicate reply %+v", result)
57+ default:
58+ }
59+ if _, err := f.Stat(); err == nil {
60+ t.Fatal("descriptor leaked")
61+ }
62+ frame.Calls = nil
63+}
M internal/adapters/drm/frame_submit.go +7 -6
@@ -16,13 +16,13 @@ func (e renderError) Unwrap() error { return e.err }
16 // submitFrame puts one frame of the scene on screen: the fullscreen window16 // submitFrame puts one frame of the scene on screen: the fullscreen window
17 // scanned out directly when the kernel takes it, else a composed frame with17 // scanned out directly when the kernel takes it, else a composed frame with
18 // an optional overlay plane, dropped when the kernel refuses it. Captures18 // an optional overlay plane, dropped when the kernel refuses it. Captures
19-// force composition and are answered from the composed frame (they are19+// force composition and are answered from the composed frame; on renderError
20-// consumed either way). direct reports a scanned-out frame.20+// the caller still owns the requests and must fail them. direct reports scanout.
21 //21 //
22 // On success the frame is in flight; errOverlayDropped asks for a new frame22 // On success the frame is in flight; errOverlayDropped asks for a new frame
23 // without the overlay; a renderError stops the output; other errors are23 // without the overlay; a renderError stops the output; other errors are
24 // commit errors for commitFailed.24 // commit errors for commitFailed.
25-func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, seen map[ports.WindowID]uint64, requests []ports.CaptureRequest, captured chan<- ports.CaptureDone) (direct bool, err error) {25+func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, seen map[ports.WindowID]uint64, requests []ports.CaptureRequest, pipeline *capture.Pipeline) (direct bool, err error) {
26 // shows is what the frame puts on screen: presentation feedback of26 // shows is what the frame puts on screen: presentation feedback of
27 // windows it does not draw is discarded, not presented.27 // windows it does not draw is discarded, not presented.
28 f := pendingFrame{shows: o.shownBy(scene, seen)}28 f := pendingFrame{shows: o.shownBy(scene, seen)}
@@ -56,9 +56,10 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
56 if len(requests) > 0 {56 if len(requests) > 0 {
57 // Scanout and the overlay plane were skipped for this frame.57 // Scanout and the overlay plane were skipped for this frame.
58 o.log.Debug().Str("connector", o.conn.name).Int("captures", len(requests)).Msg("capture frame composed")58 o.log.Debug().Str("connector", o.conn.name).Int("captures", len(requests)).Msg("capture frame composed")
59- }59+ pipeline.Submit(r, requests)
60- for _, q := range requests {60+ // Ownership moved to the worker (or an immediate failure reply).
61- capture.Write(ctx, q, r, captured)61+ // A later commit panic must not fail the same requests again.
62+ clear(requests)
62 }63 }
63 // The overlay buffer is on screen like a scanned-out one: it is64 // The overlay buffer is on screen like a scanned-out one: it is
64 // reported shown, so it is not released under the plane.65 // reported shown, so it is not released under the plane.
M internal/adapters/drm/output.go +23 -5
@@ -977,12 +977,17 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
977 var want ports.CursorChange977 var want ports.CursorChange
978 cursorScale := -1.0 // not loaded yet978 cursorScale := -1.0 // not loaded yet
979 frame := 0979 frame := 0
980- var requests []ports.CaptureRequest980+ var requestStorage [capture.MaxRequests]ports.CaptureRequest
981+ requests := requestStorage[:0]
981 ctx, cancelCaptures := context.WithCancel(ctx)982 ctx, cancelCaptures := context.WithCancel(ctx)
983+ pipeline := capture.NewPipeline(ctx, captured)
982 defer func() {984 defer func() {
983 cancelCaptures()985 cancelCaptures()
986+ pipeline.Close(r)
984 for _, q := range requests {987 for _, q := range requests {
985- capture.Fail(ctx, q, fmt.Errorf("output stopped"), captured)988+ if q.ID != 0 || q.Dst.File != nil {
989+ capture.Fail(ctx, q, fmt.Errorf("output stopped"), captured)
990+ }
986 }991 }
987 for {992 for {
988 select {993 select {
@@ -1095,9 +1100,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1095 case <-gapDue:1100 case <-gapDue:
1096 case <-ctx.Done():1101 case <-ctx.Done():
1097 return nil1102 return nil
1103+ case b := <-pipeline.Completed():
1104+ pipeline.Recycle(b, r)
1105+ continue
1098 case q := <-captures:1106 case q := <-captures:
1099 if !enabled || o.off || o.wantOff {1107 if !enabled || o.off || o.wantOff {
1100 capture.Fail(ctx, q, fmt.Errorf("output off"), captured)1108 capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
1109+ } else if len(requests) == cap(requests) {
1110+ capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), captured)
1101 } else {1111 } else {
1102 requests = append(requests, q)1112 requests = append(requests, q)
1103 dirty = haveScene1113 dirty = haveScene
@@ -1183,7 +1193,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1183 for _, q := range requests {1193 for _, q := range requests {
1184 capture.Fail(ctx, q, fmt.Errorf("output off"), captured)1194 capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
1185 }1195 }
1186- requests = nil1196+ clear(requests)
1197+ requests = requestStorage[:0]
1187 }1198 }
1188 if !enabled || o.frame.pendingCommit() {1199 if !enabled || o.frame.pendingCommit() {
1189 continue1200 continue
@@ -1224,9 +1235,16 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1224 o.flipGapUntil = time.Time{}1235 o.flipGapUntil = time.Time{}
1225 }1236 }
1226 start := time.Now()1237 start := time.Now()
1227- direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, captured)1238+ direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, pipeline)
1228- requests = nil
1229 var fatal renderError1239 var fatal renderError
1240+ if errors.As(err, &fatal) {
1241+ // A failed render did not hand these requests to the worker.
1242+ for _, q := range requests {
1243+ capture.Fail(ctx, q, err, captured)
1244+ }
1245+ }
1246+ clear(requests)
1247+ requests = requestStorage[:0]
1230 if errors.As(err, &fatal) {1248 if errors.As(err, &fatal) {
1231 return err1249 return err
1232 }1250 }
M internal/adapters/drm/output_test.go +71 -2
@@ -747,6 +747,71 @@ func TestRunTrimsRendererOnStatsTick(t *testing.T) {
747 }747 }
748 }748 }
749 749
750+// A render failure must complete captures queued before the first frame.
751+func TestCaptureRenderFailureClosesPendingRequest(t *testing.T) {
752+ o, k, _ := testOutput(t)
753+ o.cursor, o.tearing = nil, false
754+ r := portsmocks.NewMockRenderer(t)
755+ boom := errors.New("render failed")
756+ r.EXPECT().SetHDR(float64(0)).Return().Once()
757+ r.EXPECT().UseTarget(mock.Anything).Return()
758+ makeBuf := func() ports.DMABuf {
759+ f, err := os.CreateTemp(t.TempDir(), "target")
760+ if err != nil {
761+ t.Fatal(err)
762+ }
763+ t.Cleanup(func() { _ = f.Close() })
764+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
765+ }
766+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once()
767+ r.EXPECT().Render(ports.Scene{Background: "#000000"}, mock.Anything).Return(nil, nil).Twice()
768+ r.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Background != "#000000" }), mock.Anything).Return(nil, boom).Once()
769+ r.EXPECT().Close().Return().Once()
770+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
771+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
772+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
773+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
774+ ctx, cancel := context.WithCancel(context.Background())
775+ defer cancel()
776+ scenes := make(chan ports.Scene, 1)
777+ requests := make(chan ports.CaptureRequest)
778+ replies := make(chan ports.CaptureDone, 1)
779+ f, err := os.CreateTemp(t.TempDir(), "capture")
780+ if err != nil {
781+ t.Fatal(err)
782+ }
783+ done := make(chan error, 1)
784+ go func() {
785+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, requests, replies)
786+ }()
787+ // The unbuffered handoff proves Run owns the request before any scene.
788+ select {
789+ case requests <- ports.CaptureRequest{ID: 9, Dst: ports.SHMBuffer{File: f}}:
790+ case <-time.After(2 * time.Second):
791+ t.Fatal("output did not accept capture")
792+ }
793+ scenes <- ports.Scene{Seq: 1, Scale: 1}
794+ select {
795+ case err := <-done:
796+ if !errors.Is(err, boom) {
797+ t.Fatalf("run error %v", err)
798+ }
799+ case <-time.After(2 * time.Second):
800+ t.Fatal("output did not stop after render failure")
801+ }
802+ select {
803+ case result := <-replies:
804+ if result.ID != 9 || result.Err == nil {
805+ t.Fatalf("capture result %+v", result)
806+ }
807+ default:
808+ t.Fatal("render failure lost capture completion")
809+ }
810+ if _, err := f.Stat(); err == nil {
811+ t.Fatal("render failure leaked capture descriptor")
812+ }
813+}
814+
750 // A request must render even when the output already has an unchanged scene.815 // A request must render even when the output already has an unchanged scene.
751 func TestCaptureForcesDRMComposition(t *testing.T) {816 func TestCaptureForcesDRMComposition(t *testing.T) {
752 o, k, commits, commitMu := testOutputMu(t)817 o, k, commits, commitMu := testOutputMu(t)
@@ -767,7 +832,11 @@ func TestCaptureForcesDRMComposition(t *testing.T) {
767 rendered <- s832 rendered <- s
768 return nil, nil833 return nil, nil
769 })834 })
770- r.EXPECT().Capture(image.Rect(0, 0, 2, 2), mock.MatchedBy(func(p []byte) bool { return len(p) >= 16 }), 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error { copy(dst, []byte{3, 4, 5, 255}); return nil }).Once()835+ frame := portsmocks.NewMockCaptureFrame(t)
836+ frame.EXPECT().Done().Return(nil)
837+ frame.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.MatchedBy(func(p []byte) bool { return len(p) >= 16 }), 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error { copy(dst, []byte{3, 4, 5, 255}); return nil }).Once()
838+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
839+ r.EXPECT().EndCapture(frame).Return().Once()
771 r.EXPECT().Close().Return().Once()840 r.EXPECT().Close().Return().Once()
772 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()841 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
773 k.EXPECT().createBlob(mock.Anything).Return(99, nil)842 k.EXPECT().createBlob(mock.Anything).Return(99, nil)
@@ -813,7 +882,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) {
813 case <-time.After(2 * time.Second):882 case <-time.After(2 * time.Second):
814 t.Fatal("capture did not render")883 t.Fatal("capture did not render")
815 }884 }
816- r.Calls = nil // Testify must not inspect unmapped slice during expectation cleanup.885+ frame.Calls = nil // Testify must not inspect unmapped slice during expectation cleanup.
817 if _, err := f.Stat(); err == nil {886 if _, err := f.Stat(); err == nil {
818 t.Fatal("fd not closed")887 t.Fatal("fd not closed")
819 }888 }
A internal/adapters/headless/capture_bounds_test.go +113 -0
@@ -0,0 +1,113 @@
1+package headless
2+
3+import (
4+ "context"
5+ "image"
6+ "os"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/adapters/capture"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
14+)
15+
16+func TestScreenshotReadbackFailureDoesNotWriteBlackPNG(t *testing.T) {
17+ r := portsmocks.NewMockRenderer(t)
18+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
19+ scenes := make(chan ports.Scene, 1)
20+ scenes <- ports.Scene{Seq: 1, Scale: 1}
21+ dir := t.TempDir()
22+ ctx, cancel := context.WithCancel(context.Background())
23+ defer cancel()
24+ attempted := make(chan struct{})
25+ // Synchronize on the actual readback attempt without assuming timing.
26+ r.EXPECT().Pixels().Run(func() { close(attempted) }).Return(nil).Once()
27+ r.EXPECT().Close().Return().Once()
28+ done := make(chan error, 1)
29+ go func() {
30+ done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil)
31+ }()
32+ select {
33+ case <-attempted:
34+ case <-time.After(time.Second):
35+ t.Fatal("no readback attempt")
36+ }
37+ select {
38+ case err := <-done:
39+ t.Fatalf("readback unavailable stopped output: %v", err)
40+ default:
41+ }
42+ cancel()
43+ if err := <-done; err != nil {
44+ t.Fatal(err)
45+ }
46+ files, err := os.ReadDir(dir)
47+ if err != nil || len(files) != 0 {
48+ t.Fatalf("failed screenshot wrote files: %v (%v)", files, err)
49+ }
50+}
51+
52+func TestCaptureBatchBoundedAndRecycledWhileIdle(t *testing.T) {
53+ r, _ := recordingRenderer(t, nil)
54+ frame := portsmocks.NewMockCaptureFrame(t)
55+ frame.EXPECT().Done().Return(nil)
56+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Times(capture.MaxRequests)
57+ recycled := make(chan struct{})
58+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
59+ r.EXPECT().EndCapture(frame).Run(func(ports.CaptureFrame) { close(recycled) }).Return().Once()
60+ ctx, cancel := context.WithCancel(context.Background())
61+ defer cancel()
62+ incoming := make(chan ports.CaptureRequest)
63+ scenes := make(chan ports.Scene)
64+ replies := make(chan ports.CaptureDone, capture.MaxRequests+1)
65+ done := make(chan error, 1)
66+ go func() {
67+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming)
68+ }()
69+ for i := range capture.MaxRequests + 1 {
70+ f, err := os.CreateTemp(t.TempDir(), "capture")
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ t.Cleanup(func() { _ = f.Close() })
75+ if err := f.Truncate(16); err != nil {
76+ t.Fatal(err)
77+ }
78+ select {
79+ case incoming <- ports.CaptureRequest{ID: uint64(i), Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: ports.SHMBuffer{File: f}}:
80+ case <-time.After(time.Second):
81+ t.Fatal("capture admission blocked")
82+ }
83+ }
84+ select {
85+ case rejected := <-replies:
86+ if rejected.ID != capture.MaxRequests || rejected.Err == nil {
87+ t.Fatalf("batch overflow %+v", rejected)
88+ }
89+ case <-time.After(time.Second):
90+ t.Fatal("batch overflow not answered")
91+ }
92+ scenes <- ports.Scene{Seq: 1, Scale: 1}
93+ for range capture.MaxRequests {
94+ select {
95+ case result := <-replies:
96+ if result.Err != nil {
97+ t.Fatalf("capture %+v", result)
98+ }
99+ case <-time.After(time.Second):
100+ t.Fatal("accepted capture not answered")
101+ }
102+ }
103+ select {
104+ case <-recycled:
105+ case <-time.After(time.Second):
106+ t.Fatal("idle output did not return readback lease")
107+ }
108+ cancel()
109+ if err := <-done; err != nil {
110+ t.Fatal(err)
111+ }
112+ frame.Calls = nil
113+}
M internal/adapters/headless/output.go +18 -6
@@ -76,10 +76,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
76 var scene ports.Scene76 var scene ports.Scene
77 haveScene, dirty := false, false77 haveScene, dirty := false, false
78 frame := 078 frame := 0
79- var requests []ports.CaptureRequest79+ var requestStorage [capture.MaxRequests]ports.CaptureRequest
80+ requests := requestStorage[:0]
80 ctx, cancelCaptures := context.WithCancel(ctx)81 ctx, cancelCaptures := context.WithCancel(ctx)
82+ pipeline := capture.NewPipeline(ctx, opts.Captured)
81 defer func() {83 defer func() {
82 cancelCaptures()84 cancelCaptures()
85+ pipeline.Close(r)
83 for _, q := range requests {86 for _, q := range requests {
84 capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)87 capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
85 }88 }
@@ -123,9 +126,14 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
123 select {126 select {
124 case <-ctx.Done():127 case <-ctx.Done():
125 return nil128 return nil
129+ case b := <-pipeline.Completed():
130+ pipeline.Recycle(b, r)
131+ continue
126 case q := <-incoming:132 case q := <-incoming:
127 if scene.Off {133 if scene.Off {
128 capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)134 capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
135+ } else if len(requests) == cap(requests) {
136+ capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), opts.Captured)
129 } else {137 } else {
130 requests = append(requests, q)138 requests = append(requests, q)
131 dirty = haveScene139 dirty = haveScene
@@ -180,7 +188,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
180 for _, q := range requests {188 for _, q := range requests {
181 capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)189 capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
182 }190 }
183- requests = nil191+ clear(requests)
192+ requests = requestStorage[:0]
184 }193 }
185 if !haveScene || !dirty || scene.Off {194 if !haveScene || !dirty || scene.Off {
186 // Contents not drawn are still read: report them. An output195 // Contents not drawn are still read: report them. An output
@@ -217,14 +226,17 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
217 if len(requests) > 0 {226 if len(requests) > 0 {
218 opts.Log.Debug().Str("output", opts.Name).Int("captures", len(requests)).Msg("capture frame composed")227 opts.Log.Debug().Str("output", opts.Name).Int("captures", len(requests)).Msg("capture frame composed")
219 }228 }
220- for _, q := range requests {229+ pipeline.Submit(r, requests)
221- capture.Write(ctx, q, r, opts.Captured)230+ clear(requests)
222- }231+ requests = requestStorage[:0]
223- requests = nil
224 frame++232 frame++
225 pending = opts.flipped(pending, seen, scene, surfaces)233 pending = opts.flipped(pending, seen, scene, surfaces)
226 if opts.ScreenshotDir != "" {234 if opts.ScreenshotDir != "" {
227 shot := r.Pixels()235 shot := r.Pixels()
236+ if shot == nil {
237+ opts.Log.Debug().Str("component", "headless").Str("output", opts.Name).Int("frame", frame).Msg("screenshot readback unavailable; frame skipped")
238+ continue
239+ }
228 if opts.Cursor != nil {240 if opts.Cursor != nil {
229 opts.Cursor.draw(shot)241 opts.Cursor.draw(shot)
230 }242 }
M internal/adapters/headless/output_test.go +6 -2
@@ -252,7 +252,11 @@ func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
252 replies := make(chan ports.CaptureDone, 1)252 replies := make(chan ports.CaptureDone, 1)
253 r, frames := recordingRenderer(t, nil)253 r, frames := recordingRenderer(t, nil)
254 254
255- r.EXPECT().Capture(image.Rect(0, 0, 2, 2), mock.MatchedBy(func(p []byte) bool { return len(p) >= 16 }), 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error { copy(dst, []byte{1, 2, 3, 255}); return nil }).Once()255+ frame := portsmocks.NewMockCaptureFrame(t)
256+ frame.EXPECT().Done().Return(nil)
257+ frame.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.MatchedBy(func(p []byte) bool { return len(p) >= 16 }), 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error { copy(dst, []byte{1, 2, 3, 255}); return nil }).Once()
258+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
259+ r.EXPECT().EndCapture(frame).Return().Once()
256 ctx, cancel := context.WithCancel(context.Background())260 ctx, cancel := context.WithCancel(context.Background())
257 done := make(chan error, 1)261 done := make(chan error, 1)
258 go func() {262 go func() {
@@ -276,7 +280,7 @@ func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
276 case <-time.After(3 * time.Second):280 case <-time.After(3 * time.Second):
277 t.Fatal("no capture")281 t.Fatal("no capture")
278 }282 }
279- r.Calls = nil // Testify must not inspect unmapped slice during expectation cleanup.283+ frame.Calls = nil // Testify must not inspect unmapped slice during expectation cleanup.
280 if _, err := f.Stat(); err == nil {284 if _, err := f.Stat(); err == nil {
281 t.Fatal("descriptor not closed")285 t.Fatal("descriptor not closed")
282 }286 }
A internal/adapters/vulkan/capture.go +641 -0
@@ -0,0 +1,641 @@
1+package vulkan
2+
3+import (
4+ _ "embed"
5+ "errors"
6+ "fmt"
7+ "image"
8+ "math"
9+ "os"
10+ "unsafe"
11+
12+ "github.com/bnema/neferwl/internal/ports"
13+ vk "github.com/bnema/purego-vulkan/vulkan"
14+)
15+
16+//go:generate glslc -O --target-env=vulkan1.3 shaders/capture_sdr.frag -o shaders/capture_sdr.frag.spv
17+
18+//go:embed shaders/capture_sdr.frag.spv
19+var captureFrag []byte
20+
21+// Asynchronous capture (screencopy). BeginCapture records a copy of the
22+// last frame into one of captureSlots host-visible buffers and submits it
23+// on the frame queue right after the frame, signalling a fence and a
24+// semaphore exported as a sync file. A worker goroutine waits on the sync
25+// file and reads the mapped buffer; the owner never waits for the GPU.
26+// A slot is leased from BeginCapture to EndCapture; a returned slot is
27+// reused only once its fence polls signalled, so an early return (worker
28+// cancelled) never lets a running copy be recorded over.
29+//
30+// HDR frames are converted on the GPU by the capture pass (capture_sdr
31+// shader) into a reusable B8G8R8A8 image before the copy.
32+
33+// captureSlots is how many captures may be leased at once.
34+const captureSlots = 2
35+
36+// captureMaxBytes caps the host memory of one renderer's capture slots
37+// (captureSlots × width × height × 4): 128 MiB holds two 5K slots;
38+// larger outputs refuse captures.
39+const captureMaxBytes = 128 << 20
40+
41+// captureBytesPerPixel is the size of one sRGB8 BGRA capture pixel.
42+const captureBytesPerPixel = 4
43+
44+// captureCurve is the tone map of the capture pass on the largest
45+// component, mirrored from capture_sdr.frag: identity to the knee, then
46+// a rational shoulder (scale captureScale) that never reaches 1.
47+const (
48+ captureKnee = 0.9
49+ captureScale = 0.5
50+)
51+
52+func captureCurve(m float64) float64 {
53+ if m <= captureKnee {
54+ return m
55+ }
56+ t := (m - captureKnee) / captureScale
57+ return captureKnee + (1-captureKnee)*t/(1+t)
58+}
59+
60+// captureSDR maps one linear BT.709 pixel in SDR-white units to the sRGB8
61+// bytes the capture pass produces (tests compare against it).
62+func captureSDR(c [3]float64) [3]uint8 {
63+ for i, v := range c {
64+ if math.IsNaN(v) {
65+ c[i] = 0
66+ }
67+ c[i] = math.Max(-65504, math.Min(65504, c[i]))
68+ }
69+ y := math.Max(0.2126*c[0]+0.7152*c[1]+0.0722*c[2], 0)
70+ lo := math.Min(math.Min(c[0], c[1]), c[2])
71+ if lo < 0 {
72+ t := math.Min(1, -lo/math.Max(y-lo, 1e-6))
73+ for i := range c {
74+ c[i] += t * (y - c[i])
75+ }
76+ }
77+ m := 0.0
78+ for i := range c {
79+ c[i] = math.Max(c[i], 0)
80+ m = math.Max(m, c[i])
81+ }
82+ if m > captureKnee {
83+ s := captureCurve(m) / m
84+ for i := range c {
85+ c[i] *= s
86+ }
87+ }
88+ var out [3]uint8
89+ for i, v := range c {
90+ out[i] = encodeSRGB8(float32(v))
91+ }
92+ return out
93+}
94+
95+// encodeSRGB8 clips a linear-light value to [0, 1] and encodes it as sRGB.
96+func encodeSRGB8(f float32) byte {
97+ v := float64(f)
98+ if !(v > 0) { // also maps NaN to black
99+ return 0
100+ }
101+ v = math.Min(v, 1)
102+ if v <= .0031308 {
103+ v *= 12.92
104+ } else {
105+ v = 1.055*math.Pow(v, 1/2.4) - .055
106+ }
107+ return byte(math.Round(v * 255))
108+}
109+
110+// captureSync is the fence and semaphore surface of a capture slot: the
111+// non-blocking poll, the sync file export and the semaphore replacement
112+// (a seam for tests; the device implements it).
113+type captureSync interface {
114+ // fenceStatus reports Success (signalled), NotReady/Timeout (still
115+ // running) or an error, without waiting.
116+ fenceStatus(f vk.Fence) vk.Result
117+ resetFence(f vk.Fence) vk.Result
118+ // exportSyncFD exports the semaphore's pending signal as a sync file
119+ // (-1: already signalled).
120+ exportSyncFD(s vk.Semaphore) (int32, vk.Result)
121+ newSemaphore() (vk.Semaphore, error)
122+ destroySemaphore(s vk.Semaphore)
123+}
124+
125+// deviceSync is the captureSync of the renderer's device.
126+type deviceSync struct{ r *Renderer }
127+
128+func (d deviceSync) fenceStatus(f vk.Fence) vk.Result {
129+ // A zero timeout only reports the fence state (the bindings lack
130+ // vkGetFenceStatus): Timeout while the copy runs.
131+ return d.r.dd.WaitForFences(d.r.device, 1, &f, 1, 0)
132+}
133+
134+func (d deviceSync) resetFence(f vk.Fence) vk.Result {
135+ return d.r.dd.ResetFences(d.r.device, 1, &f)
136+}
137+
138+func (d deviceSync) exportSyncFD(s vk.Semaphore) (int32, vk.Result) {
139+ var fd int32
140+ get := vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit}
141+ res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &get, &fd)
142+ return fd, res
143+}
144+
145+func (d deviceSync) newSemaphore() (vk.Semaphore, error) {
146+ export := vk.ExportSemaphoreCreateInfo{SType: vk.StructureTypeExportSemaphoreCreateInfo, HandleTypes: vk.ExternalSemaphoreHandleTypeSyncFDBit}
147+ si := vk.SemaphoreCreateInfo{SType: vk.StructureTypeSemaphoreCreateInfo, Next: unsafe.Pointer(&export)}
148+ var s vk.Semaphore
149+ err := checked("vkCreateSemaphore(capture)", d.r.dd.CreateSemaphore(d.r.device, &si, nil, &s))
150+ return s, err
151+}
152+
153+func (d deviceSync) destroySemaphore(s vk.Semaphore) { d.r.dd.DestroySemaphore(d.r.device, s, nil) }
154+
155+// captureSlot is one readback buffer with its copy command and sync.
156+type captureSlot struct {
157+ buffer vk.Buffer
158+ memory vk.DeviceMemory
159+ mapped unsafe.Pointer
160+ cmd vk.CommandBuffer
161+ fence vk.Fence // signalled by the copy; polled before reuse
162+ done vk.Semaphore // signalled by the copy; exported as the sync file
163+ // leased: between BeginCapture and EndCapture. pending: submitted,
164+ // fence not yet seen signalled (set with leased, cleared by a poll).
165+ leased, pending bool
166+ // replaceDone: the last export failed, so done may stay signalled;
167+ // it is replaced once the fence polls signalled, before reuse.
168+ replaceDone bool
169+ // frame is the slot's lease object, reused for every lease: a
170+ // handle kept after EndCapture is invalid by contract (its next
171+ // state belongs to another lease).
172+ frame captureFrame
173+}
174+
175+// captureFrame is the lease handed to the worker (ports.CaptureFrame).
176+// Its fields are written by the owner before the handoff and read by the
177+// worker; the owner touches them again only after EndCapture.
178+type captureFrame struct {
179+ r *Renderer
180+ slot *captureSlot
181+ done *os.File
182+ pixels []byte // the slot's mapping, width*height*4
183+ w, h int
184+ valid bool // false after EndCapture
185+}
186+
187+func (f *captureFrame) Done() *os.File { return f.done }
188+
189+// Read copies region from the mapped slot as opaque BGRA rows.
190+func (f *captureFrame) Read(region image.Rectangle, dst []byte, stride int) error {
191+ if !f.valid {
192+ return errors.New("capture frame ended")
193+ }
194+ w, h := region.Dx(), region.Dy()
195+ need := w * captureBytesPerPixel
196+ if region.Empty() || !region.In(image.Rect(0, 0, f.w, f.h)) || stride < need || len(dst) < need {
197+ return errors.New("invalid capture region or destination")
198+ }
199+ // The last row must fit: (h-1)*stride + need <= len(dst), checked by
200+ // division so a huge stride cannot overflow the product.
201+ if h > 1 && stride > (len(dst)-need)/(h-1) {
202+ return errors.New("capture destination too small for stride")
203+ }
204+ src := f.pixels
205+ for y := range h {
206+ start := ((region.Min.Y+y)*f.w + region.Min.X) * captureBytesPerPixel
207+ row := dst[y*stride : y*stride+w*captureBytesPerPixel]
208+ copy(row, src[start:start+w*captureBytesPerPixel])
209+ // The output is opaque: x-format client buffers leave alpha undefined.
210+ for x := 3; x < len(row); x += captureBytesPerPixel {
211+ row[x] = 255
212+ }
213+ }
214+ return nil
215+}
216+
217+// captureSize is the bytes of one slot, or an error over the cap.
218+func (r *Renderer) captureSize() (vk.DeviceSize, error) {
219+ size := uint64(r.width) * uint64(r.height) * captureBytesPerPixel
220+ if size == 0 || size > math.MaxInt || size*captureSlots > captureMaxBytes {
221+ return 0, fmt.Errorf("capture of %dx%d exceeds %d bytes", r.width, r.height, captureMaxBytes)
222+ }
223+ return vk.DeviceSize(size), nil
224+}
225+
226+// createCaptureSlots allocates every slot on the first accepted capture.
227+func (r *Renderer) createCaptureSlots() (err error) {
228+ if r.captures != nil {
229+ return nil
230+ }
231+ size, err := r.captureSize()
232+ if err != nil {
233+ return err
234+ }
235+ d := r.dd
236+ cmds := make([]vk.CommandBuffer, captureSlots)
237+ ai := vk.CommandBufferAllocateInfo{SType: vk.StructureTypeCommandBufferAllocateInfo, CommandPool: r.pool, Level: vk.CommandBufferLevelPrimary, CommandBufferCount: captureSlots}
238+ if err := checked("vkAllocateCommandBuffers(capture)", d.AllocateCommandBuffers(r.device, &ai, &cmds[0])); err != nil {
239+ return err
240+ }
241+ // Every command buffer belongs to a slot before anything can fail,
242+ // so a failure frees them all.
243+ slots := make([]*captureSlot, captureSlots)
244+ for i := range slots {
245+ slots[i] = &captureSlot{cmd: cmds[i]}
246+ }
247+ defer func() {
248+ if err != nil {
249+ for _, s := range slots {
250+ r.freeCaptureSlot(s)
251+ }
252+ }
253+ }()
254+ for _, s := range slots {
255+ bi := vk.BufferCreateInfo{SType: vk.StructureTypeBufferCreateInfo, Size: size, Usage: vk.BufferUsageTransferDstBit, SharingMode: vk.SharingModeExclusive}
256+ if err := checked("vkCreateBuffer(capture)", d.CreateBuffer(r.device, &bi, nil, &s.buffer)); err != nil {
257+ return err
258+ }
259+ var req vk.MemoryRequirements
260+ d.GetBufferMemoryRequirements(r.device, s.buffer, &req)
261+ // The worker reads the whole buffer: prefer cached host memory.
262+ kind, err := r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit|vk.MemoryPropertyHostCachedBit)
263+ if err != nil {
264+ kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)
265+ }
266+ if err != nil {
267+ return err
268+ }
269+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, AllocationSize: req.Size, MemoryTypeIndex: kind}
270+ if err := checked("vkAllocateMemory(capture)", d.AllocateMemory(r.device, &alloc, nil, &s.memory)); err != nil {
271+ return err
272+ }
273+ if err := checked("vkBindBufferMemory(capture)", d.BindBufferMemory(r.device, s.buffer, s.memory, 0)); err != nil {
274+ return err
275+ }
276+ if err := checked("vkMapMemory(capture)", d.MapMemory(r.device, s.memory, 0, size, 0, &s.mapped)); err != nil {
277+ return err
278+ }
279+ fi := vk.FenceCreateInfo{SType: vk.StructureTypeFenceCreateInfo}
280+ if err := checked("vkCreateFence(capture)", d.CreateFence(r.device, &fi, nil, &s.fence)); err != nil {
281+ return err
282+ }
283+ if s.done, err = r.captureSync().newSemaphore(); err != nil {
284+ return err
285+ }
286+ s.frame = captureFrame{r: r, slot: s, w: r.width, h: r.height, pixels: unsafe.Slice((*byte)(s.mapped), int(size))}
287+ }
288+ r.captures = slots
289+ return nil
290+}
291+
292+// captureSync is the slot sync seam: the device unless a test replaced it.
293+func (r *Renderer) captureSync() captureSync {
294+ if r.sync == nil {
295+ r.sync = deviceSync{r}
296+ }
297+ return r.sync
298+}
299+
300+// freeCaptureSlot destroys a slot; the device must be idle for it.
301+func (r *Renderer) freeCaptureSlot(s *captureSlot) {
302+ d := r.dd
303+ s.frame.valid = false
304+ if s.frame.done != nil {
305+ s.frame.done.Close()
306+ s.frame.done = nil
307+ }
308+ if s.done != 0 {
309+ r.captureSync().destroySemaphore(s.done)
310+ }
311+ if s.fence != 0 {
312+ d.DestroyFence(r.device, s.fence, nil)
313+ }
314+ if s.mapped != nil {
315+ d.UnmapMemory(r.device, s.memory)
316+ }
317+ if s.buffer != 0 {
318+ d.DestroyBuffer(r.device, s.buffer, nil)
319+ }
320+ if s.memory != 0 {
321+ d.FreeMemory(r.device, s.memory, nil)
322+ }
323+ if s.cmd != 0 {
324+ d.FreeCommandBuffers(r.device, r.pool, 1, &s.cmd)
325+ }
326+ *s = captureSlot{}
327+}
328+
329+// destroyCaptures frees the slots and the HDR conversion pass. Every
330+// lease is invalid afterwards; the device is idle (Close).
331+func (r *Renderer) destroyCaptures() {
332+ for _, s := range r.captures {
333+ r.freeCaptureSlot(s)
334+ }
335+ r.captures = nil
336+ r.destroyCapturePass()
337+ r.freeTarget(&r.captureSDR)
338+}
339+
340+// freeCapture picks a slot: unleased and, if a copy was submitted, its
341+// fence signalled (polled, never waited). Nil when none is free; an
342+// error when the device fails to report or reset a fence.
343+func (r *Renderer) freeCapture() (*captureSlot, error) {
344+ sync := r.captureSync()
345+ for _, s := range r.captures {
346+ if s.leased {
347+ continue
348+ }
349+ if s.pending {
350+ switch res := sync.fenceStatus(s.fence); res {
351+ case vk.Success:
352+ case vk.Timeout, vk.NotReady:
353+ continue
354+ default:
355+ return nil, checked("vkWaitForFences(capture poll)", res)
356+ }
357+ if err := checked("vkResetFences(capture)", sync.resetFence(s.fence)); err != nil {
358+ return nil, err
359+ }
360+ s.pending = false
361+ }
362+ if s.replaceDone {
363+ // The copy is done. Retry a failed replacement before reuse,
364+ // even if its fence was already reset on the previous attempt.
365+ if s.done != 0 {
366+ sync.destroySemaphore(s.done)
367+ s.done = 0
368+ }
369+ done, err := sync.newSemaphore()
370+ if err != nil {
371+ return nil, err
372+ }
373+ s.done, s.replaceDone = done, false
374+ }
375+ return s, nil
376+ }
377+ return nil, nil
378+}
379+
380+// BeginCapture submits a GPU copy of the last frame and leases its slot.
381+// The lease is valid until EndCapture and no longer: the same object
382+// serves the slot's next lease.
383+func (r *Renderer) BeginCapture() (ports.CaptureFrame, error) {
384+ f, err := r.beginCapture(false)
385+ if err != nil {
386+ return nil, err
387+ }
388+ return f, nil
389+}
390+
391+// beginCapture is BeginCapture; debug (Pixels) skips the sync file, the
392+// caller waits on the slot's fence instead.
393+func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) {
394+ if r.device == 0 || r.dd == nil {
395+ return nil, errors.New("renderer closed")
396+ }
397+ if r.last == nil || r.last.image == 0 {
398+ return nil, errors.New("no rendered frame")
399+ }
400+ if !debug && (!r.syncFD || !r.dd.HasGetSemaphoreFdKHR()) {
401+ return nil, errors.New("capture needs sync file export")
402+ }
403+ if r.submitted != r.frame {
404+ return nil, errors.New("last frame not submitted")
405+ }
406+ if err := r.createCaptureSlots(); err != nil {
407+ return nil, err
408+ }
409+ hdr := r.last == &r.hdrOwn
410+ if hdr {
411+ if err := r.createCapturePass(); err != nil {
412+ return nil, err
413+ }
414+ }
415+ s, err := r.freeCapture()
416+ if err != nil {
417+ return nil, err
418+ }
419+ if s == nil {
420+ return nil, ports.ErrCaptureBusy
421+ }
422+ d := r.dd
423+ cmd := s.cmd
424+ if err := checked("vkResetCommandBuffer(capture)", d.ResetCommandBuffer(cmd, 0)); err != nil {
425+ return nil, err
426+ }
427+ begin := vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit}
428+ if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &begin)); err != nil {
429+ return nil, err
430+ }
431+ if hdr {
432+ r.recordCaptureHDR(cmd, s)
433+ } else {
434+ r.recordCaptureCopy(cmd, r.last, r.last.layout, s)
435+ }
436+ if err := checked("vkEndCommandBuffer(capture)", d.EndCommandBuffer(cmd)); err != nil {
437+ return nil, err
438+ }
439+ // After the frame on the same queue: the copy reads what it drew, and
440+ // the next frame's first barrier (transfer source stage) orders its
441+ // writes after this read.
442+ submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd}
443+ if !debug {
444+ // The export below takes the signal; a debug capture waits on
445+ // the fence only, so the semaphore must not be signalled (a
446+ // binary semaphore is signalled once, then waited or exported).
447+ submit.SignalSemaphoreCount, submit.SignalSemaphores = 1, &s.done
448+ }
449+ if err := checked("vkQueueSubmit(capture)", d.QueueSubmit(r.queue, 1, &submit, s.fence)); err != nil {
450+ return nil, err
451+ }
452+ s.pending = true
453+ f := &s.frame
454+ f.done = nil
455+ if !debug {
456+ fd, res := r.captureSync().exportSyncFD(s.done)
457+ if res != vk.Success {
458+ // The copy runs untracked by any sync file: the slot stays
459+ // pending until its fence polls signalled, and only then is
460+ // the semaphore (still to be signalled) replaced.
461+ s.replaceDone = true
462+ return nil, checked("vkGetSemaphoreFdKHR(capture)", res)
463+ }
464+ if fd >= 0 {
465+ // -1: already signalled at export, nothing to wait for.
466+ f.done = os.NewFile(uintptr(fd), "neferwl-capture")
467+ if f.done == nil {
468+ return nil, fmt.Errorf("invalid capture sync file %d", fd)
469+ }
470+ }
471+ }
472+ s.leased, f.valid = true, true
473+ return f, nil
474+}
475+
476+// EndCapture returns a lease. The slot's copy may still run: it stays
477+// pending until a later BeginCapture polls its fence.
478+func (r *Renderer) EndCapture(cf ports.CaptureFrame) {
479+ f, ok := cf.(*captureFrame)
480+ if !ok || f == nil || f.r != r || !f.valid {
481+ return
482+ }
483+ f.valid = false
484+ if f.done != nil {
485+ f.done.Close()
486+ f.done = nil
487+ }
488+ f.slot.leased = false
489+}
490+
491+// debugCapture leases a capture and waits for it on the CPU (slot fence):
492+// Pixels only, never the protocol path. It does not take leased slots.
493+func (r *Renderer) debugCapture() (*captureFrame, error) {
494+ f, err := r.beginCapture(true)
495+ if err != nil {
496+ return nil, err
497+ }
498+ if err := checked("vkWaitForFences(capture)", r.dd.WaitForFences(r.device, 1, &f.slot.fence, 1, math.MaxUint64)); err != nil {
499+ r.EndCapture(f)
500+ return nil, err
501+ }
502+ return f, nil
503+}
504+
505+// recordCaptureCopy copies image t (in layout) into the slot's buffer.
506+// Exported targets are borrowed from the display without a layout
507+// change: the display may be reading them at the same time.
508+func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.ImageLayout, s *captureSlot) {
509+ d := r.dd
510+ if t.exported {
511+ // Acquire from the display. The frame released the image at
512+ // its color attachment stage: this acquire waits for that.
513+ b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange}
514+ d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
515+ }
516+ region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
517+ d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, &region)
518+ hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize}
519+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil)
520+ if t.exported {
521+ b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange}
522+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
523+ }
524+}
525+
526+// recordCaptureHDR converts hdrOwn (TRANSFER_SRC after an HDR frame) into
527+// the sRGB8 capture image with the capture pass, then copies that. hdrOwn
528+// is put back in TRANSFER_SRC, as the next frame expects.
529+func (r *Renderer) recordCaptureHDR(cmd vk.CommandBuffer, s *captureSlot) {
530+ d, p, out := r.dd, &r.capturePass, &r.captureSDR
531+ in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutTransferSrcOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange}
532+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in)
533+ // The previous capture read the image as a transfer source.
534+ b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: out.image, SubresourceRange: colorRange}
535+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)
536+ attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore}
537+ info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment}
538+ d.CmdBeginRendering(cmd, &info)
539+ d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, p.pipeline)
540+ d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, p.layout, 0, 1, &p.set, 0, nil)
541+ d.CmdDraw(cmd, 3, 1, 0, 0)
542+ d.CmdEndRendering(cmd)
543+ in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit
544+ in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal
545+ d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in)
546+ b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, vk.AccessTransferReadBit
547+ b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutTransferSrcOptimal
548+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
549+ r.recordCaptureCopy(cmd, out, vk.ImageLayoutTransferSrcOptimal, s)
550+}
551+
552+// createCapturePass makes the sRGB8 image and the conversion pipeline
553+// once, on the first HDR capture.
554+func (r *Renderer) createCapturePass() (err error) {
555+ if r.capturePass.pipeline != 0 {
556+ return nil
557+ }
558+ if r.hdrOwn.view == 0 {
559+ return errors.New("no HDR composition image")
560+ }
561+ defer func() {
562+ if err != nil {
563+ r.destroyCapturePass()
564+ r.freeTarget(&r.captureSDR)
565+ }
566+ }()
567+ d, p, t := r.dd, &r.capturePass, &r.captureSDR
568+ if t.image == 0 {
569+ ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingOptimal, Usage: targetUsage, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}
570+ if err := checked("vkCreateImage(capture)", d.CreateImage(r.device, &ii, nil, &t.image)); err != nil {
571+ return err
572+ }
573+ var req vk.MemoryRequirements
574+ d.GetImageMemoryRequirements(r.device, t.image, &req)
575+ kind, err := r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyDeviceLocalBit)
576+ if err != nil {
577+ return err
578+ }
579+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, AllocationSize: req.Size, MemoryTypeIndex: kind}
580+ if err := checked("vkAllocateMemory(capture)", d.AllocateMemory(r.device, &alloc, nil, &t.memory)); err != nil {
581+ return err
582+ }
583+ if err := checked("vkBindImageMemory(capture)", d.BindImageMemory(r.device, t.image, t.memory, 0)); err != nil {
584+ return err
585+ }
586+ if err := r.createView(t); err != nil {
587+ return err
588+ }
589+ }
590+ binding := vk.DescriptorSetLayoutBinding{Binding: 0, DescriptorType: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit}
591+ sli := vk.DescriptorSetLayoutCreateInfo{SType: vk.StructureTypeDescriptorSetLayoutCreateInfo, BindingCount: 1, Bindings: &binding}
592+ if err := checked("vkCreateDescriptorSetLayout(capture)", d.CreateDescriptorSetLayout(r.device, &sli, nil, &p.setLayout)); err != nil {
593+ return err
594+ }
595+ pli := vk.PipelineLayoutCreateInfo{SType: vk.StructureTypePipelineLayoutCreateInfo, SetLayoutCount: 1, SetLayouts: &p.setLayout}
596+ if err := checked("vkCreatePipelineLayout(capture)", d.CreatePipelineLayout(r.device, &pli, nil, &p.layout)); err != nil {
597+ return err
598+ }
599+ si := vk.SamplerCreateInfo{SType: vk.StructureTypeSamplerCreateInfo, MagFilter: vk.FilterNearest, MinFilter: vk.FilterNearest, AddressModeU: vk.SamplerAddressModeClampToEdge, AddressModeV: vk.SamplerAddressModeClampToEdge, AddressModeW: vk.SamplerAddressModeClampToEdge}
600+ if err := checked("vkCreateSampler(capture)", d.CreateSampler(r.device, &si, nil, &p.sampler)); err != nil {
601+ return err
602+ }
603+ size := vk.DescriptorPoolSize{Type: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1}
604+ dpi := vk.DescriptorPoolCreateInfo{SType: vk.StructureTypeDescriptorPoolCreateInfo, MaxSets: 1, PoolSizeCount: 1, PoolSizes: &size}
605+ if err := checked("vkCreateDescriptorPool(capture)", d.CreateDescriptorPool(r.device, &dpi, nil, &p.pool)); err != nil {
606+ return err
607+ }
608+ dai := vk.DescriptorSetAllocateInfo{SType: vk.StructureTypeDescriptorSetAllocateInfo, DescriptorPool: p.pool, DescriptorSetCount: 1, SetLayouts: &p.setLayout}
609+ if err := checked("vkAllocateDescriptorSets(capture)", d.AllocateDescriptorSets(r.device, &dai, &p.set)); err != nil {
610+ return err
611+ }
612+ img := vk.DescriptorImageInfo{Sampler: p.sampler, ImageView: r.hdrOwn.view, ImageLayout: vk.ImageLayoutShaderReadOnlyOptimal}
613+ write := vk.WriteDescriptorSet{SType: vk.StructureTypeWriteDescriptorSet, DstSet: p.set, DstBinding: 0, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &img}
614+ d.UpdateDescriptorSets(r.device, 1, &write, 0, nil)
615+ // The pipeline is last: it marks the pass complete.
616+ return r.createGraphicsPipeline(hdrVert, captureFrag, vk.FormatB8g8r8a8Unorm, p.layout, false, &p.pipeline)
617+}
618+
619+// destroyCapturePass frees the conversion pipeline (device idle).
620+func (r *Renderer) destroyCapturePass() {
621+ if r.dd == nil {
622+ return
623+ }
624+ d, p := r.dd, &r.capturePass
625+ if p.pool != 0 {
626+ d.DestroyDescriptorPool(r.device, p.pool, nil)
627+ }
628+ if p.sampler != 0 {
629+ d.DestroySampler(r.device, p.sampler, nil)
630+ }
631+ if p.pipeline != 0 {
632+ d.DestroyPipeline(r.device, p.pipeline, nil)
633+ }
634+ if p.layout != 0 {
635+ d.DestroyPipelineLayout(r.device, p.layout, nil)
636+ }
637+ if p.setLayout != 0 {
638+ d.DestroyDescriptorSetLayout(r.device, p.setLayout, nil)
639+ }
640+ *p = hdrPass{}
641+}
A internal/adapters/vulkan/capture_sync_mock_test.go +301 -0
@@ -0,0 +1,301 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package vulkan
6+
7+import (
8+ "github.com/bnema/purego-vulkan/vulkan"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// newMockcaptureSync creates a new instance of mockcaptureSync. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func newMockcaptureSync(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *mockcaptureSync {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &mockcaptureSync{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// mockcaptureSync is an autogenerated mock type for the captureSync type
36+type mockcaptureSync struct {
37+ mock.Mock
38+}
39+
40+type mockcaptureSync_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *mockcaptureSync) EXPECT() *mockcaptureSync_Expecter {
45+ return &mockcaptureSync_Expecter{mock: &_m.Mock}
46+}
47+
48+// destroySemaphore provides a mock function for the type mockcaptureSync
49+func (_mock *mockcaptureSync) destroySemaphore(s vulkan.Semaphore) {
50+ _mock.Called(s)
51+ return
52+}
53+
54+// mockcaptureSync_destroySemaphore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'destroySemaphore'
55+type mockcaptureSync_destroySemaphore_Call struct {
56+ *mock.Call
57+}
58+
59+// destroySemaphore is a helper method to define mock.On call
60+// - s vulkan.Semaphore
61+func (_e *mockcaptureSync_Expecter) destroySemaphore(s any) *mockcaptureSync_destroySemaphore_Call {
62+ return &mockcaptureSync_destroySemaphore_Call{Call: _e.mock.On("destroySemaphore", s)}
63+}
64+
65+func (_c *mockcaptureSync_destroySemaphore_Call) Run(run func(s vulkan.Semaphore)) *mockcaptureSync_destroySemaphore_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ var arg0 vulkan.Semaphore
68+ if args[0] != nil {
69+ arg0 = args[0].(vulkan.Semaphore)
70+ }
71+ run(
72+ arg0,
73+ )
74+ })
75+ return _c
76+}
77+
78+func (_c *mockcaptureSync_destroySemaphore_Call) Return() *mockcaptureSync_destroySemaphore_Call {
79+ _c.Call.Return()
80+ return _c
81+}
82+
83+func (_c *mockcaptureSync_destroySemaphore_Call) RunAndReturn(run func(s vulkan.Semaphore)) *mockcaptureSync_destroySemaphore_Call {
84+ _c.Run(run)
85+ return _c
86+}
87+
88+// exportSyncFD provides a mock function for the type mockcaptureSync
89+func (_mock *mockcaptureSync) exportSyncFD(s vulkan.Semaphore) (int32, vulkan.Result) {
90+ ret := _mock.Called(s)
91+
92+ if len(ret) == 0 {
93+ panic("no return value specified for exportSyncFD")
94+ }
95+
96+ var r0 int32
97+ var r1 vulkan.Result
98+ if returnFunc, ok := ret.Get(0).(func(vulkan.Semaphore) (int32, vulkan.Result)); ok {
99+ return returnFunc(s)
100+ }
101+ if returnFunc, ok := ret.Get(0).(func(vulkan.Semaphore) int32); ok {
102+ r0 = returnFunc(s)
103+ } else {
104+ r0 = ret.Get(0).(int32)
105+ }
106+ if returnFunc, ok := ret.Get(1).(func(vulkan.Semaphore) vulkan.Result); ok {
107+ r1 = returnFunc(s)
108+ } else {
109+ r1 = ret.Get(1).(vulkan.Result)
110+ }
111+ return r0, r1
112+}
113+
114+// mockcaptureSync_exportSyncFD_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'exportSyncFD'
115+type mockcaptureSync_exportSyncFD_Call struct {
116+ *mock.Call
117+}
118+
119+// exportSyncFD is a helper method to define mock.On call
120+// - s vulkan.Semaphore
121+func (_e *mockcaptureSync_Expecter) exportSyncFD(s any) *mockcaptureSync_exportSyncFD_Call {
122+ return &mockcaptureSync_exportSyncFD_Call{Call: _e.mock.On("exportSyncFD", s)}
123+}
124+
125+func (_c *mockcaptureSync_exportSyncFD_Call) Run(run func(s vulkan.Semaphore)) *mockcaptureSync_exportSyncFD_Call {
126+ _c.Call.Run(func(args mock.Arguments) {
127+ var arg0 vulkan.Semaphore
128+ if args[0] != nil {
129+ arg0 = args[0].(vulkan.Semaphore)
130+ }
131+ run(
132+ arg0,
133+ )
134+ })
135+ return _c
136+}
137+
138+func (_c *mockcaptureSync_exportSyncFD_Call) Return(n int32, result vulkan.Result) *mockcaptureSync_exportSyncFD_Call {
139+ _c.Call.Return(n, result)
140+ return _c
141+}
142+
143+func (_c *mockcaptureSync_exportSyncFD_Call) RunAndReturn(run func(s vulkan.Semaphore) (int32, vulkan.Result)) *mockcaptureSync_exportSyncFD_Call {
144+ _c.Call.Return(run)
145+ return _c
146+}
147+
148+// fenceStatus provides a mock function for the type mockcaptureSync
149+func (_mock *mockcaptureSync) fenceStatus(f vulkan.Fence) vulkan.Result {
150+ ret := _mock.Called(f)
151+
152+ if len(ret) == 0 {
153+ panic("no return value specified for fenceStatus")
154+ }
155+
156+ var r0 vulkan.Result
157+ if returnFunc, ok := ret.Get(0).(func(vulkan.Fence) vulkan.Result); ok {
158+ r0 = returnFunc(f)
159+ } else {
160+ r0 = ret.Get(0).(vulkan.Result)
161+ }
162+ return r0
163+}
164+
165+// mockcaptureSync_fenceStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'fenceStatus'
166+type mockcaptureSync_fenceStatus_Call struct {
167+ *mock.Call
168+}
169+
170+// fenceStatus is a helper method to define mock.On call
171+// - f vulkan.Fence
172+func (_e *mockcaptureSync_Expecter) fenceStatus(f any) *mockcaptureSync_fenceStatus_Call {
173+ return &mockcaptureSync_fenceStatus_Call{Call: _e.mock.On("fenceStatus", f)}
174+}
175+
176+func (_c *mockcaptureSync_fenceStatus_Call) Run(run func(f vulkan.Fence)) *mockcaptureSync_fenceStatus_Call {
177+ _c.Call.Run(func(args mock.Arguments) {
178+ var arg0 vulkan.Fence
179+ if args[0] != nil {
180+ arg0 = args[0].(vulkan.Fence)
181+ }
182+ run(
183+ arg0,
184+ )
185+ })
186+ return _c
187+}
188+
189+func (_c *mockcaptureSync_fenceStatus_Call) Return(result vulkan.Result) *mockcaptureSync_fenceStatus_Call {
190+ _c.Call.Return(result)
191+ return _c
192+}
193+
194+func (_c *mockcaptureSync_fenceStatus_Call) RunAndReturn(run func(f vulkan.Fence) vulkan.Result) *mockcaptureSync_fenceStatus_Call {
195+ _c.Call.Return(run)
196+ return _c
197+}
198+
199+// newSemaphore provides a mock function for the type mockcaptureSync
200+func (_mock *mockcaptureSync) newSemaphore() (vulkan.Semaphore, error) {
201+ ret := _mock.Called()
202+
203+ if len(ret) == 0 {
204+ panic("no return value specified for newSemaphore")
205+ }
206+
207+ var r0 vulkan.Semaphore
208+ var r1 error
209+ if returnFunc, ok := ret.Get(0).(func() (vulkan.Semaphore, error)); ok {
210+ return returnFunc()
211+ }
212+ if returnFunc, ok := ret.Get(0).(func() vulkan.Semaphore); ok {
213+ r0 = returnFunc()
214+ } else {
215+ r0 = ret.Get(0).(vulkan.Semaphore)
216+ }
217+ if returnFunc, ok := ret.Get(1).(func() error); ok {
218+ r1 = returnFunc()
219+ } else {
220+ r1 = ret.Error(1)
221+ }
222+ return r0, r1
223+}
224+
225+// mockcaptureSync_newSemaphore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'newSemaphore'
226+type mockcaptureSync_newSemaphore_Call struct {
227+ *mock.Call
228+}
229+
230+// newSemaphore is a helper method to define mock.On call
231+func (_e *mockcaptureSync_Expecter) newSemaphore() *mockcaptureSync_newSemaphore_Call {
232+ return &mockcaptureSync_newSemaphore_Call{Call: _e.mock.On("newSemaphore")}
233+}
234+
235+func (_c *mockcaptureSync_newSemaphore_Call) Run(run func()) *mockcaptureSync_newSemaphore_Call {
236+ _c.Call.Run(func(args mock.Arguments) {
237+ run()
238+ })
239+ return _c
240+}
241+
242+func (_c *mockcaptureSync_newSemaphore_Call) Return(semaphore vulkan.Semaphore, err error) *mockcaptureSync_newSemaphore_Call {
243+ _c.Call.Return(semaphore, err)
244+ return _c
245+}
246+
247+func (_c *mockcaptureSync_newSemaphore_Call) RunAndReturn(run func() (vulkan.Semaphore, error)) *mockcaptureSync_newSemaphore_Call {
248+ _c.Call.Return(run)
249+ return _c
250+}
251+
252+// resetFence provides a mock function for the type mockcaptureSync
253+func (_mock *mockcaptureSync) resetFence(f vulkan.Fence) vulkan.Result {
254+ ret := _mock.Called(f)
255+
256+ if len(ret) == 0 {
257+ panic("no return value specified for resetFence")
258+ }
259+
260+ var r0 vulkan.Result
261+ if returnFunc, ok := ret.Get(0).(func(vulkan.Fence) vulkan.Result); ok {
262+ r0 = returnFunc(f)
263+ } else {
264+ r0 = ret.Get(0).(vulkan.Result)
265+ }
266+ return r0
267+}
268+
269+// mockcaptureSync_resetFence_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'resetFence'
270+type mockcaptureSync_resetFence_Call struct {
271+ *mock.Call
272+}
273+
274+// resetFence is a helper method to define mock.On call
275+// - f vulkan.Fence
276+func (_e *mockcaptureSync_Expecter) resetFence(f any) *mockcaptureSync_resetFence_Call {
277+ return &mockcaptureSync_resetFence_Call{Call: _e.mock.On("resetFence", f)}
278+}
279+
280+func (_c *mockcaptureSync_resetFence_Call) Run(run func(f vulkan.Fence)) *mockcaptureSync_resetFence_Call {
281+ _c.Call.Run(func(args mock.Arguments) {
282+ var arg0 vulkan.Fence
283+ if args[0] != nil {
284+ arg0 = args[0].(vulkan.Fence)
285+ }
286+ run(
287+ arg0,
288+ )
289+ })
290+ return _c
291+}
292+
293+func (_c *mockcaptureSync_resetFence_Call) Return(result vulkan.Result) *mockcaptureSync_resetFence_Call {
294+ _c.Call.Return(result)
295+ return _c
296+}
297+
298+func (_c *mockcaptureSync_resetFence_Call) RunAndReturn(run func(f vulkan.Fence) vulkan.Result) *mockcaptureSync_resetFence_Call {
299+ _c.Call.Return(run)
300+ return _c
301+}
M internal/adapters/vulkan/capture_test.go +540 -2
@@ -1,23 +1,72 @@
1 package vulkan1 package vulkan
2 2
3 import (3 import (
4+ "context"
5+ "encoding/binary"
6+ "errors"
4 "image"7 "image"
8+ "math"
9+ "os"
10+ "slices"
11+ "strings"
5 "testing"12 "testing"
13+ "unsafe"
6 14
15+ "image/color"
16+
17+ "github.com/bnema/neferwl/internal/adapters/syncfile"
7 "github.com/bnema/neferwl/internal/ports"18 "github.com/bnema/neferwl/internal/ports"
19+ vk "github.com/bnema/purego-vulkan/vulkan"
20+ "github.com/stretchr/testify/mock"
21+ "golang.org/x/sys/unix"
8 )22 )
9 23
24+// requireCaptureSync skips only integration tests that need kernel sync-file
25+// export. Software Vulkan devices can still exercise Pixels and rendering.
26+func requireCaptureSync(t *testing.T, r *Renderer) {
27+ t.Helper()
28+ if !r.syncFD || !r.dd.HasGetSemaphoreFdKHR() {
29+ t.Skip("Vulkan sync-file export unavailable")
30+ }
31+}
32+
33+// captureWait leases a capture of the last frame and waits for it.
34+func captureWait(t *testing.T, r *Renderer) ports.CaptureFrame {
35+ t.Helper()
36+ requireCaptureSync(t, r)
37+ cf, err := r.BeginCapture()
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ if done := cf.Done(); done != nil {
42+ if err := syncfile.Wait(context.Background(), done); err != nil {
43+ t.Fatal(err)
44+ }
45+ }
46+ return cf
47+}
48+
10 func TestCaptureLastFrameRegion(t *testing.T) {49 func TestCaptureLastFrameRegion(t *testing.T) {
11 r, err := New(8, 8)50 r, err := New(8, 8)
12 if err != nil {51 if err != nil {
13 t.Skipf("Vulkan unavailable: %v", err)52 t.Skipf("Vulkan unavailable: %v", err)
14 }53 }
15 defer r.Close()54 defer r.Close()
55+ if _, err := r.BeginCapture(); err == nil {
56+ t.Fatal("capture before any frame accepted")
57+ }
16 if err := render(r, ports.Scene{Background: "#123456"}, nil); err != nil {58 if err := render(r, ports.Scene{Background: "#123456"}, nil); err != nil {
17 t.Fatal(err)59 t.Fatal(err)
18 }60 }
61+ if r.captures != nil {
62+ t.Fatal("slots allocated before the first capture")
63+ }
64+ cf := captureWait(t, r)
65+ if len(r.captures) != captureSlots {
66+ t.Fatalf("%d slots", len(r.captures))
67+ }
19 dst := make([]byte, 24)68 dst := make([]byte, 24)
20- if err := r.Capture(image.Rect(2, 3, 4, 5), dst, 12); err != nil {69+ if err := cf.Read(image.Rect(2, 3, 4, 5), dst, 12); err != nil {
21 t.Fatal(err)70 t.Fatal(err)
22 }71 }
23 for _, at := range []int{0, 4, 12, 16} {72 for _, at := range []int{0, 4, 12, 16} {
@@ -25,7 +74,496 @@ func TestCaptureLastFrameRegion(t *testing.T) {
25 t.Fatalf("pixel %d: %x", at, got)74 t.Fatalf("pixel %d: %x", at, got)
26 }75 }
27 }76 }
28- if err := r.Capture(image.Rect(-1, 0, 1, 1), dst, 12); err == nil {77+ if err := cf.Read(image.Rect(-1, 0, 1, 1), dst, 12); err == nil {
29 t.Fatal("invalid region accepted")78 t.Fatal("invalid region accepted")
30 }79 }
80+ if err := cf.Read(image.Rect(0, 0, 2, 2), dst[:10], 8); err == nil {
81+ t.Fatal("short destination accepted")
82+ }
83+ // A stride near MaxInt must not overflow the row arithmetic.
84+ if err := cf.Read(image.Rect(0, 0, 2, 2), dst, math.MaxInt-1); err == nil {
85+ t.Fatal("overflowing stride accepted")
86+ }
87+ if err := cf.Read(image.Rect(0, 0, 2, 2), dst, math.MaxInt); err == nil {
88+ t.Fatal("MaxInt stride accepted")
89+ }
90+ // A one-row read needs only w*4 bytes whatever the stride.
91+ if err := cf.Read(image.Rect(0, 0, 2, 1), dst[:8], math.MaxInt); err != nil {
92+ t.Fatal(err)
93+ }
94+ r.EndCapture(cf)
95+ if err := cf.Read(image.Rect(2, 3, 4, 5), dst, 12); err == nil {
96+ t.Fatal("read after EndCapture accepted")
97+ }
98+ r.EndCapture(cf) // idempotent
99+}
100+
101+// Both slots leased: the third capture fails at once; returning one
102+// frees it again once its copy finished.
103+func TestCaptureSlotsBounded(t *testing.T) {
104+ r, err := New(8, 8)
105+ if err != nil {
106+ t.Skipf("Vulkan unavailable: %v", err)
107+ }
108+ defer r.Close()
109+ if err := render(r, ports.Scene{Background: "#ff0000"}, nil); err != nil {
110+ t.Fatal(err)
111+ }
112+ a := captureWait(t, r)
113+ b := captureWait(t, r)
114+ if _, err := r.BeginCapture(); !errors.Is(err, ports.ErrCaptureBusy) {
115+ t.Fatalf("third capture: %v", err)
116+ }
117+ r.EndCapture(a)
118+ // a's copy finished (waited above): its fence polls signalled.
119+ c := captureWait(t, r)
120+ dst := make([]byte, 4)
121+ if err := c.Read(image.Rect(0, 0, 1, 1), dst, 4); err != nil {
122+ t.Fatal(err)
123+ }
124+ if dst[2] != 255 || dst[1] != 0 || dst[0] != 0 {
125+ t.Fatalf("pixel %v", dst)
126+ }
127+ r.EndCapture(b)
128+ r.EndCapture(c)
129+ // Pixels never takes a leased slot: with both out it reports nil.
130+ a, b = captureWait(t, r), captureWait(t, r)
131+ if img := r.Pixels(); img != nil {
132+ t.Fatal("Pixels stole a leased slot")
133+ }
134+ r.EndCapture(a)
135+ r.EndCapture(b)
136+ if img := r.Pixels(); img == nil || img.RGBAAt(0, 0) != (color.RGBA{255, 0, 0, 255}) {
137+ t.Fatalf("Pixels after release: %v", img)
138+ }
139+ // Frames of a closed renderer are invalid, and Close survives leases
140+ // still out.
141+ d := captureWait(t, r)
142+ r.Close()
143+ if err := d.Read(image.Rect(0, 0, 1, 1), dst, 4); err == nil {
144+ t.Fatal("read after Close accepted")
145+ }
146+ r.EndCapture(d)
147+}
148+
149+// A returned slot whose copy has not finished is not reused: the poll
150+// sees the fence unsignalled. Simulated by marking the slot pending with
151+// its fence reset.
152+func TestCaptureReturnedSlotWaitsForFence(t *testing.T) {
153+ r, err := New(8, 8)
154+ if err != nil {
155+ t.Skipf("Vulkan unavailable: %v", err)
156+ }
157+ defer r.Close()
158+ if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
159+ t.Fatal(err)
160+ }
161+ a := captureWait(t, r)
162+ b := captureWait(t, r)
163+ r.EndCapture(a)
164+ r.EndCapture(b)
165+ for _, s := range r.captures {
166+ // The copies finished (waited above); reset only signalled fences.
167+ if err := checked("vkWaitForFences", r.dd.WaitForFences(r.device, 1, &s.fence, 1, math.MaxUint64)); err != nil {
168+ t.Fatal(err)
169+ }
170+ if err := checked("vkResetFences", r.dd.ResetFences(r.device, 1, &s.fence)); err != nil {
171+ t.Fatal(err)
172+ }
173+ s.pending = true
174+ }
175+ if _, err := r.BeginCapture(); !errors.Is(err, ports.ErrCaptureBusy) {
176+ t.Fatalf("pending slots reused: %v", err)
177+ }
178+ // Signal them again with an empty submit, then the poll frees them.
179+ for _, s := range r.captures {
180+ if err := checked("vkQueueSubmit", r.dd.QueueSubmit(r.queue, 0, nil, s.fence)); err != nil {
181+ t.Fatal(err)
182+ }
183+ if err := checked("vkWaitForFences", r.dd.WaitForFences(r.device, 1, &s.fence, 1, math.MaxUint64)); err != nil {
184+ t.Fatal(err)
185+ }
186+ }
187+ c := captureWait(t, r)
188+ r.EndCapture(c)
189+ if _, err := r.captureSize(); err != nil {
190+ t.Fatal(err)
191+ }
192+ if r.captures[0].fence == 0 || r.captures[0].done == vk.Semaphore(0) {
193+ t.Fatal("slot objects missing")
194+ }
195+}
196+
197+// A device without sync-file export refuses protocol capture before allocating
198+// slots, but keeps synchronous debug screenshots functional (e.g. lavapipe).
199+func TestCaptureWithoutSyncFileExport(t *testing.T) {
200+ r, err := New(8, 8)
201+ if err != nil {
202+ t.Skipf("Vulkan unavailable: %v", err)
203+ }
204+ defer r.Close()
205+ r.syncFD = false
206+ if err := render(r, ports.Scene{Background: "#123456"}, nil); err != nil {
207+ t.Fatal(err)
208+ }
209+ if _, err := r.BeginCapture(); err == nil || !strings.Contains(err.Error(), "sync file export") {
210+ t.Fatalf("capture without sync-file export: %v", err)
211+ }
212+ if r.captures != nil {
213+ t.Fatal("unsupported capture allocated slots")
214+ }
215+ pixels := r.Pixels()
216+ if pixels == nil || pixels.RGBAAt(0, 0) != (color.RGBA{0x12, 0x34, 0x56, 255}) {
217+ t.Fatalf("debug screenshot without sync-file export: %v", pixels)
218+ }
219+}
220+
221+// The capture size cap admits two 5K slots and rejects 8K.
222+func TestCaptureSizeCap(t *testing.T) {
223+ r := &Renderer{width: 5120, height: 2880}
224+ if _, err := r.captureSize(); err != nil {
225+ t.Fatalf("5K rejected: %v", err)
226+ }
227+ r.width, r.height = 7680, 4320
228+ if _, err := r.captureSize(); err == nil {
229+ t.Fatal("8K accepted")
230+ }
231+ r.width, r.height = 0, 4320
232+ if _, err := r.captureSize(); err == nil {
233+ t.Fatal("empty accepted")
234+ }
235+}
236+
237+// captureSDR follows the documented curve: exact below the knee, a
238+// monotonic shoulder above it that never saturates, hue kept, and
239+// negative components desaturated toward luminance.
240+func TestCaptureCurve(t *testing.T) {
241+ for _, x := range []float64{0, 0.25, 0.5, 0.9} {
242+ if got := captureCurve(x); got != x {
243+ t.Fatalf("curve(%v) = %v", x, got)
244+ }
245+ }
246+ prev := captureCurve(0.9)
247+ for _, x := range []float64{1, 1.5, 2, 4.93, 10, 100} {
248+ got := captureCurve(x)
249+ if got <= prev || got >= 1 {
250+ t.Fatalf("curve(%v) = %v after %v", x, got, prev)
251+ }
252+ prev = got
253+ }
254+ // Diffuse white stays bright; 2x and 5x (1000 nits) white stay
255+ // distinct from each other and from saturation.
256+ white := captureSDR([3]float64{1, 1, 1})
257+ twice := captureSDR([3]float64{2, 2, 2})
258+ peak := captureSDR([3]float64{4.93, 4.93, 4.93})
259+ if white[0] < 240 || white[0] != white[1] || white[1] != white[2] {
260+ t.Fatalf("diffuse white %v", white)
261+ }
262+ if !(white[0] < twice[0] && twice[0] < peak[0] && peak[0] < 255) {
263+ t.Fatalf("shoulder white %v 2x %v 5x %v", white, twice, peak)
264+ }
265+ // Bright red keeps its hue: green and blue stay zero.
266+ if px := captureSDR([3]float64{4, 0, 0}); px[1] != 0 || px[2] != 0 || px[0] <= captureSDR([3]float64{0.9, 0, 0})[0] {
267+ t.Fatalf("bright red %v", px)
268+ }
269+ // A BT.2020 red outside BT.709 (negative green and blue) is mixed
270+ // toward its luminance until the most negative component (green)
271+ // is zero; blue rises a little above zero, red stays dominant.
272+ in := [3]float64{1.2, -0.1, -0.05}
273+ px := captureSDR(in)
274+ if px[1] != 0 || px[2] == 0 || px[2] > 60 || px[0] < 200 {
275+ t.Fatalf("out-of-gamut red %v", px)
276+ }
277+ // The desaturation keeps luminance: the sRGB-decoded result has the
278+ // input's Y (0.18) within rounding.
279+ dec := func(b uint8) float64 {
280+ v := float64(b) / 255
281+ if v <= 0.04045 {
282+ return v / 12.92
283+ }
284+ return math.Pow((v+0.055)/1.055, 2.4)
285+ }
286+ if y := 0.2126*dec(px[0]) + 0.7152*dec(px[1]) + 0.0722*dec(px[2]); math.Abs(y-0.18) > 0.01 {
287+ t.Fatalf("luminance %.3f after gamut reduction of %v", y, px)
288+ }
289+ if nan := captureSDR([3]float64{math.NaN(), math.Inf(1), -1}); nan[0] != 0 || nan[1] < 250 {
290+ t.Fatalf("NaN/Inf %v", nan)
291+ }
292+}
293+
294+// The lease object of a slot is reused: a steady state of captures
295+// allocates only the sync file, and a stale handle reports invalid.
296+func TestCaptureLeaseReused(t *testing.T) {
297+ r, err := New(8, 8)
298+ if err != nil {
299+ t.Skipf("Vulkan unavailable: %v", err)
300+ }
301+ defer r.Close()
302+ if err := render(r, ports.Scene{Background: "#0000ff"}, nil); err != nil {
303+ t.Fatal(err)
304+ }
305+ first := captureWait(t, r)
306+ r.EndCapture(first)
307+ second := captureWait(t, r)
308+ if second != first {
309+ t.Fatal("lease object not reused")
310+ }
311+ r.EndCapture(second)
312+ cycle := func() {
313+ cf, err := r.BeginCapture()
314+ if err != nil {
315+ t.Fatal(err)
316+ }
317+ if done := cf.Done(); done != nil {
318+ if err := syncfile.Wait(context.Background(), done); err != nil {
319+ t.Fatal(err)
320+ }
321+ }
322+ r.EndCapture(cf)
323+ }
324+ cycle()
325+ // No lease wrapper: the allocations left are the Vulkan structs the
326+ // FFI boundary keeps on the heap (as in Render, budget 16) plus the
327+ // sync file (os.NewFile and its finalizer).
328+ if allocs := testing.AllocsPerRun(10, cycle); allocs > 16 {
329+ t.Errorf("%.1f allocations per capture, want <= 16", allocs)
330+ }
331+}
332+
333+// A failed sync file export fails the capture without waiting: the slot
334+// stays pending, its semaphore is kept until the fence polls signalled,
335+// and only then replaced.
336+func TestCaptureExportFailureKeepsSlotPending(t *testing.T) {
337+ r, err := New(8, 8)
338+ if err != nil {
339+ t.Skipf("Vulkan unavailable: %v", err)
340+ }
341+ defer r.Close()
342+ requireCaptureSync(t, r)
343+ if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
344+ t.Fatal(err)
345+ }
346+ m := newMockcaptureSync(t)
347+ r.sync = m
348+ dev := deviceSync{r}
349+ // Slots are created on this first capture: their semaphores too.
350+ m.EXPECT().newSemaphore().RunAndReturn(dev.newSemaphore).Times(captureSlots)
351+ m.EXPECT().exportSyncFD(mock.Anything).Return(-1, vk.ErrorOutOfHostMemory).Once()
352+ if _, err := r.BeginCapture(); err == nil || errors.Is(err, ports.ErrCaptureBusy) {
353+ t.Fatalf("export failure: %v", err)
354+ }
355+ s := r.captures[0]
356+ old := s.done
357+ if !s.pending || s.leased || !s.replaceDone || s.done == 0 {
358+ t.Fatalf("slot after export failure: leased=%v pending=%v replace=%v", s.leased, s.pending, s.replaceDone)
359+ }
360+ // While the copy runs the slot is skipped and its semaphore is not
361+ // touched (no destroy expected yet).
362+ m.EXPECT().fenceStatus(s.fence).Return(vk.Timeout).Once()
363+ m.EXPECT().exportSyncFD(mock.Anything).RunAndReturn(dev.exportSyncFD).Once()
364+ second := captureWait(t, r) // the other slot
365+ if second.(*captureFrame).slot == s {
366+ t.Fatal("pending slot reused")
367+ }
368+ r.EndCapture(second)
369+ // Once the fence polls signalled the semaphore is replaced (the old
370+ // one destroyed only now) and the slot works again.
371+ if err := checked("vkWaitForFences", r.dd.WaitForFences(r.device, 1, &s.fence, 1, math.MaxUint64)); err != nil {
372+ t.Fatal(err)
373+ }
374+ m.EXPECT().fenceStatus(s.fence).RunAndReturn(dev.fenceStatus).Once()
375+ m.EXPECT().resetFence(s.fence).RunAndReturn(dev.resetFence).Once()
376+ m.EXPECT().destroySemaphore(old).Run(dev.destroySemaphore).Once()
377+ boom := errors.New("replacement failed")
378+ m.EXPECT().newSemaphore().Return(vk.Semaphore(0), boom).Once()
379+ if _, err := r.BeginCapture(); !errors.Is(err, boom) {
380+ t.Fatalf("semaphore replacement error: %v", err)
381+ }
382+ if s.pending || !s.replaceDone || s.done != 0 {
383+ t.Fatal("failed replacement lost retry state")
384+ }
385+ // Retry after the fence was already reset. No second destruction or
386+ // fence poll is needed, and no null semaphore may be submitted.
387+ m.EXPECT().newSemaphore().RunAndReturn(dev.newSemaphore).Once()
388+ m.EXPECT().exportSyncFD(mock.Anything).RunAndReturn(dev.exportSyncFD).Once()
389+ third := captureWait(t, r)
390+ if third.(*captureFrame).slot != s || s.replaceDone {
391+ t.Fatal("recovered slot not reused")
392+ }
393+ r.EndCapture(third)
394+ // Close destroys both semaphores.
395+ m.EXPECT().destroySemaphore(mock.Anything).Run(dev.destroySemaphore).Times(captureSlots)
396+}
397+
398+// A device error from the fence poll or reset propagates instead of
399+// being taken as busy.
400+func TestCapturePollErrorPropagates(t *testing.T) {
401+ r, err := New(8, 8)
402+ if err != nil {
403+ t.Skipf("Vulkan unavailable: %v", err)
404+ }
405+ defer r.Close()
406+ if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
407+ t.Fatal(err)
408+ }
409+ a := captureWait(t, r)
410+ r.EndCapture(a)
411+ m := newMockcaptureSync(t)
412+ r.sync = m
413+ m.EXPECT().fenceStatus(mock.Anything).Return(vk.ErrorDeviceLost).Once()
414+ if _, err := r.BeginCapture(); err == nil || errors.Is(err, ports.ErrCaptureBusy) {
415+ t.Fatalf("poll error: %v", err)
416+ }
417+ m.EXPECT().fenceStatus(mock.Anything).Return(vk.Success).Once()
418+ m.EXPECT().resetFence(mock.Anything).Return(vk.ErrorOutOfDeviceMemory).Once()
419+ if _, err := r.BeginCapture(); err == nil || errors.Is(err, ports.ErrCaptureBusy) {
420+ t.Fatalf("reset error: %v", err)
421+ }
422+ // Close destroys the semaphores through the seam.
423+ m.EXPECT().destroySemaphore(mock.Anything).Run(deviceSync{r}.destroySemaphore).Times(captureSlots)
424+}
425+
426+// An fp16 client with NaN, +Inf and negative texels: the capture pass
427+// maps NaN to black, bounds infinity to the shoulder's ceiling and lifts
428+// the negative one to black, on the GPU as in captureSDR.
429+func TestCaptureHDRNaNInf(t *testing.T) {
430+ r := hdrTestRenderer(t)
431+ format := ports.DMABufFormat{Format: fourcc('A', 'B', '4', 'H'), Modifier: 0}
432+ if !slices.Contains(r.DMABuf().Formats, format) {
433+ t.Skip("ABGR16161616F linear import unavailable")
434+ }
435+ // x<20: NaN; 20..40: +Inf; 40..: -2.0. Alpha 1.
436+ texel := func(x int) uint16 {
437+ switch {
438+ case x < 20:
439+ return 0x7e00
440+ case x < 40:
441+ return 0x7c00
442+ }
443+ return 0xc000
444+ }
445+ f := udmabuf16(t, 64, 16, texel)
446+ buf := &ports.DMABuf{ID: 165, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 512}}}
447+ c := ports.SurfaceContent{ID: 1, Width: 64, Height: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFExtendedLinear, Primaries: ports.ColorPrimariesSRGB}}
448+ scene := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
449+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
450+ t.Fatal(err)
451+ }
452+ img := readPixels(t, r)
453+ for _, tc := range []struct {
454+ x int
455+ want [3]uint8
456+ }{
457+ {5, captureSDR([3]float64{math.NaN(), math.NaN(), math.NaN()})},
458+ {25, captureSDR([3]float64{math.Inf(1), math.Inf(1), math.Inf(1)})},
459+ {45, captureSDR([3]float64{-2, -2, -2})},
460+ } {
461+ got := img.RGBAAt(tc.x, 8)
462+ if !near(got, color.RGBA{tc.want[0], tc.want[1], tc.want[2], 255}, 2) {
463+ t.Errorf("x=%d got %v want %v", tc.x, got, tc.want)
464+ }
465+ }
466+ if img.RGBAAt(5, 8) != (color.RGBA{0, 0, 0, 255}) || img.RGBAAt(45, 8) != (color.RGBA{0, 0, 0, 255}) {
467+ t.Errorf("NaN %v / negative %v not black", img.RGBAAt(5, 8), img.RGBAAt(45, 8))
468+ }
469+ if inf := img.RGBAAt(25, 8); inf.R != 255 {
470+ // The shoulder's ceiling rounds to 255 at infinity.
471+ t.Errorf("+Inf %v", inf)
472+ }
473+}
474+
475+// udmabuf16 is a linear RGBA16F client buffer whose RGB texels are v(x).
476+func udmabuf16(t *testing.T, w, h int, v func(x int) uint16) *os.File {
477+ t.Helper()
478+ pixels := make([]byte, w*h*8)
479+ for y := range h {
480+ for x := range w {
481+ i := (y*w + x) * 8
482+ for ch := range 3 {
483+ binary.LittleEndian.PutUint16(pixels[i+ch*2:], v(x))
484+ }
485+ binary.LittleEndian.PutUint16(pixels[i+6:], 0x3c00)
486+ }
487+ }
488+ mem, err := unix.MemfdCreate("fp16-client", unix.MFD_ALLOW_SEALING)
489+ if err != nil {
490+ t.Fatal(err)
491+ }
492+ defer unix.Close(mem)
493+ if err := unix.Ftruncate(mem, int64(len(pixels))); err != nil {
494+ t.Fatal(err)
495+ }
496+ if _, err := unix.Pwrite(mem, pixels, 0); err != nil {
497+ t.Fatal(err)
498+ }
499+ if _, err := unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK); err != nil {
500+ t.Fatal(err)
501+ }
502+ dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)
503+ if err != nil {
504+ t.Skipf("udmabuf unavailable: %v", err)
505+ }
506+ defer dev.Close()
507+ arg := struct {
508+ memfd, flags uint32
509+ offset, size uint64
510+ }{memfd: uint32(mem), flags: 1, size: uint64(len(pixels))}
511+ fd, _, errno := unix.Syscall(unix.SYS_IOCTL, dev.Fd(), 0x40187542, uintptr(unsafe.Pointer(&arg)))
512+ if errno != 0 {
513+ t.Skipf("udmabuf create: %v", errno)
514+ }
515+ f := os.NewFile(fd, "fp16-client")
516+ t.Cleanup(func() { f.Close() })
517+ return f
518+}
519+
520+// Toggling HDR while a capture is leased: the lease's mapping stays
521+// readable, the next captures follow the new mode, and a solid colour
522+// round-trips SDR → HDR → SDR within the capture curve's tolerance.
523+func TestCaptureSurvivesHDRToggle(t *testing.T) {
524+ r := hdrTestRenderer(t) // HDR, exported target
525+ scene := ports.Scene{Seq: 1, Background: "#c08040"}
526+ if err := render(r, scene, nil); err != nil {
527+ t.Fatal(err)
528+ }
529+ hdrShot := readPixels(t, r).RGBAAt(3, 3)
530+ leased := captureWait(t, r) // HDR capture held across the toggle
531+ r.SetHDR(0)
532+ if _, err := r.ExportTargets(0, nil); err != nil {
533+ t.Fatal(err)
534+ }
535+ scene.Seq++
536+ if err := render(r, scene, nil); err != nil {
537+ t.Fatal(err)
538+ }
539+ sdrShot := readPixels(t, r).RGBAAt(3, 3)
540+ dst := make([]byte, 4)
541+ if err := leased.Read(image.Rect(3, 3, 4, 4), dst, 4); err != nil {
542+ t.Fatal(err)
543+ }
544+ if got := (color.RGBA{dst[2], dst[1], dst[0], 255}); got != hdrShot {
545+ t.Fatalf("leased HDR capture changed by the toggle: %v vs %v", got, hdrShot)
546+ }
547+ r.EndCapture(leased)
548+ // The SDR frame is exact; the HDR capture of the same colour is
549+ // within the curve's rounding (coverage of the transition, not a
550+ // claim about the curve's look).
551+ if sdrShot != (color.RGBA{0xc0, 0x80, 0x40, 255}) {
552+ t.Fatalf("SDR %v", sdrShot)
553+ }
554+ if !near(hdrShot, sdrShot, 10) {
555+ t.Fatalf("HDR capture %v vs SDR %v", hdrShot, sdrShot)
556+ }
557+ // Back to HDR: the capture pass and slots are reused.
558+ r.SetHDR(203)
559+ if _, err := r.ExportTargets(1, r.hdrMods); err != nil {
560+ t.Skipf("no HDR target: %v", err)
561+ }
562+ scene.Seq++
563+ if err := render(r, scene, nil); err != nil {
564+ t.Fatal(err)
565+ }
566+ if again := readPixels(t, r).RGBAAt(3, 3); again != hdrShot {
567+ t.Fatalf("HDR capture after the round trip %v, before %v", again, hdrShot)
568+ }
31 }569 }
M internal/adapters/vulkan/compose_test.go +18 -7
@@ -2,6 +2,7 @@ package vulkan
2 2
3 import (3 import (
4 "context"4 "context"
5+ "image"
5 "image/color"6 "image/color"
6 "os"7 "os"
7 "slices"8 "slices"
@@ -34,11 +35,11 @@ func TestRendererScaledSHMFiltersLinearly(t *testing.T) {
34 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {35 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
35 t.Fatal(err)36 t.Fatal(err)
36 }37 }
37- got := r.Pixels().RGBAAt(32, 8)38+ got := readPixels(t, r).RGBAAt(32, 8)
38 if !near(got, color.RGBA{128, 128, 128, 255}, 8) {39 if !near(got, color.RGBA{128, 128, 128, 255}, 8) {
39 t.Fatalf("midpoint %v, want grey", got)40 t.Fatalf("midpoint %v, want grey", got)
40 }41 }
41- if got := r.Pixels().RGBAAt(2, 8); !near(got, color.RGBA{0, 0, 0, 255}, 2) {42+ if got := readPixels(t, r).RGBAAt(2, 8); !near(got, color.RGBA{0, 0, 0, 255}, 2) {
42 t.Fatalf("left edge %v, want black (clamped)", got)43 t.Fatalf("left edge %v, want black (clamped)", got)
43 }44 }
44 }45 }
@@ -61,7 +62,7 @@ func TestRendererSourceCrop(t *testing.T) {
61 x int62 x int
62 want color.RGBA63 want color.RGBA
63 }{{0, color.RGBA{0, 255, 0, 255}}, {15, color.RGBA{0, 0, 255, 255}}} {64 }{{0, color.RGBA{0, 255, 0, 255}}, {15, color.RGBA{0, 0, 255, 255}}} {
64- if got := r.Pixels().RGBAAt(tc.x, 4); !near(got, tc.want, 5) {65+ if got := readPixels(t, r).RGBAAt(tc.x, 4); !near(got, tc.want, 5) {
65 t.Errorf("crop x=%d: %v want %v", tc.x, got, tc.want)66 t.Errorf("crop x=%d: %v want %v", tc.x, got, tc.want)
66 }67 }
67 }68 }
@@ -93,7 +94,7 @@ func TestRendererScaledDMABufFiltersLinearly(t *testing.T) {
93 }94 }
94 // Target x=64 (centre 64.5) maps to buffer 32.25: between texel 3195 // Target x=64 (centre 64.5) maps to buffer 32.25: between texel 31
95 // (black, centre 31.5) and 32 (white, centre 32.5), 75% white.96 // (black, centre 31.5) and 32 (white, centre 32.5), 75% white.
96- if got := r.Pixels().RGBAAt(64, 2); !near(got, color.RGBA{191, 191, 191, 255}, 10) {97+ if got := readPixels(t, r).RGBAAt(64, 2); !near(got, color.RGBA{191, 191, 191, 255}, 10) {
97 t.Fatalf("boundary %v, want a linear mix", got)98 t.Fatalf("boundary %v, want a linear mix", got)
98 }99 }
99 }100 }
@@ -184,7 +185,7 @@ func TestRendererSkipsOccludedSurface(t *testing.T) {
184 if r.copied != 16*16*4 {185 if r.copied != 16*16*4 {
185 t.Fatalf("copied %d bytes, want only the subsurface's %d", r.copied, 16*16*4)186 t.Fatalf("copied %d bytes, want only the subsurface's %d", r.copied, 16*16*4)
186 }187 }
187- if got := r.Pixels().At(8, 8); got != green {188+ if got := readPixels(t, r).At(8, 8); got != green {
188 t.Fatalf("At(8,8)=%v want %v", got, green)189 t.Fatalf("At(8,8)=%v want %v", got, green)
189 }190 }
190 // A smaller opaque child leaves part of the root visible: it is copied.191 // A smaller opaque child leaves part of the root visible: it is copied.
@@ -222,7 +223,7 @@ func TestRendererKeepsSurfaceBelowFailedCover(t *testing.T) {
222 if r.copied != 16*16*4 {223 if r.copied != 16*16*4 {
223 t.Fatalf("copied %d bytes, want the root's %d", r.copied, 16*16*4)224 t.Fatalf("copied %d bytes, want the root's %d", r.copied, 16*16*4)
224 }225 }
225- if got := r.Pixels().At(8, 8); got != red {226+ if got := readPixels(t, r).At(8, 8); got != red {
226 t.Fatalf("At(8,8)=%v want %v", got, red)227 t.Fatalf("At(8,8)=%v want %v", got, red)
227 }228 }
228 }229 }
@@ -268,7 +269,7 @@ func TestRendererSHMDoubleBufferWaitsForReaders(t *testing.T) {
268 t.Fatalf("buffer of frame %d rewritten with completed=%d", readers[0], r.completed)269 t.Fatalf("buffer of frame %d rewritten with completed=%d", readers[0], r.completed)
269 }270 }
270 }271 }
271- if got := r.Pixels().RGBAAt(4, 4); got != (color.RGBA{180, 0, 0, 255}) {272+ if got := readPixels(t, r).RGBAAt(4, 4); got != (color.RGBA{180, 0, 0, 255}) {
272 t.Fatalf("pixel %v", got)273 t.Fatalf("pixel %v", got)
273 }274 }
274 }275 }
@@ -302,3 +303,13 @@ func TestRendererImportsAcquireFence(t *testing.T) {
302 t.Fatal("pipe imported as a fence")303 t.Fatal("pipe imported as a fence")
303 }304 }
304 }305 }
306+
307+// readPixels reads the frame back, failing the test when Pixels cannot.
308+func readPixels(t *testing.T, r *Renderer) *image.RGBA {
309+ t.Helper()
310+ img := r.Pixels()
311+ if img == nil {
312+ t.Fatal("Pixels: no free capture slot or copy failed")
313+ }
314+ return img
315+}
M internal/adapters/vulkan/damage_test.go +2 -2
@@ -76,7 +76,7 @@ func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) {
76 if err := render(full, ports.Scene{Background: scene.Background, Windows: scene.Windows}, contents); err != nil {76 if err := render(full, ports.Scene{Background: scene.Background, Windows: scene.Windows}, contents); err != nil {
77 t.Fatal(err)77 t.Fatal(err)
78 }78 }
79- if a, b := damaged.Pixels(), full.Pixels(); !bytes.Equal(a.Pix, b.Pix) {79+ if a, b := readPixels(t, damaged), readPixels(t, full); !bytes.Equal(a.Pix, b.Pix) {
80 t.Fatalf("frame %d differs from a full redraw", i)80 t.Fatalf("frame %d differs from a full redraw", i)
81 }81 }
82 }82 }
@@ -135,7 +135,7 @@ func TestRendererDamageWindowLeftOutAndBack(t *testing.T) {
135 if err := render(full, plain, contents); err != nil {135 if err := render(full, plain, contents); err != nil {
136 t.Fatal(err)136 t.Fatal(err)
137 }137 }
138- if !bytes.Equal(damaged.Pixels().Pix, full.Pixels().Pix) {138+ if !bytes.Equal(readPixels(t, damaged).Pix, readPixels(t, full).Pix) {
139 t.Fatalf("%s differs from a full redraw", what)139 t.Fatalf("%s differs from a full redraw", what)
140 }140 }
141 }141 }
M internal/adapters/vulkan/dmabuf_test.go +1 -1
@@ -85,7 +85,7 @@ func TestRendererImportsDMABuf(t *testing.T) {
85 if err := render(r, scene, contents); err != nil {85 if err := render(r, scene, contents); err != nil {
86 t.Fatal(err)86 t.Fatal(err)
87 }87 }
88- px := r.Pixels()88+ px := readPixels(t, r)
89 for _, c := range []struct {89 for _, c := range []struct {
90 x, y int90 x, y int
91 want color.RGBA91 want color.RGBA
M internal/adapters/vulkan/extended_linear_test.go +1 -1
@@ -102,7 +102,7 @@ func TestExtendedLinearClient(t *testing.T) {
102 x int102 x int
103 v byte103 v byte
104 }{{5, 168}, {25, 255}, {45, 0}} {104 }{{5, 168}, {25, 255}, {45, 0}} {
105- got := r.Pixels().RGBAAt(tc.x, 8)105+ got := readPixels(t, r).RGBAAt(tc.x, 8)
106 if !near(got, color.RGBA{tc.v, tc.v, tc.v, 255}, 3) {106 if !near(got, color.RGBA{tc.v, tc.v, tc.v, 255}, 3) {
107 t.Errorf("x=%d got %v want %d", tc.x, got, tc.v)107 t.Errorf("x=%d got %v want %d", tc.x, got, tc.v)
108 }108 }
M internal/adapters/vulkan/frame.go +1 -1
@@ -303,7 +303,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
303 oldWindows := tg.windows303 oldWindows := tg.windows
304 tg.hold(s, dmg)304 tg.hold(s, dmg)
305 r.damageDrawn = oldWindows305 r.damageDrawn = oldWindows
306- r.last, r.readBack = tg, false306+ r.last = tg
307 r.dropUnused()307 r.dropUnused()
308 r.dropShm()308 r.dropShm()
309 if !r.syncFD {309 if !r.syncFD {
M internal/adapters/vulkan/hdr.go +20 -12
@@ -54,23 +54,14 @@ func (r *Renderer) SetHDR(nits float64) {
54 if r.hdrOwn.image == 0 {54 if r.hdrOwn.image == 0 {
55 r.hdrError = r.createHDROwn()55 r.hdrError = r.createHDROwn()
56 if r.hdrError != nil {56 if r.hdrError != nil {
57- r.freeTarget(&r.hdrOwn)57+ r.dropHDROwn()
58- return
59- }
60- }
61- if r.readbackBytes < 8 {
62- // SetHDR is called before rendering on the owner goroutine. On
63- // transitions the device has already been drained above.
64- r.hdrError = r.createReadback(8)
65- if r.hdrError != nil {
66- r.freeTarget(&r.hdrOwn)
67 return58 return
68 }59 }
69 }60 }
70 if r.compose.hdrPipeline == 0 {61 if r.compose.hdrPipeline == 0 {
71 r.hdrError = r.createGraphicsPipeline(composeVert, composeHDRFrag, vk.FormatR16g16b16a16Sfloat, r.compose.layout, true, &r.compose.hdrPipeline)62 r.hdrError = r.createGraphicsPipeline(composeVert, composeHDRFrag, vk.FormatR16g16b16a16Sfloat, r.compose.layout, true, &r.compose.hdrPipeline)
72 if r.hdrError != nil {63 if r.hdrError != nil {
73- r.freeTarget(&r.hdrOwn)64+ r.dropHDROwn()
74 return65 return
75 }66 }
76 }67 }
@@ -81,7 +72,7 @@ func (r *Renderer) SetHDR(nits float64) {
81 r.hdrError = r.createHDR()72 r.hdrError = r.createHDR()
82 if r.hdrError != nil {73 if r.hdrError != nil {
83 r.destroyHDR()74 r.destroyHDR()
84- r.freeTarget(&r.hdrOwn)75+ r.dropHDROwn()
85 }76 }
86 }77 }
87 78
@@ -202,3 +193,20 @@ func (r *Renderer) recordHDR(cmd vk.CommandBuffer, out *target) {
202 in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal193 in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal
203 d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in)194 d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in)
204 }195 }
196+
197+// dropHDROwn frees the HDR composition image with the capture pass that
198+// samples it (its descriptor set refers to the view). Pending captures
199+// finish first: this runs on SetHDR failures, never per frame.
200+func (r *Renderer) dropHDROwn() {
201+ if r.capturePass.pipeline != 0 {
202+ for _, s := range r.captures {
203+ if s.pending {
204+ _ = checked("vkDeviceWaitIdle", r.dd.DeviceWaitIdle(r.device))
205+ r.idle()
206+ break
207+ }
208+ }
209+ r.destroyCapturePass()
210+ }
211+ r.freeTarget(&r.hdrOwn)
212+}
D internal/adapters/vulkan/hdr_capture.go +0 -89
@@ -1,89 +0,0 @@
1-package vulkan
2-
3-import (
4- "encoding/binary"
5- "math"
6- "sync"
7- "unsafe"
8-)
9-
10-// halfToSRGB maps every binary16 bit pattern to its clipped sRGB byte.
11-// It is built on the first HDR capture; normal presentation never touches it.
12-var halfToSRGB = sync.OnceValue(func() *[1 << 16]byte {
13- var t [1 << 16]byte
14- for i := range t {
15- t[i] = encodeSRGB8(halfFloat(uint16(i)))
16- }
17- return &t
18-})
19-
20-// encodeSRGB8 clips a linear-light value to [0, 1] and encodes it as sRGB.
21-func encodeSRGB8(f float32) byte {
22- v := float64(f)
23- if !(v > 0) { // also maps NaN to black
24- return 0
25- }
26- v = math.Min(v, 1)
27- if v <= .0031308 {
28- v *= 12.92
29- } else {
30- v = 1.055*math.Pow(v, 1/2.4) - .055
31- }
32- return byte(math.Round(v * 255))
33-}
34-
35-// hdrRow converts n RGBA16F linear-light pixels to opaque BGRA8 sRGB.
36-func hdrRow(dst, src []byte, n int) {
37- t := halfToSRGB()
38- _ = dst[n*4-1]
39- _ = src[n*8-1]
40- for x := range n {
41- s, d := src[x*8:x*8+6], dst[x*4:x*4+4]
42- d[0] = t[binary.LittleEndian.Uint16(s[4:])]
43- d[1] = t[binary.LittleEndian.Uint16(s[2:])]
44- d[2] = t[binary.LittleEndian.Uint16(s[0:])]
45- d[3] = 255
46- }
47-}
48-
49-// hdrRegion converts a region of the mapped HDR readback, row by row.
50-// swapRB writes RGBA instead of BGRA.
51-func (r *Renderer) hdrRegion(dst []byte, stride, x0, y0, w, h int, swapRB bool) {
52- src := unsafe.Slice((*byte)(r.mapped), r.width*r.height*8)
53- for y := range h {
54- start := ((y0+y)*r.width + x0) * 8
55- row := dst[y*stride : y*stride+w*4]
56- hdrRow(row, src[start:start+w*8], w)
57- if swapRB {
58- for i := 0; i < len(row); i += 4 {
59- row[i], row[i+2] = row[i+2], row[i]
60- }
61- }
62- }
63-}
64-
65-// halfFloat expands an IEEE 754 binary16 component for capture only.
66-func halfFloat(bits uint16) float32 {
67- sign := uint32(bits&0x8000) << 16
68- exponent := uint32(bits>>10) & 31
69- mantissa := uint32(bits & 1023)
70- var value uint32
71- switch exponent {
72- case 0:
73- if mantissa == 0 {
74- value = sign
75- } else {
76- e := uint32(113)
77- for mantissa&0x400 == 0 {
78- mantissa <<= 1
79- e--
80- }
81- value = sign | e<<23 | (mantissa&1023)<<13
82- }
83- case 31:
84- value = sign | 0x7f800000 | mantissa<<13
85- default:
86- value = sign | (exponent+112)<<23 | mantissa<<13
87- }
88- return math.Float32frombits(value)
89-}
D internal/adapters/vulkan/hdr_capture_test.go +0 -61
@@ -1,61 +0,0 @@
1-package vulkan
2-
3-import (
4- "encoding/binary"
5- "math"
6- "testing"
7-)
8-
9-// referenceSRGB8 is the per-component formula the table must reproduce.
10-func referenceSRGB8(bits uint16) byte {
11- v := math.Max(0, math.Min(float64(halfFloat(bits)), 1))
12- if math.IsNaN(v) {
13- return 0
14- }
15- if v <= .0031308 {
16- v *= 12.92
17- } else {
18- v = 1.055*math.Pow(v, 1/2.4) - .055
19- }
20- return byte(math.Round(v * 255))
21-}
22-
23-func TestHalfToSRGBMatchesFormula(t *testing.T) {
24- table := halfToSRGB()
25- for i := range 1 << 16 {
26- if got, want := table[i], referenceSRGB8(uint16(i)); got != want {
27- t.Fatalf("half %#04x: got %d, want %d", i, got, want)
28- }
29- }
30-}
31-
32-func TestHdrRowWritesOpaqueBGRA(t *testing.T) {
33- // Pixel 0: red=1.0, green=0.5, blue=0 (half 0x3c00, 0x3800, 0).
34- // Pixel 1: negative red, NaN green, +Inf blue, alpha ignored.
35- src := make([]byte, 16)
36- for i, v := range []uint16{0x3c00, 0x3800, 0, 0x1234, 0xbc00, 0x7e00, 0x7c00, 0} {
37- binary.LittleEndian.PutUint16(src[i*2:], v)
38- }
39- dst := make([]byte, 8)
40- hdrRow(dst, src, 2)
41- want := []byte{0, referenceSRGB8(0x3800), 255, 255, 255, 0, 0, 255}
42- for i := range want {
43- if dst[i] != want[i] {
44- t.Fatalf("byte %d: got %v, want %v", i, dst, want)
45- }
46- }
47-}
48-
49-func BenchmarkHdrRow(b *testing.B) {
50- const n = 5120
51- src := make([]byte, n*8)
52- for i := range n * 4 {
53- binary.LittleEndian.PutUint16(src[i*2:], uint16(i*7919))
54- }
55- dst := make([]byte, n*4)
56- halfToSRGB()
57- b.SetBytes(n * 8)
58- for b.Loop() {
59- hdrRow(dst, src, n)
60- }
61-}
M internal/adapters/vulkan/hdr_composition_test.go +46 -20
@@ -19,13 +19,7 @@ func hdrTestRenderer(t *testing.T) *Renderer {
19 t.Skipf("Vulkan unavailable: %v", err)19 t.Skipf("Vulkan unavailable: %v", err)
20 }20 }
21 t.Cleanup(r.Close)21 t.Cleanup(r.Close)
22- if r.readbackBytes != 4 {
23- t.Fatalf("SDR readback bytes/pixel = %d", r.readbackBytes)
24- }
25 r.SetHDR(203)22 r.SetHDR(203)
26- if r.hdrError == nil && r.readbackBytes != 8 {
27- t.Fatalf("HDR readback bytes/pixel = %d", r.readbackBytes)
28- }
29 r.hdrReadback = true23 r.hdrReadback = true
30 if r.physical == 0 {24 if r.physical == 0 {
31 t.Skip("no exportable GPU")25 t.Skip("no exportable GPU")
@@ -42,19 +36,29 @@ func hdrTestRenderer(t *testing.T) *Renderer {
42 return r36 return r
43 }37 }
44 38
45-// Readback of a test-only PQ target (production targets are not transfer-src).39+// pqTargetWords reads a test-only PQ target back (production targets are
46-func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 {40+// not transfer-src), staging through a capture slot's buffer.
41+func pqTargetWords(t *testing.T, r *Renderer) []uint32 {
47 t.Helper()42 t.Helper()
48 if err := r.waitFrame(r.submitted); err != nil {43 if err := r.waitFrame(r.submitted); err != nil {
49 t.Fatal(err)44 t.Fatal(err)
50 }45 }
46+ if err := r.createCaptureSlots(); err != nil {
47+ t.Fatal(err)
48+ }
49+ slot := r.captures[0]
50+ if slot.leased {
51+ t.Fatal("capture slot 0 leased")
52+ }
51 target := r.targets[0]53 target := r.targets[0]
52 err := r.oneShot(func(cmd vk.CommandBuffer) {54 err := r.oneShot(func(cmd vk.CommandBuffer) {
53 d := r.dd55 d := r.dd
54 b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: vk.ImageLayoutGeneral, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: target.image, SubresourceRange: colorRange}56 b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: vk.ImageLayoutGeneral, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: target.image, SubresourceRange: colorRange}
55 d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)57 d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
56 region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}58 region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
57- d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, r.buffer, 1, &region)59+ d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, slot.buffer, 1, &region)
60+ hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: slot.buffer, Size: wholeSize}
61+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil)
58 b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 062 b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0
59 b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral63 b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral
60 b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT64 b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
@@ -63,8 +67,12 @@ func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 {
63 if err != nil {67 if err != nil {
64 t.Fatal(err)68 t.Fatal(err)
65 }69 }
66- words := unsafe.Slice((*uint32)(r.mapped), r.width*r.height)70+ return unsafe.Slice((*uint32)(slot.mapped), r.width*r.height)
67- pixel := words[y*r.width+x]71+}
72+
73+func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 {
74+ t.Helper()
75+ pixel := pqTargetWords(t, r)[y*r.width+x]
68 return [3]float64{float64(pixel>>20&1023) / 1023, float64(pixel>>10&1023) / 1023, float64(pixel&1023) / 1023}76 return [3]float64{float64(pixel>>20&1023) / 1023, float64(pixel>>10&1023) / 1023, float64(pixel&1023) / 1023}
69 }77 }
70 78
@@ -106,15 +114,33 @@ func TestHDRWindowedComposition(t *testing.T) {
106 if math.Abs(neutral[0]-pqEncode(1000)) > .008 {114 if math.Abs(neutral[0]-pqEncode(1000)) > .008 {
107 t.Fatalf("1000 nits clipped: %v", neutral)115 t.Fatalf("1000 nits clipped: %v", neutral)
108 }116 }
109- if px := r.Pixels().RGBAAt(5, 8); !near(px, color.RGBA{255, 255, 255, 255}, 2) {117+ // SDR capture tone-maps: the 1000-nit white lands on the shoulder
110- t.Fatalf("clipped capture pixel %v", px)118+ // (bright, not saturated) and the primaries keep their hue.
111- }119+ want := captureSDR([3]float64{1000.0 / 203, 1000.0 / 203, 1000.0 / 203})
112- dst := make([]byte, 8)120+ if px := readPixels(t, r).RGBAAt(5, 8); !near(px, color.RGBA{want[0], want[1], want[2], 255}, 2) {
113- if err := r.Capture(image.Rect(5, 8, 7, 9), dst, 8); err != nil {121+ t.Fatalf("tone-mapped capture pixel %v, want %v", px, want)
114- t.Fatal(err)122+ }
115- }123+ t.Run("async capture", func(t *testing.T) {
116- if dst[0] != 255 || dst[1] != 255 || dst[2] != 255 || dst[3] != 255 {124+ cf := captureWait(t, r)
117- t.Fatalf("capture %v", dst)125+ defer r.EndCapture(cf)
126+ dst := make([]byte, 8)
127+ if err := cf.Read(image.Rect(5, 8, 7, 9), dst, 8); err != nil {
128+ t.Fatal(err)
129+ }
130+ if !near(color.RGBA{dst[2], dst[1], dst[0], 255}, color.RGBA{want[0], want[1], want[2], 255}, 2) || dst[3] != 255 {
131+ t.Fatalf("capture %v, want %v", dst, want)
132+ }
133+ })
134+ // The BT.2020 red (negative BT.709 green and blue) matches the
135+ // reference gamut reduction and shoulder, not a per-channel clip.
136+ red := readPixels(t, r).RGBAAt(25, 8)
137+ lin := [3]float64{1.660491, -0.124550, -0.018151}
138+ for i := range lin {
139+ lin[i] *= 1000.0 / 203
140+ }
141+ wantRed := captureSDR(lin)
142+ if !near(red, color.RGBA{wantRed[0], wantRed[1], wantRed[2], 255}, 3) || red.G != 0 {
143+ t.Fatalf("bright BT.2020 red captured as %v, want %v", red, wantRed)
118 }144 }
119 before := r.redrawn145 before := r.redrawn
120 if err := render(r, scene, contents); err != nil {146 if err := render(r, scene, contents); err != nil {
M internal/adapters/vulkan/pq_client_test.go +1 -1
@@ -92,7 +92,7 @@ func TestPQClientComposition(t *testing.T) {
92 // Composition blends premultiplied source onto a black opaque fill.92 // Composition blends premultiplied source onto a black opaque fill.
93 *channels[j] = uint8(math.Round(v * alpha * 255))93 *channels[j] = uint8(math.Round(v * alpha * 255))
94 }94 }
95- got := r.Pixels().RGBAAt(i*10+5, 2)95+ got := readPixels(t, r).RGBAAt(i*10+5, 2)
96 if !near(got, want, 4) {96 if !near(got, want, 4) {
97 t.Errorf("sample %d: got %v want %v", i, got, want)97 t.Errorf("sample %d: got %v want %v", i, got, want)
98 }98 }
M internal/adapters/vulkan/renderer.go +25 -164
@@ -40,17 +40,17 @@ type Renderer struct {
40 physical vk.PhysicalDevice40 physical vk.PhysicalDevice
41 // One converted cursor image is kept; changes replace it in bounded memory.41 // One converted cursor image is kept; changes replace it in bounded memory.
42 cursorCache cursorConversion42 cursorCache cursorConversion
43- // last is the target of the last frame; readback copies it into43+ // last is the target of the last frame: what captures copy.
44- // buffer only when Pixels asks (stale until then).44+ last *target
45- last *target45+ // captures are the readback slots (capture.go), allocated on the
46- readBack bool46+ // first capture; capturePass converts HDR frames into captureSDR.
47- buffer vk.Buffer47+ captures []*captureSlot
48- bufferMemory vk.DeviceMemory48+ capturePass hdrPass
49- readbackBytes int // bytes per pixel allocated for the mapped readback buffer49+ captureSDR target
50- mapped unsafe.Pointer50+ sync captureSync
51- pool vk.CommandPool51+ pool vk.CommandPool
52- memory vk.PhysicalDeviceMemoryProperties52+ memory vk.PhysicalDeviceMemoryProperties
53- family uint3253+ family uint32
54 // Frames in flight (frame.go): frame is the last frame recorded,54 // Frames in flight (frame.go): frame is the last frame recorded,
55 // submitted the last submitted, completed the last known finished.55 // submitted the last submitted, completed the last known finished.
56 slots [frameSlots]frameSlot56 slots [frameSlots]frameSlot
@@ -295,9 +295,6 @@ func New(width, height int) (r *Renderer, err error) {
295 if err = checked("vkBindImageMemory", r.dd.BindImageMemory(r.device, r.own.image, r.own.memory, 0)); err != nil {295 if err = checked("vkBindImageMemory", r.dd.BindImageMemory(r.device, r.own.image, r.own.memory, 0)); err != nil {
296 return296 return
297 }297 }
298- if err = r.createReadback(4); err != nil {
299- return
300- }
301 pi := vk.CommandPoolCreateInfo{SType: vk.StructureTypeCommandPoolCreateInfo, Flags: vk.CommandPoolCreateResetCommandBufferBit, QueueFamilyIndex: family}298 pi := vk.CommandPoolCreateInfo{SType: vk.StructureTypeCommandPoolCreateInfo, Flags: vk.CommandPoolCreateResetCommandBufferBit, QueueFamilyIndex: family}
302 if err = checked("vkCreateCommandPool", r.dd.CreateCommandPool(r.device, &pi, nil, &r.pool)); err != nil {299 if err = checked("vkCreateCommandPool", r.dd.CreateCommandPool(r.device, &pi, nil, &r.pool)); err != nil {
303 return300 return
@@ -315,65 +312,6 @@ func New(width, height int) (r *Renderer, err error) {
315 return312 return
316 }313 }
317 314
318-// createReadback replaces the host buffer only while the device is idle (at
319-// construction or on an SDR→HDR transition). The current buffer stays in use
320-// until the replacement is mapped, so a failed transition keeps SDR readback.
321-func (r *Renderer) createReadback(bytesPerPixel int) (err error) {
322- d := r.dd
323- var (
324- buffer vk.Buffer
325- memory vk.DeviceMemory
326- mapped unsafe.Pointer
327- )
328- defer func() {
329- if err == nil {
330- return
331- }
332- if buffer != 0 {
333- d.DestroyBuffer(r.device, buffer, nil)
334- }
335- if memory != 0 {
336- d.FreeMemory(r.device, memory, nil)
337- }
338- }()
339- size := vk.DeviceSize(r.width) * vk.DeviceSize(r.height) * vk.DeviceSize(bytesPerPixel)
340- bi := vk.BufferCreateInfo{SType: vk.StructureTypeBufferCreateInfo, Size: size, Usage: vk.BufferUsageTransferDstBit, SharingMode: vk.SharingModeExclusive}
341- if err := checked("vkCreateBuffer", d.CreateBuffer(r.device, &bi, nil, &buffer)); err != nil {
342- return err
343- }
344- var req vk.MemoryRequirements
345- d.GetBufferMemoryRequirements(r.device, buffer, &req)
346- // Readback (screenshots) reads this buffer: prefer cached host memory.
347- kind, err := r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit|vk.MemoryPropertyHostCachedBit)
348- if err != nil {
349- kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)
350- }
351- if err != nil {
352- return err
353- }
354- alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, AllocationSize: req.Size, MemoryTypeIndex: kind}
355- if err := checked("vkAllocateMemory(buffer)", d.AllocateMemory(r.device, &alloc, nil, &memory)); err != nil {
356- return err
357- }
358- if err := checked("vkBindBufferMemory", d.BindBufferMemory(r.device, buffer, memory, 0)); err != nil {
359- return err
360- }
361- if err := checked("vkMapMemory", d.MapMemory(r.device, memory, 0, size, 0, &mapped)); err != nil {
362- return err
363- }
364- if r.mapped != nil {
365- d.UnmapMemory(r.device, r.bufferMemory)
366- }
367- if r.buffer != 0 {
368- d.DestroyBuffer(r.device, r.buffer, nil)
369- }
370- if r.bufferMemory != 0 {
371- d.FreeMemory(r.device, r.bufferMemory, nil)
372- }
373- r.buffer, r.bufferMemory, r.mapped, r.readbackBytes = buffer, memory, mapped, bytesPerPixel
374- return nil
375-}
376-
377 func (r *Renderer) findMemoryType(bits uint32, props vk.MemoryPropertyFlags) (uint32, error) {315 func (r *Renderer) findMemoryType(bits uint32, props vk.MemoryPropertyFlags) (uint32, error) {
378 for i := uint32(0); i < r.memory.MemoryTypeCount; i++ {316 for i := uint32(0); i < r.memory.MemoryTypeCount; i++ {
379 if bits&(uint32(1)<<i) != 0 && r.memory.MemoryTypes[i].PropertyFlags&props == props {317 if bits&(uint32(1)<<i) != 0 && r.memory.MemoryTypes[i].PropertyFlags&props == props {
@@ -391,50 +329,24 @@ func (r *Renderer) CopiedBytes() int { return r.copied }
391 // default.329 // default.
392 func (r *Renderer) QueuePriority() string { return r.queuePriority }330 func (r *Renderer) QueuePriority() string { return r.queuePriority }
393 331
332+// Pixels reads the last frame back through a capture slot, waiting for
333+// the GPU on the calling goroutine (slot fence, no sync file needed):
334+// headless screenshots and tests only. Nil when no slot is free (both
335+// leased to captures) or the copy failed: never a silent black image.
394 func (r *Renderer) Pixels() *image.RGBA {336 func (r *Renderer) Pixels() *image.RGBA {
395- out := image.NewRGBA(image.Rect(0, 0, r.width, r.height))337+ cf, err := r.debugCapture()
396- if r.mapped == nil || r.readback() != nil {338+ if err != nil {
397- return out339+ return nil
398- }
399- if r.last == &r.hdrOwn {
400- r.hdrRegion(out.Pix, out.Stride, 0, 0, r.width, r.height, true)
401- return out
402- }
403- src := unsafe.Slice((*byte)(r.mapped), len(out.Pix))
404- for i := 0; i < len(src); i += 4 {
405- // The output is opaque: x-format client buffers leave alpha undefined.
406- out.Pix[i], out.Pix[i+1], out.Pix[i+2], out.Pix[i+3] = src[i+2], src[i+1], src[i], 255
407- }
408- return out
409-}
410-
411-// Capture reads the last rendered image without changing the renderer's target.
412-func (r *Renderer) Capture(region image.Rectangle, dst []byte, stride int) error {
413- bounds := image.Rect(0, 0, r.width, r.height)
414- w, h := region.Dx(), region.Dy()
415- if region.Empty() || !region.In(bounds) || stride < w*4 || len(dst) < (h-1)*stride+w*4 {
416- return fmt.Errorf("invalid capture region or destination")
417- }
418- if r.mapped == nil || r.last == nil || r.last.image == 0 {
419- return fmt.Errorf("no rendered frame")
420- }
421- if err := r.readback(); err != nil {
422- return err
423 }340 }
424- if r.last == &r.hdrOwn {341+ defer r.EndCapture(cf)
425- r.hdrRegion(dst, stride, region.Min.X, region.Min.Y, w, h, false)342+ out := image.NewRGBA(image.Rect(0, 0, r.width, r.height))
343+ if err := cf.Read(image.Rect(0, 0, r.width, r.height), out.Pix, out.Stride); err != nil {
426 return nil344 return nil
427 }345 }
428- src := unsafe.Slice((*byte)(r.mapped), r.width*r.height*4)346+ for i := 0; i < len(out.Pix); i += 4 {
429- for y := range h {347+ out.Pix[i], out.Pix[i+2] = out.Pix[i+2], out.Pix[i]
430- start := ((region.Min.Y+y)*r.width + region.Min.X) * 4
431- row := dst[y*stride : y*stride+w*4]
432- copy(row, src[start:start+w*4])
433- for x := 3; x < len(row); x += 4 {
434- row[x] = 255
435- }
436 }348 }
437- return nil349+ return out
438 }350 }
439 351
440 // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE.352 // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE.
@@ -443,46 +355,6 @@ const (
443 wholeSize = ^vk.DeviceSize(0)355 wholeSize = ^vk.DeviceSize(0)
444 )356 )
445 357
446-// readback copies the last frame into the host buffer, once per frame
447-// (screenshots only).
448-func (r *Renderer) readback() error {
449- if r.readBack || r.last == nil || r.last.image == 0 {
450- return nil
451- }
452- // The copy reads the frame: wait for it, and keep the ring ordered.
453- if err := r.waitFrame(r.submitted); err != nil {
454- return err
455- }
456- t := r.last
457- err := r.oneShot(func(cmd vk.CommandBuffer) {
458- d := r.dd
459- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: t.layout, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: t.image, SubresourceRange: colorRange}
460- if t.exported {
461- b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = vk.QueueFamilyForeignEXT, r.family
462- }
463- d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
464- region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
465- d.CmdCopyImageToBuffer(cmd, t.image, vk.ImageLayoutTransferSrcOptimal, r.buffer, 1, &region)
466- // Make the copy visible to host reads of the mapped buffer.
467- hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: r.buffer, Size: wholeSize}
468- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil)
469- if t.exported {
470- b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral
471- b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0
472- b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
473- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
474- }
475- })
476- if err != nil {
477- return err
478- }
479- if !t.exported {
480- t.layout = vk.ImageLayoutTransferSrcOptimal
481- }
482- r.readBack = true
483- return nil
484-}
485-
486 func (r *Renderer) Close() {358 func (r *Renderer) Close() {
487 if r == nil {359 if r == nil {
488 return360 return
@@ -504,22 +376,11 @@ func (r *Renderer) Close() {
504 r.dropPools()376 r.dropPools()
505 r.dropShm()377 r.dropShm()
506 r.destroySlots()378 r.destroySlots()
379+ r.destroyCaptures()
507 if r.pool != 0 {380 if r.pool != 0 {
508 d.DestroyCommandPool(r.device, r.pool, nil)381 d.DestroyCommandPool(r.device, r.pool, nil)
509 r.pool = 0382 r.pool = 0
510 }383 }
511- if r.mapped != nil {
512- d.UnmapMemory(r.device, r.bufferMemory)
513- r.mapped = nil
514- }
515- if r.buffer != 0 {
516- d.DestroyBuffer(r.device, r.buffer, nil)
517- r.buffer = 0
518- }
519- if r.bufferMemory != 0 {
520- d.FreeMemory(r.device, r.bufferMemory, nil)
521- r.bufferMemory = 0
522- }
523 r.destroyComposer()384 r.destroyComposer()
524 r.freeTarget(&r.hdrOwn)385 r.freeTarget(&r.hdrOwn)
525 r.freeTarget(&r.own)386 r.freeTarget(&r.own)
M internal/adapters/vulkan/renderer_test.go +16 -16
@@ -19,7 +19,7 @@ func TestRendererClear(t *testing.T) {
19 if err := r.Clear([3]uint8{0x10, 0x20, 0x30}); err != nil {19 if err := r.Clear([3]uint8{0x10, 0x20, 0x30}); err != nil {
20 t.Fatal(err)20 t.Fatal(err)
21 }21 }
22- pixels := r.Pixels()22+ pixels := readPixels(t, r)
23 want := color.RGBA{0x10, 0x20, 0x30, 255}23 want := color.RGBA{0x10, 0x20, 0x30, 255}
24 for _, p := range [][2]int{{0, 0}, {63, 47}} {24 for _, p := range [][2]int{{0, 0}, {63, 47}} {
25 if got := pixels.At(p[0], p[1]); got != want {25 if got := pixels.At(p[0], p[1]); got != want {
@@ -56,7 +56,7 @@ func TestRendererRender(t *testing.T) {
56 t.Fatal(err)56 t.Fatal(err)
57 }57 }
58 for p, want := range expected {58 for p, want := range expected {
59- if got := r.Pixels().At(p.X, p.Y); got != want {59+ if got := readPixels(t, r).At(p.X, p.Y); got != want {
60 t.Errorf("pixel %v=%v want %v", p, got, want)60 t.Errorf("pixel %v=%v want %v", p, got, want)
61 }61 }
62 }62 }
@@ -106,7 +106,7 @@ func TestRendererFade(t *testing.T) {
106 if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {106 if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
107 t.Fatal(err)107 t.Fatal(err)
108 }108 }
109- if got := r.Pixels().At(10, 10).(color.RGBA); got.R < 99 || got.R > 101 || got.G != 0 || got.B != 0 {109+ if got := readPixels(t, r).At(10, 10).(color.RGBA); got.R < 99 || got.R > 101 || got.G != 0 || got.B != 0 {
110 t.Fatalf("faded pixel %v", got)110 t.Fatalf("faded pixel %v", got)
111 }111 }
112 }112 }
@@ -135,7 +135,7 @@ func TestRendererContents(t *testing.T) {
135 }135 }
136 check := func(x, y int, want color.RGBA) {136 check := func(x, y int, want color.RGBA) {
137 t.Helper()137 t.Helper()
138- if got := r.Pixels().At(x, y); got != want {138+ if got := readPixels(t, r).At(x, y); got != want {
139 t.Errorf("At(%d,%d)=%v want %v", x, y, got, want)139 t.Errorf("At(%d,%d)=%v want %v", x, y, got, want)
140 }140 }
141 }141 }
@@ -173,7 +173,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
173 if err := render(r, scene, upper); err != nil {173 if err := render(r, scene, upper); err != nil {
174 t.Fatal(err)174 t.Fatal(err)
175 }175 }
176- if got, want := r.Pixels().RGBAAt(10, 10), top; got != want {176+ if got, want := readPixels(t, r).RGBAAt(10, 10), top; got != want {
177 t.Errorf("overlap = %v, want %v", got, want)177 t.Errorf("overlap = %v, want %v", got, want)
178 }178 }
179 scene.Border = ports.Border{Width: 4, Active: "#ffffff"}179 scene.Border = ports.Border{Width: 4, Active: "#ffffff"}
@@ -193,7 +193,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
193 x, y int193 x, y int
194 want color.RGBA194 want color.RGBA
195 }{{3, 3, color.RGBA{255, 255, 255, 255}}, {10, 10, color.RGBA{7, 5, 3, 255}}} {195 }{{3, 3, color.RGBA{255, 255, 255, 255}}, {10, 10, color.RGBA{7, 5, 3, 255}}} {
196- if got := r.Pixels().RGBAAt(tc.x, tc.y); got != tc.want {196+ if got := readPixels(t, r).RGBAAt(tc.x, tc.y); got != tc.want {
197 t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)197 t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)
198 }198 }
199 }199 }
@@ -219,7 +219,7 @@ func TestRendererLayers(t *testing.T) {
219 if err := render(r, scene, contents); err != nil {219 if err := render(r, scene, contents); err != nil {
220 t.Fatal(err)220 t.Fatal(err)
221 }221 }
222- if got := r.Pixels().RGBAAt(5, 2); got != want {222+ if got := readPixels(t, r).RGBAAt(5, 2); got != want {
223 t.Errorf("pixel = %v, want %v", got, want)223 t.Errorf("pixel = %v, want %v", got, want)
224 }224 }
225 }225 }
@@ -263,7 +263,7 @@ func TestRendererScale(t *testing.T) {
263 if err := render(r, s, content); err != nil {263 if err := render(r, s, content); err != nil {
264 t.Fatal(err)264 t.Fatal(err)
265 }265 }
266- px := r.Pixels()266+ px := readPixels(t, r)
267 want := color.RGBA{200, 0, 0, 255}267 want := color.RGBA{200, 0, 0, 255}
268 for _, p := range []image.Point{{8, 8}, {27, 27}} {268 for _, p := range []image.Point{{8, 8}, {27, 27}} {
269 if got := px.At(p.X, p.Y); got != want {269 if got := px.At(p.X, p.Y); got != want {
@@ -281,7 +281,7 @@ func TestRendererScale(t *testing.T) {
281 if err := render(r, s, content); err != nil {281 if err := render(r, s, content); err != nil {
282 t.Fatal(err)282 t.Fatal(err)
283 }283 }
284- px = r.Pixels()284+ px = readPixels(t, r)
285 for _, p := range []image.Point{{6, 6}, {20, 20}} {285 for _, p := range []image.Point{{6, 6}, {20, 20}} {
286 if got := px.At(p.X, p.Y); got != want {286 if got := px.At(p.X, p.Y); got != want {
287 t.Errorf("1.5 %v = %v", p, got)287 t.Errorf("1.5 %v = %v", p, got)
@@ -297,7 +297,7 @@ func TestRendererScale(t *testing.T) {
297 if err := render(r, s, content); err != nil {297 if err := render(r, s, content); err != nil {
298 t.Fatal(err)298 t.Fatal(err)
299 }299 }
300- if got := r.Pixels().At(21, 10); got != (color.RGBA{0, 0, 0, 255}) {300+ if got := readPixels(t, r).At(21, 10); got != (color.RGBA{0, 0, 0, 255}) {
301 t.Errorf("clipped column = %v", got)301 t.Errorf("clipped column = %v", got)
302 }302 }
303 }303 }
@@ -326,7 +326,7 @@ func TestRendererPreview(t *testing.T) {
326 if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {326 if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
327 t.Fatal(err)327 t.Fatal(err)
328 }328 }
329- out := r.Pixels()329+ out := readPixels(t, r)
330 for p, want := range map[image.Point]color.RGBA{330 for p, want := range map[image.Point]color.RGBA{
331 {5, 5}: {200, 0, 0, 255},331 {5, 5}: {200, 0, 0, 255},
332 {22, 12}: {0, 0, 200, 255},332 {22, 12}: {0, 0, 200, 255},
@@ -360,7 +360,7 @@ func TestRendererPreviewSmooth(t *testing.T) {
360 if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {360 if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
361 t.Fatal(err)361 t.Fatal(err)
362 }362 }
363- out := r.Pixels()363+ out := readPixels(t, r)
364 for x := 1; x < 19; x++ {364 for x := 1; x < 19; x++ {
365 if v := out.RGBAAt(x, 5).R; v < 100 || v > 155 {365 if v := out.RGBAAt(x, 5).R; v < 100 || v > 155 {
366 t.Fatalf("x=%d: %d, want mid gray", x, v)366 t.Fatalf("x=%d: %d, want mid gray", x, v)
@@ -425,7 +425,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) {
425 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: root}); err != nil {425 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: root}); err != nil {
426 t.Fatal(err)426 t.Fatal(err)
427 }427 }
428- px := r.Pixels()428+ px := readPixels(t, r)
429 for _, c := range []struct {429 for _, c := range []struct {
430 x, y int430 x, y int
431 want color.RGBA431 want color.RGBA
@@ -443,7 +443,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) {
443 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: only}); err != nil {443 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: only}); err != nil {
444 t.Fatal(err)444 t.Fatal(err)
445 }445 }
446- if got := r.Pixels().At(20, 15); got != blue {446+ if got := readPixels(t, r).At(20, 15); got != blue {
447 t.Errorf("child only: %v", got)447 t.Errorf("child only: %v", got)
448 }448 }
449 }449 }
@@ -462,7 +462,7 @@ func TestRendererTallFill(t *testing.T) {
462 t.Fatal(err)462 t.Fatal(err)
463 }463 }
464 bg := color.RGBA{16, 32, 48, 255}464 bg := color.RGBA{16, 32, 48, 255}
465- px := r.Pixels()465+ px := readPixels(t, r)
466 for y := 0; y < h; y++ {466 for y := 0; y < h; y++ {
467 if got := px.At(1, y); got != wc {467 if got := px.At(1, y); got != wc {
468 t.Fatalf("At(1,%d)=%v want %v", y, got, wc)468 t.Fatalf("At(1,%d)=%v want %v", y, got, wc)
@@ -517,7 +517,7 @@ func TestRendererAlphaBlend(t *testing.T) {
517 if err := render(r, scene, contents); err != nil {517 if err := render(r, scene, contents); err != nil {
518 t.Fatal(err)518 t.Fatal(err)
519 }519 }
520- px := r.Pixels()520+ px := readPixels(t, r)
521 near := func(a, b uint8) bool { d := int(a) - int(b); return d >= -1 && d <= 1 }521 near := func(a, b uint8) bool { d := int(a) - int(b); return d >= -1 && d <= 1 }
522 for _, tc := range []struct {522 for _, tc := range []struct {
523 name string523 name string
M internal/adapters/vulkan/scene_dim_test.go +4 -4
@@ -161,7 +161,7 @@ func TestRendererDimBlendAndDamage(t *testing.T) {
161 {43, 5, color.RGBA{255, 255, 255, 255}}, // window popup161 {43, 5, color.RGBA{255, 255, 255, 255}}, // window popup
162 {53, 5, color.RGBA{255, 255, 255, 255}}, // top layer162 {53, 5, color.RGBA{255, 255, 255, 255}}, // top layer
163 } {163 } {
164- if got := r.Pixels().RGBAAt(tc.x, tc.y); !near(got, tc.want, 2) {164+ if got := readPixels(t, r).RGBAAt(tc.x, tc.y); !near(got, tc.want, 2) {
165 t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)165 t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)
166 }166 }
167 }167 }
@@ -186,7 +186,7 @@ func TestRendererDimBlendAndDamage(t *testing.T) {
186 if r.redrawn-before >= 64*48 {186 if r.redrawn-before >= 64*48 {
187 t.Fatal("content change unexpectedly triggered full redraw")187 t.Fatal("content change unexpectedly triggered full redraw")
188 }188 }
189- if got := r.Pixels().RGBAAt(3, 3); !near(got, color.RGBA{128, 0, 0, 255}, 2) {189+ if got := readPixels(t, r).RGBAAt(3, 3); !near(got, color.RGBA{128, 0, 0, 255}, 2) {
190 t.Fatalf("damaged tile beneath veil = %v, want dark red", got)190 t.Fatalf("damaged tile beneath veil = %v, want dark red", got)
191 }191 }
192 }192 }
@@ -221,7 +221,7 @@ func TestRendererWindowDimBlendAndDamage(t *testing.T) {
221 x, y int221 x, y int
222 want color.RGBA222 want color.RGBA
223 }{{5, 5, grey}, {5, 0, grey}, {35, 5, white}, {35, 0, white}, {25, 30, white}} {223 }{{5, 5, grey}, {5, 0, grey}, {35, 5, white}, {35, 0, white}, {25, 30, white}} {
224- if got := r.Pixels().RGBAAt(tc.x, tc.y); !near(got, tc.want, 2) {224+ if got := readPixels(t, r).RGBAAt(tc.x, tc.y); !near(got, tc.want, 2) {
225 t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)225 t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)
226 }226 }
227 }227 }
@@ -235,7 +235,7 @@ func TestRendererWindowDimBlendAndDamage(t *testing.T) {
235 if err := render(r, s, contents); err != nil {235 if err := render(r, s, contents); err != nil {
236 t.Fatal(err)236 t.Fatal(err)
237 }237 }
238- if got := r.Pixels().RGBAAt(4, 4); !near(got, color.RGBA{128, 0, 0, 255}, 2) {238+ if got := readPixels(t, r).RGBAAt(4, 4); !near(got, color.RGBA{128, 0, 0, 255}, 2) {
239 t.Fatalf("damaged peek = %v, want dark red", got)239 t.Fatalf("damaged peek = %v, want dark red", got)
240 }240 }
241 }241 }
M internal/adapters/vulkan/scene_perf_test.go +3 -2
@@ -1,6 +1,7 @@
1 package vulkan1 package vulkan
2 2
3 import (3 import (
4+ "image"
4 "image/color"5 "image/color"
5 "testing"6 "testing"
6 7
@@ -57,7 +58,7 @@ func TestSceneWalkUnchangedTiledSHM(t *testing.T) {
57 for _, count := range []int{16, 64} {58 for _, count := range []int{16, 64} {
58 contents[1] = ports.SurfaceContent{ID: 1, Seq: root.Seq, Surface: root.Surface, Version: root.Version, Children: root.Children[:count]}59 contents[1] = ports.SurfaceContent{ID: 1, Seq: root.Seq, Surface: root.Surface, Version: root.Version, Children: root.Children[:count]}
59 walk := func() {60 walk := func() {
60- dmg := newDamage(&target{}, scene, r.Pixels().Bounds())61+ dmg := newDamage(&target{}, scene, image.Rect(0, 0, r.width, r.height))
61 _ = r.draws(scene, contents, dmg)62 _ = r.draws(scene, contents, dmg)
62 }63 }
63 walk()64 walk()
@@ -101,7 +102,7 @@ func TestSceneWalkUnchangedTiledSHM(t *testing.T) {
101 if err := render(r, scene, contents); err != nil {102 if err := render(r, scene, contents); err != nil {
102 t.Fatal(err)103 t.Fatal(err)
103 }104 }
104- if got := r.Pixels().RGBAAt(4, 4); got != (color.RGBA{R: 2, A: 255}) {105+ if got := readPixels(t, r).RGBAAt(4, 4); got != (color.RGBA{R: 2, A: 255}) {
105 t.Fatalf("restacked tile has pixels %v, want second child's colour", got)106 t.Fatalf("restacked tile has pixels %v, want second child's colour", got)
106 }107 }
107 }108 }
A internal/adapters/vulkan/shaders/capture_sdr.frag +53 -0
@@ -0,0 +1,53 @@
1+#version 450
2+// Capture-only SDR conversion of the HDR composition image (linear BT.709
3+// in units of SDR reference white, values beyond [0, 1] on both sides).
4+// Presentation never uses this pass. The mapping is deterministic and
5+// mirrored by captureCurve in capture.go for tests:
6+//
7+// 1. Gamut: a component below zero (a BT.2020 color outside BT.709) is
8+// brought to zero by mixing the pixel toward its own luminance: hue
9+// direction and luminance are kept, chroma is reduced just enough.
10+// 2. Shoulder, on the largest component so hue is kept: identity up to
11+// the knee k = 0.9 (SDR content below 90 % white is exact), then the
12+// rational curve k + (1 - k) * t / (1 + t) with t = (m - k) / s,
13+// s = 0.5, which reaches 1 only asymptotically: diffuse white lands
14+// at 0.917 (sRGB 245), 2x white at 251, 5x (1000 nits) at 254; the
15+// order of highlights is kept instead of clipping.
16+// 3. sRGB encoding, opaque.
17+layout(set=0, binding=0) uniform sampler2D imageHDR;
18+layout(location=0) out vec4 color;
19+
20+const float knee = 0.9;
21+const float scale = 0.5;
22+const vec3 luma709 = vec3(0.2126, 0.7152, 0.0722);
23+
24+float shoulder(float m) {
25+ float t = (m - knee) / scale;
26+ return m <= knee ? m : knee + (1.0 - knee) * t / (1.0 + t);
27+}
28+
29+vec3 encodeSRGB(vec3 v) {
30+ return mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, greaterThan(v, vec3(0.0031308)));
31+}
32+
33+void main() {
34+ vec2 uv = gl_FragCoord.xy / vec2(textureSize(imageHDR, 0));
35+ vec3 c = texture(imageHDR, uv).rgb;
36+ // NaN is black before any clamp (clamping NaN is undefined);
37+ // infinities are bounded to the half range.
38+ c = mix(c, vec3(0.0), isnan(c));
39+ c = clamp(c, vec3(-65504.0), vec3(65504.0));
40+ float y = max(dot(c, luma709), 0.0);
41+ float lo = min(min(c.r, c.g), c.b);
42+ if (lo < 0.0) {
43+ // c + t * (y - c) == 0 for the lowest component: t = -lo / (y - lo).
44+ float t = -lo / max(y - lo, 1e-6);
45+ c = mix(c, vec3(y), clamp(t, 0.0, 1.0));
46+ }
47+ c = max(c, vec3(0.0));
48+ float m = max(max(c.r, c.g), c.b);
49+ if (m > knee) {
50+ c *= shoulder(m) / m;
51+ }
52+ color = vec4(encodeSRGB(clamp(c, vec3(0.0), vec3(1.0))), 1.0);
53+}
A internal/adapters/vulkan/shaders/capture_sdr.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/targets_test.go +4 -23
@@ -4,7 +4,6 @@ import (
4 "image/color"4 "image/color"
5 "math"5 "math"
6 "testing"6 "testing"
7- "unsafe"
8 7
9 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
10 vk "github.com/bnema/purego-vulkan/vulkan"9 vk "github.com/bnema/purego-vulkan/vulkan"
@@ -38,7 +37,7 @@ func TestExportTargets(t *testing.T) {
38 t.Fatal(err)37 t.Fatal(err)
39 }38 }
40 }39 }
41- if got := r.Pixels().At(3, 3); got != (color.RGBA{0, 0, 255, 255}) {40+ if got := readPixels(t, r).At(3, 3); got != (color.RGBA{0, 0, 255, 255}) {
42 t.Fatalf("readback of the last target %v", got)41 t.Fatalf("readback of the last target %v", got)
43 }42 }
44 // Import target 0 as a client buffer, like the display would.43 // Import target 0 as a client buffer, like the display would.
@@ -61,7 +60,7 @@ func TestExportTargets(t *testing.T) {
61 if err := render(viewer, scene, contents); err != nil {60 if err := render(viewer, scene, contents); err != nil {
62 t.Fatal(err)61 t.Fatal(err)
63 }62 }
64- if got := viewer.Pixels().At(10, 10); got != (color.RGBA{255, 0, 0, 255}) {63+ if got := readPixels(t, viewer).At(10, 10); got != (color.RGBA{255, 0, 0, 255}) {
65 t.Fatalf("target 0 as seen by the display %v", got)64 t.Fatalf("target 0 as seen by the display %v", got)
66 }65 }
67 }66 }
@@ -123,7 +122,7 @@ func TestHDRExportTarget(t *testing.T) {
123 if r.hdrOwn.layout != vk.ImageLayoutTransferSrcOptimal {122 if r.hdrOwn.layout != vk.ImageLayoutTransferSrcOptimal {
124 t.Fatalf("internal layout after HDR: %v", r.hdrOwn.layout)123 t.Fatalf("internal layout after HDR: %v", r.hdrOwn.layout)
125 }124 }
126- if got := r.Pixels().RGBAAt(0, 0); got.R != 128 || got.G != 128 || got.B != 128 {125+ if got := readPixels(t, r).RGBAAt(0, 0); got.R != 128 || got.G != 128 || got.B != 128 {
127 t.Fatalf("SDR readback: %v", got)126 t.Fatalf("SDR readback: %v", got)
128 }127 }
129 }128 }
@@ -131,25 +130,7 @@ func TestHDRExportTarget(t *testing.T) {
131 // Test-only target readback: production HDR images do not have transfer-src.130 // Test-only target readback: production HDR images do not have transfer-src.
132 func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) {131 func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) {
133 t.Helper()132 t.Helper()
134- if err := r.waitFrame(r.submitted); err != nil {133+ v := pqTargetWords(t, r)[0]
135- t.Fatal(err)
136- }
137- target := r.targets[0]
138- err := r.oneShot(func(cmd vk.CommandBuffer) {
139- d := r.dd
140- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: vk.ImageLayoutGeneral, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: target.image, SubresourceRange: colorRange}
141- d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
142- region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
143- d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, r.buffer, 1, &region)
144- b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0
145- b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral
146- b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
147- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
148- })
149- if err != nil {
150- t.Fatal(err)
151- }
152- v := *(*uint32)(unsafe.Pointer(r.mapped))
153 red, green, blue := hdrPixel(rgb[0], rgb[1], rgb[2], 203)134 red, green, blue := hdrPixel(rgb[0], rgb[1], rgb[2], 203)
154 for i, want := range []float64{blue, green, red} {135 for i, want := range []float64{blue, green, red} {
155 got := float64(v>>uint(i*10)&1023) / 1023136 got := float64(v>>uint(i*10)&1023) / 1023
M internal/adapters/vulkan/transform_test.go +1 -1
@@ -50,7 +50,7 @@ func TestRendererBufferTransform(t *testing.T) {
50 for x := range sw {50 for x := range sw {
51 bx, by := tr.ToBuffer(float64(x)+0.5, float64(y)+0.5, float64(sw), float64(sh))51 bx, by := tr.ToBuffer(float64(x)+0.5, float64(y)+0.5, float64(sw), float64(sh))
52 want := colours[int(by)][bx0+int(bx)]52 want := colours[int(by)][bx0+int(bx)]
53- if got := r.Pixels().RGBAAt(x, y); !near(got, want, 2) {53+ if got := readPixels(t, r).RGBAAt(x, y); !near(got, want, 2) {
54 t.Errorf("crop=%v transform %d: surface (%d,%d) = %v, want %v", crop, tr, x, y, got, want)54 t.Errorf("crop=%v transform %d: surface (%d,%d) = %v, want %v", crop, tr, x, y, got, want)
55 }55 }
56 }56 }
M internal/adapters/vulkan/trim_test.go +1 -1
@@ -80,7 +80,7 @@ func TestTrimFreesUndrawnSHMWithoutRender(t *testing.T) {
80 if r.copied != copied+16*16*4 {80 if r.copied != copied+16*16*4 {
81 t.Fatalf("returning window copied %d bytes", r.copied-copied)81 t.Fatalf("returning window copied %d bytes", r.copied-copied)
82 }82 }
83- if got := r.Pixels().RGBAAt(4, 4); got != (color.RGBA{255, 0, 0, 255}) {83+ if got := readPixels(t, r).RGBAAt(4, 4); got != (color.RGBA{255, 0, 0, 255}) {
84 t.Fatalf("returning window shows %v", got)84 t.Fatalf("returning window shows %v", got)
85 }85 }
86 }86 }
M internal/adapters/vulkan/yuv_test.go +7 -4
@@ -148,12 +148,15 @@ func TestYUVComposition(t *testing.T) {
148 t.Fatalf("PQ %v want %v", got, want)148 t.Fatalf("PQ %v want %v", got, want)
149 }149 }
150 }150 }
151- if px := r.Pixels().RGBAAt(8, 8); px != (color.RGBA{255, 255, 255, 255}) {151+ // SDR capture tone-maps the 1000-nit white onto the shoulder.
152- t.Fatalf("capture %v", px)152+ linear := pqDecode(code) / 203
153+ sdr := captureSDR([3]float64{linear, linear, linear})
154+ if px := readPixels(t, r).RGBAAt(8, 8); !near(px, color.RGBA{sdr[0], sdr[1], sdr[2], 255}, 2) || px.A != 255 {
155+ t.Fatalf("capture %v want %v", px, sdr)
153 }156 }
154 return157 return
155 }158 }
156- got := r.Pixels().RGBAAt(8, 8)159+ got := readPixels(t, r).RGBAAt(8, 8)
157 want := color.RGBA{uint8(math.Round(tc.rgb[0] * 255)), uint8(math.Round(tc.rgb[1] * 255)), uint8(math.Round(tc.rgb[2] * 255)), 255}160 want := color.RGBA{uint8(math.Round(tc.rgb[0] * 255)), uint8(math.Round(tc.rgb[1] * 255)), uint8(math.Round(tc.rgb[2] * 255)), 255}
158 if !near(got, want, 8) {161 if !near(got, want, 8) {
159 t.Errorf("got %v want %v", got, want)162 t.Errorf("got %v want %v", got, want)
@@ -184,7 +187,7 @@ func TestYUVSourceCropEdges(t *testing.T) {
184 t.Fatal(err)187 t.Fatal(err)
185 }188 }
186 for _, y := range []int{0, 1, 8, 15} {189 for _, y := range []int{0, 1, 8, 15} {
187- if got := r.Pixels().RGBAAt(8, y); !near(got, color.RGBA{255, 255, 255, 255}, 8) {190+ if got := readPixels(t, r).RGBAAt(8, y); !near(got, color.RGBA{255, 255, 255, 255}, 8) {
188 t.Errorf("cropped edge y=%d: %v, want white", y, got)191 t.Errorf("cropped edge y=%d: %v, want white", y, got)
189 }192 }
190 }193 }
A internal/adapters/wayland/capture_admission_test.go +353 -0
@@ -0,0 +1,353 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/logging"
9+ "github.com/bnema/neferwl/internal/ports"
10+ ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
11+ wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
12+ "github.com/bnema/wlturbo"
13+ "golang.org/x/sys/unix"
14+)
15+
16+// admissionHarness runs a real Server with real protocol clients. All fields
17+// except the server internals read through display.Do are test-goroutine only.
18+type admissionHarness struct {
19+ s *Server
20+ dir string
21+ requests chan ports.CaptureRequest
22+ replies chan ports.CaptureDone
23+ held []ports.CaptureRequest // dup'd descriptors handed to the test
24+}
25+
26+type admissionClient struct {
27+ c *wlturbo.Display
28+ wlr uint32
29+ output uint32
30+ buf uint32
31+ session uint32 // ext session, created on first use
32+ extMgr uint32
33+}
34+
35+// pendingCapture is one capture request and the events of its frame.
36+type pendingCapture struct {
37+ frame uint32
38+ events *captureEvents
39+ failed uint16 // opcode of the failed event
40+ ext bool
41+}
42+
43+func newAdmissionHarness(t *testing.T, queue int) *admissionHarness {
44+ t.Helper()
45+ h := &admissionHarness{dir: t.TempDir(), requests: make(chan ports.CaptureRequest, queue), replies: make(chan ports.CaptureDone, maxCaptureInflight+2)}
46+ // Registered first so it runs after Run stopped and the clients closed:
47+ // close every dup'd descriptor, including the ones a failed test left in
48+ // the queue or in held.
49+ t.Cleanup(func() {
50+ for len(h.requests) > 0 {
51+ h.held = append(h.held, <-h.requests)
52+ }
53+ for _, r := range h.held {
54+ _ = r.Dst.File.Close()
55+ }
56+ })
57+ out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}}
58+ s, err := New(Options{RuntimeDir: h.dir, Outputs: out}, Channels{Captures: h.requests, Captured: h.replies}, logging.For(context.Background(), "wayland"))
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ h.s = s
63+ ctx, cancel := context.WithCancel(context.Background())
64+ done := make(chan error, 1)
65+ go func() { done <- s.Run(ctx) }()
66+ t.Cleanup(func() {
67+ cancel()
68+ if err := <-done; err != nil {
69+ t.Errorf("Run: %v", err)
70+ }
71+ })
72+ return h
73+}
74+
75+func (h *admissionHarness) client(t *testing.T) *admissionClient {
76+ t.Helper()
77+ c := protocolClient(t, h.s, h.dir)
78+ g, ok := c.Registry().FindGlobal("wl_output")
79+ if !ok {
80+ t.Fatal("output missing")
81+ }
82+ out, err := c.Registry().BindID(g.Name, g.Interface, 4)
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ registerProtocol(t, c, out)
87+ _, buf, fd := captureSmallBuffer(t, c)
88+ t.Cleanup(func() { _ = unix.Close(fd) })
89+ return &admissionClient{c: c, wlr: bindVersion(t, c, "zwlr_screencopy_manager_v1", 3), output: out, buf: buf}
90+}
91+
92+func (a *admissionClient) newFrame() (uint32, *captureEvents) {
93+ frame := a.c.AllocateID()
94+ ev := &captureEvents{events: make(chan uint16, 8)}
95+ ev.SetID(frame)
96+ a.c.Context().Register(ev)
97+ return frame, ev
98+}
99+
100+// captureWlr asks for a wlr capture of the whole output.
101+func (a *admissionClient) captureWlr(t *testing.T) pendingCapture {
102+ t.Helper()
103+ frame, ev := a.newFrame()
104+ requestProtocol(t, a.c, a.wlr, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), a.output)
105+ requestProtocol(t, a.c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, a.buf)
106+ if err := a.c.Roundtrip(); err != nil {
107+ t.Fatal(err)
108+ }
109+ return pendingCapture{frame: frame, events: ev, failed: uint16(wlr.ZwlrScreencopyFrameV1EventFailed)}
110+}
111+
112+// captureExt asks for an ext capture. The session allows one live frame, so
113+// the caller destroys the previous frame first.
114+func (a *admissionClient) captureExt(t *testing.T) pendingCapture {
115+ t.Helper()
116+ if a.session == 0 {
117+ a.extMgr, a.session, _ = captureTestExt(t, a.c, a.output)
118+ }
119+ frame, ev := a.newFrame()
120+ requestProtocol(t, a.c, a.session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
121+ requestProtocol(t, a.c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, a.buf)
122+ requestProtocol(t, a.c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
123+ if err := a.c.Roundtrip(); err != nil {
124+ t.Fatal(err)
125+ }
126+ return pendingCapture{frame: frame, events: ev, failed: uint16(ext.ExtImageCopyCaptureFrameV1EventFailed), ext: true}
127+}
128+
129+func (a *admissionClient) destroy(t *testing.T, p pendingCapture) {
130+ t.Helper()
131+ op := wlr.ZwlrScreencopyFrameV1RequestDestroy
132+ if p.ext {
133+ op = ext.ExtImageCopyCaptureFrameV1RequestDestroy
134+ }
135+ requestProtocol(t, a.c, p.frame, op)
136+ if err := a.c.Roundtrip(); err != nil {
137+ t.Fatal(err)
138+ }
139+}
140+
141+func (h *admissionHarness) inflight(t *testing.T) int {
142+ t.Helper()
143+ n := -1
144+ if !h.s.display.Do(func() { n = len(h.s.captureInflight) }) {
145+ t.Fatal("display stopped")
146+ }
147+ return n
148+}
149+
150+func (h *admissionHarness) waitInflight(t *testing.T, want int) {
151+ t.Helper()
152+ deadline := time.Now().Add(2 * time.Second)
153+ for h.inflight(t) != want {
154+ if time.Now().After(deadline) {
155+ t.Fatalf("inflight %d, want %d", h.inflight(t), want)
156+ }
157+ time.Sleep(time.Millisecond)
158+ }
159+}
160+
161+// accepted reports whether the capture reached the backend queue; a refused
162+// capture is answered with failed and queues no request. Accepted requests
163+// are kept in held so cleanup closes their descriptors.
164+func (h *admissionHarness) accepted(t *testing.T, p pendingCapture) (ports.CaptureRequest, bool) {
165+ t.Helper()
166+ var last uint16
167+ for len(p.events.events) > 0 {
168+ last = <-p.events.events
169+ }
170+ select {
171+ case req := <-h.requests:
172+ if last == p.failed {
173+ t.Fatal("failed frame queued a request")
174+ }
175+ h.held = append(h.held, req)
176+ return req, true
177+ default:
178+ if last != p.failed {
179+ t.Fatalf("refused capture not failed: last event %d", last)
180+ }
181+ return ports.CaptureRequest{}, false
182+ }
183+}
184+
185+func (h *admissionHarness) complete(t *testing.T, req ports.CaptureRequest, remaining int) {
186+ t.Helper()
187+ _ = req.Dst.File.Close()
188+ h.replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Time: time.Now()}
189+ h.waitInflight(t, remaining)
190+}
191+
192+// saturate fills the cap with captures whose frames are destroyed right away,
193+// and returns their requests.
194+func (h *admissionHarness) saturate(t *testing.T, a *admissionClient, capture func(*testing.T) pendingCapture) []ports.CaptureRequest {
195+ t.Helper()
196+ var reqs []ports.CaptureRequest
197+ for i := 0; i < maxCaptureInflight; i++ {
198+ p := capture(t)
199+ req, ok := h.accepted(t, p)
200+ if !ok {
201+ t.Fatalf("capture %d refused below the cap", i)
202+ }
203+ reqs = append(reqs, req)
204+ a.destroy(t, p)
205+ }
206+ if got := h.inflight(t); got != maxCaptureInflight {
207+ t.Fatalf("inflight %d after destroying frames", got)
208+ }
209+ return reqs
210+}
211+
212+func TestCaptureAdmissionSaturation(t *testing.T) {
213+ h := newAdmissionHarness(t, maxCaptureInflight+2)
214+ a := h.client(t)
215+ var frames []pendingCapture
216+ var reqs []ports.CaptureRequest
217+ for i := 0; i < maxCaptureInflight; i++ {
218+ p := a.captureWlr(t)
219+ req, ok := h.accepted(t, p)
220+ if !ok {
221+ t.Fatalf("capture %d refused below the cap", i)
222+ }
223+ reqs, frames = append(reqs, req), append(frames, p)
224+ }
225+ if got := h.inflight(t); got != maxCaptureInflight {
226+ t.Fatalf("inflight %d", got)
227+ }
228+ if _, ok := h.accepted(t, a.captureWlr(t)); ok {
229+ t.Fatal("capture admitted over the cap")
230+ }
231+ // Destroying the client frames must not release the backend's credit.
232+ for _, p := range frames {
233+ a.destroy(t, p)
234+ }
235+ if got := h.inflight(t); got != maxCaptureInflight {
236+ t.Fatalf("inflight %d after destroy", got)
237+ }
238+ if _, ok := h.accepted(t, a.captureWlr(t)); ok {
239+ t.Fatal("destroying frames bypassed the cap")
240+ }
241+ // A backend completion for a destroyed frame restores exactly one credit.
242+ h.complete(t, reqs[0], maxCaptureInflight-1)
243+ req, ok := h.accepted(t, a.captureWlr(t))
244+ if !ok {
245+ t.Fatal("completion did not restore credit")
246+ }
247+ h.waitInflight(t, maxCaptureInflight)
248+ if _, ok := h.accepted(t, a.captureWlr(t)); ok {
249+ t.Fatal("cap exceeded after refill")
250+ }
251+ h.complete(t, req, maxCaptureInflight-1)
252+ for i, r := range reqs[1:] {
253+ h.complete(t, r, maxCaptureInflight-2-i)
254+ }
255+ h.waitInflight(t, 0)
256+}
257+
258+func TestExtCaptureAdmissionSaturation(t *testing.T) {
259+ h := newAdmissionHarness(t, maxCaptureInflight+2)
260+ a := h.client(t)
261+ reqs := h.saturate(t, a, a.captureExt)
262+ // Every frame is destroyed, yet the cap still holds.
263+ p := a.captureExt(t)
264+ if _, ok := h.accepted(t, p); ok {
265+ t.Fatal("destroyed ext frames bypassed the cap")
266+ }
267+ a.destroy(t, p)
268+ h.complete(t, reqs[0], maxCaptureInflight-1)
269+ p = a.captureExt(t)
270+ req, ok := h.accepted(t, p)
271+ if !ok {
272+ t.Fatal("completion did not restore ext credit")
273+ }
274+ a.destroy(t, p)
275+ h.waitInflight(t, maxCaptureInflight)
276+ if _, ok := h.accepted(t, a.captureExt(t)); ok {
277+ t.Fatal("cap exceeded after ext refill")
278+ }
279+ h.complete(t, req, maxCaptureInflight-1)
280+ for i, r := range reqs[1:] {
281+ h.complete(t, r, maxCaptureInflight-2-i)
282+ }
283+ h.waitInflight(t, 0)
284+}
285+
286+func TestCaptureAdmissionSurvivesDisconnect(t *testing.T) {
287+ h := newAdmissionHarness(t, maxCaptureInflight+2)
288+ a := h.client(t)
289+ reqs := h.saturate(t, a, a.captureExt)
290+ if err := a.c.Close(); err != nil {
291+ t.Fatal(err)
292+ }
293+ // Wait until the display loop dropped the dead client's reply callbacks.
294+ deadline := time.Now().Add(2 * time.Second)
295+ for {
296+ replies := -1
297+ if !h.s.display.Do(func() { replies = len(h.s.captureReplies) }) {
298+ t.Fatal("display stopped")
299+ }
300+ if replies == 0 {
301+ break
302+ }
303+ if time.Now().After(deadline) {
304+ t.Fatalf("%d reply callbacks retained after disconnect", replies)
305+ }
306+ time.Sleep(time.Millisecond)
307+ }
308+ if got := h.inflight(t); got != maxCaptureInflight {
309+ t.Fatalf("inflight %d after disconnect", got)
310+ }
311+ // A new client cannot use the dead client's credits until completion.
312+ b := h.client(t)
313+ if _, ok := h.accepted(t, b.captureWlr(t)); ok {
314+ t.Fatal("disconnect bypassed the cap")
315+ }
316+ h.complete(t, reqs[0], maxCaptureInflight-1)
317+ req, ok := h.accepted(t, b.captureWlr(t))
318+ if !ok {
319+ t.Fatal("completion after disconnect did not restore credit")
320+ }
321+ h.complete(t, req, maxCaptureInflight-1)
322+ for i, r := range reqs[1:] {
323+ h.complete(t, r, maxCaptureInflight-2-i)
324+ }
325+ h.waitInflight(t, 0)
326+}
327+
328+func TestCaptureAdmissionReleasedOnEnqueueFailure(t *testing.T) {
329+ h := newAdmissionHarness(t, 1)
330+ a := h.client(t)
331+ // The first capture fills the one-slot queue and stays in flight.
332+ a.captureWlr(t)
333+ h.waitInflight(t, 1)
334+ // The second is refused by the full queue and must not keep its credit.
335+ p := a.captureWlr(t)
336+ h.waitInflight(t, 1)
337+ var last uint16
338+ for len(p.events.events) > 0 {
339+ last = <-p.events.events
340+ }
341+ if last != p.failed || len(h.requests) != 1 {
342+ t.Fatalf("last event %d, queued %d; want failed and 1", last, len(h.requests))
343+ }
344+ req := <-h.requests
345+ h.held = append(h.held, req)
346+ h.complete(t, req, 0)
347+ // With the queue drained and credit restored a new capture is accepted.
348+ req, ok := h.accepted(t, a.captureWlr(t))
349+ if !ok {
350+ t.Fatal("capture refused after enqueue failure")
351+ }
352+ h.complete(t, req, 0)
353+}
M internal/adapters/wayland/screencopy.go +13 -1
@@ -15,6 +15,11 @@ import (
15 "golang.org/x/sys/unix"15 "golang.org/x/sys/unix"
16 )16 )
17 17
18+// maxCaptureInflight caps accepted captures that the backend has not completed
19+// yet, across all clients. Destroying a client frame or resource does not
20+// release its slot: only the backend completion does.
21+const maxCaptureInflight = ports.MaxCaptureInflight
22+
18 // The renderer's scene and wl_output modes are already in physical orientation;23 // The renderer's scene and wl_output modes are already in physical orientation;
19 // this compositor does not advertise rotated wl_output transforms.24 // this compositor does not advertise rotated wl_output transforms.
20 func captureRegion(o *output, logical image.Rectangle) image.Rectangle {25 func captureRegion(o *output, logical image.Rectangle) image.Rectangle {
@@ -156,6 +161,10 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
156 s.log.Debug().Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Msg("capture buffer rejected")161 s.log.Debug().Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Msg("capture buffer rejected")
157 return 0, false162 return 0, false
158 }163 }
164+ if len(s.captureInflight) >= maxCaptureInflight {
165+ s.log.Debug().Str("output", o.name()).Int("inflight", len(s.captureInflight)).Msg("capture refused: too many captures in flight")
166+ return 0, false
167+ }
159 buf := s.buffers[b.Resource].(*buffer)168 buf := s.buffers[b.Resource].(*buffer)
160 fd, err := unix.Dup(int(buf.pool.file.Fd()))169 fd, err := unix.Dup(int(buf.pool.file.Fd()))
161 if err != nil {170 if err != nil {
@@ -164,6 +173,7 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
164 }173 }
165 s.nextCapture++174 s.nextCapture++
166 id := s.nextCapture175 id := s.nextCapture
176+ s.captureInflight[id] = struct{}{}
167 start := time.Now()177 start := time.Now()
168 s.captureReplies[id] = func(done ports.CaptureDone) {178 s.captureReplies[id] = func(done ports.CaptureDone) {
169 ev := s.log.Debug()179 ev := s.log.Debug()
@@ -182,8 +192,9 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
182 return id, true192 return id, true
183 default:193 default:
184 delete(s.captureReplies, id)194 delete(s.captureReplies, id)
195+ delete(s.captureInflight, id)
185 req.Dst.File.Close()196 req.Dst.File.Close()
186- s.log.Warn().Str("output", o.name()).Msg("capture refused: request queue full")197+ s.log.Debug().Str("output", o.name()).Msg("capture refused: request queue full")
187 return 0, false198 return 0, false
188 }199 }
189 }200 }
@@ -199,6 +210,7 @@ func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {
199 return210 return
200 }211 }
201 if !s.display.Do(func() {212 if !s.display.Do(func() {
213+ delete(s.captureInflight, done.ID)
202 if fn := s.captureReplies[done.ID]; fn != nil {214 if fn := s.captureReplies[done.ID]; fn != nil {
203 delete(s.captureReplies, done.ID)215 delete(s.captureReplies, done.ID)
204 fn(done)216 fn(done)
M internal/adapters/wayland/server.go +21 -11
@@ -50,8 +50,14 @@ type Channels struct {
50 // Presented paces frame callbacks on the outputs' page flips; outputs50 // Presented paces frame callbacks on the outputs' page flips; outputs
51 // that do not flip (idle, headless) are paced at their refresh rate.51 // that do not flip (idle, headless) are paced at their refresh rate.
52 Presented <-chan ports.OutputPresented52 Presented <-chan ports.OutputPresented
53- Captures chan<- ports.CaptureRequest53+ // Captures carries accepted capture requests, never blocking. Each
54- Captured <-chan ports.CaptureDone54+ // accepted request holds one of ports.MaxCaptureInflight credits until its
55+ // CaptureDone arrives on Captured, whether or not the client is still
56+ // there; the backend must complete every request it takes. In production
57+ // both channels have capacity >= ports.MaxCaptureInflight, so replies
58+ // never block the backend; standalone tests may use smaller channels.
59+ Captures chan<- ports.CaptureRequest
60+ Captured <-chan ports.CaptureDone
55 // OutputFormats are the outputs' direct scanout formats, offered in61 // OutputFormats are the outputs' direct scanout formats, offered in
56 // dmabuf feedback to fullscreen surfaces.62 // dmabuf feedback to fullscreen surfaces.
57 OutputFormats <-chan ports.OutputFormats63 OutputFormats <-chan ports.OutputFormats
@@ -70,15 +76,18 @@ type Server struct {
70 nextLeaseID uint6476 nextLeaseID uint64
71 nextCapture uint6477 nextCapture uint64
72 captureReplies map[uint64]func(ports.CaptureDone)78 captureReplies map[uint64]func(ports.CaptureDone)
73- captureSources map[*server.Resource]*output79+ // captureInflight counts accepted captures until the backend completes
74- captureSessions map[*captureSession]struct{}80+ // them, independent of client resources; only the display loop touches it.
75- display *server.Display81+ captureInflight map[uint64]struct{}
76- env procEnv82+ captureSources map[*server.Resource]*output
77- slotsPending bool // core waits for a slot window83+ captureSessions map[*captureSession]struct{}
78- name string84+ display *server.Display
79- cleanup func()85+ env procEnv
80- log zerowrap.Logger86+ slotsPending bool // core waits for a slot window
81- channels Channels87+ name string
88+ cleanup func()
89+ log zerowrap.Logger
90+ channels Channels
82 // awaiting holds frame callbacks by output name, due at its next91 // awaiting holds frame callbacks by output name, due at its next
83 // frame (frameDue, or its page flip).92 // frame (frameDue, or its page flip).
84 awaiting map[string][]*wayland.Callback93 awaiting map[string][]*wayland.Callback
@@ -237,6 +246,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
237 s.clock = clock.System{}246 s.clock = clock.System{}
238 }247 }
239 s.captureReplies = map[uint64]func(ports.CaptureDone){}248 s.captureReplies = map[uint64]func(ports.CaptureDone){}
249+ s.captureInflight = map[uint64]struct{}{}
240 s.outputReplies = map[uint64]*outputConfiguration{}250 s.outputReplies = map[uint64]*outputConfiguration{}
241 s.managementSerial = 1251 s.managementSerial = 1
242 s.captureSources = map[*server.Resource]*output{}252 s.captureSources = map[*server.Resource]*output{}
M internal/app/app.go +4 -2
@@ -98,8 +98,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
98 // tells core about each flip (frames) to move running slides.98 // tells core about each flip (frames) to move running slides.
99 flips := make(chan ports.OutputPresented, 64)99 flips := make(chan ports.OutputPresented, 64)
100 frames := make(chan ports.OutputFrame, 8)100 frames := make(chan ports.OutputFrame, 8)
101- captures := make(chan ports.CaptureRequest, 32)101+ captures := make(chan ports.CaptureRequest, ports.MaxCaptureInflight)
102- captured := make(chan ports.CaptureDone, 64)102+ // Every admitted capture owns one reply slot until Wayland consumes it.
103+ // Keep this capacity tied to admission so output routing never waits.
104+ captured := make(chan ports.CaptureDone, ports.MaxCaptureInflight)
103 outputFormats := make(chan ports.OutputFormats, 8)105 outputFormats := make(chan ports.OutputFormats, 8)
104 outputHeads := make(chan ports.OutputHeads, 8)106 outputHeads := make(chan ports.OutputHeads, 8)
105 leaseRequests := make(chan ports.LeaseMessage, 32)107 leaseRequests := make(chan ports.LeaseMessage, 32)
M internal/app/capture_test.go +87 -0
@@ -30,6 +30,93 @@ func TestCaptureUnknownOutputClosesFile(t *testing.T) {
30 }30 }
31 }31 }
32 32
33+func TestCaptureFullOutputQueueDoesNotBlockRouting(t *testing.T) {
34+ ctx, cancel := context.WithCancel(context.Background())
35+ defer cancel()
36+ replies := make(chan ports.CaptureDone, 1)
37+ set := newOutputSet(ctx, replies)
38+ queued := make(chan ports.CaptureRequest, 1)
39+ queued <- ports.CaptureRequest{ID: 1}
40+ set.outs["test"] = &runningOutput{ctx: ctx, captures: queued, done: make(chan struct{}), stop: func() {}}
41+ f, err := os.CreateTemp(t.TempDir(), "capture")
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ routed := make(chan struct{})
46+ go func() {
47+ set.routeCapture(ports.CaptureRequest{ID: 2, Output: "test", Dst: ports.SHMBuffer{File: f}})
48+ close(routed)
49+ }()
50+ select {
51+ case <-routed:
52+ case <-time.After(time.Second):
53+ cancel()
54+ <-routed
55+ t.Fatal("full capture queue blocked output routing")
56+ }
57+ result := <-replies
58+ if result.ID != 2 || result.Err == nil {
59+ t.Fatalf("rejected capture: %+v", result)
60+ }
61+ if _, err := f.Stat(); err == nil {
62+ t.Fatal("rejected descriptor still open")
63+ }
64+ if got := <-queued; got.ID != 1 {
65+ t.Fatalf("queued request replaced: %+v", got)
66+ }
67+}
68+
69+func TestStoppedOutputCaptureStillReplies(t *testing.T) {
70+ ctx := context.Background()
71+ outputCtx, stop := context.WithCancel(ctx)
72+ stop()
73+ replies := make(chan ports.CaptureDone, 1)
74+ set := newOutputSet(ctx, replies)
75+ set.outs["test"] = &runningOutput{ctx: outputCtx, captures: make(chan ports.CaptureRequest)}
76+ f, err := os.CreateTemp(t.TempDir(), "capture")
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ set.routeCapture(ports.CaptureRequest{ID: 7, Output: "test", Dst: ports.SHMBuffer{File: f}})
81+ select {
82+ case result := <-replies:
83+ if result.ID != 7 || result.Err == nil {
84+ t.Fatalf("stopped output reply: %+v", result)
85+ }
86+ default:
87+ t.Fatal("stopped output lost capture completion")
88+ }
89+ if _, err := f.Stat(); err == nil {
90+ t.Fatal("stopped output kept descriptor open")
91+ }
92+}
93+
94+func TestFinishedOutputDrainsCapturesWithReplies(t *testing.T) {
95+ ctx := context.Background()
96+ outputCtx, stop := context.WithCancel(ctx)
97+ stop()
98+ replies := make(chan ports.CaptureDone, 1)
99+ set := newOutputSet(ctx, replies)
100+ queued := make(chan ports.CaptureRequest, 1)
101+ f, err := os.CreateTemp(t.TempDir(), "capture")
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ queued <- ports.CaptureRequest{ID: 8, Dst: ports.SHMBuffer{File: f}}
106+ set.failQueued(&runningOutput{ctx: outputCtx, captures: queued})
107+ select {
108+ case result := <-replies:
109+ if result.ID != 8 || result.Err == nil {
110+ t.Fatalf("drained capture reply: %+v", result)
111+ }
112+ default:
113+ t.Fatal("drained capture lost completion")
114+ }
115+ if _, err := f.Stat(); err == nil {
116+ t.Fatal("drained descriptor still open")
117+ }
118+}
119+
33 func TestDrainCapturesClosesQueuedFDs(t *testing.T) {120 func TestDrainCapturesClosesQueuedFDs(t *testing.T) {
34 ctx, cancel := context.WithCancel(context.Background())121 ctx, cancel := context.WithCancel(context.Background())
35 cancel()122 cancel()
M internal/app/outputs.go +5 -3
@@ -198,9 +198,11 @@ func (s *outputSet) routeCapture(req ports.CaptureRequest) {
198 case r.captures <- req:198 case r.captures <- req:
199 case <-r.done:199 case <-r.done:
200 r.stop()200 r.stop()
201- capture.Fail(r.ctx, req, fmt.Errorf("output %q stopped", req.Output), s.captured)201+ capture.Fail(s.ctx, req, fmt.Errorf("output %q stopped", req.Output), s.captured)
202 case <-r.ctx.Done():202 case <-r.ctx.Done():
203- capture.Fail(r.ctx, req, fmt.Errorf("output %q stopped", req.Output), s.captured)203+ capture.Fail(s.ctx, req, fmt.Errorf("output %q stopped", req.Output), s.captured)
204+ default:
205+ capture.Fail(s.ctx, req, fmt.Errorf("output %q capture queue full", req.Output), s.captured)
204 }206 }
205 }207 }
206 208
@@ -208,7 +210,7 @@ func (s *outputSet) failQueued(r *runningOutput) {
208 for {210 for {
209 select {211 select {
210 case req := <-r.captures:212 case req := <-r.captures:
211- capture.Fail(r.ctx, req, fmt.Errorf("output stopped"), s.captured)213+ capture.Fail(s.ctx, req, fmt.Errorf("output stopped"), s.captured)
212 default:214 default:
213 return215 return
214 }216 }
M internal/mocks/ports/devices_mock.go +197 -20
@@ -13,17 +13,17 @@ import (
13 mock "github.com/stretchr/testify/mock"13 mock "github.com/stretchr/testify/mock"
14 )14 )
15 15
16-// NewMockRenderer creates a new instance of MockRenderer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.16+// NewMockCaptureFrame creates a new instance of MockCaptureFrame. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17 // The first argument is typically a *testing.T value.17 // The first argument is typically a *testing.T value.
18-func NewMockRenderer(t interface {18+func NewMockCaptureFrame(t interface {
19 mock.TestingT19 mock.TestingT
20 Cleanup(func())20 Cleanup(func())
21-}) *MockRenderer {21+}) *MockCaptureFrame {
22 if helper, ok := t.(interface{ Helper() }); ok {22 if helper, ok := t.(interface{ Helper() }); ok {
23 helper.Helper()23 helper.Helper()
24 }24 }
25 25
26- mock := &MockRenderer{}26+ mock := &MockCaptureFrame{}
27 mock.Mock.Test(t)27 mock.Mock.Test(t)
28 28
29 t.Cleanup(func() {29 t.Cleanup(func() {
@@ -36,25 +36,71 @@ func NewMockRenderer(t interface {
36 return mock36 return mock
37 }37 }
38 38
39-// MockRenderer is an autogenerated mock type for the Renderer type39+// MockCaptureFrame is an autogenerated mock type for the CaptureFrame type
40-type MockRenderer struct {40+type MockCaptureFrame struct {
41 mock.Mock41 mock.Mock
42 }42 }
43 43
44-type MockRenderer_Expecter struct {44+type MockCaptureFrame_Expecter struct {
45 mock *mock.Mock45 mock *mock.Mock
46 }46 }
47 47
48-func (_m *MockRenderer) EXPECT() *MockRenderer_Expecter {48+func (_m *MockCaptureFrame) EXPECT() *MockCaptureFrame_Expecter {
49- return &MockRenderer_Expecter{mock: &_m.Mock}49+ return &MockCaptureFrame_Expecter{mock: &_m.Mock}
50+}
51+
52+// Done provides a mock function for the type MockCaptureFrame
53+func (_mock *MockCaptureFrame) Done() *os.File {
54+ ret := _mock.Called()
55+
56+ if len(ret) == 0 {
57+ panic("no return value specified for Done")
58+ }
59+
60+ var r0 *os.File
61+ if returnFunc, ok := ret.Get(0).(func() *os.File); ok {
62+ r0 = returnFunc()
63+ } else {
64+ if ret.Get(0) != nil {
65+ r0 = ret.Get(0).(*os.File)
66+ }
67+ }
68+ return r0
69+}
70+
71+// MockCaptureFrame_Done_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Done'
72+type MockCaptureFrame_Done_Call struct {
73+ *mock.Call
74+}
75+
76+// Done is a helper method to define mock.On call
77+func (_e *MockCaptureFrame_Expecter) Done() *MockCaptureFrame_Done_Call {
78+ return &MockCaptureFrame_Done_Call{Call: _e.mock.On("Done")}
79+}
80+
81+func (_c *MockCaptureFrame_Done_Call) Run(run func()) *MockCaptureFrame_Done_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ run()
84+ })
85+ return _c
50 }86 }
51 87
52-// Capture provides a mock function for the type MockRenderer88+func (_c *MockCaptureFrame_Done_Call) Return(file *os.File) *MockCaptureFrame_Done_Call {
53-func (_mock *MockRenderer) Capture(region image.Rectangle, dst []byte, stride int) error {89+ _c.Call.Return(file)
90+ return _c
91+}
92+
93+func (_c *MockCaptureFrame_Done_Call) RunAndReturn(run func() *os.File) *MockCaptureFrame_Done_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
97+
98+// Read provides a mock function for the type MockCaptureFrame
99+func (_mock *MockCaptureFrame) Read(region image.Rectangle, dst []byte, stride int) error {
54 ret := _mock.Called(region, dst, stride)100 ret := _mock.Called(region, dst, stride)
55 101
56 if len(ret) == 0 {102 if len(ret) == 0 {
57- panic("no return value specified for Capture")103+ panic("no return value specified for Read")
58 }104 }
59 105
60 var r0 error106 var r0 error
@@ -66,20 +112,20 @@ func (_mock *MockRenderer) Capture(region image.Rectangle, dst []byte, stride in
66 return r0112 return r0
67 }113 }
68 114
69-// MockRenderer_Capture_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Capture'115+// MockCaptureFrame_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
70-type MockRenderer_Capture_Call struct {116+type MockCaptureFrame_Read_Call struct {
71 *mock.Call117 *mock.Call
72 }118 }
73 119
74-// Capture is a helper method to define mock.On call120+// Read is a helper method to define mock.On call
75 // - region image.Rectangle121 // - region image.Rectangle
76 // - dst []byte122 // - dst []byte
77 // - stride int123 // - stride int
78-func (_e *MockRenderer_Expecter) Capture(region any, dst any, stride any) *MockRenderer_Capture_Call {124+func (_e *MockCaptureFrame_Expecter) Read(region any, dst any, stride any) *MockCaptureFrame_Read_Call {
79- return &MockRenderer_Capture_Call{Call: _e.mock.On("Capture", region, dst, stride)}125+ return &MockCaptureFrame_Read_Call{Call: _e.mock.On("Read", region, dst, stride)}
80 }126 }
81 127
82-func (_c *MockRenderer_Capture_Call) Run(run func(region image.Rectangle, dst []byte, stride int)) *MockRenderer_Capture_Call {128+func (_c *MockCaptureFrame_Read_Call) Run(run func(region image.Rectangle, dst []byte, stride int)) *MockCaptureFrame_Read_Call {
83 _c.Call.Run(func(args mock.Arguments) {129 _c.Call.Run(func(args mock.Arguments) {
84 var arg0 image.Rectangle130 var arg0 image.Rectangle
85 if args[0] != nil {131 if args[0] != nil {
@@ -102,12 +148,103 @@ func (_c *MockRenderer_Capture_Call) Run(run func(region image.Rectangle, dst []
102 return _c148 return _c
103 }149 }
104 150
105-func (_c *MockRenderer_Capture_Call) Return(err error) *MockRenderer_Capture_Call {151+func (_c *MockCaptureFrame_Read_Call) Return(err error) *MockCaptureFrame_Read_Call {
106 _c.Call.Return(err)152 _c.Call.Return(err)
107 return _c153 return _c
108 }154 }
109 155
110-func (_c *MockRenderer_Capture_Call) RunAndReturn(run func(region image.Rectangle, dst []byte, stride int) error) *MockRenderer_Capture_Call {156+func (_c *MockCaptureFrame_Read_Call) RunAndReturn(run func(region image.Rectangle, dst []byte, stride int) error) *MockCaptureFrame_Read_Call {
157+ _c.Call.Return(run)
158+ return _c
159+}
160+
161+// NewMockRenderer creates a new instance of MockRenderer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
162+// The first argument is typically a *testing.T value.
163+func NewMockRenderer(t interface {
164+ mock.TestingT
165+ Cleanup(func())
166+}) *MockRenderer {
167+ if helper, ok := t.(interface{ Helper() }); ok {
168+ helper.Helper()
169+ }
170+
171+ mock := &MockRenderer{}
172+ mock.Mock.Test(t)
173+
174+ t.Cleanup(func() {
175+ if helper, ok := t.(interface{ Helper() }); ok {
176+ helper.Helper()
177+ }
178+ mock.AssertExpectations(t)
179+ })
180+
181+ return mock
182+}
183+
184+// MockRenderer is an autogenerated mock type for the Renderer type
185+type MockRenderer struct {
186+ mock.Mock
187+}
188+
189+type MockRenderer_Expecter struct {
190+ mock *mock.Mock
191+}
192+
193+func (_m *MockRenderer) EXPECT() *MockRenderer_Expecter {
194+ return &MockRenderer_Expecter{mock: &_m.Mock}
195+}
196+
197+// BeginCapture provides a mock function for the type MockRenderer
198+func (_mock *MockRenderer) BeginCapture() (ports.CaptureFrame, error) {
199+ ret := _mock.Called()
200+
201+ if len(ret) == 0 {
202+ panic("no return value specified for BeginCapture")
203+ }
204+
205+ var r0 ports.CaptureFrame
206+ var r1 error
207+ if returnFunc, ok := ret.Get(0).(func() (ports.CaptureFrame, error)); ok {
208+ return returnFunc()
209+ }
210+ if returnFunc, ok := ret.Get(0).(func() ports.CaptureFrame); ok {
211+ r0 = returnFunc()
212+ } else {
213+ if ret.Get(0) != nil {
214+ r0 = ret.Get(0).(ports.CaptureFrame)
215+ }
216+ }
217+ if returnFunc, ok := ret.Get(1).(func() error); ok {
218+ r1 = returnFunc()
219+ } else {
220+ r1 = ret.Error(1)
221+ }
222+ return r0, r1
223+}
224+
225+// MockRenderer_BeginCapture_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginCapture'
226+type MockRenderer_BeginCapture_Call struct {
227+ *mock.Call
228+}
229+
230+// BeginCapture is a helper method to define mock.On call
231+func (_e *MockRenderer_Expecter) BeginCapture() *MockRenderer_BeginCapture_Call {
232+ return &MockRenderer_BeginCapture_Call{Call: _e.mock.On("BeginCapture")}
233+}
234+
235+func (_c *MockRenderer_BeginCapture_Call) Run(run func()) *MockRenderer_BeginCapture_Call {
236+ _c.Call.Run(func(args mock.Arguments) {
237+ run()
238+ })
239+ return _c
240+}
241+
242+func (_c *MockRenderer_BeginCapture_Call) Return(captureFrame ports.CaptureFrame, err error) *MockRenderer_BeginCapture_Call {
243+ _c.Call.Return(captureFrame, err)
244+ return _c
245+}
246+
247+func (_c *MockRenderer_BeginCapture_Call) RunAndReturn(run func() (ports.CaptureFrame, error)) *MockRenderer_BeginCapture_Call {
111 _c.Call.Return(run)248 _c.Call.Return(run)
112 return _c249 return _c
113 }250 }
@@ -207,6 +344,46 @@ func (_c *MockRenderer_CursorBuffers_Call) RunAndReturn(run func(size int) ([2]p
207 return _c344 return _c
208 }345 }
209 346
347+// EndCapture provides a mock function for the type MockRenderer
348+func (_mock *MockRenderer) EndCapture(captureFrame ports.CaptureFrame) {
349+ _mock.Called(captureFrame)
350+ return
351+}
352+
353+// MockRenderer_EndCapture_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EndCapture'
354+type MockRenderer_EndCapture_Call struct {
355+ *mock.Call
356+}
357+
358+// EndCapture is a helper method to define mock.On call
359+// - captureFrame ports.CaptureFrame
360+func (_e *MockRenderer_Expecter) EndCapture(captureFrame any) *MockRenderer_EndCapture_Call {
361+ return &MockRenderer_EndCapture_Call{Call: _e.mock.On("EndCapture", captureFrame)}
362+}
363+
364+func (_c *MockRenderer_EndCapture_Call) Run(run func(captureFrame ports.CaptureFrame)) *MockRenderer_EndCapture_Call {
365+ _c.Call.Run(func(args mock.Arguments) {
366+ var arg0 ports.CaptureFrame
367+ if args[0] != nil {
368+ arg0 = args[0].(ports.CaptureFrame)
369+ }
370+ run(
371+ arg0,
372+ )
373+ })
374+ return _c
375+}
376+
377+func (_c *MockRenderer_EndCapture_Call) Return() *MockRenderer_EndCapture_Call {
378+ _c.Call.Return()
379+ return _c
380+}
381+
382+func (_c *MockRenderer_EndCapture_Call) RunAndReturn(run func(captureFrame ports.CaptureFrame)) *MockRenderer_EndCapture_Call {
383+ _c.Run(run)
384+ return _c
385+}
386+
210 // ExportTargets provides a mock function for the type MockRenderer387 // ExportTargets provides a mock function for the type MockRenderer
211 func (_mock *MockRenderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {388 func (_mock *MockRenderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {
212 ret := _mock.Called(n, modifiers)389 ret := _mock.Called(n, modifiers)
M internal/ports/devices.go +33 -3
@@ -1,11 +1,33 @@
1 package ports1 package ports
2 2
3 import (3 import (
4+ "errors"
4 "image"5 "image"
5 "os"6 "os"
6 "time"7 "time"
7 )8 )
8 9
10+// MaxCaptureInflight bounds the capture requests admitted but not yet
11+// answered, across outputs. The reply channel of the production app has
12+// at least this capacity, so an output never blocks on a reply.
13+const MaxCaptureInflight = 32
14+
15+// ErrCaptureBusy: every capture slot of the renderer is leased.
16+var ErrCaptureBusy = errors.New("capture: no free slot")
17+
18+// CaptureFrame is a lease on one renderer readback slot holding a GPU
19+// copy of a rendered frame. Done and Read make no Vulkan call: a worker
20+// goroutine waits on Done, then reads. EndCapture returns the lease.
21+type CaptureFrame interface {
22+ // Done is the sync file signalled when the copy finished; nil means it
23+ // had finished when BeginCapture returned. The frame owns it.
24+ Done() *os.File
25+ // Read copies region (output pixels) as opaque sRGB8 BGRA rows into
26+ // dst, stride bytes per row. Valid after Done signalled and before
27+ // EndCapture.
28+ Read(region image.Rectangle, dst []byte, stride int) error
29+}
30+
9 // Renderer draws scenes for an output. One goroutine owns it. Frames go31 // Renderer draws scenes for an output. One goroutine owns it. Frames go
10 // to its own image until ExportTargets gives it scanout images; then each32 // to its own image until ExportTargets gives it scanout images; then each
11 // Render draws into the target chosen by UseTarget (ADR 014: no CPU pass).33 // Render draws into the target chosen by UseTarget (ADR 014: no CPU pass).
@@ -30,10 +52,18 @@ type Renderer interface {
30 // WriteCursor fills cursor image i with premultiplied ARGB8888 pixels52 // WriteCursor fills cursor image i with premultiplied ARGB8888 pixels
31 // (w×h, w*4 per row), cropped to the image and cleared around it.53 // (w×h, w*4 per row), cropped to the image and cleared around it.
32 WriteCursor(i int, pixels []byte, w, h int) error54 WriteCursor(i int, pixels []byte, w, h int) error
33- // Pixels reads the last frame back (headless screenshots, tests).55+ // Pixels reads the last frame back (headless screenshots, tests). It
56+ // waits for the GPU: debug only. Nil when it cannot (every capture
57+ // slot leased, or the copy failed).
34 Pixels() *image.RGBA58 Pixels() *image.RGBA
35- // Capture copies a region of the last rendered frame as opaque BGRA.59+ // BeginCapture submits a GPU copy of the last rendered frame into a
36- Capture(region image.Rectangle, dst []byte, stride int) error60+ // free slot and returns without waiting. ErrCaptureBusy when every
61+ // slot is leased; another error when the copy cannot be tracked (no
62+ // sync file): the caller fails the request.
63+ BeginCapture() (CaptureFrame, error)
64+ // EndCapture returns a lease. It never waits: a slot whose copy may
65+ // still run is reused only once the renderer sees it finished.
66+ EndCapture(CaptureFrame)
37 // Trim frees client buffer caches left undrawn for a while and what67 // Trim frees client buffer caches left undrawn for a while and what
38 // finished frames held, without rendering. Outputs call it68 // finished frames held, without rendering. Outputs call it
39 // periodically: an idle output renders no frame to free them.69 // periodically: an idle output renders no frame to free them.

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 23

Tags 10

↑↓ moveenter openesc close