palette_attach_generation_test.go RawHistoryFolder
1package client
2
3import (
4 "context"
5 "errors"
6 "io"
7 "log/slog"
8 "strings"
9 "sync"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/require"
15
16 renderer "github.com/bnema/vev-vt"
17 "github.com/bnema/vev/internal/domain"
18 "github.com/bnema/vev/internal/ports"
19)
20
21// attachPaletteClock deliberately exposes each independently-created timer.
22// Tests fire only the deadline they intend to exercise; no wall clock is used.
23type attachPaletteClock struct {
24 timers chan *attachPaletteTimer
25 handshakeTimers chan *attachPaletteTimer
26}
27
28type attachPaletteTimer struct {
29 ch chan time.Time
30 stopped chan struct{}
31 stopOnce sync.Once
32 duration time.Duration
33}
34
35func newAttachPaletteClock() *attachPaletteClock {
36 return &attachPaletteClock{
37 timers: make(chan *attachPaletteTimer, 16),
38 handshakeTimers: make(chan *attachPaletteTimer, 1),
39 }
40}
41
42func (*attachPaletteClock) Now() time.Time { return time.Time{} }
43func (c *attachPaletteClock) NewTimer(delay time.Duration) ports.Timer {
44 timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{}), duration: delay}
45 if delay == ports.HandshakeTimeout {
46 select {
47 case c.handshakeTimers <- timer:
48 default:
49 panic("unexpected extra handshake timer")
50 }
51 } else {
52 c.timers <- timer
53 }
54 return timer
55}
56func (t *attachPaletteTimer) C() <-chan time.Time { return t.ch }
57func (*attachPaletteTimer) Reset(time.Duration) bool { return false }
58func (t *attachPaletteTimer) Stop() bool {
59 t.stopOnce.Do(func() { close(t.stopped) })
60 return true
61}
62func (t *attachPaletteTimer) fire() { t.ch <- time.Time{} }
63
64type attachPaletteReader struct{ chunks chan []byte }
65
66func newAttachPaletteReader() *attachPaletteReader {
67 return &attachPaletteReader{chunks: make(chan []byte, 16)}
68}
69func (r *attachPaletteReader) Read(p []byte) (int, error) {
70 data := <-r.chunks
71 return copy(p, data), nil
72}
73func (r *attachPaletteReader) send(data string) { r.chunks <- []byte(data) }
74
75type attachPaletteWriter struct {
76 mu sync.Mutex
77 writes []string
78 wrote chan string
79 err error
80}
81
82func newAttachPaletteWriter() *attachPaletteWriter {
83 return &attachPaletteWriter{wrote: make(chan string, 16)}
84}
85func (w *attachPaletteWriter) Write(data []byte) (int, error) {
86 copyData := string(data)
87 w.mu.Lock()
88 w.writes = append(w.writes, copyData)
89 err := w.err
90 w.mu.Unlock()
91 if err != nil {
92 return 0, err
93 }
94 w.wrote <- copyData
95 return len(data), nil
96}
97
98type attachPaletteTerminalHarness struct {
99 in io.Reader
100 out *attachPaletteWriter
101 resize chan domain.Size
102}
103
104func (t *attachPaletteTerminalHarness) EnterRaw() (func() error, error) {
105 return func() error { return nil }, nil
106}
107func (*attachPaletteTerminalHarness) Size() (domain.Size, error) {
108 return domain.Size{Cols: 80, Rows: 24}, nil
109}
110func (t *attachPaletteTerminalHarness) ResizeEvents() <-chan domain.Size { return t.resize }
111func (t *attachPaletteTerminalHarness) In() io.Reader { return t.in }
112func (t *attachPaletteTerminalHarness) Out() io.Writer { return t.out }
113func (*attachPaletteTerminalHarness) Flush() error { return nil }
114
115type attachPaletteTransport struct {
116 mu sync.Mutex
117 frames []ports.Frame
118 themes []ports.Theme
119 themeCh chan ports.Theme
120 inputCh chan ports.Input
121 detached chan ports.Frame
122 themeSendErr error
123 welcomed bool
124}
125
126func newAttachPaletteTransport() *attachPaletteTransport {
127 return &attachPaletteTransport{
128 themeCh: make(chan ports.Theme, 16),
129 inputCh: make(chan ports.Input, 16),
130 detached: make(chan ports.Frame, 1),
131 }
132}
133func (t *attachPaletteTransport) Send(frame ports.Frame) error {
134 t.mu.Lock()
135 t.frames = append(t.frames, frame)
136 themeSendErr := t.themeSendErr
137 t.mu.Unlock()
138 switch frame.Type {
139 case ports.MsgTheme:
140 if themeSendErr != nil {
141 return themeSendErr
142 }
143 got, err := ports.UnmarshalTheme(frame.Payload)
144 if err != nil {
145 return err
146 }
147 t.mu.Lock()
148 t.themes = append(t.themes, got)
149 t.mu.Unlock()
150 t.themeCh <- got
151 case ports.MsgInput:
152 got, err := ports.UnmarshalInput(frame.Payload)
153 if err != nil {
154 return err
155 }
156 t.inputCh <- got
157 }
158 return nil
159}
160func (t *attachPaletteTransport) Recv() (ports.Frame, error) {
161 t.mu.Lock()
162 if !t.welcomed {
163 t.welcomed = true
164 t.mu.Unlock()
165 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s"})}, nil
166 }
167 t.mu.Unlock()
168 return <-t.detached, nil
169}
170func (*attachPaletteTransport) Close() error { return nil }
171func (t *attachPaletteTransport) snapshots() []ports.Theme {
172 t.mu.Lock()
173 defer t.mu.Unlock()
174 return append([]ports.Theme(nil), t.themes...)
175}
176
177type attachPaletteHarness struct {
178 clock *attachPaletteClock
179 handshakeTimer *attachPaletteTimer
180 reader *attachPaletteReader
181 writer *attachPaletteWriter
182 transport *attachPaletteTransport
183 done chan attachResult
184}
185
186func startAttachPaletteHarness(state *terminalThemeState) *attachPaletteHarness {
187 return startAttachPaletteHarnessWithTransport(state, newAttachPaletteTransport())
188}
189
190func startAttachPaletteHarnessWithTransport(state *terminalThemeState, transport *attachPaletteTransport) *attachPaletteHarness {
191 return startAttachPaletteHarnessWithInput(state, transport, nil)
192}
193
194func startAttachPaletteHarnessWithInput(state *terminalThemeState, transport *attachPaletteTransport, input *terminalInputPump) *attachPaletteHarness {
195 return startAttachPaletteHarnessWithInputAndWriteError(state, transport, input, nil)
196}
197
198func startAttachPaletteHarnessWithInputAndWriteError(state *terminalThemeState, transport *attachPaletteTransport, input *terminalInputPump, writeErr error) *attachPaletteHarness {
199 clock := newAttachPaletteClock()
200 reader := newAttachPaletteReader()
201 writer := newAttachPaletteWriter()
202 writer.err = writeErr
203 term := &attachPaletteTerminalHarness{in: reader, out: writer, resize: make(chan domain.Size)}
204 ms := &milestones{}
205 runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)}
206 attempt := &attachAttempt{
207 runner: runner, transport: transport, request: AttachRequest{Intent: ports.IntentAttach},
208 milestones: ms, themeState: state,
209 enterRaw: func() error { ms.rawEntered = true; return nil },
210 reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
211 terminalInput: func() *terminalInputPump {
212 return input
213 },
214 }
215 h := &attachPaletteHarness{
216 clock: clock,
217 reader: reader,
218 writer: writer,
219 transport: transport,
220 done: make(chan attachResult, 1),
221 }
222 go func() { h.done <- attempt.run(context.Background()) }()
223 h.handshakeTimer = <-clock.handshakeTimers
224 return h
225}
226
227func (h *attachPaletteHarness) nextWrite(t *testing.T) string {
228 t.Helper()
229 return <-h.writer.wrote
230}
231func (h *attachPaletteHarness) nextTheme(t *testing.T) ports.Theme {
232 t.Helper()
233 return <-h.transport.themeCh
234}
235func (h *attachPaletteHarness) nextTimer(t *testing.T) *attachPaletteTimer {
236 t.Helper()
237 return <-h.clock.timers
238}
239func (h *attachPaletteHarness) detach(t *testing.T) {
240 t.Helper()
241 h.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}
242 result := <-h.done
243 require.NoError(t, result.err)
244 select {
245 case <-h.handshakeTimer.stopped:
246 default:
247 t.Fatal("handshake timer was not canceled after initial publication")
248 }
249}
250
251func paletteReply(foreground, background string, slot uint8, color string) string {
252 return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
253}
254
255func TestAttachHandshakeDeadlineStopsBeforeRuntimeWithoutInitialOutput(t *testing.T) {
256 h := startAttachPaletteHarness(&terminalThemeState{})
257 select {
258 case <-h.handshakeTimer.stopped:
259 // Welcome and synchronous client publications commit the handshake. A
260 // quiet runtime is allowed to produce no Output frame.
261 case <-time.After(2 * time.Second):
262 t.Fatal("handshake deadline still owned the long-lived runtime transport")
263 }
264 h.detach(t)
265}
266
267func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) {
268 t.Setenv("COLORTERM", "truecolor")
269 state := &terminalThemeState{}
270 first := startAttachPaletteHarness(state)
271 require.Equal(t, paletteColorBatch, first.nextWrite(t))
272 first.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
273 _ = first.nextTheme(t) // initial cleared snapshot
274 final := first.nextTheme(t)
275 require.True(t, final.HasForeground)
276 first.detach(t)
277
278 reconnected := startAttachPaletteHarness(state)
279 require.Equal(t, paletteColorBatch, reconnected.nextWrite(t))
280 cleared := reconnected.nextTheme(t)
281 require.True(t, cleared.HasForeground)
282 require.Equal(t, final.Foreground, cleared.Foreground)
283 require.True(t, cleared.HasBackground)
284 require.Equal(t, final.Background, cleared.Background)
285 require.Zero(t, cleared.PaletteKnown)
286 require.Equal(t, [16]renderer.RGB{}, cleared.Palette)
287
288 reconnected.reader.send(paletteReply("#111213", "#141516", 2, "#202122"))
289 _ = reconnected.nextTheme(t)
290 reconnected.detach(t)
291 require.Len(t, reconnected.transport.snapshots(), 2, "reconnect must not send a restore Theme before its cleared publication")
292}
293
294func TestAttachReconnectPreservesStandaloneEscapeBeforeClaimRevocation(t *testing.T) {
295 t.Setenv("COLORTERM", "truecolor")
296 reader := newAttachPaletteReader()
297 input := newTerminalInputPump(reader)
298 input.start()
299 t.Cleanup(input.stop)
300
301 first := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
302 require.Equal(t, paletteColorBatch, first.nextWrite(t))
303 _ = first.nextTheme(t)
304 _ = first.nextTimer(t) // initial palette completion deadline
305
306 // A standalone Escape is withheld as a possible DECRQM marker prefix. Its
307 // raw read is committed, leaving only the scanner-local residual to hand
308 // over when this attach is replaced.
309 reader.send("\x1b")
310 _ = first.nextTimer(t) // standalone Escape ambiguity deadline
311
312 revoked := make(chan []byte, 1)
313 allowRevocationToReturn := make(chan struct{})
314 var releaseRevocation sync.Once
315 release := func() { releaseRevocation.Do(func() { close(allowRevocationToReturn) }) }
316 defer release()
317 input.afterRevoke = func() {
318 input.mu.Lock()
319 residual := append([]byte(nil), input.residual...)
320 input.mu.Unlock()
321 revoked <- residual
322 <-allowRevocationToReturn
323 }
324
325 // Detach starts reconnect teardown. The residual must already be owned by
326 // the lifecycle reader at the precise point the old claim is revoked.
327 first.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}
328 require.Equal(t, []byte("\x1b"), <-revoked)
329 release()
330 require.NoError(t, (<-first.done).err)
331 input.afterRevoke = nil
332
333 // The replacement attach receives the retained byte exactly once.
334 second := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
335 require.Equal(t, paletteColorBatch, second.nextWrite(t))
336 _ = second.nextTheme(t)
337 _ = second.nextTimer(t) // initial palette completion deadline
338 second.nextTimer(t).fire()
339 second.nextTimer(t).fire()
340 got := <-second.transport.inputCh
341 require.Equal(t, []byte("\x1b"), got.Data)
342 select {
343 case duplicate := <-second.transport.inputCh:
344 require.Failf(t, "duplicate input", "unexpected duplicate %q", duplicate.Data)
345 default:
346 }
347 second.detach(t)
348}
349
350func TestAttachSchemeReplacementAndDeadlinesProgressWhileReadBlocks(t *testing.T) {
351 t.Setenv("COLORTERM", "truecolor")
352 h := startAttachPaletteHarness(&terminalThemeState{})
353 require.Equal(t, paletteColorBatch, h.nextWrite(t))
354 initialDeadline := h.nextTimer(t)
355 h.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
356 _ = h.nextTheme(t)
357 _ = h.nextTheme(t)
358 // The initial completion timer was cancelled by the marker; it is distinct
359 // from both replacement deadlines.
360 _ = initialDeadline
361
362 h.reader.send("\x1b[?997;2n")
363 cleared := h.nextTheme(t)
364 require.True(t, cleared.SchemeKnown)
365 require.True(t, cleared.Light)
366 require.Zero(t, cleared.PaletteKnown)
367 require.Equal(t, paletteBoundaryQuery, h.nextWrite(t))
368 drainDeadline := h.nextTimer(t)
369
370 // stdin is now blocked in Read. Firing the independently-created drain
371 // deadline still starts the color batch, and its completion deadline still
372 // finalizes the partial generation.
373 drainDeadline.fire()
374 require.Equal(t, paletteColorBatch, h.nextWrite(t))
375 completionDeadline := h.nextTimer(t)
376 completionDeadline.fire()
377 final := h.nextTheme(t)
378 require.True(t, final.SchemeKnown)
379 require.True(t, final.Light)
380 require.False(t, final.HasForeground)
381 require.False(t, final.HasBackground)
382 h.detach(t)
383 require.Len(t, h.transport.snapshots(), 4, "initial and replacement generations each publish only cleared then definitive Theme snapshots")
384}
385
386func TestAttachInitialPaletteFailureRevokesInputClaim(t *testing.T) {
387 t.Setenv("COLORTERM", "truecolor")
388 for _, tc := range []struct {
389 name string
390 start func(*terminalInputPump) *attachPaletteHarness
391 contains string
392 }{
393 {
394 name: "publish",
395 start: func(input *terminalInputPump) *attachPaletteHarness {
396 want := errors.New("initial theme send failed")
397 transport := newAttachPaletteTransport()
398 transport.themeSendErr = want
399 return startAttachPaletteHarnessWithInput(&terminalThemeState{}, transport, input)
400 },
401 contains: "publishing theme",
402 },
403 {
404 name: "write",
405 start: func(input *terminalInputPump) *attachPaletteHarness {
406 return startAttachPaletteHarnessWithInputAndWriteError(&terminalThemeState{}, newAttachPaletteTransport(), input, errors.New("palette write failed"))
407 },
408 contains: "writing palette query",
409 },
410 } {
411 t.Run(tc.name, func(t *testing.T) {
412 input := newTerminalInputPump(nil)
413 failed := tc.start(input)
414 err := (<-failed.done).err
415 require.ErrorContains(t, err, tc.contains)
416
417 // The next attach must be able to claim the lifecycle-owned reader.
418 // Before revocation was registered at claim time this panicked above.
419 reconnected := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
420 require.Equal(t, paletteColorBatch, reconnected.nextWrite(t))
421 reconnected.detach(t)
422 })
423 }
424}
425
426func TestStdinPumpSameReadSchemeAndOldMarkerCannotAdvanceReplacement(t *testing.T) {
427 ctx, cancel := context.WithCancel(context.Background())
428 defer cancel()
429
430 coordinator := newPaletteGenerationCoordinator()
431 coordinator.start(ports.Theme{}, false)
432 var activeGeneration atomic.Uint64
433 activeGeneration.Store(uint64(coordinator.current.id))
434
435 // An unbuffered event channel forces the attach loop to process the scheme
436 // notification and publish its replacement generation before the scanner
437 // can deliver the old marker from the same terminal Read.
438 events := make(chan paletteGenerationEvent)
439 out := make(chan ports.Frame, 1)
440 schemeHandled := make(chan struct{})
441 pump := &stdinPump{
442 ctx: ctx,
443 cancel: cancel,
444 in: strings.NewReader("\x1b[?997;2n\x1b[?2031;1$y"),
445 out: out,
446 clock: newAttachPaletteClock(),
447 logger: slog.New(slog.DiscardHandler),
448 paletteEvents: events,
449 activeGeneration: &activeGeneration,
450 afterPaletteEvent: func(event paletteGenerationEvent) {
451 if event.kind == paletteEventScheme {
452 <-schemeHandled
453 }
454 },
455 }
456 pumpDone := make(chan struct{})
457 go func() {
458 pump.run()
459 close(pumpDone)
460 }()
461
462 scheme := <-events
463 require.Equal(t, paletteEventScheme, scheme.kind)
464 oldID := scheme.id
465
466 // This models attachAttempt handling the scheme event. The replacement is
467 // draining, so a marker tagged with its ID would incorrectly start its
468 // color batch before the real drain boundary arrives.
469 coordinator.start(ports.Theme{SchemeKnown: true, Light: scheme.light}, true)
470 newID := coordinator.current.id
471 activeGeneration.Store(uint64(newID))
472 close(schemeHandled)
473 require.NotEqual(t, oldID, newID)
474 require.Equal(t, generationDraining, coordinator.current.phase)
475
476 oldMarker := <-events
477 require.Equal(t, paletteEventMarker, oldMarker.kind)
478 require.Equal(t, oldID, oldMarker.id, "one read must retain its generation tag")
479 require.Empty(t, coordinator.handle(oldMarker))
480 require.True(t, coordinator.active)
481 require.Equal(t, generationDraining, coordinator.current.phase)
482 require.Equal(t, newID, coordinator.current.id)
483
484 <-pumpDone
485}
486
487func TestAttachLateDrainCannotFinalizeReplacementEarly(t *testing.T) {
488 t.Setenv("COLORTERM", "truecolor")
489 h := startAttachPaletteHarness(&terminalThemeState{})
490 require.Equal(t, paletteColorBatch, h.nextWrite(t))
491 _ = h.nextTimer(t)
492 h.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
493 _ = h.nextTheme(t)
494 _ = h.nextTheme(t)
495
496 h.reader.send("\x1b[?997;1n")
497 _ = h.nextTheme(t)
498 require.Equal(t, paletteBoundaryQuery, h.nextWrite(t))
499 drainDeadline := h.nextTimer(t)
500 drainDeadline.fire()
501 require.Equal(t, paletteColorBatch, h.nextWrite(t))
502 completionDeadline := h.nextTimer(t)
503
504 // The drain's stale reports arrive before its late boundary and must not
505 // contaminate the replacement accumulator. Reports after that boundary are
506 // the batch's reports and are collected through the second marker.
507 h.reader.send("\x1b]10;#a1a2a3\a\x1b]11;#a4a5a6\a\x1b]4;2;#a7a8a9\a\x1b[?2031;1$y\x1b]10;#111213\a\x1b]11;#141516\a\x1b]4;3;#212223\a\x1b[?2031;1$y")
508 final := h.nextTheme(t)
509 require.Equal(t, renderer.RGB{R: 17, G: 18, B: 19}, final.Foreground)
510 require.Equal(t, renderer.RGB{R: 20, G: 21, B: 22}, final.Background)
511 require.Equal(t, uint16(1<<3), final.PaletteKnown)
512 // The marker finalization cancels, rather than fires, the independently
513 // armed completion deadline.
514 _ = completionDeadline
515 h.detach(t)
516}
517
518func TestAttachForwardsStandaloneEscapeAfterAmbiguityDeadline(t *testing.T) {
519 t.Setenv("COLORTERM", "truecolor")
520 h := startAttachPaletteHarness(&terminalThemeState{})
521 require.Equal(t, paletteColorBatch, h.nextWrite(t))
522 _ = h.nextTimer(t) // initial palette completion deadline
523 _ = h.nextTheme(t) // initial cleared publication
524
525 // A bare Escape is ambiguous with the prefix of the DECRQM completion
526 // response. It must not require another Read or EOF before reaching the
527 // daemon.
528 h.reader.send("\x1b")
529 h.nextTimer(t).fire() // DECRQM ambiguity deadline
530 h.nextTimer(t).fire() // bracketed-paste ambiguity deadline
531 input := <-h.transport.inputCh
532 require.Equal(t, []byte("\x1b"), input.Data)
533 h.detach(t)
534}
535
536func TestAttachConsumesSplitMarkerAndCancelsAmbiguityDeadline(t *testing.T) {
537 t.Setenv("COLORTERM", "truecolor")
538 h := startAttachPaletteHarness(&terminalThemeState{})
539 require.Equal(t, paletteColorBatch, h.nextWrite(t))
540 _ = h.nextTimer(t) // initial palette completion deadline
541 _ = h.nextTheme(t) // initial cleared publication
542
543 h.reader.send("before\x1b[?2031;")
544 markerDeadline := h.nextTimer(t)
545 h.reader.send("1$yafter")
546
547 final := h.nextTheme(t)
548 require.False(t, final.HasForeground)
549 var got []byte
550 for len(got) < len("beforeafter") {
551 got = append(got, (<-h.transport.inputCh).Data...)
552 }
553 require.Equal(t, []byte("beforeafter"), got)
554 <-markerDeadline.stopped
555 h.detach(t)
556}
557
558func TestAttachForwardsInputOnceAndPartialOSCDoesNotPublishTheme(t *testing.T) {
559 t.Setenv("COLORTERM", "truecolor")
560 h := startAttachPaletteHarness(&terminalThemeState{})
561 require.Equal(t, paletteColorBatch, h.nextWrite(t))
562 _ = h.nextTimer(t)
563 cleared := h.nextTheme(t)
564 require.Zero(t, cleared.PaletteKnown)
565
566 h.reader.send("\x1b]10;#0102")
567 h.reader.send("03\az")
568 input := <-h.transport.inputCh
569 require.Equal(t, []byte("z"), input.Data)
570 require.Len(t, h.transport.snapshots(), 1, "partial and incomplete OSC replies must not publish Theme")
571
572 h.reader.send("\x1b[?2031;1$y")
573 final := h.nextTheme(t)
574 require.True(t, final.HasForeground)
575 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, final.Foreground)
576 require.Len(t, h.transport.snapshots(), 2)
577 h.detach(t)
578}