detached cb64d9f

feat(output): log merged flips in output stats

commit
cb64d9faec5fe95b277370b27e8cbe1b2bfd16b6
author
brice · · 2026-10-13 15:31 UTC
committer
brice · · 2026-10-14 04:34 UTC
parent
5c50d28
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +44 -11
M docs/performance.md +1 -0
@@ -53,5 +53,6 @@ Every 10 s, each output logs a `stats` entry:
5353 | `max_commit_delay_ms` | Longest wait from "frame due" to commit |
5454 | `late_fences` | Missed vblanks where the GPU finished too late (needs `--debug=drm-flip`) |
5555 | `redrawn_pixels` | Pixels drawn |
56+| `merged_flips` | Flips merged because wayland was not reading output reports |
5657
5758 Missed vblanks with `late_fences` mean the GPU was late. Without `late_fences` but with a large `max_commit_delay_ms`, the CPU was late.
@@ -53,5 +53,6 @@ Every 10 s, each output logs a `stats` entry:
53 | `max_commit_delay_ms` | Longest wait from "frame due" to commit |53 | `max_commit_delay_ms` | Longest wait from "frame due" to commit |
54 | `late_fences` | Missed vblanks where the GPU finished too late (needs `--debug=drm-flip`) |54 | `late_fences` | Missed vblanks where the GPU finished too late (needs `--debug=drm-flip`) |
55 | `redrawn_pixels` | Pixels drawn |55 | `redrawn_pixels` | Pixels drawn |
56+| `merged_flips` | Flips merged because wayland was not reading output reports |
56 57
57 Missed vblanks with `late_fences` mean the GPU was late. Without `late_fences` but with a large `max_commit_delay_ms`, the CPU was late.58 Missed vblanks with `late_fences` mean the GPU was late. Without `late_fences` but with a large `max_commit_delay_ms`, the CPU was late.
M internal/adapters/drm/output.go +6 -2
@@ -156,7 +156,10 @@ type Output struct {
156156 traceFlips bool
157157 lastFlipAt time.Duration
158158 flipStats flipStats
159- wantedAt time.Duration
159+ // mergedFlips counts flips merged because wayland was not reading
160+ // reports, reported and reset with the periodic stats.
161+ mergedFlips int
162+ wantedAt time.Duration
160163 // vrrFlipGap is the minimum time between a game frame's flip event
161164 // and the next frame commit under VRR (render.vrr-flip-gap, 0: off);
162165 // flipGapUntil is when the next frame may commit: the gap from the
@@ -1454,7 +1457,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14541457 fs := o.takeFlipStats()
14551458 ev = ev.Int("missed_vblanks", fs.missedVblanks).Float64("max_flip_interval_ms", ms(fs.maxInterval))
14561459 ev = ev.Float64("max_commit_delay_ms", ms(fs.maxCommitDelay)).Float64("max_flip_to_read_ms", ms(fs.maxFlipToRead))
1457- ev = ev.Int("redrawn_pixels", r.TakeRedrawn())
1460+ ev = ev.Int("redrawn_pixels", r.TakeRedrawn()).Int("merged_flips", o.mergedFlips)
1461+ o.mergedFlips = 0
14581462 if o.traceFlips {
14591463 ev = ev.Int("late_fences", fs.lateFences)
14601464 }
@@ -156,7 +156,10 @@ type Output struct {
156 traceFlips bool156 traceFlips bool
157 lastFlipAt time.Duration157 lastFlipAt time.Duration
158 flipStats flipStats158 flipStats flipStats
159- wantedAt time.Duration159+ // mergedFlips counts flips merged because wayland was not reading
160+ // reports, reported and reset with the periodic stats.
161+ mergedFlips int
162+ wantedAt time.Duration
160 // vrrFlipGap is the minimum time between a game frame's flip event163 // vrrFlipGap is the minimum time between a game frame's flip event
161 // and the next frame commit under VRR (render.vrr-flip-gap, 0: off);164 // and the next frame commit under VRR (render.vrr-flip-gap, 0: off);
162 // flipGapUntil is when the next frame may commit: the gap from the165 // flipGapUntil is when the next frame may commit: the gap from the
@@ -1454,7 +1457,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1454 fs := o.takeFlipStats()1457 fs := o.takeFlipStats()
1455 ev = ev.Int("missed_vblanks", fs.missedVblanks).Float64("max_flip_interval_ms", ms(fs.maxInterval))1458 ev = ev.Int("missed_vblanks", fs.missedVblanks).Float64("max_flip_interval_ms", ms(fs.maxInterval))
1456 ev = ev.Float64("max_commit_delay_ms", ms(fs.maxCommitDelay)).Float64("max_flip_to_read_ms", ms(fs.maxFlipToRead))1459 ev = ev.Float64("max_commit_delay_ms", ms(fs.maxCommitDelay)).Float64("max_flip_to_read_ms", ms(fs.maxFlipToRead))
1457- ev = ev.Int("redrawn_pixels", r.TakeRedrawn())1460+ ev = ev.Int("redrawn_pixels", r.TakeRedrawn()).Int("merged_flips", o.mergedFlips)
1461+ o.mergedFlips = 0
1458 if o.traceFlips {1462 if o.traceFlips {
1459 ev = ev.Int("late_fences", fs.lateFences)1463 ev = ev.Int("late_fences", fs.lateFences)
1460 }1464 }
M internal/adapters/drm/output_test.go +3 -2
@@ -743,6 +743,7 @@ func TestRunTrimsRendererOnStatsTick(t *testing.T) {
743743 var statsLog bytes.Buffer // written by Run's goroutine, read after it ends
744744 o.log = zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &statsLog})
745745 o.flipStats = flipStats{missedVblanks: 3, maxInterval: 50 * time.Millisecond, maxFlipToRead: 25 * time.Millisecond, lateFences: 2}
746+ o.mergedFlips = 4
746747 trimmed := make(chan struct{}, 2)
747748 failure := os.ErrInvalid
748749 calls := 0 // Run's goroutine only
@@ -769,12 +770,12 @@ func TestRunTrimsRendererOnStatsTick(t *testing.T) {
769770 t.Fatalf("run: %v", err)
770771 }
771772 // Reported once, then reset. late_fences needs flip tracing.
772- for _, want := range []string{`"message":"stats"`, `"missed_vblanks":3`, `"max_flip_interval_ms":50`, `"max_flip_to_read_ms":25`, `"redrawn_pixels":1234`} {
773+ for _, want := range []string{`"message":"stats"`, `"missed_vblanks":3`, `"max_flip_interval_ms":50`, `"max_flip_to_read_ms":25`, `"redrawn_pixels":1234`, `"merged_flips":4`} {
773774 if !strings.Contains(statsLog.String(), want) {
774775 t.Fatalf("stats entry lacks %s: %s", want, statsLog.String())
775776 }
776777 }
777- if strings.Contains(statsLog.String(), "late_fences") || o.flipStats != (flipStats{}) {
778+ if strings.Contains(statsLog.String(), "late_fences") || o.flipStats != (flipStats{}) || o.mergedFlips != 0 {
778779 t.Fatalf("late fences untraced or stats not reset: %s %+v", statsLog.String(), o.flipStats)
779780 }
780781 }
@@ -743,6 +743,7 @@ func TestRunTrimsRendererOnStatsTick(t *testing.T) {
743 var statsLog bytes.Buffer // written by Run's goroutine, read after it ends743 var statsLog bytes.Buffer // written by Run's goroutine, read after it ends
744 o.log = zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &statsLog})744 o.log = zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &statsLog})
745 o.flipStats = flipStats{missedVblanks: 3, maxInterval: 50 * time.Millisecond, maxFlipToRead: 25 * time.Millisecond, lateFences: 2}745 o.flipStats = flipStats{missedVblanks: 3, maxInterval: 50 * time.Millisecond, maxFlipToRead: 25 * time.Millisecond, lateFences: 2}
746+ o.mergedFlips = 4
746 trimmed := make(chan struct{}, 2)747 trimmed := make(chan struct{}, 2)
747 failure := os.ErrInvalid748 failure := os.ErrInvalid
748 calls := 0 // Run's goroutine only749 calls := 0 // Run's goroutine only
@@ -769,12 +770,12 @@ func TestRunTrimsRendererOnStatsTick(t *testing.T) {
769 t.Fatalf("run: %v", err)770 t.Fatalf("run: %v", err)
770 }771 }
771 // Reported once, then reset. late_fences needs flip tracing.772 // Reported once, then reset. late_fences needs flip tracing.
772- for _, want := range []string{`"message":"stats"`, `"missed_vblanks":3`, `"max_flip_interval_ms":50`, `"max_flip_to_read_ms":25`, `"redrawn_pixels":1234`} {773+ for _, want := range []string{`"message":"stats"`, `"missed_vblanks":3`, `"max_flip_interval_ms":50`, `"max_flip_to_read_ms":25`, `"redrawn_pixels":1234`, `"merged_flips":4`} {
773 if !strings.Contains(statsLog.String(), want) {774 if !strings.Contains(statsLog.String(), want) {
774 t.Fatalf("stats entry lacks %s: %s", want, statsLog.String())775 t.Fatalf("stats entry lacks %s: %s", want, statsLog.String())
775 }776 }
776 }777 }
777- if strings.Contains(statsLog.String(), "late_fences") || o.flipStats != (flipStats{}) {778+ if strings.Contains(statsLog.String(), "late_fences") || o.flipStats != (flipStats{}) || o.mergedFlips != 0 {
778 t.Fatalf("late fences untraced or stats not reset: %s %+v", statsLog.String(), o.flipStats)779 t.Fatalf("late fences untraced or stats not reset: %s %+v", statsLog.String(), o.flipStats)
779 }780 }
780 }781 }
M internal/adapters/drm/report.go +1 -0
@@ -18,6 +18,7 @@ func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
1818 seen, reads, o.capped = o.capHidden(seen)
1919 }
2020 if o.reports.Push(ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: seen, ChildReads: reads}) {
21+ o.mergedFlips++
2122 o.log.Warn().Str("connector", o.conn.name).Msg("wayland is not reading output reports; flips merged")
2223 }
2324 }
@@ -18,6 +18,7 @@ func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
18 seen, reads, o.capped = o.capHidden(seen)18 seen, reads, o.capped = o.capHidden(seen)
19 }19 }
20 if o.reports.Push(ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: seen, ChildReads: reads}) {20 if o.reports.Push(ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: seen, ChildReads: reads}) {
21+ o.mergedFlips++
21 o.log.Warn().Str("connector", o.conn.name).Msg("wayland is not reading output reports; flips merged")22 o.log.Warn().Str("connector", o.conn.name).Msg("wayland is not reading output reports; flips merged")
22 }23 }
23 }24 }
M internal/adapters/headless/output.go +18 -6
@@ -113,6 +113,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
113113 seen := map[ports.WindowID]uint64{}
114114 // reports holds what the channel could not take yet, retried soon.
115115 var reports presented.Queue
116+ // mergedFlips counts flips merged since the last stats line.
117+ mergedFlips := 0
116118 var security ports.SecurityState
117119 securityKnown := false
118120 failRequests := func(reason error) {
@@ -312,6 +314,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
312314 if err := r.Trim(clk.Now()); err != nil {
313315 return fmt.Errorf("trim renderer: %w", err)
314316 }
317+ opts.Log.Info().Str("output", opts.Name).Int("frames", frame).Int("merged_flips", mergedFlips).Msg("stats")
318+ mergedFlips = 0
315319 continue
316320 case s, ok := <-scenes:
317321 if !ok {
@@ -398,7 +402,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
398402 if !haveScene || !dirty || scene.Off {
399403 // Contents not drawn are still read: report them. An output
400404 // turned off draws nothing.
401- opts.report(&reports, nil, seen)
405+ if opts.report(&reports, nil, seen) {
406+ mergedFlips++
407+ }
402408 continue
403409 }
404410 dirty = false
@@ -447,7 +453,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
447453 if opts.Presented != nil {
448454 _, reads, _ := pipeline.CapHiddenSeen(seen)
449455 if len(reads) > 0 {
450- reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads})
456+ if reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads}) {
457+ mergedFlips++
458+ }
451459 if reports.Drain(ctx, opts.Presented) != nil {
452460 return nil
453461 }
@@ -546,7 +554,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
546554 pipeline.EndGate(nil)
547555 requests.List = capture.Waiting(requests.List, scene)
548556 frame++
549- opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen)
557+ if opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen) {
558+ mergedFlips++
559+ }
550560 if opts.ScreenshotDir != "" && !opts.protected() {
551561 shot := r.Pixels()
552562 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
@@ -618,14 +628,16 @@ func writePNGSecure(path string, img image.Image, security ports.SessionSecurity
618628
619629 // report queues what the output read, with flip for a drawn frame, and
620630 // sends what the channel takes now; the rest is retried soon.
621-func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
631+// It reports whether a flip was merged because wayland was not reading.
632+func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) (merged bool) {
622633 if opts.Presented == nil {
623- return
634+ return false
624635 }
625- if q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen}) {
636+ if merged = q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen}); merged {
626637 opts.Log.Warn().Str("output", opts.Name).Msg("wayland is not reading output reports; flips merged")
627638 }
628639 q.Flush(opts.Presented)
640+ return merged
629641 }
630642
631643 // flush retries reports the channel could not take.
@@ -113,6 +113,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
113 seen := map[ports.WindowID]uint64{}113 seen := map[ports.WindowID]uint64{}
114 // reports holds what the channel could not take yet, retried soon.114 // reports holds what the channel could not take yet, retried soon.
115 var reports presented.Queue115 var reports presented.Queue
116+ // mergedFlips counts flips merged since the last stats line.
117+ mergedFlips := 0
116 var security ports.SecurityState118 var security ports.SecurityState
117 securityKnown := false119 securityKnown := false
118 failRequests := func(reason error) {120 failRequests := func(reason error) {
@@ -312,6 +314,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
312 if err := r.Trim(clk.Now()); err != nil {314 if err := r.Trim(clk.Now()); err != nil {
313 return fmt.Errorf("trim renderer: %w", err)315 return fmt.Errorf("trim renderer: %w", err)
314 }316 }
317+ opts.Log.Info().Str("output", opts.Name).Int("frames", frame).Int("merged_flips", mergedFlips).Msg("stats")
318+ mergedFlips = 0
315 continue319 continue
316 case s, ok := <-scenes:320 case s, ok := <-scenes:
317 if !ok {321 if !ok {
@@ -398,7 +402,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
398 if !haveScene || !dirty || scene.Off {402 if !haveScene || !dirty || scene.Off {
399 // Contents not drawn are still read: report them. An output403 // Contents not drawn are still read: report them. An output
400 // turned off draws nothing.404 // turned off draws nothing.
401- opts.report(&reports, nil, seen)405+ if opts.report(&reports, nil, seen) {
406+ mergedFlips++
407+ }
402 continue408 continue
403 }409 }
404 dirty = false410 dirty = false
@@ -447,7 +453,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
447 if opts.Presented != nil {453 if opts.Presented != nil {
448 _, reads, _ := pipeline.CapHiddenSeen(seen)454 _, reads, _ := pipeline.CapHiddenSeen(seen)
449 if len(reads) > 0 {455 if len(reads) > 0 {
450- reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads})456+ if reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads}) {
457+ mergedFlips++
458+ }
451 if reports.Drain(ctx, opts.Presented) != nil {459 if reports.Drain(ctx, opts.Presented) != nil {
452 return nil460 return nil
453 }461 }
@@ -546,7 +554,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
546 pipeline.EndGate(nil)554 pipeline.EndGate(nil)
547 requests.List = capture.Waiting(requests.List, scene)555 requests.List = capture.Waiting(requests.List, scene)
548 frame++556 frame++
549- opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen)557+ if opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen) {
558+ mergedFlips++
559+ }
550 if opts.ScreenshotDir != "" && !opts.protected() {560 if opts.ScreenshotDir != "" && !opts.protected() {
551 shot := r.Pixels()561 shot := r.Pixels()
552 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {562 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
@@ -618,14 +628,16 @@ func writePNGSecure(path string, img image.Image, security ports.SessionSecurity
618 628
619 // report queues what the output read, with flip for a drawn frame, and629 // report queues what the output read, with flip for a drawn frame, and
620 // sends what the channel takes now; the rest is retried soon.630 // sends what the channel takes now; the rest is retried soon.
621-func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {631+// It reports whether a flip was merged because wayland was not reading.
632+func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) (merged bool) {
622 if opts.Presented == nil {633 if opts.Presented == nil {
623- return634+ return false
624 }635 }
625- if q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen}) {636+ if merged = q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen}); merged {
626 opts.Log.Warn().Str("output", opts.Name).Msg("wayland is not reading output reports; flips merged")637 opts.Log.Warn().Str("output", opts.Name).Msg("wayland is not reading output reports; flips merged")
627 }638 }
628 q.Flush(opts.Presented)639 q.Flush(opts.Presented)
640+ return merged
629 }641 }
630 642
631 // flush retries reports the channel could not take.643 // flush retries reports the channel could not take.
M internal/adapters/headless/output_test.go +15 -1
@@ -1,6 +1,7 @@
11 package headless
22
33 import (
4+ "bytes"
45 "context"
56 "errors"
67 "image"
@@ -9,6 +10,7 @@ import (
910 "maps"
1011 "os"
1112 "path/filepath"
13+ "strings"
1214 "sync"
1315 "testing"
1416 "time"
@@ -16,6 +18,7 @@ import (
1618 "github.com/bnema/neferwl/internal/adapters/logging"
1719 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1820 "github.com/bnema/neferwl/internal/ports"
21+ "github.com/bnema/zerowrap"
1922 "github.com/stretchr/testify/mock"
2023 )
2124
@@ -214,8 +217,10 @@ func TestRunTrimsRendererOnTick(t *testing.T) {
214217 return nil
215218 }).Twice()
216219 done := make(chan error, 1)
220+ var statsLog bytes.Buffer // written by Run's goroutine, read after it ends
221+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &statsLog})
217222 go func() {
218- 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)
223+ done <- Run(context.Background(), Options{Width: 2, Height: 2, Clock: clk, Log: log, Name: "HEADLESS-1", NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil)
219224 }()
220225 ticks <- now
221226 if got := <-trimmed; !got.Equal(now) {
@@ -228,6 +233,15 @@ func TestRunTrimsRendererOnTick(t *testing.T) {
228233 if s, _ := f.snapshot(); len(s) != 0 {
229234 t.Fatalf("%d frames for a trim", len(s))
230235 }
236+ // A trim tick that succeeded logs the periodic stats once.
237+ if got := strings.Count(statsLog.String(), `"message":"stats"`); got != 1 {
238+ t.Fatalf("%d stats entries: %s", got, statsLog.String())
239+ }
240+ for _, want := range []string{`"output":"HEADLESS-1"`, `"frames":0`, `"merged_flips":0`} {
241+ if !strings.Contains(statsLog.String(), want) {
242+ t.Fatalf("stats entry lacks %s: %s", want, statsLog.String())
243+ }
244+ }
231245 }
232246
233247 func TestWritePNGAtomic(t *testing.T) {
@@ -1,6 +1,7 @@
1 package headless1 package headless
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "errors"6 "errors"
6 "image"7 "image"
@@ -9,6 +10,7 @@ import (
9 "maps"10 "maps"
10 "os"11 "os"
11 "path/filepath"12 "path/filepath"
13+ "strings"
12 "sync"14 "sync"
13 "testing"15 "testing"
14 "time"16 "time"
@@ -16,6 +18,7 @@ import (
16 "github.com/bnema/neferwl/internal/adapters/logging"18 "github.com/bnema/neferwl/internal/adapters/logging"
17 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"19 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
18 "github.com/bnema/neferwl/internal/ports"20 "github.com/bnema/neferwl/internal/ports"
21+ "github.com/bnema/zerowrap"
19 "github.com/stretchr/testify/mock"22 "github.com/stretchr/testify/mock"
20 )23 )
21 24
@@ -214,8 +217,10 @@ func TestRunTrimsRendererOnTick(t *testing.T) {
214 return nil217 return nil
215 }).Twice()218 }).Twice()
216 done := make(chan error, 1)219 done := make(chan error, 1)
220+ var statsLog bytes.Buffer // written by Run's goroutine, read after it ends
221+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &statsLog})
217 go func() {222 go func() {
218- 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)223+ done <- Run(context.Background(), Options{Width: 2, Height: 2, Clock: clk, Log: log, Name: "HEADLESS-1", NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil)
219 }()224 }()
220 ticks <- now225 ticks <- now
221 if got := <-trimmed; !got.Equal(now) {226 if got := <-trimmed; !got.Equal(now) {
@@ -228,6 +233,15 @@ func TestRunTrimsRendererOnTick(t *testing.T) {
228 if s, _ := f.snapshot(); len(s) != 0 {233 if s, _ := f.snapshot(); len(s) != 0 {
229 t.Fatalf("%d frames for a trim", len(s))234 t.Fatalf("%d frames for a trim", len(s))
230 }235 }
236+ // A trim tick that succeeded logs the periodic stats once.
237+ if got := strings.Count(statsLog.String(), `"message":"stats"`); got != 1 {
238+ t.Fatalf("%d stats entries: %s", got, statsLog.String())
239+ }
240+ for _, want := range []string{`"output":"HEADLESS-1"`, `"frames":0`, `"merged_flips":0`} {
241+ if !strings.Contains(statsLog.String(), want) {
242+ t.Fatalf("stats entry lacks %s: %s", want, statsLog.String())
243+ }
244+ }
231 }245 }
232 246
233 func TestWritePNGAtomic(t *testing.T) {247 func TestWritePNGAtomic(t *testing.T) {

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