detached 7d23734

perf(core): make the frame fallback timer once and reset it per frame

armFrame built a time.Timer and two closures on every animation frame
(about 4 allocations). Core keeps one ports.Timer, made on the first arm
and Stop+Reset afterwards; frameC is nil while it is not armed. Mock
clocks in tests accept Reset; the multi-output timer test counts resets
instead of comparing channels.
commit
7d23734194b9d3d855ad4e23f06394a26950847e
author
brice · · 2026-10-05 01:53 UTC
parent
70589ae
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +53 -21
M internal/core/capindicator_test.go +7 -1
@@ -12,10 +12,11 @@ import (
1212
1313 // indicatorClock is a clock the test moves; its timers never fire by
1414 // themselves: the test calls captureFlashTick, and the durations asked for
15-// are recorded.
15+// are recorded: timers holds the NewTimer durations, resets the Reset ones.
1616 type indicatorClock struct {
1717 now time.Time
1818 timers []time.Duration
19+ resets []time.Duration
1920 }
2021
2122 func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
@@ -28,6 +29,10 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
2829 timer := portsmocks.NewMockTimer(t)
2930 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
3031 timer.EXPECT().Stop().Return(true).Maybe()
32+ timer.EXPECT().Reset(mock.Anything).RunAndReturn(func(d time.Duration) bool {
33+ ic.resets = append(ic.resets, d)
34+ return false
35+ }).Maybe()
3136 return timer
3237 }).Maybe()
3338 var cfg ports.Config
@@ -193,6 +198,7 @@ func TestCaptureIndicatorHiddenWorkspacePill(t *testing.T) {
193198 timer := portsmocks.NewMockTimer(t)
194199 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
195200 timer.EXPECT().Stop().Return(true).Maybe()
201+ timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
196202 clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
197203 c.ch.Clock = clock
198204 c.configures.cw.reset()
@@ -12,10 +12,11 @@ import (
12 12
13 // indicatorClock is a clock the test moves; its timers never fire by13 // indicatorClock is a clock the test moves; its timers never fire by
14 // themselves: the test calls captureFlashTick, and the durations asked for14 // themselves: the test calls captureFlashTick, and the durations asked for
15-// are recorded.15+// are recorded: timers holds the NewTimer durations, resets the Reset ones.
16 type indicatorClock struct {16 type indicatorClock struct {
17 now time.Time17 now time.Time
18 timers []time.Duration18 timers []time.Duration
19+ resets []time.Duration
19 }20 }
20 21
21 func indicatorCore(t *testing.T) (*Core, *indicatorClock) {22 func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
@@ -28,6 +29,10 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
28 timer := portsmocks.NewMockTimer(t)29 timer := portsmocks.NewMockTimer(t)
29 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()30 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
30 timer.EXPECT().Stop().Return(true).Maybe()31 timer.EXPECT().Stop().Return(true).Maybe()
32+ timer.EXPECT().Reset(mock.Anything).RunAndReturn(func(d time.Duration) bool {
33+ ic.resets = append(ic.resets, d)
34+ return false
35+ }).Maybe()
31 return timer36 return timer
32 }).Maybe()37 }).Maybe()
33 var cfg ports.Config38 var cfg ports.Config
@@ -193,6 +198,7 @@ func TestCaptureIndicatorHiddenWorkspacePill(t *testing.T) {
193 timer := portsmocks.NewMockTimer(t)198 timer := portsmocks.NewMockTimer(t)
194 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()199 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
195 timer.EXPECT().Stop().Return(true).Maybe()200 timer.EXPECT().Stop().Return(true).Maybe()
201+ timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
196 clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()202 clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
197 c.ch.Clock = clock203 c.ch.Clock = clock
198 c.configures.cw.reset()204 c.configures.cw.reset()
M internal/core/core.go +23 -9
@@ -128,10 +128,12 @@ type Core struct {
128128 // (ports.UserActivity), sent at most once per ActivityInterval.
129129 activity time.Time
130130 // swipe is the touchpad swipe in progress. frameC fires when no page
131- // flip came in time to move a running slide (frameStop stops it).
132- swipe *swipeGesture
133- frameC <-chan time.Time
134- frameStop func() bool
131+ // flip came in time to move a running slide; nil when not armed. It is
132+ // the channel of frameTimer, made once and reset per frame: an
133+ // animation frame allocates no timer.
134+ swipe *swipeGesture
135+ frameC <-chan time.Time
136+ frameTimer ports.Timer
135137 // shots is the snapshot of what the screens show before an action
136138 // (transition.go), reused by every action.
137139 shots []viewShot
@@ -360,14 +362,26 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
360362
361363 // newTimer makes a timer on clock, or the system timer when clock is nil.
362364 func newTimer(clock ports.Clock, d time.Duration) (<-chan time.Time, func() bool) {
365+ t := newPortTimer(clock, d)
366+ return t.C(), t.Stop
367+}
368+
369+// newPortTimer makes a ports.Timer on clock, or on the system clock when
370+// clock is nil.
371+func newPortTimer(clock ports.Clock, d time.Duration) ports.Timer {
363372 if clock != nil {
364- t := clock.NewTimer(d)
365- return t.C(), t.Stop
373+ return clock.NewTimer(d)
366374 }
367- t := time.NewTimer(d)
368- return t.C, t.Stop
375+ return sysTimer{time.NewTimer(d)}
369376 }
370377
378+// sysTimer is a time.Timer as a ports.Timer.
379+type sysTimer struct{ t *time.Timer }
380+
381+func (t sysTimer) C() <-chan time.Time { return t.t.C }
382+func (t sysTimer) Stop() bool { return t.t.Stop() }
383+func (t sysTimer) Reset(d time.Duration) bool { return t.t.Reset(d) }
384+
371385 func (c *Core) now() time.Time {
372386 if c.ch.Clock != nil {
373387 return c.ch.Clock.Now()
@@ -1236,7 +1250,7 @@ func (c *Core) Run(ctx context.Context) error {
12361250 if c.securityCheckpoint(ctx) != nil {
12371251 return nil
12381252 }
1239- c.frameC, c.frameStop = nil, nil
1253+ c.frameC = nil
12401254 if c.step(ctx, nil) != nil {
12411255 return nil
12421256 }
@@ -128,10 +128,12 @@ type Core struct {
128 // (ports.UserActivity), sent at most once per ActivityInterval.128 // (ports.UserActivity), sent at most once per ActivityInterval.
129 activity time.Time129 activity time.Time
130 // swipe is the touchpad swipe in progress. frameC fires when no page130 // swipe is the touchpad swipe in progress. frameC fires when no page
131- // flip came in time to move a running slide (frameStop stops it).131+ // flip came in time to move a running slide; nil when not armed. It is
132- swipe *swipeGesture132+ // the channel of frameTimer, made once and reset per frame: an
133- frameC <-chan time.Time133+ // animation frame allocates no timer.
134- frameStop func() bool134+ swipe *swipeGesture
135+ frameC <-chan time.Time
136+ frameTimer ports.Timer
135 // shots is the snapshot of what the screens show before an action137 // shots is the snapshot of what the screens show before an action
136 // (transition.go), reused by every action.138 // (transition.go), reused by every action.
137 shots []viewShot139 shots []viewShot
@@ -360,14 +362,26 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
360 362
361 // newTimer makes a timer on clock, or the system timer when clock is nil.363 // newTimer makes a timer on clock, or the system timer when clock is nil.
362 func newTimer(clock ports.Clock, d time.Duration) (<-chan time.Time, func() bool) {364 func newTimer(clock ports.Clock, d time.Duration) (<-chan time.Time, func() bool) {
365+ t := newPortTimer(clock, d)
366+ return t.C(), t.Stop
367+}
368+
369+// newPortTimer makes a ports.Timer on clock, or on the system clock when
370+// clock is nil.
371+func newPortTimer(clock ports.Clock, d time.Duration) ports.Timer {
363 if clock != nil {372 if clock != nil {
364- t := clock.NewTimer(d)373+ return clock.NewTimer(d)
365- return t.C(), t.Stop
366 }374 }
367- t := time.NewTimer(d)375+ return sysTimer{time.NewTimer(d)}
368- return t.C, t.Stop
369 }376 }
370 377
378+// sysTimer is a time.Timer as a ports.Timer.
379+type sysTimer struct{ t *time.Timer }
380+
381+func (t sysTimer) C() <-chan time.Time { return t.t.C }
382+func (t sysTimer) Stop() bool { return t.t.Stop() }
383+func (t sysTimer) Reset(d time.Duration) bool { return t.t.Reset(d) }
384+
371 func (c *Core) now() time.Time {385 func (c *Core) now() time.Time {
372 if c.ch.Clock != nil {386 if c.ch.Clock != nil {
373 return c.ch.Clock.Now()387 return c.ch.Clock.Now()
@@ -1236,7 +1250,7 @@ func (c *Core) Run(ctx context.Context) error {
1236 if c.securityCheckpoint(ctx) != nil {1250 if c.securityCheckpoint(ctx) != nil {
1237 return nil1251 return nil
1238 }1252 }
1239- c.frameC, c.frameStop = nil, nil1253+ c.frameC = nil
1240 if c.step(ctx, nil) != nil {1254 if c.step(ctx, nil) != nil {
1241 return nil1255 return nil
1242 }1256 }
M internal/core/gesture.go +14 -6
@@ -465,15 +465,23 @@ func (c *Core) frameFallback() time.Duration {
465465 return 2 * time.Second * 1000 / time.Duration(refresh)
466466 }
467467
468-// armFrame starts the fallback timer of a running slide.
468+// armFrame starts the fallback timer of a running slide. The timer is made
469+// on the first arm and reset afterwards (Go 1.23 timers deliver nothing
470+// stale after a Stop or Reset), so a frame costs no allocation.
469471 func (c *Core) armFrame() {
470- c.stopFrame()
471- c.frameC, c.frameStop = newTimer(c.ch.Clock, c.frameFallback())
472+ d := c.frameFallback()
473+ if c.frameTimer == nil {
474+ c.frameTimer = newPortTimer(c.ch.Clock, d)
475+ } else {
476+ c.frameTimer.Stop()
477+ c.frameTimer.Reset(d)
478+ }
479+ c.frameC = c.frameTimer.C()
472480 }
473481
474482 func (c *Core) stopFrame() {
475- if c.frameStop != nil {
476- c.frameStop()
483+ if c.frameC != nil {
484+ c.frameTimer.Stop()
477485 }
478- c.frameC, c.frameStop = nil, nil
486+ c.frameC = nil
479487 }
@@ -465,15 +465,23 @@ func (c *Core) frameFallback() time.Duration {
465 return 2 * time.Second * 1000 / time.Duration(refresh)465 return 2 * time.Second * 1000 / time.Duration(refresh)
466 }466 }
467 467
468-// armFrame starts the fallback timer of a running slide.468+// armFrame starts the fallback timer of a running slide. The timer is made
469+// on the first arm and reset afterwards (Go 1.23 timers deliver nothing
470+// stale after a Stop or Reset), so a frame costs no allocation.
469 func (c *Core) armFrame() {471 func (c *Core) armFrame() {
470- c.stopFrame()472+ d := c.frameFallback()
471- c.frameC, c.frameStop = newTimer(c.ch.Clock, c.frameFallback())473+ if c.frameTimer == nil {
474+ c.frameTimer = newPortTimer(c.ch.Clock, d)
475+ } else {
476+ c.frameTimer.Stop()
477+ c.frameTimer.Reset(d)
478+ }
479+ c.frameC = c.frameTimer.C()
472 }480 }
473 481
474 func (c *Core) stopFrame() {482 func (c *Core) stopFrame() {
475- if c.frameStop != nil {483+ if c.frameC != nil {
476- c.frameStop()484+ c.frameTimer.Stop()
477 }485 }
478- c.frameC, c.frameStop = nil, nil486+ c.frameC = nil
479 }487 }
M internal/core/persist_test.go +1 -0
@@ -28,6 +28,7 @@ func steppingClock(t *testing.T) *portsmocks.MockClock {
2828 timer := portsmocks.NewMockTimer(t)
2929 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
3030 timer.EXPECT().Stop().Return(true).Maybe()
31+ timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
3132 return timer
3233 }).Maybe()
3334 return clock
@@ -28,6 +28,7 @@ func steppingClock(t *testing.T) *portsmocks.MockClock {
28 timer := portsmocks.NewMockTimer(t)28 timer := portsmocks.NewMockTimer(t)
29 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()29 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
30 timer.EXPECT().Stop().Return(true).Maybe()30 timer.EXPECT().Stop().Return(true).Maybe()
31+ timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
31 return timer32 return timer
32 }).Maybe()33 }).Maybe()
33 return clock34 return clock
M internal/core/step_clock_test.go +1 -0
@@ -33,6 +33,7 @@ func newStepClock(t *testing.T) *stepClock {
3333 timer := portsmocks.NewMockTimer(t)
3434 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
3535 timer.EXPECT().Stop().Return(true).Maybe()
36+ timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
3637 clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
3738 s.clock = clock
3839 return s
@@ -33,6 +33,7 @@ func newStepClock(t *testing.T) *stepClock {
33 timer := portsmocks.NewMockTimer(t)33 timer := portsmocks.NewMockTimer(t)
34 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()34 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
35 timer.EXPECT().Stop().Return(true).Maybe()35 timer.EXPECT().Stop().Return(true).Maybe()
36+ timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
36 clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()37 clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
37 s.clock = clock38 s.clock = clock
38 return s39 return s
M internal/core/transition_chain_internal_test.go +7 -5
@@ -146,15 +146,17 @@ func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) {
146146 w := sc.mon.Current()
147147 w.shift, w.motion = 100, c.spring(viewSpring(100, 0), ic.now)
148148 }
149+ // The one frame timer is made on the first arm and reset afterwards:
150+ // a re-arm shows as a Reset on the clock.
149151 c.armFrame()
150- armed := c.frameC
152+ resets := len(ic.resets)
151153 ic.now = ic.now.Add(16 * time.Millisecond)
152154 if err := c.step(context.Background(), a); err != nil {
153155 t.Fatal(err)
154156 }
155157 <-c.ch.Scenes
156- if c.frameC == nil || c.frameC != armed {
157- t.Fatal("a flip of A re-armed or dropped the fallback timer while B animates")
158+ if c.frameC == nil || len(ic.resets) != resets {
159+ t.Fatalf("a flip of A re-armed or dropped the fallback timer while B animates: armed %t, resets %d", c.frameC != nil, len(ic.resets)-resets)
158160 }
159161
160162 // With nothing else animating the flip's own publish decides.
@@ -163,8 +165,8 @@ func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) {
163165 t.Fatal(err)
164166 }
165167 <-c.ch.Scenes
166- if c.frameC == nil || c.frameC == armed {
167- t.Fatal("A still animates but its timer was not re-armed")
168+ if c.frameC == nil || len(ic.resets) != resets+1 {
169+ t.Fatalf("A still animates but its timer was not re-armed: armed %t, resets %d", c.frameC != nil, len(ic.resets)-resets)
168170 }
169171 ic.now = ic.now.Add(5 * time.Second)
170172 if err := c.step(context.Background(), nil); err != nil {
@@ -146,15 +146,17 @@ func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) {
146 w := sc.mon.Current()146 w := sc.mon.Current()
147 w.shift, w.motion = 100, c.spring(viewSpring(100, 0), ic.now)147 w.shift, w.motion = 100, c.spring(viewSpring(100, 0), ic.now)
148 }148 }
149+ // The one frame timer is made on the first arm and reset afterwards:
150+ // a re-arm shows as a Reset on the clock.
149 c.armFrame()151 c.armFrame()
150- armed := c.frameC152+ resets := len(ic.resets)
151 ic.now = ic.now.Add(16 * time.Millisecond)153 ic.now = ic.now.Add(16 * time.Millisecond)
152 if err := c.step(context.Background(), a); err != nil {154 if err := c.step(context.Background(), a); err != nil {
153 t.Fatal(err)155 t.Fatal(err)
154 }156 }
155 <-c.ch.Scenes157 <-c.ch.Scenes
156- if c.frameC == nil || c.frameC != armed {158+ if c.frameC == nil || len(ic.resets) != resets {
157- t.Fatal("a flip of A re-armed or dropped the fallback timer while B animates")159+ t.Fatalf("a flip of A re-armed or dropped the fallback timer while B animates: armed %t, resets %d", c.frameC != nil, len(ic.resets)-resets)
158 }160 }
159 161
160 // With nothing else animating the flip's own publish decides.162 // With nothing else animating the flip's own publish decides.
@@ -163,8 +165,8 @@ func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) {
163 t.Fatal(err)165 t.Fatal(err)
164 }166 }
165 <-c.ch.Scenes167 <-c.ch.Scenes
166- if c.frameC == nil || c.frameC == armed {168+ if c.frameC == nil || len(ic.resets) != resets+1 {
167- t.Fatal("A still animates but its timer was not re-armed")169+ t.Fatalf("A still animates but its timer was not re-armed: armed %t, resets %d", c.frameC != nil, len(ic.resets)-resets)
168 }170 }
169 ic.now = ic.now.Add(5 * time.Second)171 ic.now = ic.now.Add(5 * time.Second)
170 if err := c.step(context.Background(), nil); err != nil {172 if err := c.step(context.Background(), nil); err != nil {

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