detached f3b7322

fix(daemon): hold copy-scroll on a full output window and drain the backlog in capped steps

commit
f3b7322782ea70c0ef228c3fbc6fde5315ebf8e3
author
brice · · 2026-10-08 10:14 UTC
parent
af4a4eb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +268 -121
M docs/performance.md +10 -6
@@ -226,12 +226,16 @@ The unadorned live-pane cache stays separate from the displayed copy viewport.
226226 Neither committed frame is mutated while preparing a candidate; failed output
227227 cannot publish its viewport metadata. Copy exit refreshes live content.
228228
229-The animation moves at most 9 rows per 16 ms frame, and waits while the
230-attachment's output window is full of unacknowledged frames. On a slow remote
231-link the scroll pauses and resumes with the same step sizes instead of
232-catching up in one jump. `TestCopyScrollOverDelayedAcks` replays a flick and a
233-sustained scroll over 0–400 ms acknowledgement delays and logs the rows moved
234-per frame.
229+Remote clients request the full output window (`MaxOutputWindow`, 8 frames),
230+so the animation keeps moving while acknowledgements are in flight; it stays
231+smooth up to about 100 ms of round trip. When the window is full the animation
232+holds still instead of piling rows into one frame, then drains the backlog at
233+no more than 9 rows per frame once acknowledgements return. A hold longer than
234+about one second abandons the scroll. Local scrolling is unchanged.
235+`TestCopyScrollOverDelayedAcks` replays a flick and a sustained scroll over
236+0–400 ms acknowledgement delays with windows of 1 and 8 frames; it asserts
237+that no frame jumps more than 9 rows and that every requested row is shown,
238+and logs the rows moved per frame.
235239
236240 Mouse animation is tested separately with injected clocks: first response,
237241 16 ms pacing, burst accumulation, deceleration, reversal, cancellation, and the
@@ -226,12 +226,16 @@ The unadorned live-pane cache stays separate from the displayed copy viewport.
226 Neither committed frame is mutated while preparing a candidate; failed output226 Neither committed frame is mutated while preparing a candidate; failed output
227 cannot publish its viewport metadata. Copy exit refreshes live content.227 cannot publish its viewport metadata. Copy exit refreshes live content.
228 228
229-The animation moves at most 9 rows per 16 ms frame, and waits while the229+Remote clients request the full output window (`MaxOutputWindow`, 8 frames),
230-attachment's output window is full of unacknowledged frames. On a slow remote230+so the animation keeps moving while acknowledgements are in flight; it stays
231-link the scroll pauses and resumes with the same step sizes instead of231+smooth up to about 100 ms of round trip. When the window is full the animation
232-catching up in one jump. `TestCopyScrollOverDelayedAcks` replays a flick and a232+holds still instead of piling rows into one frame, then drains the backlog at
233-sustained scroll over 0–400 ms acknowledgement delays and logs the rows moved233+no more than 9 rows per frame once acknowledgements return. A hold longer than
234-per frame.234+about one second abandons the scroll. Local scrolling is unchanged.
235+`TestCopyScrollOverDelayedAcks` replays a flick and a sustained scroll over
236+0–400 ms acknowledgement delays with windows of 1 and 8 frames; it asserts
237+that no frame jumps more than 9 rows and that every requested row is shown,
238+and logs the rows moved per frame.
235 239
236 Mouse animation is tested separately with injected clocks: first response,240 Mouse animation is tested separately with injected clocks: first response,
237 16 ms pacing, burst accumulation, deceleration, reversal, cancellation, and the241 16 ms pacing, burst accumulation, deceleration, reversal, cancellation, and the
M internal/usecase/daemon/copy_scroll_animation.go +23 -4
@@ -9,9 +9,14 @@ import (
99 const (
1010 copyScrollFrame = 16 * time.Millisecond
1111 copyScrollTail = 120 * time.Millisecond
12- // copyScrollMaxStep bounds one animation frame so a long backlog (a stalled
13- // remote link, a big flick) is drained over several frames, not one jump.
12+ // copyScrollMaxStep bounds each frame drained after the animation was held
13+ // on a full output window, so the backlog that built up during the stall
14+ // resumes as motion rather than one catch-up jump.
1415 copyScrollMaxStep = 9
16+ // copyScrollMaxHeldFrames ends an animation held on a full output window
17+ // for about one second: the link is effectively gone, and scrolling on its
18+ // own once acknowledgements finally return would surprise the user.
19+ copyScrollMaxHeldFrames = int(time.Second / copyScrollFrame)
1520 )
1621
1722 // copyMu owns the animation and its single cancellable overlay-input timer.
@@ -20,6 +25,10 @@ type copyScrollAnimation struct {
2025 remaining int
2126 lastInput time.Time
2227 timer pendingByteTimer
28+ // heldFrames counts consecutive frames held on a full output window.
29+ heldFrames int
30+ // recovering caps the steps that drain the backlog left by a hold.
31+ recovering bool
2332 }
2433
2534 func (rt *overlayRuntime) stopCopyScrollLocked() {
@@ -81,7 +90,15 @@ func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (boo
8190 // viewport would only pile rows into one catch-up jump when the window
8291 // reopens. Hold the animation and keep polling at frame pace instead.
8392 changed, exit := false, false
84- if ac.output == nil || !ac.output.atCapacity() {
93+ if ac.output.atCapacity() {
94+ motion.heldFrames++
95+ motion.recovering = true
96+ if motion.heldFrames > copyScrollMaxHeldFrames {
97+ rt.stopCopyScrollLocked()
98+ return false, false
99+ }
100+ } else {
101+ motion.heldFrames = 0
85102 magnitude := remaining
86103 if magnitude < 0 {
87104 magnitude = -magnitude
@@ -90,7 +107,9 @@ func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (boo
90107 if d.clock.Now().Sub(motion.lastInput) >= copyScrollTail {
91108 step = magnitude
92109 }
93- step = min(step, copyScrollMaxStep)
110+ if motion.recovering {
111+ step = min(step, copyScrollMaxStep)
112+ }
94113 if remaining < 0 {
95114 step = -step
96115 }
@@ -9,9 +9,14 @@ import (
9 const (9 const (
10 copyScrollFrame = 16 * time.Millisecond10 copyScrollFrame = 16 * time.Millisecond
11 copyScrollTail = 120 * time.Millisecond11 copyScrollTail = 120 * time.Millisecond
12- // copyScrollMaxStep bounds one animation frame so a long backlog (a stalled12+ // copyScrollMaxStep bounds each frame drained after the animation was held
13- // remote link, a big flick) is drained over several frames, not one jump.13+ // on a full output window, so the backlog that built up during the stall
14+ // resumes as motion rather than one catch-up jump.
14 copyScrollMaxStep = 915 copyScrollMaxStep = 9
16+ // copyScrollMaxHeldFrames ends an animation held on a full output window
17+ // for about one second: the link is effectively gone, and scrolling on its
18+ // own once acknowledgements finally return would surprise the user.
19+ copyScrollMaxHeldFrames = int(time.Second / copyScrollFrame)
15 )20 )
16 21
17 // copyMu owns the animation and its single cancellable overlay-input timer.22 // copyMu owns the animation and its single cancellable overlay-input timer.
@@ -20,6 +25,10 @@ type copyScrollAnimation struct {
20 remaining int25 remaining int
21 lastInput time.Time26 lastInput time.Time
22 timer pendingByteTimer27 timer pendingByteTimer
28+ // heldFrames counts consecutive frames held on a full output window.
29+ heldFrames int
30+ // recovering caps the steps that drain the backlog left by a hold.
31+ recovering bool
23 }32 }
24 33
25 func (rt *overlayRuntime) stopCopyScrollLocked() {34 func (rt *overlayRuntime) stopCopyScrollLocked() {
@@ -81,7 +90,15 @@ func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (boo
81 // viewport would only pile rows into one catch-up jump when the window90 // viewport would only pile rows into one catch-up jump when the window
82 // reopens. Hold the animation and keep polling at frame pace instead.91 // reopens. Hold the animation and keep polling at frame pace instead.
83 changed, exit := false, false92 changed, exit := false, false
84- if ac.output == nil || !ac.output.atCapacity() {93+ if ac.output.atCapacity() {
94+ motion.heldFrames++
95+ motion.recovering = true
96+ if motion.heldFrames > copyScrollMaxHeldFrames {
97+ rt.stopCopyScrollLocked()
98+ return false, false
99+ }
100+ } else {
101+ motion.heldFrames = 0
85 magnitude := remaining102 magnitude := remaining
86 if magnitude < 0 {103 if magnitude < 0 {
87 magnitude = -magnitude104 magnitude = -magnitude
@@ -90,7 +107,9 @@ func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (boo
90 if d.clock.Now().Sub(motion.lastInput) >= copyScrollTail {107 if d.clock.Now().Sub(motion.lastInput) >= copyScrollTail {
91 step = magnitude108 step = magnitude
92 }109 }
93- step = min(step, copyScrollMaxStep)110+ if motion.recovering {
111+ step = min(step, copyScrollMaxStep)
112+ }
94 if remaining < 0 {113 if remaining < 0 {
95 step = -step114 step = -step
96 }115 }
M internal/usecase/daemon/copy_scroll_animation_test.go +52 -0
@@ -133,3 +133,55 @@ func TestCopyScrollReducedMotionAndKeyboardCancellation(t *testing.T) {
133133 t.Fatal("keyboard navigation must cancel inertia")
134134 }
135135 }
136+
137+// TestCopyScrollAnimationFullOutputWindow covers a remote link whose output
138+// window is full: the animation holds still, drains the backlog in capped
139+// steps once the window reopens, and gives up after about a second of hold.
140+func TestCopyScrollAnimationFullOutputWindow(t *testing.T) {
141+ tests := []struct {
142+ name string
143+ heldFrames int
144+ reopen bool
145+ wantMoved int
146+ wantActive bool
147+ }{
148+ {name: "full window holds the viewport", heldFrames: 1, wantMoved: 0, wantActive: true},
149+ {name: "reopened window drains in capped steps", heldFrames: 1, reopen: true, wantMoved: copyScrollMaxStep, wantActive: true},
150+ {name: "long hold abandons the scroll", heldFrames: copyScrollMaxHeldFrames + 1, wantMoved: 0, wantActive: false},
151+ }
152+ for _, tt := range tests {
153+ t.Run(tt.name, func(t *testing.T) {
154+ f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200})
155+ f.ac.output.setWindow(1)
156+ f.d.enterCopyMode(f.sess, f.ac)
157+ clock := newCoordinatorMockClock(t, 2*copyScrollMaxHeldFrames)
158+ f.d.clock = clock.clock
159+ require.True(t, f.ac.output.atCapacity(), "entering copy mode fills a one-frame window")
160+ rt := f.ac.overlays
161+ rt.copyMu.Lock()
162+ defer rt.copyMu.Unlock()
163+ start := rt.copyMode.ViewportTop
164+ // Past the tail deadline the whole backlog is due at once.
165+ rt.copyScroll.remaining = -60
166+ rt.copyScroll.lastInput = time.Time{}.Add(-copyScrollTail)
167+ for range tt.heldFrames {
168+ // Each frame is driven directly, as its timer callback would:
169+ // retire the previous frame timer before advancing.
170+ rt.copyScroll.timer.stop()
171+ changed, exit := f.d.advanceCopyScrollLocked(f.sess, f.ac)
172+ require.False(t, changed || exit, "a held frame must not move or exit")
173+ }
174+ if tt.reopen {
175+ f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
176+ rt.copyScroll.timer.stop()
177+ changed, exit := f.d.advanceCopyScrollLocked(f.sess, f.ac)
178+ require.True(t, changed)
179+ require.False(t, exit)
180+ }
181+ require.Equal(t, tt.wantMoved, start-rt.copyMode.ViewportTop)
182+ require.Equal(t, tt.wantActive, rt.copyScroll.remaining != 0)
183+ require.Equal(t, tt.wantActive, rt.copyScroll.timer.timer != nil, "the frame timer stays armed only while the scroll is active")
184+ rt.copyScroll.timer.stop()
185+ })
186+ }
187+}
@@ -133,3 +133,55 @@ func TestCopyScrollReducedMotionAndKeyboardCancellation(t *testing.T) {
133 t.Fatal("keyboard navigation must cancel inertia")133 t.Fatal("keyboard navigation must cancel inertia")
134 }134 }
135 }135 }
136+
137+// TestCopyScrollAnimationFullOutputWindow covers a remote link whose output
138+// window is full: the animation holds still, drains the backlog in capped
139+// steps once the window reopens, and gives up after about a second of hold.
140+func TestCopyScrollAnimationFullOutputWindow(t *testing.T) {
141+ tests := []struct {
142+ name string
143+ heldFrames int
144+ reopen bool
145+ wantMoved int
146+ wantActive bool
147+ }{
148+ {name: "full window holds the viewport", heldFrames: 1, wantMoved: 0, wantActive: true},
149+ {name: "reopened window drains in capped steps", heldFrames: 1, reopen: true, wantMoved: copyScrollMaxStep, wantActive: true},
150+ {name: "long hold abandons the scroll", heldFrames: copyScrollMaxHeldFrames + 1, wantMoved: 0, wantActive: false},
151+ }
152+ for _, tt := range tests {
153+ t.Run(tt.name, func(t *testing.T) {
154+ f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200})
155+ f.ac.output.setWindow(1)
156+ f.d.enterCopyMode(f.sess, f.ac)
157+ clock := newCoordinatorMockClock(t, 2*copyScrollMaxHeldFrames)
158+ f.d.clock = clock.clock
159+ require.True(t, f.ac.output.atCapacity(), "entering copy mode fills a one-frame window")
160+ rt := f.ac.overlays
161+ rt.copyMu.Lock()
162+ defer rt.copyMu.Unlock()
163+ start := rt.copyMode.ViewportTop
164+ // Past the tail deadline the whole backlog is due at once.
165+ rt.copyScroll.remaining = -60
166+ rt.copyScroll.lastInput = time.Time{}.Add(-copyScrollTail)
167+ for range tt.heldFrames {
168+ // Each frame is driven directly, as its timer callback would:
169+ // retire the previous frame timer before advancing.
170+ rt.copyScroll.timer.stop()
171+ changed, exit := f.d.advanceCopyScrollLocked(f.sess, f.ac)
172+ require.False(t, changed || exit, "a held frame must not move or exit")
173+ }
174+ if tt.reopen {
175+ f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
176+ rt.copyScroll.timer.stop()
177+ changed, exit := f.d.advanceCopyScrollLocked(f.sess, f.ac)
178+ require.True(t, changed)
179+ require.False(t, exit)
180+ }
181+ require.Equal(t, tt.wantMoved, start-rt.copyMode.ViewportTop)
182+ require.Equal(t, tt.wantActive, rt.copyScroll.remaining != 0)
183+ require.Equal(t, tt.wantActive, rt.copyScroll.timer.timer != nil, "the frame timer stays armed only while the scroll is active")
184+ rt.copyScroll.timer.stop()
185+ })
186+ }
187+}
M internal/usecase/daemon/copy_scroll_remote_test.go +183 -111
@@ -7,128 +7,200 @@ import (
77 "time"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
1011 )
1112
12-// TestCopyScrollOverDelayedAcks measures how a wheel flick looks on a link
13-// whose acknowledgements arrive rtt late. One tick is one animation frame
14-// (copyScrollFrame). It logs, per tick, the rows jumped by the frame the client
15-// would see, so stalls (0) and catch-up jumps (>1) are visible.
13+// TestCopyScrollOverDelayedAcks replays a wheel scroll on a link whose
14+// acknowledgements arrive rtt late, for several output windows. One tick is one
15+// animation frame (copyScrollFrame). The log shows, per tick, the rows the
16+// viewport moved: "-" is a stall, a value above one frame's worth is a catch-up
17+// jump. The test asserts that no frame jumps further than copyScrollMaxStep,
18+// that every requested row is eventually shown, and that the window the daemon
19+// is allowed to fill bounds the stalls.
1620 func TestCopyScrollOverDelayedAcks(t *testing.T) {
1721 // flick: 12 notches at once. sustained: one notch per tick for 30 ticks.
18- for _, scenario := range []struct {
22+ scenarios := []struct {
1923 name string
2024 upfront, perTick int
21- }{{"flick", 12, 0}, {"sustained", 1, 1}} {
22- for _, rtt := range []time.Duration{0, 50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond, 400 * time.Millisecond} {
23- t.Run(scenario.name+"/"+rtt.String(), func(t *testing.T) {
24- f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 400})
25- f.d.enterCopyMode(f.sess, f.ac)
26- f.d.copyWheel(f.sess, f.ac, -150)
27- f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
28- clock := newCoordinatorMockClock(t, 64)
29- f.d.clock = clock.clock
30- rt := f.ac.overlays
31-
32- type ack struct {
33- at int
34- epoch, seen uint64
35- }
36- var (
37- acks []ack
38- tick int
39- emitted = make(chan struct{}, 64)
40- )
41- // emit runs under sendMu: only signal here, read ACK coordinates later.
42- f.ac.renderStages.emit = func() { emitted <- struct{}{} }
43- drainTimers := func() *coordinatorMockTimer {
44- var frame *coordinatorMockTimer
45- for {
46- select {
47- case tm := <-clock.timers:
48- if tm.duration == copyScrollFrame {
49- frame = tm
50- }
51- default:
52- return frame
53- }
25+ }{{"flick", 12, 0}, {"sustained", 1, 1}}
26+ rtts := []time.Duration{0, 50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond, 400 * time.Millisecond}
27+ for _, scenario := range scenarios {
28+ for _, window := range []uint8{1, protocol.MaxOutputWindow} {
29+ for _, rtt := range rtts {
30+ t.Run(fmt.Sprintf("%s/window%d/%v", scenario.name, window, rtt), func(t *testing.T) {
31+ res := replayCopyScroll(t, scenario.upfront, scenario.perTick, window, rtt)
32+ t.Logf("rows per tick (- = stall): %s", res.log)
33+ if res.maxJump > copyScrollMaxStep {
34+ t.Errorf("one frame jumped %d rows, want at most %d", res.maxJump, copyScrollMaxStep)
5435 }
55- }
56- viewport := func() int {
57- rt.copyMu.Lock()
58- defer rt.copyMu.Unlock()
59- return rt.copyMode.ViewportTop
60- }
61- pending := func() int {
62- rt.copyMu.Lock()
63- defer rt.copyMu.Unlock()
64- return rt.copyScroll.remaining
65- }
66- delivered := func() bool {
67- select {
68- case <-emitted:
69- f.ac.sendMu.Lock()
70- epoch, state := f.ac.output.currentEpoch(), f.ac.output.next
71- f.ac.sendMu.Unlock()
72- acks = append(acks, ack{at: tick + int((rtt+copyScrollFrame-1)/copyScrollFrame), epoch: epoch, seen: state})
73- return true
74- case <-time.After(15 * time.Millisecond):
75- return false
36+ if res.scrolled != res.requested {
37+ t.Fatalf("scrolled %d rows, want %d", res.scrolled, res.requested)
7638 }
77- }
39+ })
40+ }
41+ }
42+ }
43+}
7844
79- start := viewport()
80- last := start
81- maxJump := 0
82- var jumps []string
83- // A fast flick: 12 notches of 3 rows arrive in the first tick.
84- fed := 0
85- feed := func(n int) {
86- for range n {
87- f.d.smoothCopyWheel(f.sess, f.ac, -3)
88- fed++
89- }
90- }
91- feed(scenario.upfront)
92- shown := func() {
93- if delivered() {
94- top := viewport()
95- jumps = append(jumps, fmt.Sprint(last-top))
96- maxJump = max(maxJump, last-top)
97- last = top
98- } else {
99- jumps = append(jumps, "-")
100- }
101- }
102- shown()
103- for ; tick < 80 && (pending() != 0 || len(acks) != 0 || (scenario.perTick > 0 && tick < 30)); tick++ {
104- if tick < 30 {
105- feed(scenario.perTick)
106- }
107- for len(acks) > 0 && acks[0].at <= tick {
108- f.ac.ackOutputState(acks[0].epoch, acks[0].seen)
109- if rc := f.sess.renderCoordinator(); rc != nil {
110- rc.notifyAck()
111- }
112- acks = acks[1:]
113- }
114- // With a full window no timer is armed: the server is stalled
115- // until an ACK arrives, so keep ticking to let it release.
116- if frame := drainTimers(); frame != nil {
117- frame.ch <- time.Time{}
118- }
119- shown()
120- }
121- t.Logf("%s rtt=%-6v rows per tick (- = nothing sent): %s", scenario.name, rtt, strings.Join(jumps, " "))
122- // A slow link may delay frames but must not make the picture jump
123- // further than one animation frame allows.
124- if maxJump > copyScrollMaxStep {
125- t.Errorf("one frame jumped %d rows, want at most %d", maxJump, copyScrollMaxStep)
45+// TestCopyScrollWindowBoundsStalls pins the reason the output window matters:
46+// on a 100 ms link a one-frame window leaves the viewport still most ticks,
47+// while the default window keeps it moving.
48+func TestCopyScrollWindowBoundsStalls(t *testing.T) {
49+ rtt := 100 * time.Millisecond
50+ narrow := replayCopyScroll(t, 1, 1, 1, rtt)
51+ wide := replayCopyScroll(t, 1, 1, protocol.MaxOutputWindow, rtt)
52+ if wide.stalls >= narrow.stalls {
53+ t.Fatalf("window %d stalled %d ticks, window 1 stalled %d: a wider window must stall less", protocol.MaxOutputWindow, wide.stalls, narrow.stalls)
54+ }
55+}
56+
57+type copyScrollReplay struct {
58+ log string
59+ maxJump, stalls int
60+ scrolled, requested int
61+}
62+
63+// replayCopyScroll feeds upfront notches at once, then perTick notches per tick
64+// for 30 ticks, acknowledging each emitted frame rtt later. A fake clock drives
65+// the animation and every step waits for its exact expected effect (a frame
66+// emitted, or the frame timer re-armed while the window is full), so the
67+// timeouts below only guard against hangs and never decide a result.
68+func replayCopyScroll(t *testing.T, upfront, perTick int, window uint8, rtt time.Duration) copyScrollReplay {
69+ t.Helper()
70+ f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 400})
71+ f.ac.output.setWindow(window)
72+ f.d.enterCopyMode(f.sess, f.ac)
73+ f.d.copyWheel(f.sess, f.ac, -150)
74+ f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
75+ clock := newCoordinatorMockClock(t, 256)
76+ f.d.clock = clock.clock
77+ rt := f.ac.overlays
78+
79+ type ack struct {
80+ at int
81+ epoch, seen uint64
82+ }
83+ type emitted struct{ epoch, state uint64 }
84+ var (
85+ acks []ack
86+ tick int
87+ frameTimer *coordinatorMockTimer
88+ // emit runs under sendMu inside the render, so the epoch and state
89+ // it reports are exactly those of the frame sent.
90+ sent = make(chan emitted, 256)
91+ )
92+ f.ac.renderStages.emit = func() { sent <- emitted{f.ac.output.currentEpoch(), f.ac.output.next} }
93+ ticksPerRTT := int((rtt + copyScrollFrame - 1) / copyScrollFrame)
94+
95+ viewport := func() int {
96+ rt.copyMu.Lock()
97+ defer rt.copyMu.Unlock()
98+ return rt.copyMode.ViewportTop
99+ }
100+ pending := func() int {
101+ rt.copyMu.Lock()
102+ defer rt.copyMu.Unlock()
103+ return rt.copyScroll.remaining
104+ }
105+ // collectTimer keeps the newest armed animation frame timer, if any.
106+ collectTimer := func() {
107+ for {
108+ select {
109+ case tm := <-clock.timers:
110+ if tm.duration == copyScrollFrame {
111+ frameTimer = tm
126112 }
127- // Whatever the link, every requested row is eventually shown.
128- if got, want := start-viewport(), 3*fed; got != want {
129- t.Fatalf("scrolled %d rows, want %d", got, want)
113+ default:
114+ return
115+ }
116+ }
117+ }
118+ frames := 0
119+ awaitFrame := func() {
120+ select {
121+ case e := <-sent:
122+ acks = append(acks, ack{at: tick + ticksPerRTT, epoch: e.epoch, seen: e.state})
123+ frames++
124+ case <-time.After(5 * time.Second):
125+ t.Fatal("expected a scroll frame, none was emitted")
126+ }
127+ }
128+ awaitTimer := func() {
129+ deadline := time.After(5 * time.Second)
130+ for frameTimer == nil {
131+ select {
132+ case tm := <-clock.timers:
133+ if tm.duration == copyScrollFrame {
134+ frameTimer = tm
130135 }
131- })
136+ case <-deadline:
137+ t.Fatal("held animation did not re-arm its frame timer")
138+ }
139+ }
140+ }
141+ fed := 0
142+ feed := func(n int) {
143+ for range n {
144+ idle, full := frameTimer == nil, f.ac.output.atCapacity()
145+ f.d.smoothCopyWheel(f.sess, f.ac, -3)
146+ fed++
147+ if idle && !full {
148+ awaitFrame()
149+ }
150+ collectTimer()
151+ }
152+ }
153+ fire := func() {
154+ if frameTimer == nil {
155+ return
156+ }
157+ full := f.ac.output.atCapacity()
158+ timer := frameTimer
159+ frameTimer = nil
160+ timer.ch <- time.Time{}
161+ if full {
162+ awaitTimer()
163+ return
164+ }
165+ awaitFrame()
166+ collectTimer()
167+ }
168+
169+ var res copyScrollReplay
170+ var log []string
171+ start := viewport()
172+ last := start
173+ record := func() {
174+ if frames == 0 {
175+ log = append(log, "-")
176+ res.stalls++
177+ return
178+ }
179+ top := viewport()
180+ log = append(log, fmt.Sprint(last-top))
181+ res.maxJump = max(res.maxJump, last-top)
182+ last = top
183+ frames = 0
184+ }
185+
186+ feed(upfront)
187+ record()
188+ for ; tick < 1200 && (pending() != 0 || len(acks) != 0 || tick < 30 && perTick > 0); tick++ {
189+ if tick < 30 {
190+ feed(perTick)
191+ }
192+ for len(acks) > 0 && acks[0].at <= tick {
193+ f.ac.ackOutputState(acks[0].epoch, acks[0].seen)
194+ if rc := f.sess.renderCoordinator(); rc != nil {
195+ rc.notifyAck()
196+ }
197+ acks = acks[1:]
132198 }
199+ fire()
200+ record()
133201 }
202+ res.log = strings.Join(log, " ")
203+ res.scrolled = start - viewport()
204+ res.requested = 3 * fed
205+ return res
134206 }
@@ -7,128 +7,200 @@ import (
7 "time"7 "time"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
10 )11 )
11 12
12-// TestCopyScrollOverDelayedAcks measures how a wheel flick looks on a link13+// TestCopyScrollOverDelayedAcks replays a wheel scroll on a link whose
13-// whose acknowledgements arrive rtt late. One tick is one animation frame14+// acknowledgements arrive rtt late, for several output windows. One tick is one
14-// (copyScrollFrame). It logs, per tick, the rows jumped by the frame the client15+// animation frame (copyScrollFrame). The log shows, per tick, the rows the
15-// would see, so stalls (0) and catch-up jumps (>1) are visible.16+// viewport moved: "-" is a stall, a value above one frame's worth is a catch-up
17+// jump. The test asserts that no frame jumps further than copyScrollMaxStep,
18+// that every requested row is eventually shown, and that the window the daemon
19+// is allowed to fill bounds the stalls.
16 func TestCopyScrollOverDelayedAcks(t *testing.T) {20 func TestCopyScrollOverDelayedAcks(t *testing.T) {
17 // flick: 12 notches at once. sustained: one notch per tick for 30 ticks.21 // flick: 12 notches at once. sustained: one notch per tick for 30 ticks.
18- for _, scenario := range []struct {22+ scenarios := []struct {
19 name string23 name string
20 upfront, perTick int24 upfront, perTick int
21- }{{"flick", 12, 0}, {"sustained", 1, 1}} {25+ }{{"flick", 12, 0}, {"sustained", 1, 1}}
22- for _, rtt := range []time.Duration{0, 50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond, 400 * time.Millisecond} {26+ rtts := []time.Duration{0, 50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond, 400 * time.Millisecond}
23- t.Run(scenario.name+"/"+rtt.String(), func(t *testing.T) {27+ for _, scenario := range scenarios {
24- f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 400})28+ for _, window := range []uint8{1, protocol.MaxOutputWindow} {
25- f.d.enterCopyMode(f.sess, f.ac)29+ for _, rtt := range rtts {
26- f.d.copyWheel(f.sess, f.ac, -150)30+ t.Run(fmt.Sprintf("%s/window%d/%v", scenario.name, window, rtt), func(t *testing.T) {
27- f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)31+ res := replayCopyScroll(t, scenario.upfront, scenario.perTick, window, rtt)
28- clock := newCoordinatorMockClock(t, 64)32+ t.Logf("rows per tick (- = stall): %s", res.log)
29- f.d.clock = clock.clock33+ if res.maxJump > copyScrollMaxStep {
30- rt := f.ac.overlays34+ t.Errorf("one frame jumped %d rows, want at most %d", res.maxJump, copyScrollMaxStep)
31-
32- type ack struct {
33- at int
34- epoch, seen uint64
35- }
36- var (
37- acks []ack
38- tick int
39- emitted = make(chan struct{}, 64)
40- )
41- // emit runs under sendMu: only signal here, read ACK coordinates later.
42- f.ac.renderStages.emit = func() { emitted <- struct{}{} }
43- drainTimers := func() *coordinatorMockTimer {
44- var frame *coordinatorMockTimer
45- for {
46- select {
47- case tm := <-clock.timers:
48- if tm.duration == copyScrollFrame {
49- frame = tm
50- }
51- default:
52- return frame
53- }
54 }35 }
55- }36+ if res.scrolled != res.requested {
56- viewport := func() int {37+ t.Fatalf("scrolled %d rows, want %d", res.scrolled, res.requested)
57- rt.copyMu.Lock()
58- defer rt.copyMu.Unlock()
59- return rt.copyMode.ViewportTop
60- }
61- pending := func() int {
62- rt.copyMu.Lock()
63- defer rt.copyMu.Unlock()
64- return rt.copyScroll.remaining
65- }
66- delivered := func() bool {
67- select {
68- case <-emitted:
69- f.ac.sendMu.Lock()
70- epoch, state := f.ac.output.currentEpoch(), f.ac.output.next
71- f.ac.sendMu.Unlock()
72- acks = append(acks, ack{at: tick + int((rtt+copyScrollFrame-1)/copyScrollFrame), epoch: epoch, seen: state})
73- return true
74- case <-time.After(15 * time.Millisecond):
75- return false
76 }38 }
77- }39+ })
40+ }
41+ }
42+ }
43+}
78 44
79- start := viewport()45+// TestCopyScrollWindowBoundsStalls pins the reason the output window matters:
80- last := start46+// on a 100 ms link a one-frame window leaves the viewport still most ticks,
81- maxJump := 047+// while the default window keeps it moving.
82- var jumps []string48+func TestCopyScrollWindowBoundsStalls(t *testing.T) {
83- // A fast flick: 12 notches of 3 rows arrive in the first tick.49+ rtt := 100 * time.Millisecond
84- fed := 050+ narrow := replayCopyScroll(t, 1, 1, 1, rtt)
85- feed := func(n int) {51+ wide := replayCopyScroll(t, 1, 1, protocol.MaxOutputWindow, rtt)
86- for range n {52+ if wide.stalls >= narrow.stalls {
87- f.d.smoothCopyWheel(f.sess, f.ac, -3)53+ t.Fatalf("window %d stalled %d ticks, window 1 stalled %d: a wider window must stall less", protocol.MaxOutputWindow, wide.stalls, narrow.stalls)
88- fed++54+ }
89- }55+}
90- }56+
91- feed(scenario.upfront)57+type copyScrollReplay struct {
92- shown := func() {58+ log string
93- if delivered() {59+ maxJump, stalls int
94- top := viewport()60+ scrolled, requested int
95- jumps = append(jumps, fmt.Sprint(last-top))61+}
96- maxJump = max(maxJump, last-top)62+
97- last = top63+// replayCopyScroll feeds upfront notches at once, then perTick notches per tick
98- } else {64+// for 30 ticks, acknowledging each emitted frame rtt later. A fake clock drives
99- jumps = append(jumps, "-")65+// the animation and every step waits for its exact expected effect (a frame
100- }66+// emitted, or the frame timer re-armed while the window is full), so the
101- }67+// timeouts below only guard against hangs and never decide a result.
102- shown()68+func replayCopyScroll(t *testing.T, upfront, perTick int, window uint8, rtt time.Duration) copyScrollReplay {
103- for ; tick < 80 && (pending() != 0 || len(acks) != 0 || (scenario.perTick > 0 && tick < 30)); tick++ {69+ t.Helper()
104- if tick < 30 {70+ f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 400})
105- feed(scenario.perTick)71+ f.ac.output.setWindow(window)
106- }72+ f.d.enterCopyMode(f.sess, f.ac)
107- for len(acks) > 0 && acks[0].at <= tick {73+ f.d.copyWheel(f.sess, f.ac, -150)
108- f.ac.ackOutputState(acks[0].epoch, acks[0].seen)74+ f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
109- if rc := f.sess.renderCoordinator(); rc != nil {75+ clock := newCoordinatorMockClock(t, 256)
110- rc.notifyAck()76+ f.d.clock = clock.clock
111- }77+ rt := f.ac.overlays
112- acks = acks[1:]78+
113- }79+ type ack struct {
114- // With a full window no timer is armed: the server is stalled80+ at int
115- // until an ACK arrives, so keep ticking to let it release.81+ epoch, seen uint64
116- if frame := drainTimers(); frame != nil {82+ }
117- frame.ch <- time.Time{}83+ type emitted struct{ epoch, state uint64 }
118- }84+ var (
119- shown()85+ acks []ack
120- }86+ tick int
121- t.Logf("%s rtt=%-6v rows per tick (- = nothing sent): %s", scenario.name, rtt, strings.Join(jumps, " "))87+ frameTimer *coordinatorMockTimer
122- // A slow link may delay frames but must not make the picture jump88+ // emit runs under sendMu inside the render, so the epoch and state
123- // further than one animation frame allows.89+ // it reports are exactly those of the frame sent.
124- if maxJump > copyScrollMaxStep {90+ sent = make(chan emitted, 256)
125- t.Errorf("one frame jumped %d rows, want at most %d", maxJump, copyScrollMaxStep)91+ )
92+ f.ac.renderStages.emit = func() { sent <- emitted{f.ac.output.currentEpoch(), f.ac.output.next} }
93+ ticksPerRTT := int((rtt + copyScrollFrame - 1) / copyScrollFrame)
94+
95+ viewport := func() int {
96+ rt.copyMu.Lock()
97+ defer rt.copyMu.Unlock()
98+ return rt.copyMode.ViewportTop
99+ }
100+ pending := func() int {
101+ rt.copyMu.Lock()
102+ defer rt.copyMu.Unlock()
103+ return rt.copyScroll.remaining
104+ }
105+ // collectTimer keeps the newest armed animation frame timer, if any.
106+ collectTimer := func() {
107+ for {
108+ select {
109+ case tm := <-clock.timers:
110+ if tm.duration == copyScrollFrame {
111+ frameTimer = tm
126 }112 }
127- // Whatever the link, every requested row is eventually shown.113+ default:
128- if got, want := start-viewport(), 3*fed; got != want {114+ return
129- t.Fatalf("scrolled %d rows, want %d", got, want)115+ }
116+ }
117+ }
118+ frames := 0
119+ awaitFrame := func() {
120+ select {
121+ case e := <-sent:
122+ acks = append(acks, ack{at: tick + ticksPerRTT, epoch: e.epoch, seen: e.state})
123+ frames++
124+ case <-time.After(5 * time.Second):
125+ t.Fatal("expected a scroll frame, none was emitted")
126+ }
127+ }
128+ awaitTimer := func() {
129+ deadline := time.After(5 * time.Second)
130+ for frameTimer == nil {
131+ select {
132+ case tm := <-clock.timers:
133+ if tm.duration == copyScrollFrame {
134+ frameTimer = tm
130 }135 }
131- })136+ case <-deadline:
137+ t.Fatal("held animation did not re-arm its frame timer")
138+ }
139+ }
140+ }
141+ fed := 0
142+ feed := func(n int) {
143+ for range n {
144+ idle, full := frameTimer == nil, f.ac.output.atCapacity()
145+ f.d.smoothCopyWheel(f.sess, f.ac, -3)
146+ fed++
147+ if idle && !full {
148+ awaitFrame()
149+ }
150+ collectTimer()
151+ }
152+ }
153+ fire := func() {
154+ if frameTimer == nil {
155+ return
156+ }
157+ full := f.ac.output.atCapacity()
158+ timer := frameTimer
159+ frameTimer = nil
160+ timer.ch <- time.Time{}
161+ if full {
162+ awaitTimer()
163+ return
164+ }
165+ awaitFrame()
166+ collectTimer()
167+ }
168+
169+ var res copyScrollReplay
170+ var log []string
171+ start := viewport()
172+ last := start
173+ record := func() {
174+ if frames == 0 {
175+ log = append(log, "-")
176+ res.stalls++
177+ return
178+ }
179+ top := viewport()
180+ log = append(log, fmt.Sprint(last-top))
181+ res.maxJump = max(res.maxJump, last-top)
182+ last = top
183+ frames = 0
184+ }
185+
186+ feed(upfront)
187+ record()
188+ for ; tick < 1200 && (pending() != 0 || len(acks) != 0 || tick < 30 && perTick > 0); tick++ {
189+ if tick < 30 {
190+ feed(perTick)
191+ }
192+ for len(acks) > 0 && acks[0].at <= tick {
193+ f.ac.ackOutputState(acks[0].epoch, acks[0].seen)
194+ if rc := f.sess.renderCoordinator(); rc != nil {
195+ rc.notifyAck()
196+ }
197+ acks = acks[1:]
132 }198 }
199+ fire()
200+ record()
133 }201 }
202+ res.log = strings.Join(log, " ")
203+ res.scrolled = start - viewport()
204+ res.requested = 3 * fed
205+ return res
134 }206 }

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 12

Tags 11

↑↓ moveenter openesc close