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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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. | |||
| 226 | 226 | Neither committed frame is mutated while preparing a candidate; failed output | |
| 227 | 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 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. | |
| 235 | 239 | ||
| 236 | 240 | Mouse animation is tested separately with injected clocks: first response, | |
| 237 | 241 | 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 output | 226 | 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 the | 229 | + | Remote clients request the full output window (`MaxOutputWindow`, 8 frames), |
| 230 | - | attachment's output window is full of unacknowledged frames. On a slow remote | 230 | + | 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 of | 231 | + | 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 a | 232 | + | 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 moved | 233 | + | 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 the | 241 | 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 ( | |||
| 9 | 9 | const ( | |
| 10 | 10 | copyScrollFrame = 16 * time.Millisecond | |
| 11 | 11 | 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. | |
| 14 | 15 | 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 | 22 | // copyMu owns the animation and its single cancellable overlay-input timer. | |
| @@ -20,6 +25,10 @@ type copyScrollAnimation struct { | |||
| 20 | 25 | remaining int | |
| 21 | 26 | lastInput time.Time | |
| 22 | 27 | 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 | 34 | func (rt *overlayRuntime) stopCopyScrollLocked() { | |
| @@ -81,7 +90,15 @@ func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (boo | |||
| 81 | 90 | // viewport would only pile rows into one catch-up jump when the window | |
| 82 | 91 | // reopens. Hold the animation and keep polling at frame pace instead. | |
| 83 | 92 | 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 | 102 | magnitude := remaining | |
| 86 | 103 | if magnitude < 0 { | |
| 87 | 104 | magnitude = -magnitude | |
| @@ -90,7 +107,9 @@ func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (boo | |||
| 90 | 107 | if d.clock.Now().Sub(motion.lastInput) >= copyScrollTail { | |
| 91 | 108 | step = magnitude | |
| 92 | 109 | } | |
| 93 | - | step = min(step, copyScrollMaxStep) | |
| 110 | + | if motion.recovering { | |
| 111 | + | step = min(step, copyScrollMaxStep) | |
| 112 | + | } | |
| 94 | 113 | if remaining < 0 { | |
| 95 | 114 | step = -step | |
| 96 | 115 | } | |
| @@ -9,9 +9,14 @@ import ( | |||||
| 9 | const ( | 9 | const ( | ||
| 10 | copyScrollFrame = 16 * time.Millisecond | 10 | copyScrollFrame = 16 * time.Millisecond | ||
| 11 | copyScrollTail = 120 * time.Millisecond | 11 | copyScrollTail = 120 * time.Millisecond | ||
| 12 | - | // copyScrollMaxStep bounds one animation frame so a long backlog (a stalled | 12 | + | // 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 = 9 | 15 | 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 int | 25 | remaining int | ||
| 21 | lastInput time.Time | 26 | lastInput time.Time | ||
| 22 | timer pendingByteTimer | 27 | 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 window | 90 | // 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, false | 92 | 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 := remaining | 102 | magnitude := remaining | ||
| 86 | if magnitude < 0 { | 103 | if magnitude < 0 { | ||
| 87 | magnitude = -magnitude | 104 | 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 = magnitude | 108 | 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 = -step | 114 | step = -step | ||
| 96 | } | 115 | } | ||
M internal/usecase/daemon/copy_scroll_animation_test.go +52 -0
| @@ -133,3 +133,55 @@ func TestCopyScrollReducedMotionAndKeyboardCancellation(t *testing.T) { | |||
| 133 | 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 | + | } | |
| @@ -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 ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 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 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. | |
| 16 | 20 | func TestCopyScrollOverDelayedAcks(t *testing.T) { | |
| 17 | 21 | // flick: 12 notches at once. sustained: one notch per tick for 30 ticks. | |
| 18 | - | for _, scenario := range []struct { | |
| 22 | + | scenarios := []struct { | |
| 19 | 23 | name string | |
| 20 | 24 | 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) | |
| 54 | 35 | } | |
| 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) | |
| 76 | 38 | } | |
| 77 | - | } | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | } | |
| 78 | 44 | ||
| 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 | |
| 126 | 112 | } | |
| 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 | |
| 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 | } | |
| @@ -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 link | 13 | + | // TestCopyScrollOverDelayedAcks replays a wheel scroll on a link whose |
| 13 | - | // whose acknowledgements arrive rtt late. One tick is one animation frame | 14 | + | // 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 client | 15 | + | // 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 string | 23 | name string | ||
| 20 | upfront, perTick int | 24 | 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.clock | 33 | + | if res.maxJump > copyScrollMaxStep { |
| 30 | - | rt := f.ac.overlays | 34 | + | 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 := start | 46 | + | // on a 100 ms link a one-frame window leaves the viewport still most ticks, |
| 81 | - | maxJump := 0 | 47 | + | // while the default window keeps it moving. |
| 82 | - | var jumps []string | 48 | + | 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 := 0 | 50 | + | 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 = top | 63 | + | // 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 stalled | 80 | + | 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 jump | 88 | + | // 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 | } | ||