detached 3213e44

perf(vulkan): capture frames without per-frame allocations

Each capture slot keeps one sync file descriptor for its lifetime: every
export is moved onto it with dup3 and the source closed, so a capture
makes no os.File. The Vulkan structs of the capture path (fence poll,
sync export, command recording, HDR pass) live in the renderer instead
of escaping to the heap. EndCapture no longer closes the descriptor;
freeCaptureSlot closes it once.

Adds TestCaptureCycleAllocations (0 allocs, SDR and HDR, no descriptor
growth) to perf-check.
commit
3213e44c329f6e58e9d8d2cb7d67611daf154e8e
author
brice · · 2026-10-14 20:58 UTC
committer
brice · · 2026-10-14 21:39 UTC
parent
3d39787
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +308 -62
M Makefile +1 -1
@@ -69,7 +69,7 @@ adapter-imports:
6969 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7070 # Fast, mandatory guards for tiled content publications and SHM copies.
7171 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations)$$' -count=1
72+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations)$$' -count=1
7373 check: vet test arch fakes-check log-check perf-check
7474
7575 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -69,7 +69,7 @@ adapter-imports:
69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
70 # Fast, mandatory guards for tiled content publications and SHM copies.70 # Fast, mandatory guards for tiled content publications and SHM copies.
71 perf-check:71 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations)$$' -count=172+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations)$$' -count=1
73 check: vet test arch fakes-check log-check perf-check73 check: vet test arch fakes-check log-check perf-check
74 74
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless75 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M internal/adapters/vulkan/capture.go +97 -44
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/bnema/neferwl/internal/ports"
1313 vk "github.com/bnema/purego-vulkan/vulkan"
14+ "golang.org/x/sys/unix"
1415 )
1516
1617 //go:generate glslc -O --target-env=vulkan1.3 shaders/capture_sdr.frag -o shaders/capture_sdr.frag.spv
@@ -107,6 +108,27 @@ func encodeSRGB8(f float32) byte {
107108 return byte(math.Round(v * 255))
108109 }
109110
111+// captureScratch holds the structs a capture passes to Vulkan by pointer.
112+// The purego call makes a pointer to a local escape, so a local would be a
113+// heap allocation per capture; these live in the Renderer instead (the
114+// owner goroutine uses them, and Vulkan reads each only during its call).
115+// Every field is set in full before use. Sequential uses may share a field;
116+// the sync export uses Renderer.call.getFD and .fd.
117+type captureScratch struct {
118+ cmd vk.CommandBuffer
119+ fence vk.Fence
120+ begin vk.CommandBufferBeginInfo
121+ submit vk.SubmitInfo
122+ // The copy (recordCaptureCopy).
123+ region vk.BufferImageCopy
124+ hb vk.BufferMemoryBarrier
125+ b vk.ImageMemoryBarrier
126+ // The HDR conversion pass (recordCaptureHDR).
127+ hdrIn, hdrB vk.ImageMemoryBarrier
128+ attachment vk.RenderingAttachmentInfo
129+ info vk.RenderingInfo
130+}
131+
110132 // captureSync is the fence and semaphore surface of a capture slot: the
111133 // non-blocking poll, the sync file export and the semaphore replacement
112134 // (a seam for tests; the device implements it).
@@ -128,18 +150,21 @@ type deviceSync struct{ r *Renderer }
128150 func (d deviceSync) fenceStatus(f vk.Fence) vk.Result {
129151 // A zero timeout only reports the fence state (the bindings lack
130152 // vkGetFenceStatus): Timeout while the copy runs.
131- return d.r.dd.WaitForFences(d.r.device, 1, &f, 1, 0)
153+ d.r.capCall.fence = f
154+ return d.r.dd.WaitForFences(d.r.device, 1, &d.r.capCall.fence, 1, 0)
132155 }
133156
134157 func (d deviceSync) resetFence(f vk.Fence) vk.Result {
135- return d.r.dd.ResetFences(d.r.device, 1, &f)
158+ d.r.capCall.fence = f
159+ return d.r.dd.ResetFences(d.r.device, 1, &d.r.capCall.fence)
136160 }
137161
138162 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
163+ sc := &d.r.call
164+ sc.fd = -1
165+ sc.getFD = vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit}
166+ res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &sc.getFD, &sc.fd)
167+ return sc.fd, res
143168 }
144169
145170 func (d deviceSync) newSemaphore() (vk.Semaphore, error) {
@@ -170,14 +195,43 @@ type captureSlot struct {
170195 // handle kept after EndCapture is invalid by contract (its next
171196 // state belongs to another lease).
172197 frame captureFrame
198+ // file wraps syncFD, the slot's sync file descriptor, which the slot
199+ // owns from the first export to freeCaptureSlot. Every later export
200+ // is moved onto the same descriptor number (dup3), so a capture
201+ // makes no os.File and the descriptor is closed exactly once. Nil
202+ // until the first export.
203+ file *os.File
204+ syncFD int
205+}
206+
207+// adoptSync makes fd, a sync file the caller owns, the slot's. It always
208+// takes ownership of fd: fd is closed on failure and after the move. The
209+// slot's previous sync file is replaced, so a lease must have ended first.
210+func (s *captureSlot) adoptSync(fd int) error {
211+ if s.file == nil {
212+ f := os.NewFile(uintptr(fd), "neferwl-capture")
213+ if f == nil {
214+ return fmt.Errorf("invalid capture sync file %d", fd)
215+ }
216+ s.file, s.syncFD = f, fd
217+ return nil
218+ }
219+ err := unix.Dup3(fd, s.syncFD, unix.O_CLOEXEC)
220+ _ = unix.Close(fd)
221+ if err != nil {
222+ return fmt.Errorf("move capture sync file: %w", err)
223+ }
224+ return nil
173225 }
174226
175227 // captureFrame is the lease handed to the worker (ports.CaptureFrame).
176228 // Its fields are written by the owner before the handoff and read by the
177229 // worker; the owner touches them again only after EndCapture.
178230 type captureFrame struct {
179- r *Renderer
180- slot *captureSlot
231+ r *Renderer
232+ slot *captureSlot
233+ // done is the slot's sync file (nil: nothing to wait for). The slot
234+ // owns it: valid until EndCapture, never closed by the reader.
181235 done *os.File
182236 pixels []byte // the slot's mapping, width*height*4
183237 w, h int
@@ -301,9 +355,9 @@ func (r *Renderer) captureSync() captureSync {
301355 func (r *Renderer) freeCaptureSlot(s *captureSlot) {
302356 d := r.dd
303357 s.frame.valid = false
304- if s.frame.done != nil {
305- s.frame.done.Close()
306- s.frame.done = nil
358+ s.frame.done = nil
359+ if s.file != nil {
360+ _ = s.file.Close()
307361 }
308362 if s.done != 0 {
309363 r.captureSync().destroySemaphore(s.done)
@@ -425,13 +479,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) {
425479 if s == nil {
426480 return nil, ports.ErrCaptureBusy
427481 }
428- d := r.dd
429- cmd := s.cmd
482+ d, sc := r.dd, &r.capCall
483+ sc.cmd = s.cmd
484+ cmd := sc.cmd
430485 if err := checked("vkResetCommandBuffer(capture)", d.ResetCommandBuffer(cmd, 0)); err != nil {
431486 return nil, err
432487 }
433- begin := vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit}
434- if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &begin)); err != nil {
488+ sc.begin = vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit}
489+ if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &sc.begin)); err != nil {
435490 return nil, err
436491 }
437492 if hdr {
@@ -445,14 +500,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) {
445500 // After the frame on the same queue: the copy reads what it drew, and
446501 // the next frame's first barrier (transfer source stage) orders its
447502 // writes after this read.
448- submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd}
503+ sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd}
449504 if !debug {
450505 // The export below takes the signal; a debug capture waits on
451506 // the fence only, so the semaphore must not be signalled (a
452507 // binary semaphore is signalled once, then waited or exported).
453- submit.SignalSemaphoreCount, submit.SignalSemaphores = 1, &s.done
508+ sc.submit.SignalSemaphoreCount, sc.submit.SignalSemaphores = 1, &s.done
454509 }
455- if err := checked("vkQueueSubmit(capture)", d.QueueSubmit(r.queue, 1, &submit, s.fence)); err != nil {
510+ if err := checked("vkQueueSubmit(capture)", d.QueueSubmit(r.queue, 1, &sc.submit, s.fence)); err != nil {
456511 return nil, err
457512 }
458513 s.pending = true
@@ -469,10 +524,10 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) {
469524 }
470525 if fd >= 0 {
471526 // -1: already signalled at export, nothing to wait for.
472- f.done = os.NewFile(uintptr(fd), "neferwl-capture")
473- if f.done == nil {
474- return nil, fmt.Errorf("invalid capture sync file %d", fd)
527+ if err := s.adoptSync(int(fd)); err != nil {
528+ return nil, err
475529 }
530+ f.done = s.file
476531 }
477532 }
478533 s.leased, f.valid = true, true
@@ -487,10 +542,7 @@ func (r *Renderer) EndCapture(cf ports.CaptureFrame) {
487542 return
488543 }
489544 f.valid = false
490- if f.done != nil {
491- f.done.Close()
492- f.done = nil
493- }
545+ f.done = nil // the slot keeps the descriptor for its next lease
494546 f.slot.leased = false
495547 }
496548
@@ -566,20 +618,20 @@ func (r *Renderer) readHDRTarget(read func(words []uint32)) error {
566618 // Exported targets are borrowed from the display without a layout
567619 // change: the display may be reading them at the same time.
568620 func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.ImageLayout, s *captureSlot) {
569- d := r.dd
621+ d, sc := r.dd, &r.capCall
570622 if t.exported {
571623 // Acquire from the display. The frame released the image at
572624 // its color attachment stage: this acquire waits for that.
573- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange}
574- d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
625+ sc.b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange}
626+ d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &sc.b)
575627 }
576- region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
577- d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, &region)
578- hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize}
579- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil)
628+ sc.region = vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
629+ d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, &sc.region)
630+ sc.hb = vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize}
631+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &sc.hb, 0, nil)
580632 if t.exported {
581- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange}
582- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
633+ sc.b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange}
634+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &sc.b)
583635 }
584636 }
585637
@@ -587,25 +639,26 @@ func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.
587639 // the sRGB8 capture image with the capture pass, then copies that. hdrOwn
588640 // is put back in TRANSFER_SRC, as the next frame expects.
589641 func (r *Renderer) recordCaptureHDR(cmd vk.CommandBuffer, s *captureSlot) {
590- d, p, out := r.dd, &r.capturePass, &r.captureSDR
591- 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}
592- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in)
642+ d, p, out, sc := r.dd, &r.capturePass, &r.captureSDR, &r.capCall
643+ in, b := &sc.hdrIn, &sc.hdrB
644+ *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}
645+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, in)
593646 // The previous capture read the image as a transfer source.
594- 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}
595- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)
596- attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore}
597- 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}
598- d.CmdBeginRendering(cmd, &info)
647+ *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}
648+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, b)
649+ sc.attachment = vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore}
650+ sc.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: &sc.attachment}
651+ d.CmdBeginRendering(cmd, &sc.info)
599652 d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, p.pipeline)
600653 d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, p.layout, 0, 1, &p.set, 0, nil)
601654 d.CmdDraw(cmd, 3, 1, 0, 0)
602655 d.CmdEndRendering(cmd)
603656 in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit
604657 in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal
605- d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in)
658+ d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, in)
606659 b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, vk.AccessTransferReadBit
607660 b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutTransferSrcOptimal
608- d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
661+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, b)
609662 r.recordCaptureCopy(cmd, out, vk.ImageLayoutTransferSrcOptimal, s)
610663 }
611664
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/bnema/neferwl/internal/ports"12 "github.com/bnema/neferwl/internal/ports"
13 vk "github.com/bnema/purego-vulkan/vulkan"13 vk "github.com/bnema/purego-vulkan/vulkan"
14+ "golang.org/x/sys/unix"
14 )15 )
15 16
16 //go:generate glslc -O --target-env=vulkan1.3 shaders/capture_sdr.frag -o shaders/capture_sdr.frag.spv17 //go:generate glslc -O --target-env=vulkan1.3 shaders/capture_sdr.frag -o shaders/capture_sdr.frag.spv
@@ -107,6 +108,27 @@ func encodeSRGB8(f float32) byte {
107 return byte(math.Round(v * 255))108 return byte(math.Round(v * 255))
108 }109 }
109 110
111+// captureScratch holds the structs a capture passes to Vulkan by pointer.
112+// The purego call makes a pointer to a local escape, so a local would be a
113+// heap allocation per capture; these live in the Renderer instead (the
114+// owner goroutine uses them, and Vulkan reads each only during its call).
115+// Every field is set in full before use. Sequential uses may share a field;
116+// the sync export uses Renderer.call.getFD and .fd.
117+type captureScratch struct {
118+ cmd vk.CommandBuffer
119+ fence vk.Fence
120+ begin vk.CommandBufferBeginInfo
121+ submit vk.SubmitInfo
122+ // The copy (recordCaptureCopy).
123+ region vk.BufferImageCopy
124+ hb vk.BufferMemoryBarrier
125+ b vk.ImageMemoryBarrier
126+ // The HDR conversion pass (recordCaptureHDR).
127+ hdrIn, hdrB vk.ImageMemoryBarrier
128+ attachment vk.RenderingAttachmentInfo
129+ info vk.RenderingInfo
130+}
131+
110 // captureSync is the fence and semaphore surface of a capture slot: the132 // captureSync is the fence and semaphore surface of a capture slot: the
111 // non-blocking poll, the sync file export and the semaphore replacement133 // non-blocking poll, the sync file export and the semaphore replacement
112 // (a seam for tests; the device implements it).134 // (a seam for tests; the device implements it).
@@ -128,18 +150,21 @@ type deviceSync struct{ r *Renderer }
128 func (d deviceSync) fenceStatus(f vk.Fence) vk.Result {150 func (d deviceSync) fenceStatus(f vk.Fence) vk.Result {
129 // A zero timeout only reports the fence state (the bindings lack151 // A zero timeout only reports the fence state (the bindings lack
130 // vkGetFenceStatus): Timeout while the copy runs.152 // vkGetFenceStatus): Timeout while the copy runs.
131- return d.r.dd.WaitForFences(d.r.device, 1, &f, 1, 0)153+ d.r.capCall.fence = f
154+ return d.r.dd.WaitForFences(d.r.device, 1, &d.r.capCall.fence, 1, 0)
132 }155 }
133 156
134 func (d deviceSync) resetFence(f vk.Fence) vk.Result {157 func (d deviceSync) resetFence(f vk.Fence) vk.Result {
135- return d.r.dd.ResetFences(d.r.device, 1, &f)158+ d.r.capCall.fence = f
159+ return d.r.dd.ResetFences(d.r.device, 1, &d.r.capCall.fence)
136 }160 }
137 161
138 func (d deviceSync) exportSyncFD(s vk.Semaphore) (int32, vk.Result) {162 func (d deviceSync) exportSyncFD(s vk.Semaphore) (int32, vk.Result) {
139- var fd int32163+ sc := &d.r.call
140- get := vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit}164+ sc.fd = -1
141- res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &get, &fd)165+ sc.getFD = vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit}
142- return fd, res166+ res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &sc.getFD, &sc.fd)
167+ return sc.fd, res
143 }168 }
144 169
145 func (d deviceSync) newSemaphore() (vk.Semaphore, error) {170 func (d deviceSync) newSemaphore() (vk.Semaphore, error) {
@@ -170,14 +195,43 @@ type captureSlot struct {
170 // handle kept after EndCapture is invalid by contract (its next195 // handle kept after EndCapture is invalid by contract (its next
171 // state belongs to another lease).196 // state belongs to another lease).
172 frame captureFrame197 frame captureFrame
198+ // file wraps syncFD, the slot's sync file descriptor, which the slot
199+ // owns from the first export to freeCaptureSlot. Every later export
200+ // is moved onto the same descriptor number (dup3), so a capture
201+ // makes no os.File and the descriptor is closed exactly once. Nil
202+ // until the first export.
203+ file *os.File
204+ syncFD int
205+}
206+
207+// adoptSync makes fd, a sync file the caller owns, the slot's. It always
208+// takes ownership of fd: fd is closed on failure and after the move. The
209+// slot's previous sync file is replaced, so a lease must have ended first.
210+func (s *captureSlot) adoptSync(fd int) error {
211+ if s.file == nil {
212+ f := os.NewFile(uintptr(fd), "neferwl-capture")
213+ if f == nil {
214+ return fmt.Errorf("invalid capture sync file %d", fd)
215+ }
216+ s.file, s.syncFD = f, fd
217+ return nil
218+ }
219+ err := unix.Dup3(fd, s.syncFD, unix.O_CLOEXEC)
220+ _ = unix.Close(fd)
221+ if err != nil {
222+ return fmt.Errorf("move capture sync file: %w", err)
223+ }
224+ return nil
173 }225 }
174 226
175 // captureFrame is the lease handed to the worker (ports.CaptureFrame).227 // captureFrame is the lease handed to the worker (ports.CaptureFrame).
176 // Its fields are written by the owner before the handoff and read by the228 // Its fields are written by the owner before the handoff and read by the
177 // worker; the owner touches them again only after EndCapture.229 // worker; the owner touches them again only after EndCapture.
178 type captureFrame struct {230 type captureFrame struct {
179- r *Renderer231+ r *Renderer
180- slot *captureSlot232+ slot *captureSlot
233+ // done is the slot's sync file (nil: nothing to wait for). The slot
234+ // owns it: valid until EndCapture, never closed by the reader.
181 done *os.File235 done *os.File
182 pixels []byte // the slot's mapping, width*height*4236 pixels []byte // the slot's mapping, width*height*4
183 w, h int237 w, h int
@@ -301,9 +355,9 @@ func (r *Renderer) captureSync() captureSync {
301 func (r *Renderer) freeCaptureSlot(s *captureSlot) {355 func (r *Renderer) freeCaptureSlot(s *captureSlot) {
302 d := r.dd356 d := r.dd
303 s.frame.valid = false357 s.frame.valid = false
304- if s.frame.done != nil {358+ s.frame.done = nil
305- s.frame.done.Close()359+ if s.file != nil {
306- s.frame.done = nil360+ _ = s.file.Close()
307 }361 }
308 if s.done != 0 {362 if s.done != 0 {
309 r.captureSync().destroySemaphore(s.done)363 r.captureSync().destroySemaphore(s.done)
@@ -425,13 +479,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) {
425 if s == nil {479 if s == nil {
426 return nil, ports.ErrCaptureBusy480 return nil, ports.ErrCaptureBusy
427 }481 }
428- d := r.dd482+ d, sc := r.dd, &r.capCall
429- cmd := s.cmd483+ sc.cmd = s.cmd
484+ cmd := sc.cmd
430 if err := checked("vkResetCommandBuffer(capture)", d.ResetCommandBuffer(cmd, 0)); err != nil {485 if err := checked("vkResetCommandBuffer(capture)", d.ResetCommandBuffer(cmd, 0)); err != nil {
431 return nil, err486 return nil, err
432 }487 }
433- begin := vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit}488+ sc.begin = vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit}
434- if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &begin)); err != nil {489+ if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &sc.begin)); err != nil {
435 return nil, err490 return nil, err
436 }491 }
437 if hdr {492 if hdr {
@@ -445,14 +500,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) {
445 // After the frame on the same queue: the copy reads what it drew, and500 // After the frame on the same queue: the copy reads what it drew, and
446 // the next frame's first barrier (transfer source stage) orders its501 // the next frame's first barrier (transfer source stage) orders its
447 // writes after this read.502 // writes after this read.
448- submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd}503+ sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd}
449 if !debug {504 if !debug {
450 // The export below takes the signal; a debug capture waits on505 // The export below takes the signal; a debug capture waits on
451 // the fence only, so the semaphore must not be signalled (a506 // the fence only, so the semaphore must not be signalled (a
452 // binary semaphore is signalled once, then waited or exported).507 // binary semaphore is signalled once, then waited or exported).
453- submit.SignalSemaphoreCount, submit.SignalSemaphores = 1, &s.done508+ sc.submit.SignalSemaphoreCount, sc.submit.SignalSemaphores = 1, &s.done
454 }509 }
455- if err := checked("vkQueueSubmit(capture)", d.QueueSubmit(r.queue, 1, &submit, s.fence)); err != nil {510+ if err := checked("vkQueueSubmit(capture)", d.QueueSubmit(r.queue, 1, &sc.submit, s.fence)); err != nil {
456 return nil, err511 return nil, err
457 }512 }
458 s.pending = true513 s.pending = true
@@ -469,10 +524,10 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) {
469 }524 }
470 if fd >= 0 {525 if fd >= 0 {
471 // -1: already signalled at export, nothing to wait for.526 // -1: already signalled at export, nothing to wait for.
472- f.done = os.NewFile(uintptr(fd), "neferwl-capture")527+ if err := s.adoptSync(int(fd)); err != nil {
473- if f.done == nil {528+ return nil, err
474- return nil, fmt.Errorf("invalid capture sync file %d", fd)
475 }529 }
530+ f.done = s.file
476 }531 }
477 }532 }
478 s.leased, f.valid = true, true533 s.leased, f.valid = true, true
@@ -487,10 +542,7 @@ func (r *Renderer) EndCapture(cf ports.CaptureFrame) {
487 return542 return
488 }543 }
489 f.valid = false544 f.valid = false
490- if f.done != nil {545+ f.done = nil // the slot keeps the descriptor for its next lease
491- f.done.Close()
492- f.done = nil
493- }
494 f.slot.leased = false546 f.slot.leased = false
495 }547 }
496 548
@@ -566,20 +618,20 @@ func (r *Renderer) readHDRTarget(read func(words []uint32)) error {
566 // Exported targets are borrowed from the display without a layout618 // Exported targets are borrowed from the display without a layout
567 // change: the display may be reading them at the same time.619 // change: the display may be reading them at the same time.
568 func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.ImageLayout, s *captureSlot) {620 func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.ImageLayout, s *captureSlot) {
569- d := r.dd621+ d, sc := r.dd, &r.capCall
570 if t.exported {622 if t.exported {
571 // Acquire from the display. The frame released the image at623 // Acquire from the display. The frame released the image at
572 // its color attachment stage: this acquire waits for that.624 // its color attachment stage: this acquire waits for that.
573- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange}625+ sc.b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange}
574- d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)626+ d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &sc.b)
575 }627 }
576- region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}628+ sc.region = vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
577- d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, &region)629+ d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, &sc.region)
578- hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize}630+ sc.hb = vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize}
579- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil)631+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &sc.hb, 0, nil)
580 if t.exported {632 if t.exported {
581- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange}633+ sc.b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange}
582- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)634+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &sc.b)
583 }635 }
584 }636 }
585 637
@@ -587,25 +639,26 @@ func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.
587 // the sRGB8 capture image with the capture pass, then copies that. hdrOwn639 // the sRGB8 capture image with the capture pass, then copies that. hdrOwn
588 // is put back in TRANSFER_SRC, as the next frame expects.640 // is put back in TRANSFER_SRC, as the next frame expects.
589 func (r *Renderer) recordCaptureHDR(cmd vk.CommandBuffer, s *captureSlot) {641 func (r *Renderer) recordCaptureHDR(cmd vk.CommandBuffer, s *captureSlot) {
590- d, p, out := r.dd, &r.capturePass, &r.captureSDR642+ d, p, out, sc := r.dd, &r.capturePass, &r.captureSDR, &r.capCall
591- 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}643+ in, b := &sc.hdrIn, &sc.hdrB
592- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in)644+ *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}
645+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, in)
593 // The previous capture read the image as a transfer source.646 // The previous capture read the image as a transfer source.
594- 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}647+ *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}
595- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)648+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, b)
596- attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore}649+ sc.attachment = vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore}
597- 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}650+ sc.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: &sc.attachment}
598- d.CmdBeginRendering(cmd, &info)651+ d.CmdBeginRendering(cmd, &sc.info)
599 d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, p.pipeline)652 d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, p.pipeline)
600 d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, p.layout, 0, 1, &p.set, 0, nil)653 d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, p.layout, 0, 1, &p.set, 0, nil)
601 d.CmdDraw(cmd, 3, 1, 0, 0)654 d.CmdDraw(cmd, 3, 1, 0, 0)
602 d.CmdEndRendering(cmd)655 d.CmdEndRendering(cmd)
603 in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit656 in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit
604 in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal657 in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal
605- d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in)658+ d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, in)
606 b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, vk.AccessTransferReadBit659 b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, vk.AccessTransferReadBit
607 b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutTransferSrcOptimal660 b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutTransferSrcOptimal
608- d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)661+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, b)
609 r.recordCaptureCopy(cmd, out, vk.ImageLayoutTransferSrcOptimal, s)662 r.recordCaptureCopy(cmd, out, vk.ImageLayoutTransferSrcOptimal, s)
610 }663 }
611 664
M internal/adapters/vulkan/capture_test.go +206 -16
@@ -291,8 +291,7 @@ func TestCaptureCurve(t *testing.T) {
291291 }
292292 }
293293
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.
294+// The lease object of a slot is reused, and a stale handle reports invalid.
296295 func TestCaptureLeaseReused(t *testing.T) {
297296 r, err := New(8, 8)
298297 if err != nil {
@@ -309,24 +308,215 @@ func TestCaptureLeaseReused(t *testing.T) {
309308 t.Fatal("lease object not reused")
310309 }
311310 r.EndCapture(second)
312- cycle := func() {
313- cf, err := r.BeginCapture()
314- if err != nil {
315- t.Fatal(err)
311+ dst := make([]byte, 4)
312+ if err := second.Read(image.Rect(0, 0, 1, 1), dst, 4); err == nil {
313+ t.Fatal("stale lease read accepted")
314+ }
315+}
316+
317+// pollSyncFile waits for a sync file without allocating (syncfile.Wait
318+// builds its poll set per call).
319+func pollSyncFile(f *os.File, fds []unix.PollFd) error {
320+ fds[0] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}
321+ for {
322+ n, err := unix.Poll(fds, 1000)
323+ switch {
324+ case errors.Is(err, unix.EINTR):
325+ case err != nil:
326+ return err
327+ case n > 0 && fds[0].Revents&unix.POLLIN != 0:
328+ return nil
329+ case n > 0:
330+ return errors.New("sync file poll error")
331+ default:
332+ return errors.New("sync file poll timeout")
316333 }
317- if done := cf.Done(); done != nil {
318- if err := syncfile.Wait(context.Background(), done); err != nil {
334+ }
335+}
336+
337+// openFDs counts the process's open descriptors.
338+func openFDs(t *testing.T) int {
339+ t.Helper()
340+ ents, err := os.ReadDir("/proc/self/fd")
341+ if err != nil {
342+ t.Skipf("no /proc/self/fd: %v", err)
343+ }
344+ return len(ents)
345+}
346+
347+// The renderer capture cycle (lease, wait for the copy, read the rows,
348+// return the lease) allocates nothing once the slots exist, on the SDR and
349+// the HDR conversion path, and holds a constant number of descriptors: the
350+// slot keeps its sync file for every lease. No mock stands in the measured
351+// closure.
352+func TestCaptureCycleAllocations(t *testing.T) {
353+ for _, tc := range []struct {
354+ name string
355+ new func(*testing.T) *Renderer
356+ }{
357+ {"sdr", func(t *testing.T) *Renderer {
358+ r, err := New(16, 8)
359+ if err != nil {
360+ t.Skipf("Vulkan unavailable: %v", err)
361+ }
362+ t.Cleanup(r.Close)
363+ return r
364+ }},
365+ {"hdr", hdrTestRenderer},
366+ } {
367+ t.Run(tc.name, func(t *testing.T) {
368+ r := tc.new(t)
369+ requireCaptureSync(t, r)
370+ if err := render(r, ports.Scene{Seq: 1, Background: "#3060c0"}, nil); err != nil {
319371 t.Fatal(err)
320372 }
373+ dst := make([]byte, 16*8*4)
374+ fds := make([]unix.PollFd, 1)
375+ region := image.Rect(0, 0, 16, 8)
376+ var cycleErr error
377+ cycle := func() {
378+ cf, err := r.BeginCapture()
379+ if err != nil {
380+ cycleErr = err
381+ return
382+ }
383+ if done := cf.Done(); done != nil {
384+ if err := pollSyncFile(done, fds); err != nil {
385+ cycleErr = err
386+ }
387+ }
388+ if err := cf.Read(region, dst, 16*4); err != nil {
389+ cycleErr = err
390+ }
391+ r.EndCapture(cf)
392+ }
393+ cycle() // creates the slots, the capture pass and the sync files
394+ cycle()
395+ if cycleErr != nil {
396+ t.Fatal(cycleErr)
397+ }
398+ fdsBefore := openFDs(t)
399+ if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 {
400+ t.Errorf("%.1f allocations per capture cycle, want 0", allocs)
401+ }
402+ if cycleErr != nil {
403+ t.Fatal(cycleErr)
404+ }
405+ if after := openFDs(t); after != fdsBefore {
406+ t.Errorf("%d descriptors open after the cycles, %d before: a sync file leaked", after, fdsBefore)
407+ }
408+ if dst[0] == 0 && dst[1] == 0 && dst[2] == 0 {
409+ t.Error("capture read back black")
410+ }
411+ })
412+ }
413+}
414+
415+// adoptSync takes ownership of the descriptor it is given: the first one is
416+// wrapped, a later one is moved onto the same descriptor number and its
417+// source is closed, so nothing leaks and nothing is closed twice.
418+func TestCaptureAdoptSync(t *testing.T) {
419+ pipe := func() (r, w int) {
420+ var p [2]int
421+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
422+ t.Fatal(err)
321423 }
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)
424+ return p[0], p[1]
425+ }
426+ r1, w1 := pipe()
427+ r2, w2 := pipe()
428+ t.Cleanup(func() { _ = unix.Close(w1); _ = unix.Close(w2) })
429+ var s captureSlot
430+ if err := s.adoptSync(r1); err != nil {
431+ t.Fatal(err)
432+ }
433+ if s.syncFD != r1 || s.file == nil {
434+ t.Fatalf("first adoption: fd %d file %v, want %d", s.syncFD, s.file, r1)
435+ }
436+ file := s.file
437+ if err := s.adoptSync(r2); err != nil {
438+ t.Fatal(err)
439+ }
440+ if s.file != file || s.syncFD != r1 {
441+ t.Fatalf("second adoption replaced the file: %p fd %d", s.file, s.syncFD)
442+ }
443+ if _, err := unix.FcntlInt(uintptr(r2), unix.F_GETFD, 0); err != unix.EBADF {
444+ t.Errorf("source descriptor still open after the move: %v", err)
445+ }
446+ if _, err := unix.Write(w2, []byte{7}); err != nil {
447+ t.Fatal(err)
448+ }
449+ var b [1]byte
450+ if n, err := s.file.Read(b[:]); err != nil || n != 1 || b[0] != 7 {
451+ t.Fatalf("slot file does not read the second pipe: %d %v %v", n, b[0], err)
452+ }
453+ // A failed move still closes the source.
454+ r3, w3 := pipe()
455+ t.Cleanup(func() { _ = unix.Close(w3) })
456+ bad := captureSlot{file: file, syncFD: -1}
457+ if err := bad.adoptSync(r3); err == nil {
458+ t.Error("move onto an invalid descriptor accepted")
459+ }
460+ if _, err := unix.FcntlInt(uintptr(r3), unix.F_GETFD, 0); err != unix.EBADF {
461+ t.Errorf("source descriptor leaked by a failed move: %v", err)
462+ }
463+ if err := file.Close(); err != nil {
464+ t.Errorf("slot file close: %v", err)
465+ }
466+}
467+
468+// A slot keeps one descriptor for its sync files: it is the same number and
469+// the same file for every lease, closed once by Close, and a lease the
470+// device finished at export (no sync file) hands out none.
471+func TestCaptureSyncFileOwnership(t *testing.T) {
472+ r, err := New(8, 8)
473+ if err != nil {
474+ t.Skipf("Vulkan unavailable: %v", err)
475+ }
476+ requireCaptureSync(t, r)
477+ closed := false
478+ defer func() {
479+ if !closed {
480+ r.Close()
481+ }
482+ }()
483+ if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
484+ t.Fatal(err)
485+ }
486+ before := openFDs(t)
487+ cf := captureWait(t, r).(*captureFrame)
488+ s := cf.slot
489+ file, fd := cf.Done(), s.syncFD
490+ if file == nil || s.file != file {
491+ t.Fatalf("first lease: done %v, slot file %v", file, s.file)
492+ }
493+ r.EndCapture(cf)
494+ if cf.Done() != nil {
495+ t.Error("ended lease still exposes the sync file")
496+ }
497+ if _, err := file.Stat(); err != nil {
498+ t.Errorf("EndCapture closed the slot's sync file: %v", err)
499+ }
500+ for range 4 {
501+ next := captureWait(t, r).(*captureFrame)
502+ if next.slot == s {
503+ if next.Done() != file || s.syncFD != fd {
504+ t.Fatalf("slot sync file changed: %p fd %d, was %p fd %d", next.Done(), s.syncFD, file, fd)
505+ }
506+ }
507+ r.EndCapture(next)
508+ }
509+ // Two slots, each holding one sync file.
510+ if held := openFDs(t) - before; held > captureSlots {
511+ t.Errorf("%d descriptors held by %d slots", held, captureSlots)
512+ }
513+ r.Close()
514+ closed = true
515+ if _, err := file.Stat(); err == nil {
516+ t.Error("Close left the slot's sync file open")
517+ }
518+ if got := openFDs(t); got > before {
519+ t.Errorf("%d descriptors open after Close, %d before the captures", got, before)
330520 }
331521 }
332522
@@ -291,8 +291,7 @@ func TestCaptureCurve(t *testing.T) {
291 }291 }
292 }292 }
293 293
294-// The lease object of a slot is reused: a steady state of captures294+// The lease object of a slot is reused, and a stale handle reports invalid.
295-// allocates only the sync file, and a stale handle reports invalid.
296 func TestCaptureLeaseReused(t *testing.T) {295 func TestCaptureLeaseReused(t *testing.T) {
297 r, err := New(8, 8)296 r, err := New(8, 8)
298 if err != nil {297 if err != nil {
@@ -309,24 +308,215 @@ func TestCaptureLeaseReused(t *testing.T) {
309 t.Fatal("lease object not reused")308 t.Fatal("lease object not reused")
310 }309 }
311 r.EndCapture(second)310 r.EndCapture(second)
312- cycle := func() {311+ dst := make([]byte, 4)
313- cf, err := r.BeginCapture()312+ if err := second.Read(image.Rect(0, 0, 1, 1), dst, 4); err == nil {
314- if err != nil {313+ t.Fatal("stale lease read accepted")
315- t.Fatal(err)314+ }
315+}
316+
317+// pollSyncFile waits for a sync file without allocating (syncfile.Wait
318+// builds its poll set per call).
319+func pollSyncFile(f *os.File, fds []unix.PollFd) error {
320+ fds[0] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}
321+ for {
322+ n, err := unix.Poll(fds, 1000)
323+ switch {
324+ case errors.Is(err, unix.EINTR):
325+ case err != nil:
326+ return err
327+ case n > 0 && fds[0].Revents&unix.POLLIN != 0:
328+ return nil
329+ case n > 0:
330+ return errors.New("sync file poll error")
331+ default:
332+ return errors.New("sync file poll timeout")
316 }333 }
317- if done := cf.Done(); done != nil {334+ }
318- if err := syncfile.Wait(context.Background(), done); err != nil {335+}
336+
337+// openFDs counts the process's open descriptors.
338+func openFDs(t *testing.T) int {
339+ t.Helper()
340+ ents, err := os.ReadDir("/proc/self/fd")
341+ if err != nil {
342+ t.Skipf("no /proc/self/fd: %v", err)
343+ }
344+ return len(ents)
345+}
346+
347+// The renderer capture cycle (lease, wait for the copy, read the rows,
348+// return the lease) allocates nothing once the slots exist, on the SDR and
349+// the HDR conversion path, and holds a constant number of descriptors: the
350+// slot keeps its sync file for every lease. No mock stands in the measured
351+// closure.
352+func TestCaptureCycleAllocations(t *testing.T) {
353+ for _, tc := range []struct {
354+ name string
355+ new func(*testing.T) *Renderer
356+ }{
357+ {"sdr", func(t *testing.T) *Renderer {
358+ r, err := New(16, 8)
359+ if err != nil {
360+ t.Skipf("Vulkan unavailable: %v", err)
361+ }
362+ t.Cleanup(r.Close)
363+ return r
364+ }},
365+ {"hdr", hdrTestRenderer},
366+ } {
367+ t.Run(tc.name, func(t *testing.T) {
368+ r := tc.new(t)
369+ requireCaptureSync(t, r)
370+ if err := render(r, ports.Scene{Seq: 1, Background: "#3060c0"}, nil); err != nil {
319 t.Fatal(err)371 t.Fatal(err)
320 }372 }
373+ dst := make([]byte, 16*8*4)
374+ fds := make([]unix.PollFd, 1)
375+ region := image.Rect(0, 0, 16, 8)
376+ var cycleErr error
377+ cycle := func() {
378+ cf, err := r.BeginCapture()
379+ if err != nil {
380+ cycleErr = err
381+ return
382+ }
383+ if done := cf.Done(); done != nil {
384+ if err := pollSyncFile(done, fds); err != nil {
385+ cycleErr = err
386+ }
387+ }
388+ if err := cf.Read(region, dst, 16*4); err != nil {
389+ cycleErr = err
390+ }
391+ r.EndCapture(cf)
392+ }
393+ cycle() // creates the slots, the capture pass and the sync files
394+ cycle()
395+ if cycleErr != nil {
396+ t.Fatal(cycleErr)
397+ }
398+ fdsBefore := openFDs(t)
399+ if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 {
400+ t.Errorf("%.1f allocations per capture cycle, want 0", allocs)
401+ }
402+ if cycleErr != nil {
403+ t.Fatal(cycleErr)
404+ }
405+ if after := openFDs(t); after != fdsBefore {
406+ t.Errorf("%d descriptors open after the cycles, %d before: a sync file leaked", after, fdsBefore)
407+ }
408+ if dst[0] == 0 && dst[1] == 0 && dst[2] == 0 {
409+ t.Error("capture read back black")
410+ }
411+ })
412+ }
413+}
414+
415+// adoptSync takes ownership of the descriptor it is given: the first one is
416+// wrapped, a later one is moved onto the same descriptor number and its
417+// source is closed, so nothing leaks and nothing is closed twice.
418+func TestCaptureAdoptSync(t *testing.T) {
419+ pipe := func() (r, w int) {
420+ var p [2]int
421+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
422+ t.Fatal(err)
321 }423 }
322- r.EndCapture(cf)424+ return p[0], p[1]
323- }425+ }
324- cycle()426+ r1, w1 := pipe()
325- // No lease wrapper: the allocations left are the Vulkan structs the427+ r2, w2 := pipe()
326- // FFI boundary keeps on the heap (as in Render, budget 16) plus the428+ t.Cleanup(func() { _ = unix.Close(w1); _ = unix.Close(w2) })
327- // sync file (os.NewFile and its finalizer).429+ var s captureSlot
328- if allocs := testing.AllocsPerRun(10, cycle); allocs > 16 {430+ if err := s.adoptSync(r1); err != nil {
329- t.Errorf("%.1f allocations per capture, want <= 16", allocs)431+ t.Fatal(err)
432+ }
433+ if s.syncFD != r1 || s.file == nil {
434+ t.Fatalf("first adoption: fd %d file %v, want %d", s.syncFD, s.file, r1)
435+ }
436+ file := s.file
437+ if err := s.adoptSync(r2); err != nil {
438+ t.Fatal(err)
439+ }
440+ if s.file != file || s.syncFD != r1 {
441+ t.Fatalf("second adoption replaced the file: %p fd %d", s.file, s.syncFD)
442+ }
443+ if _, err := unix.FcntlInt(uintptr(r2), unix.F_GETFD, 0); err != unix.EBADF {
444+ t.Errorf("source descriptor still open after the move: %v", err)
445+ }
446+ if _, err := unix.Write(w2, []byte{7}); err != nil {
447+ t.Fatal(err)
448+ }
449+ var b [1]byte
450+ if n, err := s.file.Read(b[:]); err != nil || n != 1 || b[0] != 7 {
451+ t.Fatalf("slot file does not read the second pipe: %d %v %v", n, b[0], err)
452+ }
453+ // A failed move still closes the source.
454+ r3, w3 := pipe()
455+ t.Cleanup(func() { _ = unix.Close(w3) })
456+ bad := captureSlot{file: file, syncFD: -1}
457+ if err := bad.adoptSync(r3); err == nil {
458+ t.Error("move onto an invalid descriptor accepted")
459+ }
460+ if _, err := unix.FcntlInt(uintptr(r3), unix.F_GETFD, 0); err != unix.EBADF {
461+ t.Errorf("source descriptor leaked by a failed move: %v", err)
462+ }
463+ if err := file.Close(); err != nil {
464+ t.Errorf("slot file close: %v", err)
465+ }
466+}
467+
468+// A slot keeps one descriptor for its sync files: it is the same number and
469+// the same file for every lease, closed once by Close, and a lease the
470+// device finished at export (no sync file) hands out none.
471+func TestCaptureSyncFileOwnership(t *testing.T) {
472+ r, err := New(8, 8)
473+ if err != nil {
474+ t.Skipf("Vulkan unavailable: %v", err)
475+ }
476+ requireCaptureSync(t, r)
477+ closed := false
478+ defer func() {
479+ if !closed {
480+ r.Close()
481+ }
482+ }()
483+ if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
484+ t.Fatal(err)
485+ }
486+ before := openFDs(t)
487+ cf := captureWait(t, r).(*captureFrame)
488+ s := cf.slot
489+ file, fd := cf.Done(), s.syncFD
490+ if file == nil || s.file != file {
491+ t.Fatalf("first lease: done %v, slot file %v", file, s.file)
492+ }
493+ r.EndCapture(cf)
494+ if cf.Done() != nil {
495+ t.Error("ended lease still exposes the sync file")
496+ }
497+ if _, err := file.Stat(); err != nil {
498+ t.Errorf("EndCapture closed the slot's sync file: %v", err)
499+ }
500+ for range 4 {
501+ next := captureWait(t, r).(*captureFrame)
502+ if next.slot == s {
503+ if next.Done() != file || s.syncFD != fd {
504+ t.Fatalf("slot sync file changed: %p fd %d, was %p fd %d", next.Done(), s.syncFD, file, fd)
505+ }
506+ }
507+ r.EndCapture(next)
508+ }
509+ // Two slots, each holding one sync file.
510+ if held := openFDs(t) - before; held > captureSlots {
511+ t.Errorf("%d descriptors held by %d slots", held, captureSlots)
512+ }
513+ r.Close()
514+ closed = true
515+ if _, err := file.Stat(); err == nil {
516+ t.Error("Close left the slot's sync file open")
517+ }
518+ if got := openFDs(t); got > before {
519+ t.Errorf("%d descriptors open after Close, %d before the captures", got, before)
330 }520 }
331 }521 }
332 522
M internal/adapters/vulkan/renderer.go +2 -0
@@ -112,6 +112,8 @@ type Renderer struct {
112112 frameStages []vk.PipelineStageFlags
113113 // call holds the structs a per-frame Vulkan call is handed by pointer.
114114 call callScratch
115+ // capCall holds the structs a capture hands Vulkan by pointer.
116+ capCall captureScratch
115117 }
116118
117119 // priorityAttempts falls back to an ordinary queue when elevated priority is unavailable.
@@ -112,6 +112,8 @@ type Renderer struct {
112 frameStages []vk.PipelineStageFlags112 frameStages []vk.PipelineStageFlags
113 // call holds the structs a per-frame Vulkan call is handed by pointer.113 // call holds the structs a per-frame Vulkan call is handed by pointer.
114 call callScratch114 call callScratch
115+ // capCall holds the structs a capture hands Vulkan by pointer.
116+ capCall captureScratch
115 }117 }
116 118
117 // priorityAttempts falls back to an ordinary queue when elevated priority is unavailable.119 // priorityAttempts falls back to an ordinary queue when elevated priority is unavailable.
M internal/ports/devices.go +2 -1
@@ -26,7 +26,8 @@ var ErrCaptureBusy = fmt.Errorf("%w: no free slot", ErrCaptureTransient)
2626 // goroutine waits on Done, then reads. EndCapture returns the lease.
2727 type CaptureFrame interface {
2828 // Done is the sync file signalled when the copy finished; nil means it
29- // had finished when BeginCapture returned. The frame owns it.
29+ // had finished when BeginCapture returned. The frame owns it: it stays
30+ // valid until EndCapture, and the reader must not close it.
3031 Done() *os.File
3132 // Read copies region (output pixels) as opaque sRGB8 BGRA rows into
3233 // dst, stride bytes per row. Valid after Done signalled and before
@@ -26,7 +26,8 @@ var ErrCaptureBusy = fmt.Errorf("%w: no free slot", ErrCaptureTransient)
26 // goroutine waits on Done, then reads. EndCapture returns the lease.26 // goroutine waits on Done, then reads. EndCapture returns the lease.
27 type CaptureFrame interface {27 type CaptureFrame interface {
28 // Done is the sync file signalled when the copy finished; nil means it28 // Done is the sync file signalled when the copy finished; nil means it
29- // had finished when BeginCapture returned. The frame owns it.29+ // had finished when BeginCapture returned. The frame owns it: it stays
30+ // valid until EndCapture, and the reader must not close it.
30 Done() *os.File31 Done() *os.File
31 // Read copies region (output pixels) as opaque sRGB8 BGRA rows into32 // Read copies region (output pixels) as opaque sRGB8 BGRA rows into
32 // dst, stride bytes per row. Valid after Done signalled and before33 // dst, stride bytes per row. Valid after Done signalled and before

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close