1package daemon
2
3import (
4 "fmt"
5 "strings"
6 "testing"
7 "time"
8
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/protocol"
11)
12
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.
20func TestCopyScrollOverDelayedAcks(t *testing.T) {
21 // flick: 12 notches at once. sustained: one notch per tick for 30 ticks.
22 scenarios := []struct {
23 name string
24 upfront, perTick int
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)
35 }
36 if res.scrolled != res.requested {
37 t.Fatalf("scrolled %d rows, want %d", res.scrolled, res.requested)
38 }
39 })
40 }
41 }
42 }
43}
44
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.
48func 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
57type 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.
68func 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
112 }
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
135 }
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:]
198 }
199 fire()
200 record()
201 }
202 res.log = strings.Join(log, " ")
203 res.scrolled = start - viewport()
204 res.requested = 3 * fed
205 return res
206}