1package daemon
2
3import (
4 "errors"
5 "runtime"
6 "testing"
7
8 vt "github.com/bnema/vev-vt"
9 renderer "github.com/bnema/vev-vt/ansi"
10 "github.com/bnema/vev-vt/graphics"
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/domain/layout"
13 "github.com/bnema/vev/internal/domain/terminalcap"
14 "github.com/bnema/vev/internal/protocol"
15 "github.com/bnema/vev/internal/protocol/wire"
16 "github.com/stretchr/testify/require"
17)
18
19func TestAttachmentOutputBuildsPipelinedDependencyChain(t *testing.T) {
20 stream := newOutputStateStream()
21 firstFrame := renderer.NewFrame(3, 1)
22 fillOutputStateRows(firstFrame, []string{"abc"})
23 first, ok, err := drawOutputState(t, stream, firstFrame, nil, true, 7)
24 require.NoError(t, err)
25 require.True(t, ok)
26 firstOut := unmarshalTestOutput(t, first.Payload)
27 require.Equal(t, uint64(0), firstOut.Base)
28 require.Equal(t, uint64(1), firstOut.New)
29 require.Equal(t, uint64(7), firstOut.Echo)
30
31 secondFrame := firstFrame.Clone()
32 secondFrame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
33 second, ok, err := drawOutputState(t, stream, secondFrame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 8)
34 require.NoError(t, err)
35 require.True(t, ok)
36 secondOut := unmarshalTestOutput(t, second.Payload)
37 require.Equal(t, firstOut.New, secondOut.Base)
38 require.Equal(t, uint64(2), secondOut.New)
39 require.Equal(t, uint64(2), stream.outstanding())
40}
41
42func TestAttachmentOutputUsesTerminalColorProfile(t *testing.T) {
43 frame := renderer.NewFrame(1, 1)
44 frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.Style{
45 Foreground: -1,
46 Background: -1,
47 HasForegroundRGB: true,
48 ForegroundRGB: renderer.RGB{R: 255, G: 0, B: 0},
49 HasBackgroundRGB: true,
50 BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 255},
51 HasUnderlineColorRGB: true,
52 UnderlineColorRGB: renderer.RGB{R: 0, G: 255, B: 0},
53 }})
54
55 cases := []struct {
56 name string
57 capabilities terminalcap.Capabilities
58 want string
59 }{
60 {
61 name: "truecolor",
62 capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
63 want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m",
64 },
65 {
66 name: "indexed 256",
67 capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
68 want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m",
69 },
70 }
71
72 for _, tc := range cases {
73 t.Run(tc.name, func(t *testing.T) {
74 stream := newOutputStateStreamForCapabilities(tc.capabilities)
75 prepared, err := stream.prepare(frame, nil, true)
76 require.NoError(t, err)
77 require.Equal(t, tc.want, string(prepared.data))
78 })
79 }
80}
81
82func TestAttachmentOutputCapacityProbeDoesNotRaceWithSend(t *testing.T) {
83 stream := newOutputStateStream(1)
84 frame := renderer.NewFrame(3, 1)
85 fillOutputStateRows(frame, []string{"abc"})
86 prepared, err := stream.prepareTestOutput(frame, nil, true)
87 require.NoError(t, err)
88
89 entered := make(chan struct{})
90 release := make(chan struct{})
91 sendDone := make(chan error, 1)
92 go func() {
93 sendDone <- prepared.send(prepared.data, 0, outputFrameSender(func(wire.Envelope) error {
94 close(entered)
95 <-release
96 return nil
97 }))
98 }()
99 awaitTestCompletion(t, entered, "output send was not admitted")
100
101 probed := make(chan struct{})
102 go func() {
103 for range 100_000 {
104 _ = stream.atCapacity()
105 runtime.Gosched()
106 }
107 close(probed)
108 }()
109 close(release)
110 require.NoError(t, <-sendDone)
111 <-probed
112 require.True(t, stream.atCapacity())
113
114 stream.ack(1, 1)
115 require.False(t, stream.atCapacity())
116 stream.rebase()
117 require.False(t, stream.atCapacity())
118}
119
120func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *testing.T) {
121 frame := renderer.NewFrame(3, 1)
122 fillOutputStateRows(frame, []string{"abc"})
123 newAttachment := func() *attachmentOutput {
124 stream := newOutputStateStream()
125 ac := &attachedClient{output: stream, geometry: domain.Geometry{Size: domain.Size{Cols: 3, Rows: 1}}}
126 stream.attachment = ac
127 return stream
128 }
129
130 first := newAttachment()
131 second := newAttachment()
132 firstPrepared, err := first.prepareTestOutput(frame, nil, true)
133 require.NoError(t, err)
134 var firstFrame wire.Envelope
135 require.NoError(t, firstPrepared.send(firstPrepared.data, 0, outputFrameSender(func(frame wire.Envelope) error {
136 firstFrame = frame
137 return nil
138 })))
139 firstOutput := unmarshalTestOutput(t, firstFrame.Payload)
140 require.Equal(t, uint64(1), firstOutput.Epoch)
141 require.Equal(t, uint64(1), firstOutput.New)
142 require.True(t, firstOutput.Full)
143 first.ack(firstOutput.Epoch, firstOutput.New)
144
145 secondPrepared, err := second.prepareTestOutput(frame, nil, true)
146 require.NoError(t, err)
147 require.NoError(t, secondPrepared.send(secondPrepared.data, 0, outputFrameSender(func(wire.Envelope) error { return nil })))
148 require.Equal(t, uint64(1), second.epoch)
149 require.Equal(t, uint64(1), second.next)
150 require.Equal(t, uint64(1), first.next)
151
152 changed := frame.Clone()
153 changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
154 pending, err := first.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
155 require.NoError(t, err)
156 first.rebase()
157 require.Equal(t, uint64(2), first.epoch)
158 require.Zero(t, first.next)
159 require.Zero(t, first.acked)
160 var staleSent bool
161 require.NoError(t, pending.send(pending.data, 0, outputFrameSender(func(wire.Envelope) error {
162 staleSent = true
163 return nil
164 })))
165 require.False(t, staleSent)
166 require.Zero(t, first.next)
167 first.ack(1, 1)
168 require.Zero(t, first.acked, "an ACK from the retired epoch must not advance the new epoch")
169
170 reset, err := first.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
171 require.NoError(t, err)
172 var resetFrame wire.Envelope
173 require.NoError(t, reset.send(reset.data, 0, outputFrameSender(func(frame wire.Envelope) error {
174 resetFrame = frame
175 return nil
176 })))
177 resetOutput := unmarshalTestOutput(t, resetFrame.Payload)
178 require.Equal(t, uint64(2), resetOutput.Epoch)
179 require.Zero(t, resetOutput.Base)
180 require.Equal(t, uint64(1), resetOutput.New)
181 require.True(t, resetOutput.Full)
182}
183
184func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) {
185 for _, tt := range []struct {
186 name string
187 change func(*attachedClient)
188 }{
189 {name: "connection generation", change: func(ac *attachedClient) { ac.lifecycle.generation.Add(1) }},
190 {name: "view revision", change: func(ac *attachedClient) {
191 ac.viewMu.Lock()
192 ac.view.revision++
193 ac.viewMu.Unlock()
194 }},
195 } {
196 t.Run(tt.name, func(t *testing.T) {
197 stream := newOutputStateStream()
198 ac := &attachedClient{output: stream, geometry: domain.Geometry{Size: domain.Size{Cols: 3, Rows: 1}}}
199 stream.attachment = ac
200 frame := renderer.NewFrame(3, 1)
201 fillOutputStateRows(frame, []string{"abc"})
202 initial, err := stream.prepareTestOutput(frame, nil, true)
203 require.NoError(t, err)
204 require.NoError(t, initial.send(initial.data, 0, outputFrameSender(func(wire.Envelope) error { return nil })))
205 changed := frame.Clone()
206 changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
207 pending, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
208 require.NoError(t, err)
209 tt.change(ac)
210 var sent bool
211 require.NoError(t, pending.send(pending.data, 0, outputFrameSender(func(wire.Envelope) error {
212 sent = true
213 return nil
214 })))
215 require.False(t, sent)
216 require.Equal(t, uint64(1), stream.next)
217
218 retry, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
219 require.NoError(t, err)
220 require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(wire.Envelope) error { return nil })))
221 require.Equal(t, uint64(2), stream.next)
222 })
223 }
224}
225
226func TestOutputStateAckRequiresCurrentEpoch(t *testing.T) {
227 stream := newOutputStateStream()
228 stream.next = 2
229 stream.ack(2, 1)
230 require.Zero(t, stream.acked, "a stale epoch must not retire output state")
231 stream.ack(1, 1)
232 require.Equal(t, uint64(1), stream.acked)
233 stream.ack(1, 3)
234 require.Equal(t, uint64(1), stream.acked, "a future state must not advance the ACK window")
235}
236
237func TestOutputStateSideEffectsDoNotAdvanceEpochStateOrACK(t *testing.T) {
238 stream := newOutputStateStream()
239 stream.ack(1, 0)
240 beforeEpoch, beforeNext, beforeAck := stream.epoch, stream.next, stream.acked
241 out, err := stream.sideEffect([]byte("pty"), 9)
242 require.NoError(t, err)
243 require.Equal(t, beforeEpoch, out.Epoch)
244 require.Zero(t, out.Base)
245 require.Zero(t, out.New)
246 require.False(t, out.Full)
247 require.Equal(t, uint64(9), out.Echo)
248 require.Equal(t, beforeNext, stream.next)
249 require.Equal(t, beforeAck, stream.acked)
250}
251
252func TestAttachmentOutputFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {
253 stream := newOutputStateStream()
254 initial := renderer.NewFrame(3, 1)
255 fillOutputStateRows(initial, []string{"abc"})
256 first, err := stream.prepareTestOutput(initial, nil, true)
257 require.NoError(t, err)
258 require.NoError(t, first.send(first.data, 0, outputFrameSender(func(wire.Envelope) error { return nil })))
259 require.Equal(t, uint64(1), stream.next)
260
261 changed := initial.Clone()
262 changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
263 pending, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
264 require.NoError(t, err)
265 probe, err := stream.renderer.Prepare(initial, nil, false)
266 require.NoError(t, err)
267 require.Empty(t, probe.Bytes(), "preparation must not advance the renderer shadow")
268
269 sendErr := errors.New("send failed")
270 require.ErrorIs(t, pending.send(pending.data, 0, outputFrameSender(func(wire.Envelope) error { return sendErr })), sendErr)
271 require.Equal(t, uint64(1), stream.next, "failed send must not advance the state chain")
272 pending.commitNoSend()
273 probe, err = stream.renderer.Prepare(initial, nil, false)
274 require.NoError(t, err)
275 require.Empty(t, probe.Bytes(), "failed send must retain the committed renderer shadow")
276
277 retry, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
278 require.NoError(t, err)
279 var sent wire.Envelope
280 require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(frame wire.Envelope) error {
281 sent = frame
282 return nil
283 })))
284 out := unmarshalTestOutput(t, sent.Payload)
285 require.Zero(t, out.Base, "retry after ambiguous send failure must be dependency-free")
286 require.Equal(t, uint64(2), out.New, "state numbers remain monotonic across rebases")
287 require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once")
288 require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(wire.Envelope) error {
289 t.Fatal("completed output sent twice")
290 return nil
291 })))
292 require.Equal(t, uint64(2), stream.next)
293 probe, err = stream.renderer.Prepare(changed, nil, false)
294 require.NoError(t, err)
295 require.Empty(t, probe.Bytes(), "successful retry commits the candidate shadow")
296}
297
298func TestAttachmentOutputNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
299 stream := newOutputStateStream()
300 frame := renderer.NewFrame(3, 1)
301 fillOutputStateRows(frame, []string{"abc"})
302 initial, err := stream.prepareTestOutput(frame, nil, true)
303 require.NoError(t, err)
304 require.NoError(t, initial.send(initial.data, 0, outputFrameSender(func(wire.Envelope) error { return nil })))
305
306 noOp, err := stream.prepare(frame, nil, false)
307 require.NoError(t, err)
308 require.Empty(t, noOp.data)
309 noOp.commitNoSend()
310 noOp.commitNoSend()
311 require.Equal(t, uint64(1), stream.next, "no-byte commit must not create a state frame")
312
313 probe, err := stream.renderer.Prepare(frame, nil, false)
314 require.NoError(t, err)
315 require.Empty(t, probe.Bytes(), "no-byte commit retains the prepared visual candidate")
316}
317
318func TestAttachmentOutputRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) {
319 stream := newOutputStateStream()
320 client := vt.NewScreen(4, 3)
321 initial := renderer.NewFrame(4, 3)
322 fillOutputStateRows(initial, []string{"AAAA", "BBBB", "CCCC"})
323 first, ok, err := drawOutputState(t, stream, initial, nil, true, 0)
324 require.NoError(t, err)
325 require.True(t, ok)
326 mustApplyOutput(t, client, first)
327 stream.ack(1, 1)
328
329 scrolled := renderer.NewFrame(4, 3)
330 fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N "})
331 damage := []renderer.Damage{
332 {Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
333 {Kind: renderer.DamageText, X: 0, Y: 2, Width: 4, Height: 1},
334 }
335 second, ok, err := drawOutputState(t, stream, scrolled, damage, false, 0)
336 require.NoError(t, err)
337 require.True(t, ok)
338 mustApplyOutput(t, client, second)
339
340 // The renderer baseline is the preceding emitted frame, so repeating stale
341 // scroll damage falls back to an overwrite instead of applying a second
342 // incompatible scroll program.
343 third, ok, err := drawOutputState(t, stream, scrolled, damage, false, 0)
344 require.NoError(t, err)
345 require.True(t, ok)
346 mustApplyOutput(t, client, third)
347 for y, want := range []string{"BBBB", "CCCC", "N "} {
348 require.Equal(t, want, outputStateRow(client.RowCells(y)))
349 }
350}
351
352func (s *attachmentOutput) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {
353 prepared, err := s.prepare(frame, damage, reset)
354 if err != nil {
355 return nil, err
356 }
357 if len(prepared.data) == 0 {
358 prepared.commitNoSend()
359 return nil, nil
360 }
361 // This helper tests renderer replay bytes without publishing a wire frame.
362 // Production state advancement happens only through preparedOutput.send.
363 prepared.draw.Commit()
364 s.forceSnapshot = false
365 return prepared.data, nil
366}
367
368func outputStateFrame(stream *attachmentOutput, data []byte, reset bool, echoAck uint64) wire.Envelope {
369 stream.next++
370 base := stream.next - 1
371 if reset {
372 base = 0
373 }
374 context := publicationTestContext()
375 context.Publication = stream.next
376 output := protocol.Output{
377 Epoch: 1, Base: base, New: stream.next, Echo: echoAck,
378 Size: domain.Size{Cols: 1, Rows: 1}, Full: reset, Data: data, Context: &context,
379 }
380 return mustServerEnvelope(output)
381}
382
383// prepareTestOutput gives replay-only fixtures explicit semantic ownership;
384// production obtains this from capturedRenderState in prepareFrame.
385func (s *attachmentOutput) prepareTestOutput(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
386 prepared, err := s.prepare(frame, damage, reset)
387 if err == nil {
388 context := publicationTestContext()
389 prepared.context = &context
390 }
391 return prepared, err
392}
393
394func drawOutputState(t *testing.T, stream *attachmentOutput, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (wire.Envelope, bool, error) {
395 t.Helper()
396 prepared, err := stream.prepareTestOutput(frame, damage, reset)
397 if err != nil || len(prepared.data) == 0 {
398 if prepared != nil {
399 prepared.commitNoSend()
400 }
401 return wire.Envelope{}, false, err
402 }
403 var output wire.Envelope
404 err = prepared.send(prepared.data, echoAck, outputFrameSender(func(frame wire.Envelope) error {
405 output = frame
406 return nil
407 }))
408 if err != nil {
409 return wire.Envelope{}, false, err
410 }
411 return output, true, nil
412}
413
414// A resize is a state reset, but it must not turn subsequent idle or damaged
415// renders into full frames.
416func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing.T) {
417 screen := vt.NewScreen(4, 2)
418 screen.Write([]byte("abcd"))
419 screen.ClearDamage()
420 screen.Resize(6, 2)
421
422 stream := newOutputStateStream()
423 resized, ok, err := drawOutputState(t, stream, captureTestFrame(screen), screen.Damage(), true, 0)
424 require.NoError(t, err)
425 require.True(t, ok)
426 resizeOutput := unmarshalTestOutput(t, resized.Payload)
427 require.Zero(t, resizeOutput.Base, "resize must emit the one reset frame")
428 screen.ClearDamage()
429
430 _, ok, err = drawOutputState(t, stream, captureTestFrame(screen), screen.Damage(), false, 0)
431 require.NoError(t, err)
432 require.False(t, ok, "an unchanged render after resize must emit nothing")
433
434 screen.Write([]byte("\x1b[1;1Hx"))
435 damaged, ok, err := drawOutputState(t, stream, captureTestFrame(screen), []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0)
436 require.NoError(t, err)
437 require.True(t, ok)
438 damageOutput := unmarshalTestOutput(t, damaged.Payload)
439 require.Equal(t, resizeOutput.New, damageOutput.Base, "later damage must remain incremental")
440}
441
442func TestAttachmentOutputFailedSendKeepsTextCursorAndGraphicsSpeculative(t *testing.T) {
443 scene := graphics.NewScene(graphics.Limits{})
444 asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("asset"), Width: 1, Height: 1})
445 require.NoError(t, err)
446 _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
447 require.NoError(t, err)
448 state := &capturedRenderState{
449 route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}},
450 view: attachmentView{tabID: "tab-1"},
451 panes: []capturedPaneRenderState{{
452 stableID: "pane-1", focused: true,
453 graphics: scene.Snapshot(),
454 graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 1, Rows: 1}, PixelWidth: 1, PixelHeight: 1},
455 placement: layout.Placement{Content: domain.Rect{Width: 1, Height: 1}},
456 }}}
457 graphicsState := newGraphicsOutputState()
458 output := attachmentOutputWithGraphics(graphicsState)
459 output.lastCursor = cursorOut{valid: true, row: 1, col: 1}
460 ac := &attachedClient{output: output, terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true}}
461 output.attachment = ac
462 frame := renderer.NewFrame(1, 1)
463 prepared, err := output.prepareFrame(nil, state, frame, []renderer.Damage{renderer.FullRedraw()}, true, cursorOut{row: 2, col: 3})
464 require.NoError(t, err)
465
466 sendErr := errors.New("send failed")
467 require.ErrorIs(t, prepared.send(0, outputFrameSender(func(wire.Envelope) error { return sendErr })), sendErr)
468
469 require.Equal(t, cursorOut{valid: true, row: 1, col: 1}, output.lastCursor)
470 require.Empty(t, graphicsState.assets)
471 require.Empty(t, graphicsState.placements)
472 require.NotEmpty(t, graphicsState.pendingImages)
473 require.NotEmpty(t, graphicsState.pendingPlaces)
474}
475
476func TestAttachmentOutputRebaseRetiresAttachmentState(t *testing.T) {
477 output := newOutputStateStream()
478 output.next = 3
479 output.acked = 2
480 output.lastCursor = cursorOut{valid: true, row: 2, col: 3}
481 output.lastRoutePosition = protocol.RoutePosition{ActiveTabID: "tab"}
482
483 output.rebaseAttachment()
484
485 require.Equal(t, uint64(2), output.epoch)
486 require.Zero(t, output.next)
487 require.Zero(t, output.acked)
488 require.True(t, output.forceSnapshot)
489 require.Equal(t, cursorOut{valid: true, row: 2, col: 3}, output.lastCursor, "the forced frame will reassert cursor state")
490 require.Equal(t, protocol.RoutePosition{}, output.lastRoutePosition)
491}
492
493func TestAttachmentOutputDefaultsAndNormalizesWindow(t *testing.T) {
494 for _, tt := range []struct {
495 name string
496 window []uint8
497 want uint64
498 }{
499 {name: "omitted", want: 8},
500 {name: "zero", window: []uint8{0}, want: 8},
501 {name: "oversized", window: []uint8{9}, want: 8},
502 {name: "exact window", window: []uint8{1}, want: 1},
503 } {
504 t.Run(tt.name, func(t *testing.T) {
505 require.Equal(t, tt.want, newOutputStateStream(tt.window...).maxOutstanding)
506 })
507 }
508
509 output := newOutputStateStream()
510 output.setWindow(2)
511 require.Equal(t, uint64(2), output.maxOutstanding)
512 require.Equal(t, uint64(2), output.maxOutstandingAtomic.Load())
513}
514
515func TestNormalizeOutputWindow(t *testing.T) {
516 for _, tt := range []struct {
517 input, want uint8
518 }{{0, 8}, {1, 1}, {8, 8}, {9, 8}, {255, 8}} {
519 require.Equal(t, tt.want, normalizeOutputWindow(tt.input))
520 }
521}