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 Unverified
B5690EEEBB952194The key is unknown
48 files · +3351 -490
M .mockery.yml +8 -0
| @@ -13,6 +13,7 @@ packages: | |||
| 13 | 13 | pkgname: portsmocks | |
| 14 | 14 | filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go' | |
| 15 | 15 | interfaces: | |
| 16 | + | CaptureFrame: | |
| 16 | 17 | Clock: | |
| 17 | 18 | Notifier: | |
| 18 | 19 | OutputScaleStore: | |
| @@ -28,6 +29,13 @@ packages: | |||
| 28 | 29 | dir: internal/adapters/drm | |
| 29 | 30 | pkgname: drm | |
| 30 | 31 | 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 | 39 | github.com/bnema/neferwl/internal/adapters/wayland: | |
| 32 | 40 | interfaces: | |
| 33 | 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 | 78 | - Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged. | |
| 79 | 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 | 81 | - The display gets the metadata from its own EDID, not the client's MaxCLL/MaxFALL. | |
| 82 | 82 | ||
| 83 | 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 | 7 | 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 | 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 | 5 | ## Design | |
| 6 | 6 | ||
| 7 | 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 | 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 | 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 | 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 | 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 | 7 | "context" | |
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | + | "runtime/debug" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | 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 | 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 | 30 | if req.Dst.File != nil { | |
| 18 | 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 | 41 | // A nil channel is allowed: cancellation still releases the sender. | |
| 21 | 42 | select { | |
| 22 | 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 | 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 | 62 | var err error | |
| 42 | 63 | 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 | 67 | defer func() { | |
| 68 | + | if p := recover(); p != nil { | |
| 69 | + | err = fmt.Errorf("capture read panic: %v\n%s", p, debug.Stack()) | |
| 70 | + | } | |
| 44 | 71 | if data != nil { | |
| 45 | 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 | 77 | err = fmt.Errorf("invalid capture destination") | |
| 51 | 78 | return | |
| 52 | 79 | } | |
| 53 | - | size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + int64(req.Width)*4 | |
| 54 | - | if size <= 0 || size > int64(^uint(0)>>1) || size > 1<<30 { | |
| 80 | + | rowBytes := int64(req.Width) * 4 | |
| 81 | + | // Validate the bound before multiplication: padding must not wrap size. | |
| 82 | + | if int64(req.Dst.Offset) > maxMappedBytes-rowBytes || (req.Height > 1 && int64(req.Stride) > (maxMappedBytes-int64(req.Dst.Offset)-rowBytes)/int64(req.Height-1)) { | |
| 83 | + | err = fmt.Errorf("capture too large") | |
| 84 | + | return | |
| 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 | 88 | err = fmt.Errorf("capture too large") | |
| 56 | 89 | return | |
| 57 | 90 | } | |
| 58 | - | st, e := req.Dst.File.Stat() | |
| 59 | - | if e != nil { | |
| 60 | - | err = e | |
| 91 | + | var st unix.Stat_t | |
| 92 | + | if e := unix.Fstat(int(req.Dst.File.Fd()), &st); e != nil { | |
| 93 | + | err = fmt.Errorf("fstat capture destination: %w", e) | |
| 61 | 94 | return | |
| 62 | 95 | } | |
| 63 | - | if st.Size() < size { | |
| 96 | + | if st.Size < size { | |
| 64 | 97 | err = fmt.Errorf("capture buffer truncated") | |
| 65 | 98 | return | |
| 66 | 99 | } | |
| 67 | 100 | // Map only for the duration of this capture; the renderer copies BGRA rows | |
| 68 | 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) | |
| 70 | - | if e != nil { | |
| 71 | - | err = e | |
| 102 | + | data, err = unix.Mmap(int(req.Dst.File.Fd()), 0, int(size), unix.PROT_WRITE, unix.MAP_SHARED) | |
| 103 | + | if err != nil { | |
| 72 | 104 | 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 | 57 | func TestFailCancelledWithFullReplies(t *testing.T) { | |
| 34 | 58 | ctx, cancel := context.WithCancel(context.Background()) | |
| 35 | 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 | 16 | // submitFrame puts one frame of the scene on screen: the fullscreen window | |
| 17 | 17 | // scanned out directly when the kernel takes it, else a composed frame with | |
| 18 | 18 | // an optional overlay plane, dropped when the kernel refuses it. Captures | |
| 19 | - | // force composition and are answered from the composed frame (they are | |
| 20 | - | // consumed either way). direct reports a scanned-out frame. | |
| 19 | + | // force composition and are answered from the composed frame; on renderError | |
| 20 | + | // the caller still owns the requests and must fail them. direct reports scanout. | |
| 21 | 21 | // | |
| 22 | 22 | // On success the frame is in flight; errOverlayDropped asks for a new frame | |
| 23 | 23 | // without the overlay; a renderError stops the output; other errors are | |
| 24 | 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 | 26 | // shows is what the frame puts on screen: presentation feedback of | |
| 27 | 27 | // windows it does not draw is discarded, not presented. | |
| 28 | 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 | 56 | if len(requests) > 0 { | |
| 57 | 57 | // Scanout and the overlay plane were skipped for this frame. | |
| 58 | 58 | o.log.Debug().Str("connector", o.conn.name).Int("captures", len(requests)).Msg("capture frame composed") | |
| 59 | - | } | |
| 60 | - | for _, q := range requests { | |
| 61 | - | capture.Write(ctx, q, r, captured) | |
| 59 | + | pipeline.Submit(r, requests) | |
| 60 | + | // Ownership moved to the worker (or an immediate failure reply). | |
| 61 | + | // A later commit panic must not fail the same requests again. | |
| 62 | + | clear(requests) | |
| 62 | 63 | } | |
| 63 | 64 | // The overlay buffer is on screen like a scanned-out one: it is | |
| 64 | 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 | 977 | var want ports.CursorChange | |
| 978 | 978 | cursorScale := -1.0 // not loaded yet | |
| 979 | 979 | frame := 0 | |
| 980 | - | var requests []ports.CaptureRequest | |
| 980 | + | var requestStorage [capture.MaxRequests]ports.CaptureRequest | |
| 981 | + | requests := requestStorage[:0] | |
| 981 | 982 | ctx, cancelCaptures := context.WithCancel(ctx) | |
| 983 | + | pipeline := capture.NewPipeline(ctx, captured) | |
| 982 | 984 | defer func() { | |
| 983 | 985 | cancelCaptures() | |
| 986 | + | pipeline.Close(r) | |
| 984 | 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 | 992 | for { | |
| 988 | 993 | select { | |
| @@ -1095,9 +1100,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1095 | 1100 | case <-gapDue: | |
| 1096 | 1101 | case <-ctx.Done(): | |
| 1097 | 1102 | return nil | |
| 1103 | + | case b := <-pipeline.Completed(): | |
| 1104 | + | pipeline.Recycle(b, r) | |
| 1105 | + | continue | |
| 1098 | 1106 | case q := <-captures: | |
| 1099 | 1107 | if !enabled || o.off || o.wantOff { | |
| 1100 | 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 | 1111 | } else { | |
| 1102 | 1112 | requests = append(requests, q) | |
| 1103 | 1113 | dirty = haveScene | |
| @@ -1183,7 +1193,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1183 | 1193 | for _, q := range requests { | |
| 1184 | 1194 | capture.Fail(ctx, q, fmt.Errorf("output off"), captured) | |
| 1185 | 1195 | } | |
| 1186 | - | requests = nil | |
| 1196 | + | clear(requests) | |
| 1197 | + | requests = requestStorage[:0] | |
| 1187 | 1198 | } | |
| 1188 | 1199 | if !enabled || o.frame.pendingCommit() { | |
| 1189 | 1200 | continue | |
| @@ -1224,9 +1235,16 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1224 | 1235 | o.flipGapUntil = time.Time{} | |
| 1225 | 1236 | } | |
| 1226 | 1237 | start := time.Now() | |
| 1227 | - | direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, captured) | |
| 1228 | - | requests = nil | |
| 1238 | + | direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, pipeline) | |
| 1229 | 1239 | 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 | 1248 | if errors.As(err, &fatal) { | |
| 1231 | 1249 | 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 | 815 | // A request must render even when the output already has an unchanged scene. | |
| 751 | 816 | func TestCaptureForcesDRMComposition(t *testing.T) { | |
| 752 | 817 | o, k, commits, commitMu := testOutputMu(t) | |
| @@ -767,7 +832,11 @@ func TestCaptureForcesDRMComposition(t *testing.T) { | |||
| 767 | 832 | rendered <- s | |
| 768 | 833 | 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 | 840 | r.EXPECT().Close().Return().Once() | |
| 772 | 841 | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice() | |
| 773 | 842 | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |
| @@ -813,7 +882,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) { | |||
| 813 | 882 | case <-time.After(2 * time.Second): | |
| 814 | 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 | 886 | if _, err := f.Stat(); err == nil { | |
| 818 | 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 | 76 | var scene ports.Scene | |
| 77 | 77 | haveScene, dirty := false, false | |
| 78 | 78 | frame := 0 | |
| 79 | - | var requests []ports.CaptureRequest | |
| 79 | + | var requestStorage [capture.MaxRequests]ports.CaptureRequest | |
| 80 | + | requests := requestStorage[:0] | |
| 80 | 81 | ctx, cancelCaptures := context.WithCancel(ctx) | |
| 82 | + | pipeline := capture.NewPipeline(ctx, opts.Captured) | |
| 81 | 83 | defer func() { | |
| 82 | 84 | cancelCaptures() | |
| 85 | + | pipeline.Close(r) | |
| 83 | 86 | for _, q := range requests { | |
| 84 | 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 | 126 | select { | |
| 124 | 127 | case <-ctx.Done(): | |
| 125 | 128 | return nil | |
| 129 | + | case b := <-pipeline.Completed(): | |
| 130 | + | pipeline.Recycle(b, r) | |
| 131 | + | continue | |
| 126 | 132 | case q := <-incoming: | |
| 127 | 133 | if scene.Off { | |
| 128 | 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 | 137 | } else { | |
| 130 | 138 | requests = append(requests, q) | |
| 131 | 139 | dirty = haveScene | |
| @@ -180,7 +188,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 180 | 188 | for _, q := range requests { | |
| 181 | 189 | capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured) | |
| 182 | 190 | } | |
| 183 | - | requests = nil | |
| 191 | + | clear(requests) | |
| 192 | + | requests = requestStorage[:0] | |
| 184 | 193 | } | |
| 185 | 194 | if !haveScene || !dirty || scene.Off { | |
| 186 | 195 | // 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 | 226 | if len(requests) > 0 { | |
| 218 | 227 | opts.Log.Debug().Str("output", opts.Name).Int("captures", len(requests)).Msg("capture frame composed") | |
| 219 | 228 | } | |
| 220 | - | for _, q := range requests { | |
| 221 | - | capture.Write(ctx, q, r, opts.Captured) | |
| 222 | - | } | |
| 223 | - | requests = nil | |
| 229 | + | pipeline.Submit(r, requests) | |
| 230 | + | clear(requests) | |
| 231 | + | requests = requestStorage[:0] | |
| 224 | 232 | frame++ | |
| 225 | 233 | pending = opts.flipped(pending, seen, scene, surfaces) | |
| 226 | 234 | if opts.ScreenshotDir != "" { | |
| 227 | 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 | 240 | if opts.Cursor != nil { | |
| 229 | 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 | 252 | replies := make(chan ports.CaptureDone, 1) | |
| 253 | 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 | 260 | ctx, cancel := context.WithCancel(context.Background()) | |
| 257 | 261 | done := make(chan error, 1) | |
| 258 | 262 | go func() { | |
| @@ -276,7 +280,7 @@ func TestCaptureForcesFreshHeadlessFrame(t *testing.T) { | |||
| 276 | 280 | case <-time.After(3 * time.Second): | |
| 277 | 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 | 284 | if _, err := f.Stat(); err == nil { | |
| 281 | 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, ®ion) | |
| 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 | 1 | package vulkan | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "errors" | |
| 4 | 7 | "image" | |
| 8 | + | "math" | |
| 9 | + | "os" | |
| 10 | + | "slices" | |
| 11 | + | "strings" | |
| 5 | 12 | "testing" | |
| 13 | + | "unsafe" | |
| 6 | 14 | ||
| 15 | + | "image/color" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/neferwl/internal/adapters/syncfile" | |
| 7 | 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 | 49 | func TestCaptureLastFrameRegion(t *testing.T) { | |
| 11 | 50 | r, err := New(8, 8) | |
| 12 | 51 | if err != nil { | |
| 13 | 52 | t.Skipf("Vulkan unavailable: %v", err) | |
| 14 | 53 | } | |
| 15 | 54 | defer r.Close() | |
| 55 | + | if _, err := r.BeginCapture(); err == nil { | |
| 56 | + | t.Fatal("capture before any frame accepted") | |
| 57 | + | } | |
| 16 | 58 | if err := render(r, ports.Scene{Background: "#123456"}, nil); err != nil { | |
| 17 | 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 | 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 | 70 | t.Fatal(err) | |
| 22 | 71 | } | |
| 23 | 72 | for _, at := range []int{0, 4, 12, 16} { | |
| @@ -25,7 +74,496 @@ func TestCaptureLastFrameRegion(t *testing.T) { | |||
| 25 | 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 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "image" | |
| 5 | 6 | "image/color" | |
| 6 | 7 | "os" | |
| 7 | 8 | "slices" | |
| @@ -34,11 +35,11 @@ func TestRendererScaledSHMFiltersLinearly(t *testing.T) { | |||
| 34 | 35 | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | |
| 35 | 36 | t.Fatal(err) | |
| 36 | 37 | } | |
| 37 | - | got := r.Pixels().RGBAAt(32, 8) | |
| 38 | + | got := readPixels(t, r).RGBAAt(32, 8) | |
| 38 | 39 | if !near(got, color.RGBA{128, 128, 128, 255}, 8) { | |
| 39 | 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 | 43 | t.Fatalf("left edge %v, want black (clamped)", got) | |
| 43 | 44 | } | |
| 44 | 45 | } | |
| @@ -61,7 +62,7 @@ func TestRendererSourceCrop(t *testing.T) { | |||
| 61 | 62 | x int | |
| 62 | 63 | want color.RGBA | |
| 63 | 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 | 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 | 95 | // Target x=64 (centre 64.5) maps to buffer 32.25: between texel 31 | |
| 95 | 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 | 98 | t.Fatalf("boundary %v, want a linear mix", got) | |
| 98 | 99 | } | |
| 99 | 100 | } | |
| @@ -184,7 +185,7 @@ func TestRendererSkipsOccludedSurface(t *testing.T) { | |||
| 184 | 185 | if r.copied != 16*16*4 { | |
| 185 | 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 | 189 | t.Fatalf("At(8,8)=%v want %v", got, green) | |
| 189 | 190 | } | |
| 190 | 191 | // A smaller opaque child leaves part of the root visible: it is copied. | |
| @@ -222,7 +223,7 @@ func TestRendererKeepsSurfaceBelowFailedCover(t *testing.T) { | |||
| 222 | 223 | if r.copied != 16*16*4 { | |
| 223 | 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 | 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 | 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 | 273 | t.Fatalf("pixel %v", got) | |
| 273 | 274 | } | |
| 274 | 275 | } | |
| @@ -302,3 +303,13 @@ func TestRendererImportsAcquireFence(t *testing.T) { | |||
| 302 | 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 | 76 | if err := render(full, ports.Scene{Background: scene.Background, Windows: scene.Windows}, contents); err != nil { | |
| 77 | 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 | 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 | 135 | if err := render(full, plain, contents); err != nil { | |
| 136 | 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 | 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 | 85 | if err := render(r, scene, contents); err != nil { | |
| 86 | 86 | t.Fatal(err) | |
| 87 | 87 | } | |
| 88 | - | px := r.Pixels() | |
| 88 | + | px := readPixels(t, r) | |
| 89 | 89 | for _, c := range []struct { | |
| 90 | 90 | x, y int | |
| 91 | 91 | want color.RGBA | |
M internal/adapters/vulkan/extended_linear_test.go +1 -1
| @@ -102,7 +102,7 @@ func TestExtendedLinearClient(t *testing.T) { | |||
| 102 | 102 | x int | |
| 103 | 103 | v byte | |
| 104 | 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 | 106 | if !near(got, color.RGBA{tc.v, tc.v, tc.v, 255}, 3) { | |
| 107 | 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 | 303 | oldWindows := tg.windows | |
| 304 | 304 | tg.hold(s, dmg) | |
| 305 | 305 | r.damageDrawn = oldWindows | |
| 306 | - | r.last, r.readBack = tg, false | |
| 306 | + | r.last = tg | |
| 307 | 307 | r.dropUnused() | |
| 308 | 308 | r.dropShm() | |
| 309 | 309 | if !r.syncFD { | |
M internal/adapters/vulkan/hdr.go +20 -12
| @@ -54,23 +54,14 @@ func (r *Renderer) SetHDR(nits float64) { | |||
| 54 | 54 | if r.hdrOwn.image == 0 { | |
| 55 | 55 | r.hdrError = r.createHDROwn() | |
| 56 | 56 | if r.hdrError != nil { | |
| 57 | - | r.freeTarget(&r.hdrOwn) | |
| 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) | |
| 57 | + | r.dropHDROwn() | |
| 67 | 58 | return | |
| 68 | 59 | } | |
| 69 | 60 | } | |
| 70 | 61 | if r.compose.hdrPipeline == 0 { | |
| 71 | 62 | r.hdrError = r.createGraphicsPipeline(composeVert, composeHDRFrag, vk.FormatR16g16b16a16Sfloat, r.compose.layout, true, &r.compose.hdrPipeline) | |
| 72 | 63 | if r.hdrError != nil { | |
| 73 | - | r.freeTarget(&r.hdrOwn) | |
| 64 | + | r.dropHDROwn() | |
| 74 | 65 | return | |
| 75 | 66 | } | |
| 76 | 67 | } | |
| @@ -81,7 +72,7 @@ func (r *Renderer) SetHDR(nits float64) { | |||
| 81 | 72 | r.hdrError = r.createHDR() | |
| 82 | 73 | if r.hdrError != nil { | |
| 83 | 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 | 193 | in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal | |
| 203 | 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 | 19 | t.Skipf("Vulkan unavailable: %v", err) | |
| 20 | 20 | } | |
| 21 | 21 | t.Cleanup(r.Close) | |
| 22 | - | if r.readbackBytes != 4 { | |
| 23 | - | t.Fatalf("SDR readback bytes/pixel = %d", r.readbackBytes) | |
| 24 | - | } | |
| 25 | 22 | r.SetHDR(203) | |
| 26 | - | if r.hdrError == nil && r.readbackBytes != 8 { | |
| 27 | - | t.Fatalf("HDR readback bytes/pixel = %d", r.readbackBytes) | |
| 28 | - | } | |
| 29 | 23 | r.hdrReadback = true | |
| 30 | 24 | if r.physical == 0 { | |
| 31 | 25 | t.Skip("no exportable GPU") | |
| @@ -42,19 +36,29 @@ func hdrTestRenderer(t *testing.T) *Renderer { | |||
| 42 | 36 | return r | |
| 43 | 37 | } | |
| 44 | 38 | ||
| 45 | - | // Readback of a test-only PQ target (production targets are not transfer-src). | |
| 46 | - | func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 { | |
| 39 | + | // pqTargetWords reads a test-only PQ target back (production targets are | |
| 40 | + | // not transfer-src), staging through a capture slot's buffer. | |
| 41 | + | func pqTargetWords(t *testing.T, r *Renderer) []uint32 { | |
| 47 | 42 | t.Helper() | |
| 48 | 43 | if err := r.waitFrame(r.submitted); err != nil { | |
| 49 | 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 | 53 | target := r.targets[0] | |
| 52 | 54 | err := r.oneShot(func(cmd vk.CommandBuffer) { | |
| 53 | 55 | d := r.dd | |
| 54 | 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 | 57 | d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |
| 56 | 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, ®ion) | |
| 59 | + | d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, slot.buffer, 1, ®ion) | |
| 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 | 62 | b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0 | |
| 59 | 63 | b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral | |
| 60 | 64 | 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 | 67 | if err != nil { | |
| 64 | 68 | t.Fatal(err) | |
| 65 | 69 | } | |
| 66 | - | words := unsafe.Slice((*uint32)(r.mapped), r.width*r.height) | |
| 67 | - | pixel := words[y*r.width+x] | |
| 70 | + | return unsafe.Slice((*uint32)(slot.mapped), r.width*r.height) | |
| 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 | 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 | 114 | if math.Abs(neutral[0]-pqEncode(1000)) > .008 { | |
| 107 | 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) { | |
| 110 | - | t.Fatalf("clipped capture pixel %v", px) | |
| 111 | - | } | |
| 112 | - | dst := make([]byte, 8) | |
| 113 | - | if err := r.Capture(image.Rect(5, 8, 7, 9), dst, 8); err != nil { | |
| 114 | - | t.Fatal(err) | |
| 115 | - | } | |
| 116 | - | if dst[0] != 255 || dst[1] != 255 || dst[2] != 255 || dst[3] != 255 { | |
| 117 | - | t.Fatalf("capture %v", dst) | |
| 117 | + | // SDR capture tone-maps: the 1000-nit white lands on the shoulder | |
| 118 | + | // (bright, not saturated) and the primaries keep their hue. | |
| 119 | + | want := captureSDR([3]float64{1000.0 / 203, 1000.0 / 203, 1000.0 / 203}) | |
| 120 | + | if px := readPixels(t, r).RGBAAt(5, 8); !near(px, color.RGBA{want[0], want[1], want[2], 255}, 2) { | |
| 121 | + | t.Fatalf("tone-mapped capture pixel %v, want %v", px, want) | |
| 122 | + | } | |
| 123 | + | t.Run("async capture", func(t *testing.T) { | |
| 124 | + | cf := captureWait(t, r) | |
| 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 | 145 | before := r.redrawn | |
| 120 | 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 | 92 | // Composition blends premultiplied source onto a black opaque fill. | |
| 93 | 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 | 96 | if !near(got, want, 4) { | |
| 97 | 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 | 40 | physical vk.PhysicalDevice | |
| 41 | 41 | // One converted cursor image is kept; changes replace it in bounded memory. | |
| 42 | 42 | cursorCache cursorConversion | |
| 43 | - | // last is the target of the last frame; readback copies it into | |
| 44 | - | // buffer only when Pixels asks (stale until then). | |
| 45 | - | last *target | |
| 46 | - | readBack bool | |
| 47 | - | buffer vk.Buffer | |
| 48 | - | bufferMemory vk.DeviceMemory | |
| 49 | - | readbackBytes int // bytes per pixel allocated for the mapped readback buffer | |
| 50 | - | mapped unsafe.Pointer | |
| 51 | - | pool vk.CommandPool | |
| 52 | - | memory vk.PhysicalDeviceMemoryProperties | |
| 53 | - | family uint32 | |
| 43 | + | // last is the target of the last frame: what captures copy. | |
| 44 | + | last *target | |
| 45 | + | // captures are the readback slots (capture.go), allocated on the | |
| 46 | + | // first capture; capturePass converts HDR frames into captureSDR. | |
| 47 | + | captures []*captureSlot | |
| 48 | + | capturePass hdrPass | |
| 49 | + | captureSDR target | |
| 50 | + | sync captureSync | |
| 51 | + | pool vk.CommandPool | |
| 52 | + | memory vk.PhysicalDeviceMemoryProperties | |
| 53 | + | family uint32 | |
| 54 | 54 | // Frames in flight (frame.go): frame is the last frame recorded, | |
| 55 | 55 | // submitted the last submitted, completed the last known finished. | |
| 56 | 56 | slots [frameSlots]frameSlot | |
| @@ -295,9 +295,6 @@ func New(width, height int) (r *Renderer, err error) { | |||
| 295 | 295 | if err = checked("vkBindImageMemory", r.dd.BindImageMemory(r.device, r.own.image, r.own.memory, 0)); err != nil { | |
| 296 | 296 | return | |
| 297 | 297 | } | |
| 298 | - | if err = r.createReadback(4); err != nil { | |
| 299 | - | return | |
| 300 | - | } | |
| 301 | 298 | pi := vk.CommandPoolCreateInfo{SType: vk.StructureTypeCommandPoolCreateInfo, Flags: vk.CommandPoolCreateResetCommandBufferBit, QueueFamilyIndex: family} | |
| 302 | 299 | if err = checked("vkCreateCommandPool", r.dd.CreateCommandPool(r.device, &pi, nil, &r.pool)); err != nil { | |
| 303 | 300 | return | |
| @@ -315,65 +312,6 @@ func New(width, height int) (r *Renderer, err error) { | |||
| 315 | 312 | 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 | 315 | func (r *Renderer) findMemoryType(bits uint32, props vk.MemoryPropertyFlags) (uint32, error) { | |
| 378 | 316 | for i := uint32(0); i < r.memory.MemoryTypeCount; i++ { | |
| 379 | 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 | 329 | // default. | |
| 392 | 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 | 336 | func (r *Renderer) Pixels() *image.RGBA { | |
| 395 | - | out := image.NewRGBA(image.Rect(0, 0, r.width, r.height)) | |
| 396 | - | if r.mapped == nil || r.readback() != nil { | |
| 397 | - | return out | |
| 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 | |
| 337 | + | cf, err := r.debugCapture() | |
| 338 | + | if err != nil { | |
| 339 | + | return nil | |
| 423 | 340 | } | |
| 424 | - | if r.last == &r.hdrOwn { | |
| 425 | - | r.hdrRegion(dst, stride, region.Min.X, region.Min.Y, w, h, false) | |
| 341 | + | defer r.EndCapture(cf) | |
| 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 | 344 | return nil | |
| 427 | 345 | } | |
| 428 | - | src := unsafe.Slice((*byte)(r.mapped), r.width*r.height*4) | |
| 429 | - | for y := range h { | |
| 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 | - | } | |
| 346 | + | for i := 0; i < len(out.Pix); i += 4 { | |
| 347 | + | out.Pix[i], out.Pix[i+2] = out.Pix[i+2], out.Pix[i] | |
| 436 | 348 | } | |
| 437 | - | return nil | |
| 349 | + | return out | |
| 438 | 350 | } | |
| 439 | 351 | ||
| 440 | 352 | // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE. | |
| @@ -443,46 +355,6 @@ const ( | |||
| 443 | 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, ®ion) | |
| 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 | 358 | func (r *Renderer) Close() { | |
| 487 | 359 | if r == nil { | |
| 488 | 360 | return | |
| @@ -504,22 +376,11 @@ func (r *Renderer) Close() { | |||
| 504 | 376 | r.dropPools() | |
| 505 | 377 | r.dropShm() | |
| 506 | 378 | r.destroySlots() | |
| 379 | + | r.destroyCaptures() | |
| 507 | 380 | if r.pool != 0 { | |
| 508 | 381 | d.DestroyCommandPool(r.device, r.pool, nil) | |
| 509 | 382 | 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 | 384 | r.destroyComposer() | |
| 524 | 385 | r.freeTarget(&r.hdrOwn) | |
| 525 | 386 | r.freeTarget(&r.own) | |
M internal/adapters/vulkan/renderer_test.go +16 -16
| @@ -19,7 +19,7 @@ func TestRendererClear(t *testing.T) { | |||
| 19 | 19 | if err := r.Clear([3]uint8{0x10, 0x20, 0x30}); err != nil { | |
| 20 | 20 | t.Fatal(err) | |
| 21 | 21 | } | |
| 22 | - | pixels := r.Pixels() | |
| 22 | + | pixels := readPixels(t, r) | |
| 23 | 23 | want := color.RGBA{0x10, 0x20, 0x30, 255} | |
| 24 | 24 | for _, p := range [][2]int{{0, 0}, {63, 47}} { | |
| 25 | 25 | if got := pixels.At(p[0], p[1]); got != want { | |
| @@ -56,7 +56,7 @@ func TestRendererRender(t *testing.T) { | |||
| 56 | 56 | t.Fatal(err) | |
| 57 | 57 | } | |
| 58 | 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 | 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 | 106 | if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil { | |
| 107 | 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 | 110 | t.Fatalf("faded pixel %v", got) | |
| 111 | 111 | } | |
| 112 | 112 | } | |
| @@ -135,7 +135,7 @@ func TestRendererContents(t *testing.T) { | |||
| 135 | 135 | } | |
| 136 | 136 | check := func(x, y int, want color.RGBA) { | |
| 137 | 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 | 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 | 173 | if err := render(r, scene, upper); err != nil { | |
| 174 | 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 | 177 | t.Errorf("overlap = %v, want %v", got, want) | |
| 178 | 178 | } | |
| 179 | 179 | scene.Border = ports.Border{Width: 4, Active: "#ffffff"} | |
| @@ -193,7 +193,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) { | |||
| 193 | 193 | x, y int | |
| 194 | 194 | want color.RGBA | |
| 195 | 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 | 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 | 219 | if err := render(r, scene, contents); err != nil { | |
| 220 | 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 | 223 | t.Errorf("pixel = %v, want %v", got, want) | |
| 224 | 224 | } | |
| 225 | 225 | } | |
| @@ -263,7 +263,7 @@ func TestRendererScale(t *testing.T) { | |||
| 263 | 263 | if err := render(r, s, content); err != nil { | |
| 264 | 264 | t.Fatal(err) | |
| 265 | 265 | } | |
| 266 | - | px := r.Pixels() | |
| 266 | + | px := readPixels(t, r) | |
| 267 | 267 | want := color.RGBA{200, 0, 0, 255} | |
| 268 | 268 | for _, p := range []image.Point{{8, 8}, {27, 27}} { | |
| 269 | 269 | if got := px.At(p.X, p.Y); got != want { | |
| @@ -281,7 +281,7 @@ func TestRendererScale(t *testing.T) { | |||
| 281 | 281 | if err := render(r, s, content); err != nil { | |
| 282 | 282 | t.Fatal(err) | |
| 283 | 283 | } | |
| 284 | - | px = r.Pixels() | |
| 284 | + | px = readPixels(t, r) | |
| 285 | 285 | for _, p := range []image.Point{{6, 6}, {20, 20}} { | |
| 286 | 286 | if got := px.At(p.X, p.Y); got != want { | |
| 287 | 287 | t.Errorf("1.5 %v = %v", p, got) | |
| @@ -297,7 +297,7 @@ func TestRendererScale(t *testing.T) { | |||
| 297 | 297 | if err := render(r, s, content); err != nil { | |
| 298 | 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 | 301 | t.Errorf("clipped column = %v", got) | |
| 302 | 302 | } | |
| 303 | 303 | } | |
| @@ -326,7 +326,7 @@ func TestRendererPreview(t *testing.T) { | |||
| 326 | 326 | if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | |
| 327 | 327 | t.Fatal(err) | |
| 328 | 328 | } | |
| 329 | - | out := r.Pixels() | |
| 329 | + | out := readPixels(t, r) | |
| 330 | 330 | for p, want := range map[image.Point]color.RGBA{ | |
| 331 | 331 | {5, 5}: {200, 0, 0, 255}, | |
| 332 | 332 | {22, 12}: {0, 0, 200, 255}, | |
| @@ -360,7 +360,7 @@ func TestRendererPreviewSmooth(t *testing.T) { | |||
| 360 | 360 | if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | |
| 361 | 361 | t.Fatal(err) | |
| 362 | 362 | } | |
| 363 | - | out := r.Pixels() | |
| 363 | + | out := readPixels(t, r) | |
| 364 | 364 | for x := 1; x < 19; x++ { | |
| 365 | 365 | if v := out.RGBAAt(x, 5).R; v < 100 || v > 155 { | |
| 366 | 366 | t.Fatalf("x=%d: %d, want mid gray", x, v) | |
| @@ -425,7 +425,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) { | |||
| 425 | 425 | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: root}); err != nil { | |
| 426 | 426 | t.Fatal(err) | |
| 427 | 427 | } | |
| 428 | - | px := r.Pixels() | |
| 428 | + | px := readPixels(t, r) | |
| 429 | 429 | for _, c := range []struct { | |
| 430 | 430 | x, y int | |
| 431 | 431 | want color.RGBA | |
| @@ -443,7 +443,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) { | |||
| 443 | 443 | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: only}); err != nil { | |
| 444 | 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 | 447 | t.Errorf("child only: %v", got) | |
| 448 | 448 | } | |
| 449 | 449 | } | |
| @@ -462,7 +462,7 @@ func TestRendererTallFill(t *testing.T) { | |||
| 462 | 462 | t.Fatal(err) | |
| 463 | 463 | } | |
| 464 | 464 | bg := color.RGBA{16, 32, 48, 255} | |
| 465 | - | px := r.Pixels() | |
| 465 | + | px := readPixels(t, r) | |
| 466 | 466 | for y := 0; y < h; y++ { | |
| 467 | 467 | if got := px.At(1, y); got != wc { | |
| 468 | 468 | t.Fatalf("At(1,%d)=%v want %v", y, got, wc) | |
| @@ -517,7 +517,7 @@ func TestRendererAlphaBlend(t *testing.T) { | |||
| 517 | 517 | if err := render(r, scene, contents); err != nil { | |
| 518 | 518 | t.Fatal(err) | |
| 519 | 519 | } | |
| 520 | - | px := r.Pixels() | |
| 520 | + | px := readPixels(t, r) | |
| 521 | 521 | near := func(a, b uint8) bool { d := int(a) - int(b); return d >= -1 && d <= 1 } | |
| 522 | 522 | for _, tc := range []struct { | |
| 523 | 523 | name string | |
M internal/adapters/vulkan/scene_dim_test.go +4 -4
| @@ -161,7 +161,7 @@ func TestRendererDimBlendAndDamage(t *testing.T) { | |||
| 161 | 161 | {43, 5, color.RGBA{255, 255, 255, 255}}, // window popup | |
| 162 | 162 | {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 | 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 | 186 | if r.redrawn-before >= 64*48 { | |
| 187 | 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 | 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 | 221 | x, y int | |
| 222 | 222 | want color.RGBA | |
| 223 | 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 | 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 | 235 | if err := render(r, s, contents); err != nil { | |
| 236 | 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 | 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 | 1 | package vulkan | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "image" | |
| 4 | 5 | "image/color" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -57,7 +58,7 @@ func TestSceneWalkUnchangedTiledSHM(t *testing.T) { | |||
| 57 | 58 | for _, count := range []int{16, 64} { | |
| 58 | 59 | contents[1] = ports.SurfaceContent{ID: 1, Seq: root.Seq, Surface: root.Surface, Version: root.Version, Children: root.Children[:count]} | |
| 59 | 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 | 62 | _ = r.draws(scene, contents, dmg) | |
| 62 | 63 | } | |
| 63 | 64 | walk() | |
| @@ -101,7 +102,7 @@ func TestSceneWalkUnchangedTiledSHM(t *testing.T) { | |||
| 101 | 102 | if err := render(r, scene, contents); err != nil { | |
| 102 | 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 | 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 | 4 | "image/color" | |
| 5 | 5 | "math" | |
| 6 | 6 | "testing" | |
| 7 | - | "unsafe" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 10 | 9 | vk "github.com/bnema/purego-vulkan/vulkan" | |
| @@ -38,7 +37,7 @@ func TestExportTargets(t *testing.T) { | |||
| 38 | 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 | 41 | t.Fatalf("readback of the last target %v", got) | |
| 43 | 42 | } | |
| 44 | 43 | // Import target 0 as a client buffer, like the display would. | |
| @@ -61,7 +60,7 @@ func TestExportTargets(t *testing.T) { | |||
| 61 | 60 | if err := render(viewer, scene, contents); err != nil { | |
| 62 | 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 | 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 | 122 | if r.hdrOwn.layout != vk.ImageLayoutTransferSrcOptimal { | |
| 124 | 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 | 126 | t.Fatalf("SDR readback: %v", got) | |
| 128 | 127 | } | |
| 129 | 128 | } | |
| @@ -131,25 +130,7 @@ func TestHDRExportTarget(t *testing.T) { | |||
| 131 | 130 | // Test-only target readback: production HDR images do not have transfer-src. | |
| 132 | 131 | func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) { | |
| 133 | 132 | t.Helper() | |
| 134 | - | if err := r.waitFrame(r.submitted); err != nil { | |
| 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, ®ion) | |
| 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)) | |
| 133 | + | v := pqTargetWords(t, r)[0] | |
| 153 | 134 | red, green, blue := hdrPixel(rgb[0], rgb[1], rgb[2], 203) | |
| 154 | 135 | for i, want := range []float64{blue, green, red} { | |
| 155 | 136 | 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 | 50 | for x := range sw { | |
| 51 | 51 | bx, by := tr.ToBuffer(float64(x)+0.5, float64(y)+0.5, float64(sw), float64(sh)) | |
| 52 | 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 | 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 | 80 | if r.copied != copied+16*16*4 { | |
| 81 | 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 | 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 | 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}) { | |
| 152 | - | t.Fatalf("capture %v", px) | |
| 151 | + | // SDR capture tone-maps the 1000-nit white onto the shoulder. | |
| 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 | 157 | return | |
| 155 | 158 | } | |
| 156 | - | got := r.Pixels().RGBAAt(8, 8) | |
| 159 | + | got := readPixels(t, r).RGBAAt(8, 8) | |
| 157 | 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 | 161 | if !near(got, want, 8) { | |
| 159 | 162 | t.Errorf("got %v want %v", got, want) | |
| @@ -184,7 +187,7 @@ func TestYUVSourceCropEdges(t *testing.T) { | |||
| 184 | 187 | t.Fatal(err) | |
| 185 | 188 | } | |
| 186 | 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 | 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 | 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 | 23 | // The renderer's scene and wl_output modes are already in physical orientation; | |
| 19 | 24 | // this compositor does not advertise rotated wl_output transforms. | |
| 20 | 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 | 161 | s.log.Debug().Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Msg("capture buffer rejected") | |
| 157 | 162 | 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 | 168 | buf := s.buffers[b.Resource].(*buffer) | |
| 160 | 169 | fd, err := unix.Dup(int(buf.pool.file.Fd())) | |
| 161 | 170 | if err != nil { | |
| @@ -164,6 +173,7 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b | |||
| 164 | 173 | } | |
| 165 | 174 | s.nextCapture++ | |
| 166 | 175 | id := s.nextCapture | |
| 176 | + | s.captureInflight[id] = struct{}{} | |
| 167 | 177 | start := time.Now() | |
| 168 | 178 | s.captureReplies[id] = func(done ports.CaptureDone) { | |
| 169 | 179 | ev := s.log.Debug() | |
| @@ -182,8 +192,9 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b | |||
| 182 | 192 | return id, true | |
| 183 | 193 | default: | |
| 184 | 194 | delete(s.captureReplies, id) | |
| 195 | + | delete(s.captureInflight, id) | |
| 185 | 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 | 198 | return 0, false | |
| 188 | 199 | } | |
| 189 | 200 | } | |
| @@ -199,6 +210,7 @@ func (s *Server) forwardCaptured(ctxDone <-chan struct{}) { | |||
| 199 | 210 | return | |
| 200 | 211 | } | |
| 201 | 212 | if !s.display.Do(func() { | |
| 213 | + | delete(s.captureInflight, done.ID) | |
| 202 | 214 | if fn := s.captureReplies[done.ID]; fn != nil { | |
| 203 | 215 | delete(s.captureReplies, done.ID) | |
| 204 | 216 | fn(done) | |
M internal/adapters/wayland/server.go +21 -11
| @@ -50,8 +50,14 @@ type Channels struct { | |||
| 50 | 50 | // Presented paces frame callbacks on the outputs' page flips; outputs | |
| 51 | 51 | // that do not flip (idle, headless) are paced at their refresh rate. | |
| 52 | 52 | Presented <-chan ports.OutputPresented | |
| 53 | - | Captures chan<- ports.CaptureRequest | |
| 54 | - | Captured <-chan ports.CaptureDone | |
| 53 | + | // Captures carries accepted capture requests, never blocking. Each | |
| 54 | + | // 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 | 61 | // OutputFormats are the outputs' direct scanout formats, offered in | |
| 56 | 62 | // dmabuf feedback to fullscreen surfaces. | |
| 57 | 63 | OutputFormats <-chan ports.OutputFormats | |
| @@ -70,15 +76,18 @@ type Server struct { | |||
| 70 | 76 | nextLeaseID uint64 | |
| 71 | 77 | nextCapture uint64 | |
| 72 | 78 | captureReplies map[uint64]func(ports.CaptureDone) | |
| 73 | - | captureSources map[*server.Resource]*output | |
| 74 | - | captureSessions map[*captureSession]struct{} | |
| 75 | - | display *server.Display | |
| 76 | - | env procEnv | |
| 77 | - | slotsPending bool // core waits for a slot window | |
| 78 | - | name string | |
| 79 | - | cleanup func() | |
| 80 | - | log zerowrap.Logger | |
| 81 | - | channels Channels | |
| 79 | + | // captureInflight counts accepted captures until the backend completes | |
| 80 | + | // them, independent of client resources; only the display loop touches it. | |
| 81 | + | captureInflight map[uint64]struct{} | |
| 82 | + | captureSources map[*server.Resource]*output | |
| 83 | + | captureSessions map[*captureSession]struct{} | |
| 84 | + | display *server.Display | |
| 85 | + | env procEnv | |
| 86 | + | slotsPending bool // core waits for a slot window | |
| 87 | + | name string | |
| 88 | + | cleanup func() | |
| 89 | + | log zerowrap.Logger | |
| 90 | + | channels Channels | |
| 82 | 91 | // awaiting holds frame callbacks by output name, due at its next | |
| 83 | 92 | // frame (frameDue, or its page flip). | |
| 84 | 93 | awaiting map[string][]*wayland.Callback | |
| @@ -237,6 +246,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 237 | 246 | s.clock = clock.System{} | |
| 238 | 247 | } | |
| 239 | 248 | s.captureReplies = map[uint64]func(ports.CaptureDone){} | |
| 249 | + | s.captureInflight = map[uint64]struct{}{} | |
| 240 | 250 | s.outputReplies = map[uint64]*outputConfiguration{} | |
| 241 | 251 | s.managementSerial = 1 | |
| 242 | 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 | 98 | // tells core about each flip (frames) to move running slides. | |
| 99 | 99 | flips := make(chan ports.OutputPresented, 64) | |
| 100 | 100 | frames := make(chan ports.OutputFrame, 8) | |
| 101 | - | captures := make(chan ports.CaptureRequest, 32) | |
| 102 | - | captured := make(chan ports.CaptureDone, 64) | |
| 101 | + | captures := make(chan ports.CaptureRequest, ports.MaxCaptureInflight) | |
| 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 | 105 | outputFormats := make(chan ports.OutputFormats, 8) | |
| 104 | 106 | outputHeads := make(chan ports.OutputHeads, 8) | |
| 105 | 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 | 120 | func TestDrainCapturesClosesQueuedFDs(t *testing.T) { | |
| 34 | 121 | ctx, cancel := context.WithCancel(context.Background()) | |
| 35 | 122 | cancel() | |
M internal/app/outputs.go +5 -3
| @@ -198,9 +198,11 @@ func (s *outputSet) routeCapture(req ports.CaptureRequest) { | |||
| 198 | 198 | case r.captures <- req: | |
| 199 | 199 | case <-r.done: | |
| 200 | 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 | 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 | 210 | for { | |
| 209 | 211 | select { | |
| 210 | 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 | 214 | default: | |
| 213 | 215 | return | |
| 214 | 216 | } | |
M internal/mocks/ports/devices_mock.go +197 -20
| @@ -13,17 +13,17 @@ import ( | |||
| 13 | 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 | 17 | // The first argument is typically a *testing.T value. | |
| 18 | - | func NewMockRenderer(t interface { | |
| 18 | + | func NewMockCaptureFrame(t interface { | |
| 19 | 19 | mock.TestingT | |
| 20 | 20 | Cleanup(func()) | |
| 21 | - | }) *MockRenderer { | |
| 21 | + | }) *MockCaptureFrame { | |
| 22 | 22 | if helper, ok := t.(interface{ Helper() }); ok { | |
| 23 | 23 | helper.Helper() | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | - | mock := &MockRenderer{} | |
| 26 | + | mock := &MockCaptureFrame{} | |
| 27 | 27 | mock.Mock.Test(t) | |
| 28 | 28 | ||
| 29 | 29 | t.Cleanup(func() { | |
| @@ -36,25 +36,71 @@ func NewMockRenderer(t interface { | |||
| 36 | 36 | return mock | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | - | // MockRenderer is an autogenerated mock type for the Renderer type | |
| 40 | - | type MockRenderer struct { | |
| 39 | + | // MockCaptureFrame is an autogenerated mock type for the CaptureFrame type | |
| 40 | + | type MockCaptureFrame struct { | |
| 41 | 41 | mock.Mock | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | - | type MockRenderer_Expecter struct { | |
| 44 | + | type MockCaptureFrame_Expecter struct { | |
| 45 | 45 | mock *mock.Mock | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | - | func (_m *MockRenderer) EXPECT() *MockRenderer_Expecter { | |
| 49 | - | return &MockRenderer_Expecter{mock: &_m.Mock} | |
| 48 | + | func (_m *MockCaptureFrame) EXPECT() *MockCaptureFrame_Expecter { | |
| 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 MockRenderer | |
| 53 | - | func (_mock *MockRenderer) Capture(region image.Rectangle, dst []byte, stride int) error { | |
| 88 | + | func (_c *MockCaptureFrame_Done_Call) Return(file *os.File) *MockCaptureFrame_Done_Call { | |
| 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 | 100 | ret := _mock.Called(region, dst, stride) | |
| 55 | 101 | ||
| 56 | 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 | 106 | var r0 error | |
| @@ -66,20 +112,20 @@ func (_mock *MockRenderer) Capture(region image.Rectangle, dst []byte, stride in | |||
| 66 | 112 | 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' | |
| 70 | - | type MockRenderer_Capture_Call struct { | |
| 115 | + | // MockCaptureFrame_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' | |
| 116 | + | type MockCaptureFrame_Read_Call struct { | |
| 71 | 117 | *mock.Call | |
| 72 | 118 | } | |
| 73 | 119 | ||
| 74 | - | // Capture is a helper method to define mock.On call | |
| 120 | + | // Read is a helper method to define mock.On call | |
| 75 | 121 | // - region image.Rectangle | |
| 76 | 122 | // - dst []byte | |
| 77 | 123 | // - stride int | |
| 78 | - | func (_e *MockRenderer_Expecter) Capture(region any, dst any, stride any) *MockRenderer_Capture_Call { | |
| 79 | - | return &MockRenderer_Capture_Call{Call: _e.mock.On("Capture", region, dst, stride)} | |
| 124 | + | func (_e *MockCaptureFrame_Expecter) Read(region any, dst any, stride any) *MockCaptureFrame_Read_Call { | |
| 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 | 129 | _c.Call.Run(func(args mock.Arguments) { | |
| 84 | 130 | var arg0 image.Rectangle | |
| 85 | 131 | if args[0] != nil { | |
| @@ -102,12 +148,103 @@ func (_c *MockRenderer_Capture_Call) Run(run func(region image.Rectangle, dst [] | |||
| 102 | 148 | 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 | 152 | _c.Call.Return(err) | |
| 107 | 153 | 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 | 248 | _c.Call.Return(run) | |
| 112 | 249 | return _c | |
| 113 | 250 | } | |
| @@ -207,6 +344,46 @@ func (_c *MockRenderer_CursorBuffers_Call) RunAndReturn(run func(size int) ([2]p | |||
| 207 | 344 | 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 | 387 | // ExportTargets provides a mock function for the type MockRenderer | |
| 211 | 388 | func (_mock *MockRenderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) { | |
| 212 | 389 | ret := _mock.Called(n, modifiers) | |
M internal/ports/devices.go +33 -3
| @@ -1,11 +1,33 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "image" | |
| 5 | 6 | "os" | |
| 6 | 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 | 31 | // Renderer draws scenes for an output. One goroutine owns it. Frames go | |
| 10 | 32 | // to its own image until ExportTargets gives it scanout images; then each | |
| 11 | 33 | // Render draws into the target chosen by UseTarget (ADR 014: no CPU pass). | |
| @@ -30,10 +52,18 @@ type Renderer interface { | |||
| 30 | 52 | // WriteCursor fills cursor image i with premultiplied ARGB8888 pixels | |
| 31 | 53 | // (w×h, w*4 per row), cropped to the image and cleared around it. | |
| 32 | 54 | 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 | 58 | Pixels() *image.RGBA | |
| 35 | - | // Capture copies a region of the last rendered frame as opaque BGRA. | |
| 36 | - | Capture(region image.Rectangle, dst []byte, stride int) error | |
| 59 | + | // BeginCapture submits a GPU copy of the last rendered frame into a | |
| 60 | + | // 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 | 67 | // Trim frees client buffer caches left undrawn for a while and what | |
| 38 | 68 | // finished frames held, without rendering. Outputs call it | |
| 39 | 69 | // periodically: an idle output renders no frame to free them. | |