detached d885d0f

fix(client): preserve palette read generation

commit
d885d0f550c9306980d1c12ce676280d4067f2e1
author
brice · · 2026-07-18 06:54 UTC
parent
d059917
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +85 -16
M internal/usecase/client/client.go +22 -16
@@ -919,15 +919,16 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
919919 // preserving all ordinary terminal input byte-for-byte. It never publishes a
920920 // Theme and never writes terminal output; attachAttempt owns both operations.
921921 type stdinPump struct {
922- ctx context.Context
923- cancel context.CancelFunc
924- in io.Reader
925- out chan<- ports.Frame
926- clock ports.Clock
927- clipboard ports.ClipboardReader
928- logger *slog.Logger
929- paletteEvents chan<- paletteGenerationEvent
930- activeGeneration *atomic.Uint64
922+ ctx context.Context
923+ cancel context.CancelFunc
924+ in io.Reader
925+ out chan<- ports.Frame
926+ clock ports.Clock
927+ clipboard ports.ClipboardReader
928+ logger *slog.Logger
929+ paletteEvents chan<- paletteGenerationEvent
930+ activeGeneration *atomic.Uint64
931+ afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
931932 }
932933
933934 func (p *stdinPump) run() {
@@ -948,6 +949,9 @@ func (p *stdinPump) run() {
948949 sendEvent := func(event paletteGenerationEvent) {
949950 select {
950951 case p.paletteEvents <- event:
952+ if p.afterPaletteEvent != nil {
953+ p.afterPaletteEvent(event)
954+ }
951955 case <-p.ctx.Done():
952956 sendOK.Store(false)
953957 }
@@ -974,25 +978,27 @@ func (p *stdinPump) run() {
974978 }
975979 sink = ci.Scan
976980 }
977- id := func() paletteGenerationID {
978- return paletteGenerationID(p.activeGeneration.Load())
979- }
980981 for {
981982 n, rerr := p.in.Read(buf)
983+ // All scanner callbacks from this Read must retain this one generation.
984+ // A scheme notification can start its replacement before a later byte in
985+ // the same read is scanned; reloading there would misclassify an old
986+ // completion marker as the replacement's drain response.
987+ readGeneration := paletteGenerationID(p.activeGeneration.Load())
982988 if n > 0 {
983989 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
984990 kindEvent := paletteEventForeground
985991 if kind == 11 {
986992 kindEvent = paletteEventBackground
987993 }
988- sendEvent(paletteGenerationEvent{id: id(), kind: kindEvent, rgb: rgb})
994+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb})
989995 }, func(slot int, rgb renderer.RGB) {
990- sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventPalette, slot: uint8(slot), rgb: rgb})
996+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: uint8(slot), rgb: rgb})
991997 }, func(light bool) {
992- sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventScheme, light: light})
998+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light})
993999 }, func(data []byte) {
9941000 markers.scan(data, sink, func() {
995- sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventMarker})
1001+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker})
9961002 })
9971003 })
9981004 if !sendOK.Load() {
@@ -919,15 +919,16 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
919 // preserving all ordinary terminal input byte-for-byte. It never publishes a919 // preserving all ordinary terminal input byte-for-byte. It never publishes a
920 // Theme and never writes terminal output; attachAttempt owns both operations.920 // Theme and never writes terminal output; attachAttempt owns both operations.
921 type stdinPump struct {921 type stdinPump struct {
922- ctx context.Context922+ ctx context.Context
923- cancel context.CancelFunc923+ cancel context.CancelFunc
924- in io.Reader924+ in io.Reader
925- out chan<- ports.Frame925+ out chan<- ports.Frame
926- clock ports.Clock926+ clock ports.Clock
927- clipboard ports.ClipboardReader927+ clipboard ports.ClipboardReader
928- logger *slog.Logger928+ logger *slog.Logger
929- paletteEvents chan<- paletteGenerationEvent929+ paletteEvents chan<- paletteGenerationEvent
930- activeGeneration *atomic.Uint64930+ activeGeneration *atomic.Uint64
931+ afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
931 }932 }
932 933
933 func (p *stdinPump) run() {934 func (p *stdinPump) run() {
@@ -948,6 +949,9 @@ func (p *stdinPump) run() {
948 sendEvent := func(event paletteGenerationEvent) {949 sendEvent := func(event paletteGenerationEvent) {
949 select {950 select {
950 case p.paletteEvents <- event:951 case p.paletteEvents <- event:
952+ if p.afterPaletteEvent != nil {
953+ p.afterPaletteEvent(event)
954+ }
951 case <-p.ctx.Done():955 case <-p.ctx.Done():
952 sendOK.Store(false)956 sendOK.Store(false)
953 }957 }
@@ -974,25 +978,27 @@ func (p *stdinPump) run() {
974 }978 }
975 sink = ci.Scan979 sink = ci.Scan
976 }980 }
977- id := func() paletteGenerationID {
978- return paletteGenerationID(p.activeGeneration.Load())
979- }
980 for {981 for {
981 n, rerr := p.in.Read(buf)982 n, rerr := p.in.Read(buf)
983+ // All scanner callbacks from this Read must retain this one generation.
984+ // A scheme notification can start its replacement before a later byte in
985+ // the same read is scanned; reloading there would misclassify an old
986+ // completion marker as the replacement's drain response.
987+ readGeneration := paletteGenerationID(p.activeGeneration.Load())
982 if n > 0 {988 if n > 0 {
983 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {989 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
984 kindEvent := paletteEventForeground990 kindEvent := paletteEventForeground
985 if kind == 11 {991 if kind == 11 {
986 kindEvent = paletteEventBackground992 kindEvent = paletteEventBackground
987 }993 }
988- sendEvent(paletteGenerationEvent{id: id(), kind: kindEvent, rgb: rgb})994+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb})
989 }, func(slot int, rgb renderer.RGB) {995 }, func(slot int, rgb renderer.RGB) {
990- sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventPalette, slot: uint8(slot), rgb: rgb})996+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: uint8(slot), rgb: rgb})
991 }, func(light bool) {997 }, func(light bool) {
992- sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventScheme, light: light})998+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light})
993 }, func(data []byte) {999 }, func(data []byte) {
994 markers.scan(data, sink, func() {1000 markers.scan(data, sink, func() {
995- sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventMarker})1001+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker})
996 })1002 })
997 })1003 })
998 if !sendOK.Load() {1004 if !sendOK.Load() {
M internal/usecase/client/palette_attach_generation_test.go +63 -0
@@ -5,7 +5,9 @@ import (
55 "errors"
66 "io"
77 "log/slog"
8+ "strings"
89 "sync"
10+ "sync/atomic"
911 "testing"
1012 "time"
1113
@@ -276,6 +278,67 @@ func TestAttachInitialClearedThemeSendReturnsTransportError(t *testing.T) {
276278 require.ErrorContains(t, err, "publishing theme")
277279 }
278280
281+func TestStdinPumpSameReadSchemeAndOldMarkerCannotAdvanceReplacement(t *testing.T) {
282+ ctx, cancel := context.WithCancel(context.Background())
283+ defer cancel()
284+
285+ coordinator := newPaletteGenerationCoordinator()
286+ coordinator.start(ports.Theme{}, false)
287+ var activeGeneration atomic.Uint64
288+ activeGeneration.Store(uint64(coordinator.current.id))
289+
290+ // An unbuffered event channel forces the attach loop to process the scheme
291+ // notification and publish its replacement generation before the scanner
292+ // can deliver the old marker from the same terminal Read.
293+ events := make(chan paletteGenerationEvent)
294+ out := make(chan ports.Frame, 1)
295+ schemeHandled := make(chan struct{})
296+ pump := &stdinPump{
297+ ctx: ctx,
298+ cancel: cancel,
299+ in: strings.NewReader("\x1b[?997;2n\x1b[?2031;1$y"),
300+ out: out,
301+ clock: newAttachPaletteClock(),
302+ logger: slog.New(slog.DiscardHandler),
303+ paletteEvents: events,
304+ activeGeneration: &activeGeneration,
305+ afterPaletteEvent: func(event paletteGenerationEvent) {
306+ if event.kind == paletteEventScheme {
307+ <-schemeHandled
308+ }
309+ },
310+ }
311+ pumpDone := make(chan struct{})
312+ go func() {
313+ pump.run()
314+ close(pumpDone)
315+ }()
316+
317+ scheme := <-events
318+ require.Equal(t, paletteEventScheme, scheme.kind)
319+ oldID := scheme.id
320+
321+ // This models attachAttempt handling the scheme event. The replacement is
322+ // draining, so a marker tagged with its ID would incorrectly start its
323+ // color batch before the real drain boundary arrives.
324+ coordinator.start(ports.Theme{SchemeKnown: true, Light: scheme.light}, true)
325+ newID := coordinator.current.id
326+ activeGeneration.Store(uint64(newID))
327+ close(schemeHandled)
328+ require.NotEqual(t, oldID, newID)
329+ require.Equal(t, generationDraining, coordinator.current.phase)
330+
331+ oldMarker := <-events
332+ require.Equal(t, paletteEventMarker, oldMarker.kind)
333+ require.Equal(t, oldID, oldMarker.id, "one read must retain its generation tag")
334+ require.Empty(t, coordinator.handle(oldMarker))
335+ require.True(t, coordinator.active)
336+ require.Equal(t, generationDraining, coordinator.current.phase)
337+ require.Equal(t, newID, coordinator.current.id)
338+
339+ <-pumpDone
340+}
341+
279342 func TestAttachLateDrainCannotFinalizeReplacementEarly(t *testing.T) {
280343 t.Setenv("COLORTERM", "truecolor")
281344 h := startAttachPaletteHarness(&terminalThemeState{})
@@ -5,7 +5,9 @@ import (
5 "errors"5 "errors"
6 "io"6 "io"
7 "log/slog"7 "log/slog"
8+ "strings"
8 "sync"9 "sync"
10+ "sync/atomic"
9 "testing"11 "testing"
10 "time"12 "time"
11 13
@@ -276,6 +278,67 @@ func TestAttachInitialClearedThemeSendReturnsTransportError(t *testing.T) {
276 require.ErrorContains(t, err, "publishing theme")278 require.ErrorContains(t, err, "publishing theme")
277 }279 }
278 280
281+func TestStdinPumpSameReadSchemeAndOldMarkerCannotAdvanceReplacement(t *testing.T) {
282+ ctx, cancel := context.WithCancel(context.Background())
283+ defer cancel()
284+
285+ coordinator := newPaletteGenerationCoordinator()
286+ coordinator.start(ports.Theme{}, false)
287+ var activeGeneration atomic.Uint64
288+ activeGeneration.Store(uint64(coordinator.current.id))
289+
290+ // An unbuffered event channel forces the attach loop to process the scheme
291+ // notification and publish its replacement generation before the scanner
292+ // can deliver the old marker from the same terminal Read.
293+ events := make(chan paletteGenerationEvent)
294+ out := make(chan ports.Frame, 1)
295+ schemeHandled := make(chan struct{})
296+ pump := &stdinPump{
297+ ctx: ctx,
298+ cancel: cancel,
299+ in: strings.NewReader("\x1b[?997;2n\x1b[?2031;1$y"),
300+ out: out,
301+ clock: newAttachPaletteClock(),
302+ logger: slog.New(slog.DiscardHandler),
303+ paletteEvents: events,
304+ activeGeneration: &activeGeneration,
305+ afterPaletteEvent: func(event paletteGenerationEvent) {
306+ if event.kind == paletteEventScheme {
307+ <-schemeHandled
308+ }
309+ },
310+ }
311+ pumpDone := make(chan struct{})
312+ go func() {
313+ pump.run()
314+ close(pumpDone)
315+ }()
316+
317+ scheme := <-events
318+ require.Equal(t, paletteEventScheme, scheme.kind)
319+ oldID := scheme.id
320+
321+ // This models attachAttempt handling the scheme event. The replacement is
322+ // draining, so a marker tagged with its ID would incorrectly start its
323+ // color batch before the real drain boundary arrives.
324+ coordinator.start(ports.Theme{SchemeKnown: true, Light: scheme.light}, true)
325+ newID := coordinator.current.id
326+ activeGeneration.Store(uint64(newID))
327+ close(schemeHandled)
328+ require.NotEqual(t, oldID, newID)
329+ require.Equal(t, generationDraining, coordinator.current.phase)
330+
331+ oldMarker := <-events
332+ require.Equal(t, paletteEventMarker, oldMarker.kind)
333+ require.Equal(t, oldID, oldMarker.id, "one read must retain its generation tag")
334+ require.Empty(t, coordinator.handle(oldMarker))
335+ require.True(t, coordinator.active)
336+ require.Equal(t, generationDraining, coordinator.current.phase)
337+ require.Equal(t, newID, coordinator.current.id)
338+
339+ <-pumpDone
340+}
341+
279 func TestAttachLateDrainCannotFinalizeReplacementEarly(t *testing.T) {342 func TestAttachLateDrainCannotFinalizeReplacementEarly(t *testing.T) {
280 t.Setenv("COLORTERM", "truecolor")343 t.Setenv("COLORTERM", "truecolor")
281 h := startAttachPaletteHarness(&terminalThemeState{})344 h := startAttachPaletteHarness(&terminalThemeState{})

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close