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 Unverified
B5690EEEBB952194The key is unknown
10 files · +483 -9
M internal/adapters/drm/output.go +16 -3
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "unsafe" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 14 | + | "github.com/bnema/neferwl/internal/adapters/clock" | |
| 14 | 15 | ||
| 15 | 16 | "github.com/bnema/neferwl/internal/adapters/syncfile" | |
| 16 | 17 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -41,6 +42,8 @@ type Output struct { | |||
| 41 | 42 | hdrBlob uint32 | |
| 42 | 43 | hdrBlobData hdrOutputMetadata | |
| 43 | 44 | log zerowrap.Logger | |
| 45 | + | // clock paces the periodic stats and renderer trim (nil: system). | |
| 46 | + | clock ports.Clock | |
| 44 | 47 | // Properties: CRTC and connector property IDs by name. | |
| 45 | 48 | crtcProps map[string]uint32 | |
| 46 | 49 | 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 | 886 | defer stats.Stop() | |
| 880 | 887 | // Timer channels are enabled only when their condition holds. Go 1.27 | |
| 881 | 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 | 1038 | // Windows on other outputs or workspaces do not need a frame. | |
| 1032 | 1039 | 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 | + | } | |
| 1035 | 1048 | ev := o.log.Info().Str("connector", o.conn.name).Int("frames", frame).Int("flips", o.flips).Bool("pending", o.frame.pendingCommit()) | |
| 1036 | 1049 | if o.cursor != nil { | |
| 1037 | 1050 | 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 uint32 | 42 | hdrBlob uint32 | ||
| 42 | hdrBlobData hdrOutputMetadata | 43 | hdrBlobData hdrOutputMetadata | ||
| 43 | log zerowrap.Logger | 44 | 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]uint32 | 48 | crtcProps map[string]uint32 | ||
| 46 | connCrtc uint32 // the connector's CRTC_ID property | 49 | 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.27 | 887 | // 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "image" | |
| 6 | 7 | "os" | |
| 7 | 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 | 749 | // A request must render even when the output already has an unchanged scene. | |
| 705 | 750 | func TestCaptureForcesDRMComposition(t *testing.T) { | |
| 706 | 751 | 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 ( | |||
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 15 | + | "github.com/bnema/neferwl/internal/adapters/clock" | |
| 15 | 16 | "github.com/bnema/neferwl/internal/adapters/syncfile" | |
| 16 | 17 | "github.com/bnema/neferwl/internal/ports" | |
| 17 | 18 | "github.com/bnema/zerowrap" | |
| @@ -33,8 +34,14 @@ type Options struct { | |||
| 33 | 34 | Name string | |
| 34 | 35 | Presented chan<- ports.OutputPresented | |
| 35 | 36 | 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 | 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 | 46 | r, err := opts.NewRenderer(opts.Width, opts.Height) | |
| 40 | 47 | if err != nil { | |
| @@ -99,6 +106,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 99 | 106 | 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 | 115 | for { | |
| 103 | 116 | if ctx.Err() != nil { | |
| 104 | 117 | return nil | |
| @@ -120,6 +133,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 120 | 133 | case <-retry: | |
| 121 | 134 | pending = opts.send(pending) | |
| 122 | 135 | 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 | 143 | case s, ok := <-scenes: | |
| 124 | 144 | if !ok { | |
| 125 | 145 | 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 string | 34 | Name string | ||
| 34 | Presented chan<- ports.OutputPresented | 35 | Presented chan<- ports.OutputPresented | ||
| 35 | Captured chan<- ports.CaptureDone | 36 | 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] = c | 106 | 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 nil | 117 | 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 | continue | 135 | 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 = nil | 145 | scenes = nil | ||
M internal/adapters/headless/output_test.go +39 -0
| @@ -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 | 191 | func TestWritePNGAtomic(t *testing.T) { | |
| 153 | 192 | dir := t.TempDir() | |
| 154 | 193 | 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 { | |||
| 67 | 67 | // the bytes copied into them, for tests. | |
| 68 | 68 | shm map[shmKey]*shmSurface | |
| 69 | 69 | copied int | |
| 70 | + | // marks date the frames for Trim's wall-clock eviction (trim.go). | |
| 71 | + | marks []trimMark | |
| 70 | 72 | // redrawn counts the target pixels drawn, for tests. | |
| 71 | 73 | redrawn int | |
| 72 | 74 | // 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]*shmSurface | 68 | shm map[shmKey]*shmSurface | ||
| 69 | copied int | 69 | 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 int | 73 | 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 ( | |||
| 16 | 16 | // that the fragment shader reads directly: one memcpy per row, no staging | |
| 17 | 17 | // copy, no resample, no alpha fix-up on the CPU. Each surface has two GPU | |
| 18 | 18 | // 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 | 22 | // mapping is a client pool mapped read-only. | |
| 22 | 23 | type mapping struct { | |
| @@ -263,12 +264,18 @@ func (r *Renderer) freeShmCopy(c *shmCopy) { | |||
| 263 | 264 | func (r *Renderer) dropShm() { | |
| 264 | 265 | for key, s := range r.shm { | |
| 265 | 266 | 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) | |
| 271 | 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 | + | } | |
| @@ -16,7 +16,8 @@ import ( | |||||
| 16 | // that the fragment shader reads directly: one memcpy per row, no staging | 16 | // 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 GPU | 17 | // 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 flight | 18 | // 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 | |||
| 7 | 7 | import ( | |
| 8 | 8 | "image" | |
| 9 | 9 | "os" | |
| 10 | + | "time" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| 12 | 13 | mock "github.com/stretchr/testify/mock" | |
| @@ -428,6 +429,57 @@ func (_c *MockRenderer_SetHDR_Call) RunAndReturn(run func(sdrNits float64)) *Moc | |||
| 428 | 429 | 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 | 483 | // UseTarget provides a mock function for the type MockRenderer | |
| 432 | 484 | func (_mock *MockRenderer) UseTarget(i int) { | |
| 433 | 485 | _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 _c | 429 | 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 MockRenderer | 483 | // 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "image" | |
| 5 | 5 | "os" | |
| 6 | + | "time" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 9 | // Renderer draws scenes for an output. One goroutine owns it. Frames go | |
| @@ -33,6 +34,10 @@ type Renderer interface { | |||
| 33 | 34 | Pixels() *image.RGBA | |
| 34 | 35 | // Capture copies a region of the last rendered frame as opaque BGRA. | |
| 35 | 36 | 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 | 41 | Close() | |
| 37 | 42 | } | |
| 38 | 43 | ||
| @@ -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 go | 9 | // Renderer draws scenes for an output. One goroutine owns it. Frames go | ||
| @@ -33,6 +34,10 @@ type Renderer interface { | |||||
| 33 | Pixels() *image.RGBA | 34 | 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) error | 36 | 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 | ||||