detached 9682d35

fix(vulkan): free buffers of undrawn windows on idle outputs (#55)

Pool mappings, shm GPU copies and dmabuf imports were freed only after
120 rendered frames without them. An idle output renders nothing, so the
buffers of windows that left the screen, or were closed, stayed mapped.

Add Renderer.Trim(now): it frees what no frame drew for 20 s and polls
finished frames without waiting to run their deferred frees. The DRM
stats tick and a headless ticker call it.

Refs #52
commit
9682d3572441a734c367c30f436c6ef70d4db852
author
bnema · · 2026-09-29 01:54 UTC
committer
GitHub · · 2026-09-29 01:54 UTC
parent
ffbd0df
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +483 -9
M internal/adapters/drm/output.go +16 -3
@@ -11,6 +11,7 @@ import (
1111 "unsafe"
1212
1313 "github.com/bnema/neferwl/internal/adapters/capture"
14+ "github.com/bnema/neferwl/internal/adapters/clock"
1415
1516 "github.com/bnema/neferwl/internal/adapters/syncfile"
1617 "github.com/bnema/neferwl/internal/ports"
@@ -41,6 +42,8 @@ type Output struct {
4142 hdrBlob uint32
4243 hdrBlobData hdrOutputMetadata
4344 log zerowrap.Logger
45+ // clock paces the periodic stats and renderer trim (nil: system).
46+ clock ports.Clock
4447 // Properties: CRTC and connector property IDs by name.
4548 crtcProps map[string]uint32
4649 connCrtc uint32 // the connector's CRTC_ID property
@@ -875,7 +878,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
875878 }
876879 }
877880 }()
878- stats := time.NewTicker(10 * time.Second)
881+ clk := o.clock
882+ if clk == nil {
883+ clk = clock.System{}
884+ }
885+ stats := clk.NewTicker(10 * time.Second)
879886 defer stats.Stop()
880887 // Timer channels are enabled only when their condition holds. Go 1.27
881888 // guarantees a stopped/reset timer cannot deliver its old value.
@@ -1030,8 +1037,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10301037 }
10311038 // Windows on other outputs or workspaces do not need a frame.
10321039 dirty = dirty || scene.Shows(c.ID)
1033- case <-stats.C:
1034- o.dropClientFBs(time.Now(), false)
1040+ case <-stats.C():
1041+ now := clk.Now()
1042+ o.dropClientFBs(now, false)
1043+ // An idle output renders nothing: free what windows that left
1044+ // it held.
1045+ if err := r.Trim(now); err != nil {
1046+ return fmt.Errorf("trim renderer: %w", err)
1047+ }
10351048 ev := o.log.Info().Str("connector", o.conn.name).Int("frames", frame).Int("flips", o.flips).Bool("pending", o.frame.pendingCommit())
10361049 if o.cursor != nil {
10371050 cs := o.cursor.TakeStats()
@@ -11,6 +11,7 @@ import (
11 "unsafe"11 "unsafe"
12 12
13 "github.com/bnema/neferwl/internal/adapters/capture"13 "github.com/bnema/neferwl/internal/adapters/capture"
14+ "github.com/bnema/neferwl/internal/adapters/clock"
14 15
15 "github.com/bnema/neferwl/internal/adapters/syncfile"16 "github.com/bnema/neferwl/internal/adapters/syncfile"
16 "github.com/bnema/neferwl/internal/ports"17 "github.com/bnema/neferwl/internal/ports"
@@ -41,6 +42,8 @@ type Output struct {
41 hdrBlob uint3242 hdrBlob uint32
42 hdrBlobData hdrOutputMetadata43 hdrBlobData hdrOutputMetadata
43 log zerowrap.Logger44 log zerowrap.Logger
45+ // clock paces the periodic stats and renderer trim (nil: system).
46+ clock ports.Clock
44 // Properties: CRTC and connector property IDs by name.47 // Properties: CRTC and connector property IDs by name.
45 crtcProps map[string]uint3248 crtcProps map[string]uint32
46 connCrtc uint32 // the connector's CRTC_ID property49 connCrtc uint32 // the connector's CRTC_ID property
@@ -875,7 +878,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
875 }878 }
876 }879 }
877 }()880 }()
878- stats := time.NewTicker(10 * time.Second)881+ clk := o.clock
882+ if clk == nil {
883+ clk = clock.System{}
884+ }
885+ stats := clk.NewTicker(10 * time.Second)
879 defer stats.Stop()886 defer stats.Stop()
880 // Timer channels are enabled only when their condition holds. Go 1.27887 // Timer channels are enabled only when their condition holds. Go 1.27
881 // guarantees a stopped/reset timer cannot deliver its old value.888 // guarantees a stopped/reset timer cannot deliver its old value.
@@ -1030,8 +1037,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1030 }1037 }
1031 // Windows on other outputs or workspaces do not need a frame.1038 // Windows on other outputs or workspaces do not need a frame.
1032 dirty = dirty || scene.Shows(c.ID)1039 dirty = dirty || scene.Shows(c.ID)
1033- case <-stats.C:1040+ case <-stats.C():
1034- o.dropClientFBs(time.Now(), false)1041+ now := clk.Now()
1042+ o.dropClientFBs(now, false)
1043+ // An idle output renders nothing: free what windows that left
1044+ // it held.
1045+ if err := r.Trim(now); err != nil {
1046+ return fmt.Errorf("trim renderer: %w", err)
1047+ }
1035 ev := o.log.Info().Str("connector", o.conn.name).Int("frames", frame).Int("flips", o.flips).Bool("pending", o.frame.pendingCommit())1048 ev := o.log.Info().Str("connector", o.conn.name).Int("frames", frame).Int("flips", o.flips).Bool("pending", o.frame.pendingCommit())
1036 if o.cursor != nil {1049 if o.cursor != nil {
1037 cs := o.cursor.TakeStats()1050 cs := o.cursor.TakeStats()
M internal/adapters/drm/output_test.go +45 -0
@@ -2,6 +2,7 @@ package drm
22
33 import (
44 "context"
5+ "errors"
56 "image"
67 "os"
78 "sync"
@@ -701,6 +702,50 @@ func waitFor(t *testing.T, cond func() bool) {
701702 }
702703 }
703704
705+// The stats tick lets the renderer free what the output no longer draws,
706+// even while nothing renders; a failed trim stops the output.
707+func TestRunTrimsRendererOnStatsTick(t *testing.T) {
708+ o, k, _ := testOutput(t)
709+ o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
710+ ticks := make(chan time.Time)
711+ clk := portsmocks.NewMockClock(t)
712+ tk := portsmocks.NewMockTicker(t)
713+ clk.EXPECT().NewTicker(10 * time.Second).Return(tk).Once()
714+ tk.EXPECT().C().Return(ticks)
715+ tk.EXPECT().Stop().Return().Once()
716+ now := time.Unix(1000, 0)
717+ clk.EXPECT().Now().Return(now)
718+ o.clock = clk
719+ r := portsmocks.NewMockRenderer(t)
720+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
721+ r.EXPECT().Close().Return().Once()
722+ trimmed := make(chan struct{}, 2)
723+ failure := os.ErrInvalid
724+ calls := 0 // Run's goroutine only
725+ r.EXPECT().Trim(now).RunAndReturn(func(time.Time) error {
726+ trimmed <- struct{}{}
727+ if calls++; calls == 2 {
728+ return failure
729+ }
730+ return nil
731+ }).Twice()
732+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
733+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
734+ // Switched away from the start: no target, no frame.
735+ active := make(chan bool, 1)
736+ active <- false
737+ done := make(chan error, 1)
738+ go func() {
739+ done <- o.Run(context.Background(), func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil)
740+ }()
741+ ticks <- now
742+ <-trimmed
743+ ticks <- now
744+ if err := <-done; !errors.Is(err, failure) {
745+ t.Fatalf("run: %v", err)
746+ }
747+}
748+
704749 // A request must render even when the output already has an unchanged scene.
705750 func TestCaptureForcesDRMComposition(t *testing.T) {
706751 o, k, commits, commitMu := testOutputMu(t)
@@ -2,6 +2,7 @@ package drm
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "image"6 "image"
6 "os"7 "os"
7 "sync"8 "sync"
@@ -701,6 +702,50 @@ func waitFor(t *testing.T, cond func() bool) {
701 }702 }
702 }703 }
703 704
705+// The stats tick lets the renderer free what the output no longer draws,
706+// even while nothing renders; a failed trim stops the output.
707+func TestRunTrimsRendererOnStatsTick(t *testing.T) {
708+ o, k, _ := testOutput(t)
709+ o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
710+ ticks := make(chan time.Time)
711+ clk := portsmocks.NewMockClock(t)
712+ tk := portsmocks.NewMockTicker(t)
713+ clk.EXPECT().NewTicker(10 * time.Second).Return(tk).Once()
714+ tk.EXPECT().C().Return(ticks)
715+ tk.EXPECT().Stop().Return().Once()
716+ now := time.Unix(1000, 0)
717+ clk.EXPECT().Now().Return(now)
718+ o.clock = clk
719+ r := portsmocks.NewMockRenderer(t)
720+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
721+ r.EXPECT().Close().Return().Once()
722+ trimmed := make(chan struct{}, 2)
723+ failure := os.ErrInvalid
724+ calls := 0 // Run's goroutine only
725+ r.EXPECT().Trim(now).RunAndReturn(func(time.Time) error {
726+ trimmed <- struct{}{}
727+ if calls++; calls == 2 {
728+ return failure
729+ }
730+ return nil
731+ }).Twice()
732+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
733+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
734+ // Switched away from the start: no target, no frame.
735+ active := make(chan bool, 1)
736+ active <- false
737+ done := make(chan error, 1)
738+ go func() {
739+ done <- o.Run(context.Background(), func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil)
740+ }()
741+ ticks <- now
742+ <-trimmed
743+ ticks <- now
744+ if err := <-done; !errors.Is(err, failure) {
745+ t.Fatalf("run: %v", err)
746+ }
747+}
748+
704 // A request must render even when the output already has an unchanged scene.749 // A request must render even when the output already has an unchanged scene.
705 func TestCaptureForcesDRMComposition(t *testing.T) {750 func TestCaptureForcesDRMComposition(t *testing.T) {
706 o, k, commits, commitMu := testOutputMu(t)751 o, k, commits, commitMu := testOutputMu(t)
M internal/adapters/headless/output.go +20 -0
@@ -12,6 +12,7 @@ import (
1212 "time"
1313
1414 "github.com/bnema/neferwl/internal/adapters/capture"
15+ "github.com/bnema/neferwl/internal/adapters/clock"
1516 "github.com/bnema/neferwl/internal/adapters/syncfile"
1617 "github.com/bnema/neferwl/internal/ports"
1718 "github.com/bnema/zerowrap"
@@ -33,8 +34,14 @@ type Options struct {
3334 Name string
3435 Presented chan<- ports.OutputPresented
3536 Captured chan<- ports.CaptureDone
37+ // Clock paces the renderer trim (nil: system clock).
38+ Clock ports.Clock
3639 }
3740
41+// trimEvery is how often an output lets its renderer free the buffers of
42+// windows it no longer draws.
43+const trimEvery = 10 * time.Second
44+
3845 func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, incoming <-chan ports.CaptureRequest) error {
3946 r, err := opts.NewRenderer(opts.Width, opts.Height)
4047 if err != nil {
@@ -99,6 +106,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
99106 surfaces[c.ID] = c
100107 }
101108 }
109+ clk := opts.Clock
110+ if clk == nil {
111+ clk = clock.System{}
112+ }
113+ trim := clk.NewTicker(trimEvery)
114+ defer trim.Stop()
102115 for {
103116 if ctx.Err() != nil {
104117 return nil
@@ -120,6 +133,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
120133 case <-retry:
121134 pending = opts.send(pending)
122135 continue
136+ case <-trim.C():
137+ // An idle output renders nothing: free what windows that left
138+ // it held.
139+ if err := r.Trim(clk.Now()); err != nil {
140+ return fmt.Errorf("trim renderer: %w", err)
141+ }
142+ continue
123143 case s, ok := <-scenes:
124144 if !ok {
125145 scenes = nil
@@ -12,6 +12,7 @@ import (
12 "time"12 "time"
13 13
14 "github.com/bnema/neferwl/internal/adapters/capture"14 "github.com/bnema/neferwl/internal/adapters/capture"
15+ "github.com/bnema/neferwl/internal/adapters/clock"
15 "github.com/bnema/neferwl/internal/adapters/syncfile"16 "github.com/bnema/neferwl/internal/adapters/syncfile"
16 "github.com/bnema/neferwl/internal/ports"17 "github.com/bnema/neferwl/internal/ports"
17 "github.com/bnema/zerowrap"18 "github.com/bnema/zerowrap"
@@ -33,8 +34,14 @@ type Options struct {
33 Name string34 Name string
34 Presented chan<- ports.OutputPresented35 Presented chan<- ports.OutputPresented
35 Captured chan<- ports.CaptureDone36 Captured chan<- ports.CaptureDone
37+ // Clock paces the renderer trim (nil: system clock).
38+ Clock ports.Clock
36 }39 }
37 40
41+// trimEvery is how often an output lets its renderer free the buffers of
42+// windows it no longer draws.
43+const trimEvery = 10 * time.Second
44+
38 func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, incoming <-chan ports.CaptureRequest) error {45 func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, incoming <-chan ports.CaptureRequest) error {
39 r, err := opts.NewRenderer(opts.Width, opts.Height)46 r, err := opts.NewRenderer(opts.Width, opts.Height)
40 if err != nil {47 if err != nil {
@@ -99,6 +106,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
99 surfaces[c.ID] = c106 surfaces[c.ID] = c
100 }107 }
101 }108 }
109+ clk := opts.Clock
110+ if clk == nil {
111+ clk = clock.System{}
112+ }
113+ trim := clk.NewTicker(trimEvery)
114+ defer trim.Stop()
102 for {115 for {
103 if ctx.Err() != nil {116 if ctx.Err() != nil {
104 return nil117 return nil
@@ -120,6 +133,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
120 case <-retry:133 case <-retry:
121 pending = opts.send(pending)134 pending = opts.send(pending)
122 continue135 continue
136+ case <-trim.C():
137+ // An idle output renders nothing: free what windows that left
138+ // it held.
139+ if err := r.Trim(clk.Now()); err != nil {
140+ return fmt.Errorf("trim renderer: %w", err)
141+ }
142+ continue
123 case s, ok := <-scenes:143 case s, ok := <-scenes:
124 if !ok {144 if !ok {
125 scenes = nil145 scenes = nil
M internal/adapters/headless/output_test.go +39 -0
@@ -149,6 +149,45 @@ func TestErrors(t *testing.T) {
149149 }
150150 }
151151
152+// Each trim tick lets the idle renderer free what it no longer draws,
153+// without a frame; a failed trim stops the output.
154+func TestRunTrimsRendererOnTick(t *testing.T) {
155+ ticks := make(chan time.Time)
156+ clk := portsmocks.NewMockClock(t)
157+ tk := portsmocks.NewMockTicker(t)
158+ clk.EXPECT().NewTicker(trimEvery).Return(tk).Once()
159+ tk.EXPECT().C().Return(ticks)
160+ tk.EXPECT().Stop().Return().Once()
161+ now := time.Unix(1000, 0)
162+ clk.EXPECT().Now().Return(now)
163+ r, f := recordingRenderer(t, nil)
164+ trimmed := make(chan time.Time, 2)
165+ failure := errors.New("trim failed")
166+ calls := 0 // Run's goroutine only
167+ r.EXPECT().Trim(now).RunAndReturn(func(at time.Time) error {
168+ trimmed <- at
169+ if calls++; calls == 2 {
170+ return failure
171+ }
172+ return nil
173+ }).Twice()
174+ done := make(chan error, 1)
175+ go func() {
176+ done <- Run(context.Background(), Options{Width: 2, Height: 2, Clock: clk, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil)
177+ }()
178+ ticks <- now
179+ if got := <-trimmed; !got.Equal(now) {
180+ t.Fatalf("trimmed at %v", got)
181+ }
182+ ticks <- now
183+ if err := <-done; !errors.Is(err, failure) {
184+ t.Fatalf("run: %v", err)
185+ }
186+ if s, _ := f.snapshot(); len(s) != 0 {
187+ t.Fatalf("%d frames for a trim", len(s))
188+ }
189+}
190+
152191 func TestWritePNGAtomic(t *testing.T) {
153192 dir := t.TempDir()
154193 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
@@ -149,6 +149,45 @@ func TestErrors(t *testing.T) {
149 }149 }
150 }150 }
151 151
152+// Each trim tick lets the idle renderer free what it no longer draws,
153+// without a frame; a failed trim stops the output.
154+func TestRunTrimsRendererOnTick(t *testing.T) {
155+ ticks := make(chan time.Time)
156+ clk := portsmocks.NewMockClock(t)
157+ tk := portsmocks.NewMockTicker(t)
158+ clk.EXPECT().NewTicker(trimEvery).Return(tk).Once()
159+ tk.EXPECT().C().Return(ticks)
160+ tk.EXPECT().Stop().Return().Once()
161+ now := time.Unix(1000, 0)
162+ clk.EXPECT().Now().Return(now)
163+ r, f := recordingRenderer(t, nil)
164+ trimmed := make(chan time.Time, 2)
165+ failure := errors.New("trim failed")
166+ calls := 0 // Run's goroutine only
167+ r.EXPECT().Trim(now).RunAndReturn(func(at time.Time) error {
168+ trimmed <- at
169+ if calls++; calls == 2 {
170+ return failure
171+ }
172+ return nil
173+ }).Twice()
174+ done := make(chan error, 1)
175+ go func() {
176+ done <- Run(context.Background(), Options{Width: 2, Height: 2, Clock: clk, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil)
177+ }()
178+ ticks <- now
179+ if got := <-trimmed; !got.Equal(now) {
180+ t.Fatalf("trimmed at %v", got)
181+ }
182+ ticks <- now
183+ if err := <-done; !errors.Is(err, failure) {
184+ t.Fatalf("run: %v", err)
185+ }
186+ if s, _ := f.snapshot(); len(s) != 0 {
187+ t.Fatalf("%d frames for a trim", len(s))
188+ }
189+}
190+
152 func TestWritePNGAtomic(t *testing.T) {191 func TestWritePNGAtomic(t *testing.T) {
153 dir := t.TempDir()192 dir := t.TempDir()
154 img := image.NewRGBA(image.Rect(0, 0, 1, 1))193 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
M internal/adapters/vulkan/renderer.go +2 -0
@@ -67,6 +67,8 @@ type Renderer struct {
6767 // the bytes copied into them, for tests.
6868 shm map[shmKey]*shmSurface
6969 copied int
70+ // marks date the frames for Trim's wall-clock eviction (trim.go).
71+ marks []trimMark
7072 // redrawn counts the target pixels drawn, for tests.
7173 redrawn int
7274 // cursors are the exported cursor images (CursorBuffers).
@@ -67,6 +67,8 @@ type Renderer struct {
67 // the bytes copied into them, for tests.67 // the bytes copied into them, for tests.
68 shm map[shmKey]*shmSurface68 shm map[shmKey]*shmSurface
69 copied int69 copied int
70+ // marks date the frames for Trim's wall-clock eviction (trim.go).
71+ marks []trimMark
70 // redrawn counts the target pixels drawn, for tests.72 // redrawn counts the target pixels drawn, for tests.
71 redrawn int73 redrawn int
72 // cursors are the exported cursor images (CursorBuffers).74 // cursors are the exported cursor images (CursorBuffers).
M internal/adapters/vulkan/shm.go +13 -6
@@ -16,7 +16,8 @@ import (
1616 // that the fragment shader reads directly: one memcpy per row, no staging
1717 // copy, no resample, no alpha fix-up on the CPU. Each surface has two GPU
1818 // buffers used in turn, so the CPU never writes one a frame in flight
19-// reads. Mappings and buffers are dropped after importTTL frames unused.
19+// reads. Mappings and buffers are dropped after importTTL frames unused,
20+// or idleTTL of wall-clock time (Trim).
2021
2122 // mapping is a client pool mapped read-only.
2223 type mapping struct {
@@ -263,12 +264,18 @@ func (r *Renderer) freeShmCopy(c *shmCopy) {
263264 func (r *Renderer) dropShm() {
264265 for key, s := range r.shm {
265266 if r.frame-s.last > importTTL {
266- for _, c := range s.bufs {
267- if c != nil {
268- r.retire(c.gpu.last, func() { r.freeShmCopy(c) })
269- }
270- }
267+ r.retireShm(s)
271268 delete(r.shm, key)
272269 }
273270 }
274271 }
272+
273+// retireShm frees a surface's GPU buffers once the frames reading them
274+// completed.
275+func (r *Renderer) retireShm(s *shmSurface) {
276+ for _, c := range s.bufs {
277+ if c != nil {
278+ r.retire(c.gpu.last, func() { r.freeShmCopy(c) })
279+ }
280+ }
281+}
@@ -16,7 +16,8 @@ import (
16 // that the fragment shader reads directly: one memcpy per row, no staging16 // that the fragment shader reads directly: one memcpy per row, no staging
17 // copy, no resample, no alpha fix-up on the CPU. Each surface has two GPU17 // copy, no resample, no alpha fix-up on the CPU. Each surface has two GPU
18 // buffers used in turn, so the CPU never writes one a frame in flight18 // buffers used in turn, so the CPU never writes one a frame in flight
19-// reads. Mappings and buffers are dropped after importTTL frames unused.19+// reads. Mappings and buffers are dropped after importTTL frames unused,
20+// or idleTTL of wall-clock time (Trim).
20 21
21 // mapping is a client pool mapped read-only.22 // mapping is a client pool mapped read-only.
22 type mapping struct {23 type mapping struct {
@@ -263,12 +264,18 @@ func (r *Renderer) freeShmCopy(c *shmCopy) {
263 func (r *Renderer) dropShm() {264 func (r *Renderer) dropShm() {
264 for key, s := range r.shm {265 for key, s := range r.shm {
265 if r.frame-s.last > importTTL {266 if r.frame-s.last > importTTL {
266- for _, c := range s.bufs {267+ r.retireShm(s)
267- if c != nil {
268- r.retire(c.gpu.last, func() { r.freeShmCopy(c) })
269- }
270- }
271 delete(r.shm, key)268 delete(r.shm, key)
272 }269 }
273 }270 }
274 }271 }
272+
273+// retireShm frees a surface's GPU buffers once the frames reading them
274+// completed.
275+func (r *Renderer) retireShm(s *shmSurface) {
276+ for _, c := range s.bufs {
277+ if c != nil {
278+ r.retire(c.gpu.last, func() { r.freeShmCopy(c) })
279+ }
280+ }
281+}
A internal/adapters/vulkan/trim.go +99 -0
@@ -0,0 +1,99 @@
1+package vulkan
2+
3+import (
4+ "time"
5+
6+ vk "github.com/bnema/purego-vulkan/vulkan"
7+ "golang.org/x/sys/unix"
8+)
9+
10+// idleTTL is how long a client buffer cache survives undrawn in wall-clock
11+// time. An idle output renders no frame, so importTTL alone never frees
12+// the pools, GPU copies and imports of windows that left the screen.
13+const idleTTL = 20 * time.Second
14+
15+// trimMark is the last frame recorded when Trim ran at a time.
16+type trimMark struct {
17+ frame uint64
18+ at time.Time
19+}
20+
21+// Trim frees the pool mappings, GPU copies and dmabuf imports not drawn
22+// for idleTTL, and the objects of frames the GPU finished, without a new
23+// frame. What the last frame drew stays: it is on screen and a redraw
24+// would copy it again. The output calls it periodically on its goroutine.
25+func (r *Renderer) Trim(now time.Time) error {
26+ // An idle output keeps its oldest mark: the frame is the same.
27+ if n := len(r.marks); n == 0 || r.marks[n-1].frame != r.frame {
28+ r.marks = append(r.marks, trimMark{frame: r.frame, at: now})
29+ }
30+ // cutoff is the newest mark at least idleTTL old: everything last
31+ // drawn at or before it has been undrawn for idleTTL.
32+ i := -1
33+ for j, m := range r.marks {
34+ if now.Sub(m.at) >= idleTTL {
35+ i = j
36+ }
37+ }
38+ if i >= 0 {
39+ cutoff := r.marks[i].frame
40+ r.marks = append(r.marks[:0], r.marks[i:]...)
41+ stale := func(last uint64) bool { return last <= cutoff && last < r.frame }
42+ // The GPU reads the copies, never a mapping, and no frame is being
43+ // recorded: unmapping now is safe.
44+ for id, m := range r.pools {
45+ if stale(m.last) {
46+ _ = unix.Munmap(m.data)
47+ delete(r.pools, id)
48+ }
49+ }
50+ for key, s := range r.shm {
51+ if stale(s.last) {
52+ r.retireShm(s)
53+ delete(r.shm, key)
54+ }
55+ }
56+ for id, im := range r.imports {
57+ if stale(im.last) {
58+ r.retire(im.last, func() { r.release(im) })
59+ delete(r.imports, id)
60+ }
61+ }
62+ }
63+ return r.poll()
64+}
65+
66+// poll records the frames the GPU finished, without waiting, and frees
67+// what they held.
68+func (r *Renderer) poll() error {
69+ var pending uint64 // oldest frame still running, 0: none
70+ for i := range r.slots {
71+ s := &r.slots[i]
72+ if !s.busy {
73+ continue
74+ }
75+ res := r.dd.WaitForFences(r.device, 1, &s.fence, 1, 0)
76+ if res == vk.Timeout {
77+ if pending == 0 || s.frame < pending {
78+ pending = s.frame
79+ }
80+ continue
81+ }
82+ if err := checked("vkWaitForFences", res); err != nil {
83+ return err
84+ }
85+ if err := checked("vkResetFences", r.dd.ResetFences(r.device, 1, &s.fence)); err != nil {
86+ return err
87+ }
88+ s.busy = false
89+ }
90+ if pending == 0 {
91+ // Nothing runs: frames recorded but never submitted have no GPU
92+ // work either (like idle).
93+ r.completed = r.frame
94+ } else {
95+ r.completed = max(r.completed, pending-1)
96+ }
97+ r.runDeferred()
98+ return nil
99+}
@@ -0,0 +1,99 @@
1+package vulkan
2+
3+import (
4+ "time"
5+
6+ vk "github.com/bnema/purego-vulkan/vulkan"
7+ "golang.org/x/sys/unix"
8+)
9+
10+// idleTTL is how long a client buffer cache survives undrawn in wall-clock
11+// time. An idle output renders no frame, so importTTL alone never frees
12+// the pools, GPU copies and imports of windows that left the screen.
13+const idleTTL = 20 * time.Second
14+
15+// trimMark is the last frame recorded when Trim ran at a time.
16+type trimMark struct {
17+ frame uint64
18+ at time.Time
19+}
20+
21+// Trim frees the pool mappings, GPU copies and dmabuf imports not drawn
22+// for idleTTL, and the objects of frames the GPU finished, without a new
23+// frame. What the last frame drew stays: it is on screen and a redraw
24+// would copy it again. The output calls it periodically on its goroutine.
25+func (r *Renderer) Trim(now time.Time) error {
26+ // An idle output keeps its oldest mark: the frame is the same.
27+ if n := len(r.marks); n == 0 || r.marks[n-1].frame != r.frame {
28+ r.marks = append(r.marks, trimMark{frame: r.frame, at: now})
29+ }
30+ // cutoff is the newest mark at least idleTTL old: everything last
31+ // drawn at or before it has been undrawn for idleTTL.
32+ i := -1
33+ for j, m := range r.marks {
34+ if now.Sub(m.at) >= idleTTL {
35+ i = j
36+ }
37+ }
38+ if i >= 0 {
39+ cutoff := r.marks[i].frame
40+ r.marks = append(r.marks[:0], r.marks[i:]...)
41+ stale := func(last uint64) bool { return last <= cutoff && last < r.frame }
42+ // The GPU reads the copies, never a mapping, and no frame is being
43+ // recorded: unmapping now is safe.
44+ for id, m := range r.pools {
45+ if stale(m.last) {
46+ _ = unix.Munmap(m.data)
47+ delete(r.pools, id)
48+ }
49+ }
50+ for key, s := range r.shm {
51+ if stale(s.last) {
52+ r.retireShm(s)
53+ delete(r.shm, key)
54+ }
55+ }
56+ for id, im := range r.imports {
57+ if stale(im.last) {
58+ r.retire(im.last, func() { r.release(im) })
59+ delete(r.imports, id)
60+ }
61+ }
62+ }
63+ return r.poll()
64+}
65+
66+// poll records the frames the GPU finished, without waiting, and frees
67+// what they held.
68+func (r *Renderer) poll() error {
69+ var pending uint64 // oldest frame still running, 0: none
70+ for i := range r.slots {
71+ s := &r.slots[i]
72+ if !s.busy {
73+ continue
74+ }
75+ res := r.dd.WaitForFences(r.device, 1, &s.fence, 1, 0)
76+ if res == vk.Timeout {
77+ if pending == 0 || s.frame < pending {
78+ pending = s.frame
79+ }
80+ continue
81+ }
82+ if err := checked("vkWaitForFences", res); err != nil {
83+ return err
84+ }
85+ if err := checked("vkResetFences", r.dd.ResetFences(r.device, 1, &s.fence)); err != nil {
86+ return err
87+ }
88+ s.busy = false
89+ }
90+ if pending == 0 {
91+ // Nothing runs: frames recorded but never submitted have no GPU
92+ // work either (like idle).
93+ r.completed = r.frame
94+ } else {
95+ r.completed = max(r.completed, pending-1)
96+ }
97+ r.runDeferred()
98+ return nil
99+}
A internal/adapters/vulkan/trim_test.go +192 -0
@@ -0,0 +1,192 @@
1+package vulkan
2+
3+import (
4+ "image/color"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// trimSettled calls Trim at now until the frames the GPU was running
13+// finished and their deferred frees ran: Trim never waits for them.
14+func trimSettled(t *testing.T, r *Renderer, now time.Time) {
15+ t.Helper()
16+ deadline := time.Now().Add(5 * time.Second)
17+ for {
18+ if err := r.Trim(now); err != nil {
19+ t.Fatal(err)
20+ }
21+ if len(r.deferred) == 0 {
22+ return
23+ }
24+ if time.Now().After(deadline) {
25+ t.Fatalf("%d frees never ran", len(r.deferred))
26+ }
27+ time.Sleep(time.Millisecond)
28+ }
29+}
30+
31+// An idle output frees the pool mapping and GPU copies of a window it no
32+// longer draws once idleTTL passed, without rendering; the window drawn
33+// by the last frame stays, and a window coming back is copied again.
34+func TestTrimFreesUndrawnSHMWithoutRender(t *testing.T) {
35+ r, err := New(32, 32)
36+ if err != nil {
37+ t.Skipf("Vulkan unavailable: %v", err)
38+ }
39+ defer r.Close()
40+ a := solidContent(t, 16, 16, color.RGBA{255, 0, 0, 255})
41+ a.ID, a.Seq, a.Version = 1, 1, 1
42+ b := solidContent(t, 16, 16, color.RGBA{0, 0, 255, 255})
43+ b.ID, b.Seq, b.Version = 2, 1, 1
44+ contents := map[ports.WindowID]ports.SurfaceContent{1: a, 2: b}
45+ both := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}, {ID: 2, Rect: ports.Rect{X: 16, W: 16, H: 16}}}}
46+ onlyB := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 2, Rect: ports.Rect{X: 16, W: 16, H: 16}}}}
47+ if err := render(r, both, contents); err != nil {
48+ t.Fatal(err)
49+ }
50+ if err := render(r, onlyB, contents); err != nil {
51+ t.Fatal(err)
52+ }
53+ start := time.Unix(1000, 0)
54+ for _, dt := range []time.Duration{0, 10 * time.Second, idleTTL - time.Second} {
55+ if err := r.Trim(start.Add(dt)); err != nil {
56+ t.Fatal(err)
57+ }
58+ }
59+ if len(r.pools) != 2 || len(r.shm) != 2 {
60+ t.Fatalf("freed before idleTTL: pools %d, copies %d", len(r.pools), len(r.shm))
61+ }
62+ trimSettled(t, r, start.Add(idleTTL))
63+ if _, ok := r.pools[a.SHM.Pool]; ok || len(r.pools) != 1 {
64+ t.Fatalf("undrawn pool kept: %d pools", len(r.pools))
65+ }
66+ if _, ok := r.shm[shmKey{win: 1}]; ok || len(r.shm) != 1 {
67+ t.Fatalf("undrawn copies kept: %d surfaces", len(r.shm))
68+ }
69+ // Still idle: the window on screen stays.
70+ if err := r.Trim(start.Add(10 * idleTTL)); err != nil {
71+ t.Fatal(err)
72+ }
73+ if len(r.pools) != 1 || len(r.shm) != 1 {
74+ t.Fatalf("shown window freed: pools %d, copies %d", len(r.pools), len(r.shm))
75+ }
76+ copied := r.copied
77+ if err := render(r, both, contents); err != nil {
78+ t.Fatal(err)
79+ }
80+ if r.copied != copied+16*16*4 {
81+ t.Fatalf("returning window copied %d bytes", r.copied-copied)
82+ }
83+ if got := r.Pixels().RGBAAt(4, 4); got != (color.RGBA{255, 0, 0, 255}) {
84+ t.Fatalf("returning window shows %v", got)
85+ }
86+}
87+
88+// A window drawn again within idleTTL keeps its caches: frames date them.
89+func TestTrimKeepsRecentlyDrawn(t *testing.T) {
90+ r, err := New(32, 32)
91+ if err != nil {
92+ t.Skipf("Vulkan unavailable: %v", err)
93+ }
94+ defer r.Close()
95+ a := solidContent(t, 16, 16, color.RGBA{255, 0, 0, 255})
96+ a.ID, a.Seq, a.Version = 1, 1, 1
97+ contents := map[ports.WindowID]ports.SurfaceContent{1: a}
98+ shown := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}}}
99+ empty := ports.Scene{Background: "#000000"}
100+ start := time.Unix(1000, 0)
101+ if err := render(r, shown, contents); err != nil {
102+ t.Fatal(err)
103+ }
104+ if err := render(r, empty, contents); err != nil {
105+ t.Fatal(err)
106+ }
107+ if err := r.Trim(start); err != nil {
108+ t.Fatal(err)
109+ }
110+ // Back on screen, then away again just before idleTTL.
111+ if err := render(r, shown, contents); err != nil {
112+ t.Fatal(err)
113+ }
114+ if err := render(r, empty, contents); err != nil {
115+ t.Fatal(err)
116+ }
117+ if err := r.Trim(start.Add(idleTTL - time.Second)); err != nil {
118+ t.Fatal(err)
119+ }
120+ if err := r.Trim(start.Add(idleTTL)); err != nil {
121+ t.Fatal(err)
122+ }
123+ if len(r.pools) != 1 || len(r.shm) != 1 {
124+ t.Fatalf("drawn %v ago but freed", time.Second)
125+ }
126+ if err := r.Trim(start.Add(2*idleTTL - time.Second)); err != nil {
127+ t.Fatal(err)
128+ }
129+ if len(r.pools) != 0 || len(r.shm) != 0 {
130+ t.Fatalf("kept after idleTTL: pools %d, copies %d", len(r.pools), len(r.shm))
131+ }
132+}
133+
134+// A dmabuf import left undrawn is released by Trim without a frame.
135+func TestTrimReleasesUndrawnDMABuf(t *testing.T) {
136+ r, err := New(80, 32)
137+ if err != nil {
138+ t.Skipf("Vulkan unavailable: %v", err)
139+ }
140+ defer r.Close()
141+ linear := ports.DMABufFormat{Format: fourcc('A', 'R', '2', '4'), Modifier: 0}
142+ if !slices.Contains(r.DMABuf().Formats, linear) {
143+ t.Skipf("linear ARGB8888 not importable: %+v", r.DMABuf())
144+ }
145+ f := udmabuf(t, 64, 16, func(int, int) [4]byte { return [4]byte{0, 0, 255, 255} })
146+ buf := &ports.DMABuf{ID: 9, Width: 64, Height: 16, Format: linear.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
147+ scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
148+ contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Width: 64, Height: 16, DMABuf: buf}}
149+ if err := render(r, scene, contents); err != nil {
150+ t.Fatal(err)
151+ }
152+ if err := render(r, ports.Scene{Background: "#000000"}, nil); err != nil {
153+ t.Fatal(err)
154+ }
155+ start := time.Unix(1000, 0)
156+ if err := r.Trim(start); err != nil {
157+ t.Fatal(err)
158+ }
159+ trimSettled(t, r, start.Add(idleTTL))
160+ if len(r.imports) != 0 {
161+ t.Fatalf("imports %d", len(r.imports))
162+ }
163+}
164+
165+// poll frees what a finished frame held without waiting for it.
166+func TestTrimPollsFinishedFrames(t *testing.T) {
167+ r, err := New(32, 32)
168+ if err != nil {
169+ t.Skipf("Vulkan unavailable: %v", err)
170+ }
171+ defer r.Close()
172+ if !r.syncFD {
173+ t.Skip("frames are finished on return without sync files")
174+ }
175+ if err := render(r, ports.Scene{Background: "#000000"}, nil); err != nil {
176+ t.Fatal(err)
177+ }
178+ freed := false
179+ r.retire(r.frame, func() { freed = true })
180+ deadline := time.Now().Add(5 * time.Second)
181+ for !freed {
182+ if time.Now().After(deadline) {
183+ t.Fatal("finished frame never polled")
184+ }
185+ if err := r.Trim(time.Now()); err != nil {
186+ t.Fatal(err)
187+ }
188+ }
189+ if r.completed != r.frame {
190+ t.Fatalf("completed %d, frame %d", r.completed, r.frame)
191+ }
192+}
@@ -0,0 +1,192 @@
1+package vulkan
2+
3+import (
4+ "image/color"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// trimSettled calls Trim at now until the frames the GPU was running
13+// finished and their deferred frees ran: Trim never waits for them.
14+func trimSettled(t *testing.T, r *Renderer, now time.Time) {
15+ t.Helper()
16+ deadline := time.Now().Add(5 * time.Second)
17+ for {
18+ if err := r.Trim(now); err != nil {
19+ t.Fatal(err)
20+ }
21+ if len(r.deferred) == 0 {
22+ return
23+ }
24+ if time.Now().After(deadline) {
25+ t.Fatalf("%d frees never ran", len(r.deferred))
26+ }
27+ time.Sleep(time.Millisecond)
28+ }
29+}
30+
31+// An idle output frees the pool mapping and GPU copies of a window it no
32+// longer draws once idleTTL passed, without rendering; the window drawn
33+// by the last frame stays, and a window coming back is copied again.
34+func TestTrimFreesUndrawnSHMWithoutRender(t *testing.T) {
35+ r, err := New(32, 32)
36+ if err != nil {
37+ t.Skipf("Vulkan unavailable: %v", err)
38+ }
39+ defer r.Close()
40+ a := solidContent(t, 16, 16, color.RGBA{255, 0, 0, 255})
41+ a.ID, a.Seq, a.Version = 1, 1, 1
42+ b := solidContent(t, 16, 16, color.RGBA{0, 0, 255, 255})
43+ b.ID, b.Seq, b.Version = 2, 1, 1
44+ contents := map[ports.WindowID]ports.SurfaceContent{1: a, 2: b}
45+ both := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}, {ID: 2, Rect: ports.Rect{X: 16, W: 16, H: 16}}}}
46+ onlyB := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 2, Rect: ports.Rect{X: 16, W: 16, H: 16}}}}
47+ if err := render(r, both, contents); err != nil {
48+ t.Fatal(err)
49+ }
50+ if err := render(r, onlyB, contents); err != nil {
51+ t.Fatal(err)
52+ }
53+ start := time.Unix(1000, 0)
54+ for _, dt := range []time.Duration{0, 10 * time.Second, idleTTL - time.Second} {
55+ if err := r.Trim(start.Add(dt)); err != nil {
56+ t.Fatal(err)
57+ }
58+ }
59+ if len(r.pools) != 2 || len(r.shm) != 2 {
60+ t.Fatalf("freed before idleTTL: pools %d, copies %d", len(r.pools), len(r.shm))
61+ }
62+ trimSettled(t, r, start.Add(idleTTL))
63+ if _, ok := r.pools[a.SHM.Pool]; ok || len(r.pools) != 1 {
64+ t.Fatalf("undrawn pool kept: %d pools", len(r.pools))
65+ }
66+ if _, ok := r.shm[shmKey{win: 1}]; ok || len(r.shm) != 1 {
67+ t.Fatalf("undrawn copies kept: %d surfaces", len(r.shm))
68+ }
69+ // Still idle: the window on screen stays.
70+ if err := r.Trim(start.Add(10 * idleTTL)); err != nil {
71+ t.Fatal(err)
72+ }
73+ if len(r.pools) != 1 || len(r.shm) != 1 {
74+ t.Fatalf("shown window freed: pools %d, copies %d", len(r.pools), len(r.shm))
75+ }
76+ copied := r.copied
77+ if err := render(r, both, contents); err != nil {
78+ t.Fatal(err)
79+ }
80+ if r.copied != copied+16*16*4 {
81+ t.Fatalf("returning window copied %d bytes", r.copied-copied)
82+ }
83+ if got := r.Pixels().RGBAAt(4, 4); got != (color.RGBA{255, 0, 0, 255}) {
84+ t.Fatalf("returning window shows %v", got)
85+ }
86+}
87+
88+// A window drawn again within idleTTL keeps its caches: frames date them.
89+func TestTrimKeepsRecentlyDrawn(t *testing.T) {
90+ r, err := New(32, 32)
91+ if err != nil {
92+ t.Skipf("Vulkan unavailable: %v", err)
93+ }
94+ defer r.Close()
95+ a := solidContent(t, 16, 16, color.RGBA{255, 0, 0, 255})
96+ a.ID, a.Seq, a.Version = 1, 1, 1
97+ contents := map[ports.WindowID]ports.SurfaceContent{1: a}
98+ shown := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}}}
99+ empty := ports.Scene{Background: "#000000"}
100+ start := time.Unix(1000, 0)
101+ if err := render(r, shown, contents); err != nil {
102+ t.Fatal(err)
103+ }
104+ if err := render(r, empty, contents); err != nil {
105+ t.Fatal(err)
106+ }
107+ if err := r.Trim(start); err != nil {
108+ t.Fatal(err)
109+ }
110+ // Back on screen, then away again just before idleTTL.
111+ if err := render(r, shown, contents); err != nil {
112+ t.Fatal(err)
113+ }
114+ if err := render(r, empty, contents); err != nil {
115+ t.Fatal(err)
116+ }
117+ if err := r.Trim(start.Add(idleTTL - time.Second)); err != nil {
118+ t.Fatal(err)
119+ }
120+ if err := r.Trim(start.Add(idleTTL)); err != nil {
121+ t.Fatal(err)
122+ }
123+ if len(r.pools) != 1 || len(r.shm) != 1 {
124+ t.Fatalf("drawn %v ago but freed", time.Second)
125+ }
126+ if err := r.Trim(start.Add(2*idleTTL - time.Second)); err != nil {
127+ t.Fatal(err)
128+ }
129+ if len(r.pools) != 0 || len(r.shm) != 0 {
130+ t.Fatalf("kept after idleTTL: pools %d, copies %d", len(r.pools), len(r.shm))
131+ }
132+}
133+
134+// A dmabuf import left undrawn is released by Trim without a frame.
135+func TestTrimReleasesUndrawnDMABuf(t *testing.T) {
136+ r, err := New(80, 32)
137+ if err != nil {
138+ t.Skipf("Vulkan unavailable: %v", err)
139+ }
140+ defer r.Close()
141+ linear := ports.DMABufFormat{Format: fourcc('A', 'R', '2', '4'), Modifier: 0}
142+ if !slices.Contains(r.DMABuf().Formats, linear) {
143+ t.Skipf("linear ARGB8888 not importable: %+v", r.DMABuf())
144+ }
145+ f := udmabuf(t, 64, 16, func(int, int) [4]byte { return [4]byte{0, 0, 255, 255} })
146+ buf := &ports.DMABuf{ID: 9, Width: 64, Height: 16, Format: linear.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
147+ scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
148+ contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Width: 64, Height: 16, DMABuf: buf}}
149+ if err := render(r, scene, contents); err != nil {
150+ t.Fatal(err)
151+ }
152+ if err := render(r, ports.Scene{Background: "#000000"}, nil); err != nil {
153+ t.Fatal(err)
154+ }
155+ start := time.Unix(1000, 0)
156+ if err := r.Trim(start); err != nil {
157+ t.Fatal(err)
158+ }
159+ trimSettled(t, r, start.Add(idleTTL))
160+ if len(r.imports) != 0 {
161+ t.Fatalf("imports %d", len(r.imports))
162+ }
163+}
164+
165+// poll frees what a finished frame held without waiting for it.
166+func TestTrimPollsFinishedFrames(t *testing.T) {
167+ r, err := New(32, 32)
168+ if err != nil {
169+ t.Skipf("Vulkan unavailable: %v", err)
170+ }
171+ defer r.Close()
172+ if !r.syncFD {
173+ t.Skip("frames are finished on return without sync files")
174+ }
175+ if err := render(r, ports.Scene{Background: "#000000"}, nil); err != nil {
176+ t.Fatal(err)
177+ }
178+ freed := false
179+ r.retire(r.frame, func() { freed = true })
180+ deadline := time.Now().Add(5 * time.Second)
181+ for !freed {
182+ if time.Now().After(deadline) {
183+ t.Fatal("finished frame never polled")
184+ }
185+ if err := r.Trim(time.Now()); err != nil {
186+ t.Fatal(err)
187+ }
188+ }
189+ if r.completed != r.frame {
190+ t.Fatalf("completed %d, frame %d", r.completed, r.frame)
191+ }
192+}
M internal/mocks/ports/devices_mock.go +52 -0
@@ -7,6 +7,7 @@ package portsmocks
77 import (
88 "image"
99 "os"
10+ "time"
1011
1112 "github.com/bnema/neferwl/internal/ports"
1213 mock "github.com/stretchr/testify/mock"
@@ -428,6 +429,57 @@ func (_c *MockRenderer_SetHDR_Call) RunAndReturn(run func(sdrNits float64)) *Moc
428429 return _c
429430 }
430431
432+// Trim provides a mock function for the type MockRenderer
433+func (_mock *MockRenderer) Trim(now time.Time) error {
434+ ret := _mock.Called(now)
435+
436+ if len(ret) == 0 {
437+ panic("no return value specified for Trim")
438+ }
439+
440+ var r0 error
441+ if returnFunc, ok := ret.Get(0).(func(time.Time) error); ok {
442+ r0 = returnFunc(now)
443+ } else {
444+ r0 = ret.Error(0)
445+ }
446+ return r0
447+}
448+
449+// MockRenderer_Trim_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Trim'
450+type MockRenderer_Trim_Call struct {
451+ *mock.Call
452+}
453+
454+// Trim is a helper method to define mock.On call
455+// - now time.Time
456+func (_e *MockRenderer_Expecter) Trim(now any) *MockRenderer_Trim_Call {
457+ return &MockRenderer_Trim_Call{Call: _e.mock.On("Trim", now)}
458+}
459+
460+func (_c *MockRenderer_Trim_Call) Run(run func(now time.Time)) *MockRenderer_Trim_Call {
461+ _c.Call.Run(func(args mock.Arguments) {
462+ var arg0 time.Time
463+ if args[0] != nil {
464+ arg0 = args[0].(time.Time)
465+ }
466+ run(
467+ arg0,
468+ )
469+ })
470+ return _c
471+}
472+
473+func (_c *MockRenderer_Trim_Call) Return(err error) *MockRenderer_Trim_Call {
474+ _c.Call.Return(err)
475+ return _c
476+}
477+
478+func (_c *MockRenderer_Trim_Call) RunAndReturn(run func(now time.Time) error) *MockRenderer_Trim_Call {
479+ _c.Call.Return(run)
480+ return _c
481+}
482+
431483 // UseTarget provides a mock function for the type MockRenderer
432484 func (_mock *MockRenderer) UseTarget(i int) {
433485 _mock.Called(i)
@@ -7,6 +7,7 @@ package portsmocks
7 import (7 import (
8 "image"8 "image"
9 "os"9 "os"
10+ "time"
10 11
11 "github.com/bnema/neferwl/internal/ports"12 "github.com/bnema/neferwl/internal/ports"
12 mock "github.com/stretchr/testify/mock"13 mock "github.com/stretchr/testify/mock"
@@ -428,6 +429,57 @@ func (_c *MockRenderer_SetHDR_Call) RunAndReturn(run func(sdrNits float64)) *Moc
428 return _c429 return _c
429 }430 }
430 431
432+// Trim provides a mock function for the type MockRenderer
433+func (_mock *MockRenderer) Trim(now time.Time) error {
434+ ret := _mock.Called(now)
435+
436+ if len(ret) == 0 {
437+ panic("no return value specified for Trim")
438+ }
439+
440+ var r0 error
441+ if returnFunc, ok := ret.Get(0).(func(time.Time) error); ok {
442+ r0 = returnFunc(now)
443+ } else {
444+ r0 = ret.Error(0)
445+ }
446+ return r0
447+}
448+
449+// MockRenderer_Trim_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Trim'
450+type MockRenderer_Trim_Call struct {
451+ *mock.Call
452+}
453+
454+// Trim is a helper method to define mock.On call
455+// - now time.Time
456+func (_e *MockRenderer_Expecter) Trim(now any) *MockRenderer_Trim_Call {
457+ return &MockRenderer_Trim_Call{Call: _e.mock.On("Trim", now)}
458+}
459+
460+func (_c *MockRenderer_Trim_Call) Run(run func(now time.Time)) *MockRenderer_Trim_Call {
461+ _c.Call.Run(func(args mock.Arguments) {
462+ var arg0 time.Time
463+ if args[0] != nil {
464+ arg0 = args[0].(time.Time)
465+ }
466+ run(
467+ arg0,
468+ )
469+ })
470+ return _c
471+}
472+
473+func (_c *MockRenderer_Trim_Call) Return(err error) *MockRenderer_Trim_Call {
474+ _c.Call.Return(err)
475+ return _c
476+}
477+
478+func (_c *MockRenderer_Trim_Call) RunAndReturn(run func(now time.Time) error) *MockRenderer_Trim_Call {
479+ _c.Call.Return(run)
480+ return _c
481+}
482+
431 // UseTarget provides a mock function for the type MockRenderer483 // UseTarget provides a mock function for the type MockRenderer
432 func (_mock *MockRenderer) UseTarget(i int) {484 func (_mock *MockRenderer) UseTarget(i int) {
433 _mock.Called(i)485 _mock.Called(i)
M internal/ports/devices.go +5 -0
@@ -3,6 +3,7 @@ package ports
33 import (
44 "image"
55 "os"
6+ "time"
67 )
78
89 // Renderer draws scenes for an output. One goroutine owns it. Frames go
@@ -33,6 +34,10 @@ type Renderer interface {
3334 Pixels() *image.RGBA
3435 // Capture copies a region of the last rendered frame as opaque BGRA.
3536 Capture(region image.Rectangle, dst []byte, stride int) error
37+ // Trim frees client buffer caches left undrawn for a while and what
38+ // finished frames held, without rendering. Outputs call it
39+ // periodically: an idle output renders no frame to free them.
40+ Trim(now time.Time) error
3641 Close()
3742 }
3843
@@ -3,6 +3,7 @@ package ports
3 import (3 import (
4 "image"4 "image"
5 "os"5 "os"
6+ "time"
6 )7 )
7 8
8 // Renderer draws scenes for an output. One goroutine owns it. Frames go9 // Renderer draws scenes for an output. One goroutine owns it. Frames go
@@ -33,6 +34,10 @@ type Renderer interface {
33 Pixels() *image.RGBA34 Pixels() *image.RGBA
34 // Capture copies a region of the last rendered frame as opaque BGRA.35 // Capture copies a region of the last rendered frame as opaque BGRA.
35 Capture(region image.Rectangle, dst []byte, stride int) error36 Capture(region image.Rectangle, dst []byte, stride int) error
37+ // Trim frees client buffer caches left undrawn for a while and what
38+ // finished frames held, without rendering. Outputs call it
39+ // periodically: an idle output renders no frame to free them.
40+ Trim(now time.Time) error
36 Close()41 Close()
37 }42 }
38 43

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close