detached c5fa06a

fix(client): preserve scanner residuals across reconnect

commit
c5fa06a4062569f9901071116d75ab655ed1340c
author
brice · · 2026-07-18 11:33 UTC
parent
f5d748a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +333 -66
M internal/usecase/client/client.go +119 -32
@@ -503,6 +503,16 @@ func boundedPreWelcome(ctx context.Context, clk ports.Clock, transport ports.Tra
503503 }
504504 }
505505
506+// paletteSlot validates the scanner's signed slot before narrowing it for the
507+// wire accumulator. Keep this boundary even though the in-tree Scanner also
508+// validates slots: callbacks are an interface boundary.
509+func paletteSlot(slot int) (uint8, bool) {
510+ if slot < 0 || slot > 15 {
511+ return 0, false
512+ }
513+ return uint8(slot), true
514+}
515+
506516 // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
507517 func DetectTrueColor(termEnv, colorTerm string) bool {
508518 switch strings.ToLower(strings.TrimSpace(colorTerm)) {
@@ -980,19 +990,25 @@ type terminalInputPump struct {
980990 in io.Reader
981991 done chan struct{}
982992
983- mu sync.Mutex
984- pending *terminalReadResult
985- consumer uint64
986- delivering uint64
987- nextID uint64
988- closed bool
989- active bool
990- ready chan struct{}
991- space chan struct{}
992- state chan struct{}
993- exited chan struct{}
994- startMu sync.Once
995- stopMu sync.Once
993+ mu sync.Mutex
994+ // residual holds scanner bytes that were read but not delivered when an
995+ // attempt is cancelled. It is consumed before the next terminal Read so a
996+ // replacement scanner neither loses a partial marker nor replays bytes
997+ // whose callbacks already ran.
998+ residual []byte
999+ pending *terminalReadResult
1000+ consumer uint64
1001+ delivering uint64
1002+ deliveringResidual bool
1003+ nextID uint64
1004+ closed bool
1005+ active bool
1006+ ready chan struct{}
1007+ space chan struct{}
1008+ state chan struct{}
1009+ exited chan struct{}
1010+ startMu sync.Once
1011+ stopMu sync.Once
9961012 }
9971013
9981014 func newTerminalInputPump(in io.Reader) *terminalInputPump {
@@ -1033,6 +1049,7 @@ func (p *terminalInputPump) revoke(consumer uint64) {
10331049 // pending and make it available to its replacement.
10341050 if p.delivering == consumer {
10351051 p.delivering = 0
1052+ p.deliveringResidual = false
10361053 }
10371054 p.mu.Unlock()
10381055 p.signalReady()
@@ -1070,20 +1087,48 @@ func (p *terminalInputPump) enqueue(result terminalReadResult) bool {
10701087 // scanner delivery leaves the exact bytes available to the next attempt.
10711088 func (p *terminalInputPump) take(ctx context.Context, consumer uint64) (terminalReadResult, bool) {
10721089 p.mu.Lock()
1073- if ctx.Err() != nil || p.consumer != consumer || p.pending == nil || p.delivering != 0 {
1074- pending := p.pending != nil && p.delivering == 0
1090+ if ctx.Err() != nil || p.consumer != consumer || p.delivering != 0 {
1091+ ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0
10751092 p.mu.Unlock()
1076- if pending {
1093+ if ready {
10771094 p.signalReady()
10781095 }
10791096 return terminalReadResult{}, false
10801097 }
1098+ if len(p.residual) != 0 {
1099+ result := terminalReadResult{data: append([]byte(nil), p.residual...)}
1100+ p.delivering = consumer
1101+ p.deliveringResidual = true
1102+ p.mu.Unlock()
1103+ return result, true
1104+ }
1105+ if p.pending == nil {
1106+ p.mu.Unlock()
1107+ return terminalReadResult{}, false
1108+ }
10811109 result := *p.pending
10821110 p.delivering = consumer
1111+ p.deliveringResidual = false
10831112 p.mu.Unlock()
10841113 return result, true
10851114 }
10861115
1116+// preserveResidual records the undecided marker prefix after callbacks for the
1117+// rest of its read have completed. It is intentionally bounded by the marker
1118+// scanner's fixed response prefixes and is replayed before later terminal
1119+// reads on a replacement attempt.
1120+func (p *terminalInputPump) preserveResidual(consumer uint64, data []byte) {
1121+ if len(data) == 0 {
1122+ return
1123+ }
1124+ p.mu.Lock()
1125+ defer p.mu.Unlock()
1126+ if p.consumer != consumer {
1127+ return
1128+ }
1129+ p.residual = append(p.residual[:0], data...)
1130+}
1131+
10871132 // ack commits scanner delivery of a leased read and lets the lifecycle reader
10881133 // accept the next result. Only the consumer that holds the lease can ack it.
10891134 func (p *terminalInputPump) ack(consumer uint64) {
@@ -1092,9 +1137,22 @@ func (p *terminalInputPump) ack(consumer uint64) {
10921137 p.mu.Unlock()
10931138 return
10941139 }
1095- p.pending = nil
1140+ wasResidual := p.deliveringResidual
1141+ if wasResidual {
1142+ p.residual = nil
1143+ } else {
1144+ p.pending = nil
1145+ }
10961146 p.delivering = 0
1147+ p.deliveringResidual = false
1148+ more := len(p.residual) != 0 || p.pending != nil
10971149 p.mu.Unlock()
1150+ if wasResidual {
1151+ if more {
1152+ p.signalReady()
1153+ }
1154+ return
1155+ }
10981156 p.space <- struct{}{}
10991157 }
11001158
@@ -1183,8 +1241,10 @@ func (p *terminalInputPump) suspend() {
11831241 p.mu.Lock()
11841242 p.active = false
11851243 discarded := p.pending != nil
1244+ p.residual = nil
11861245 p.pending = nil
11871246 p.delivering = 0
1247+ p.deliveringResidual = false
11881248 p.mu.Unlock()
11891249 if discarded {
11901250 select {
@@ -1224,19 +1284,20 @@ func (p *terminalInputPump) stop() {
12241284 // terminal directly, publishes a Theme, or writes terminal output;
12251285 // attachAttempt owns those operations.
12261286 type stdinPump struct {
1227- ctx context.Context
1228- cancel context.CancelFunc
1229- in io.Reader // compatibility for isolated scanner tests
1230- input *terminalInputPump
1231- consumer uint64
1232- out chan<- ports.Frame
1233- clock ports.Clock
1234- clipboard ports.ClipboardReader
1235- logger *slog.Logger
1236- paletteEvents chan<- paletteGenerationEvent
1237- activeGeneration *atomic.Uint64
1238- afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
1239- afterInputTake func() // test synchronization hook
1287+ ctx context.Context
1288+ cancel context.CancelFunc
1289+ in io.Reader // compatibility for isolated scanner tests
1290+ input *terminalInputPump
1291+ consumer uint64
1292+ out chan<- ports.Frame
1293+ clock ports.Clock
1294+ clipboard ports.ClipboardReader
1295+ logger *slog.Logger
1296+ paletteEvents chan<- paletteGenerationEvent
1297+ activeGeneration *atomic.Uint64
1298+ afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
1299+ afterInputTake func() // test synchronization hook
1300+ afterInputDelivered func() // test synchronization hook
12401301 }
12411302
12421303 func (p *stdinPump) run() {
@@ -1249,6 +1310,9 @@ func (p *stdinPump) run() {
12491310 send := func(frame ports.Frame) {
12501311 select {
12511312 case p.out <- frame:
1313+ if p.afterInputDelivered != nil {
1314+ p.afterInputDelivered()
1315+ }
12521316 case <-p.ctx.Done():
12531317 sendOK.Store(false)
12541318 }
@@ -1297,6 +1361,15 @@ func (p *stdinPump) run() {
12971361 consumer = input.claim()
12981362 defer input.revoke(consumer)
12991363 }
1364+ // A marker prefix is ordinary terminal input until it becomes a complete
1365+ // DECRQM reply. If cancellation wins while it is withheld, preserve just
1366+ // that undecided suffix; the rest of its source read has already been
1367+ // delivered and must not be replayed by the next attempt.
1368+ defer func() {
1369+ if p.ctx.Err() != nil {
1370+ input.preserveResidual(consumer, markers.takePending())
1371+ }
1372+ }()
13001373
13011374 var markerTimer ports.Timer
13021375 var markerTimerC <-chan time.Time
@@ -1371,7 +1444,14 @@ func (p *stdinPump) run() {
13711444 }
13721445 sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb})
13731446 }, func(slot int, rgb renderer.RGB) {
1374- sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: uint8(slot), rgb: rgb})
1447+ // Scanner implementations are independently testable; retain the
1448+ // protocol boundary here too so a malformed implementation cannot
1449+ // wrap a negative or oversized int into an ANSI palette slot.
1450+ slot8, ok := paletteSlot(slot)
1451+ if !ok {
1452+ return
1453+ }
1454+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: slot8, rgb: rgb})
13751455 }, func(light bool) {
13761456 sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light})
13771457 }, func(data []byte) {
@@ -1396,10 +1476,17 @@ func (p *stdinPump) run() {
13961476 p.cancel()
13971477 return
13981478 }
1399- if !sendOK.Load() || p.ctx.Err() != nil {
1479+ if !sendOK.Load() {
14001480 return
14011481 }
1482+ // Commit every callback from this raw read before observing a
1483+ // concurrent cancellation. Any remaining marker prefix is retained
1484+ // by the deferred handoff above, so reconnects never duplicate the
1485+ // delivered bytes or lose a standalone Escape.
14021486 input.ack(consumer)
1487+ if p.ctx.Err() != nil {
1488+ return
1489+ }
14031490 }
14041491 }
14051492 }
@@ -503,6 +503,16 @@ func boundedPreWelcome(ctx context.Context, clk ports.Clock, transport ports.Tra
503 }503 }
504 }504 }
505 505
506+// paletteSlot validates the scanner's signed slot before narrowing it for the
507+// wire accumulator. Keep this boundary even though the in-tree Scanner also
508+// validates slots: callbacks are an interface boundary.
509+func paletteSlot(slot int) (uint8, bool) {
510+ if slot < 0 || slot > 15 {
511+ return 0, false
512+ }
513+ return uint8(slot), true
514+}
515+
506 // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.516 // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
507 func DetectTrueColor(termEnv, colorTerm string) bool {517 func DetectTrueColor(termEnv, colorTerm string) bool {
508 switch strings.ToLower(strings.TrimSpace(colorTerm)) {518 switch strings.ToLower(strings.TrimSpace(colorTerm)) {
@@ -980,19 +990,25 @@ type terminalInputPump struct {
980 in io.Reader990 in io.Reader
981 done chan struct{}991 done chan struct{}
982 992
983- mu sync.Mutex993+ mu sync.Mutex
984- pending *terminalReadResult994+ // residual holds scanner bytes that were read but not delivered when an
985- consumer uint64995+ // attempt is cancelled. It is consumed before the next terminal Read so a
986- delivering uint64996+ // replacement scanner neither loses a partial marker nor replays bytes
987- nextID uint64997+ // whose callbacks already ran.
988- closed bool998+ residual []byte
989- active bool999+ pending *terminalReadResult
990- ready chan struct{}1000+ consumer uint64
991- space chan struct{}1001+ delivering uint64
992- state chan struct{}1002+ deliveringResidual bool
993- exited chan struct{}1003+ nextID uint64
994- startMu sync.Once1004+ closed bool
995- stopMu sync.Once1005+ active bool
1006+ ready chan struct{}
1007+ space chan struct{}
1008+ state chan struct{}
1009+ exited chan struct{}
1010+ startMu sync.Once
1011+ stopMu sync.Once
996 }1012 }
997 1013
998 func newTerminalInputPump(in io.Reader) *terminalInputPump {1014 func newTerminalInputPump(in io.Reader) *terminalInputPump {
@@ -1033,6 +1049,7 @@ func (p *terminalInputPump) revoke(consumer uint64) {
1033 // pending and make it available to its replacement.1049 // pending and make it available to its replacement.
1034 if p.delivering == consumer {1050 if p.delivering == consumer {
1035 p.delivering = 01051 p.delivering = 0
1052+ p.deliveringResidual = false
1036 }1053 }
1037 p.mu.Unlock()1054 p.mu.Unlock()
1038 p.signalReady()1055 p.signalReady()
@@ -1070,20 +1087,48 @@ func (p *terminalInputPump) enqueue(result terminalReadResult) bool {
1070 // scanner delivery leaves the exact bytes available to the next attempt.1087 // scanner delivery leaves the exact bytes available to the next attempt.
1071 func (p *terminalInputPump) take(ctx context.Context, consumer uint64) (terminalReadResult, bool) {1088 func (p *terminalInputPump) take(ctx context.Context, consumer uint64) (terminalReadResult, bool) {
1072 p.mu.Lock()1089 p.mu.Lock()
1073- if ctx.Err() != nil || p.consumer != consumer || p.pending == nil || p.delivering != 0 {1090+ if ctx.Err() != nil || p.consumer != consumer || p.delivering != 0 {
1074- pending := p.pending != nil && p.delivering == 01091+ ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0
1075 p.mu.Unlock()1092 p.mu.Unlock()
1076- if pending {1093+ if ready {
1077 p.signalReady()1094 p.signalReady()
1078 }1095 }
1079 return terminalReadResult{}, false1096 return terminalReadResult{}, false
1080 }1097 }
1098+ if len(p.residual) != 0 {
1099+ result := terminalReadResult{data: append([]byte(nil), p.residual...)}
1100+ p.delivering = consumer
1101+ p.deliveringResidual = true
1102+ p.mu.Unlock()
1103+ return result, true
1104+ }
1105+ if p.pending == nil {
1106+ p.mu.Unlock()
1107+ return terminalReadResult{}, false
1108+ }
1081 result := *p.pending1109 result := *p.pending
1082 p.delivering = consumer1110 p.delivering = consumer
1111+ p.deliveringResidual = false
1083 p.mu.Unlock()1112 p.mu.Unlock()
1084 return result, true1113 return result, true
1085 }1114 }
1086 1115
1116+// preserveResidual records the undecided marker prefix after callbacks for the
1117+// rest of its read have completed. It is intentionally bounded by the marker
1118+// scanner's fixed response prefixes and is replayed before later terminal
1119+// reads on a replacement attempt.
1120+func (p *terminalInputPump) preserveResidual(consumer uint64, data []byte) {
1121+ if len(data) == 0 {
1122+ return
1123+ }
1124+ p.mu.Lock()
1125+ defer p.mu.Unlock()
1126+ if p.consumer != consumer {
1127+ return
1128+ }
1129+ p.residual = append(p.residual[:0], data...)
1130+}
1131+
1087 // ack commits scanner delivery of a leased read and lets the lifecycle reader1132 // ack commits scanner delivery of a leased read and lets the lifecycle reader
1088 // accept the next result. Only the consumer that holds the lease can ack it.1133 // accept the next result. Only the consumer that holds the lease can ack it.
1089 func (p *terminalInputPump) ack(consumer uint64) {1134 func (p *terminalInputPump) ack(consumer uint64) {
@@ -1092,9 +1137,22 @@ func (p *terminalInputPump) ack(consumer uint64) {
1092 p.mu.Unlock()1137 p.mu.Unlock()
1093 return1138 return
1094 }1139 }
1095- p.pending = nil1140+ wasResidual := p.deliveringResidual
1141+ if wasResidual {
1142+ p.residual = nil
1143+ } else {
1144+ p.pending = nil
1145+ }
1096 p.delivering = 01146 p.delivering = 0
1147+ p.deliveringResidual = false
1148+ more := len(p.residual) != 0 || p.pending != nil
1097 p.mu.Unlock()1149 p.mu.Unlock()
1150+ if wasResidual {
1151+ if more {
1152+ p.signalReady()
1153+ }
1154+ return
1155+ }
1098 p.space <- struct{}{}1156 p.space <- struct{}{}
1099 }1157 }
1100 1158
@@ -1183,8 +1241,10 @@ func (p *terminalInputPump) suspend() {
1183 p.mu.Lock()1241 p.mu.Lock()
1184 p.active = false1242 p.active = false
1185 discarded := p.pending != nil1243 discarded := p.pending != nil
1244+ p.residual = nil
1186 p.pending = nil1245 p.pending = nil
1187 p.delivering = 01246 p.delivering = 0
1247+ p.deliveringResidual = false
1188 p.mu.Unlock()1248 p.mu.Unlock()
1189 if discarded {1249 if discarded {
1190 select {1250 select {
@@ -1224,19 +1284,20 @@ func (p *terminalInputPump) stop() {
1224 // terminal directly, publishes a Theme, or writes terminal output;1284 // terminal directly, publishes a Theme, or writes terminal output;
1225 // attachAttempt owns those operations.1285 // attachAttempt owns those operations.
1226 type stdinPump struct {1286 type stdinPump struct {
1227- ctx context.Context1287+ ctx context.Context
1228- cancel context.CancelFunc1288+ cancel context.CancelFunc
1229- in io.Reader // compatibility for isolated scanner tests1289+ in io.Reader // compatibility for isolated scanner tests
1230- input *terminalInputPump1290+ input *terminalInputPump
1231- consumer uint641291+ consumer uint64
1232- out chan<- ports.Frame1292+ out chan<- ports.Frame
1233- clock ports.Clock1293+ clock ports.Clock
1234- clipboard ports.ClipboardReader1294+ clipboard ports.ClipboardReader
1235- logger *slog.Logger1295+ logger *slog.Logger
1236- paletteEvents chan<- paletteGenerationEvent1296+ paletteEvents chan<- paletteGenerationEvent
1237- activeGeneration *atomic.Uint641297+ activeGeneration *atomic.Uint64
1238- afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook1298+ afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
1239- afterInputTake func() // test synchronization hook1299+ afterInputTake func() // test synchronization hook
1300+ afterInputDelivered func() // test synchronization hook
1240 }1301 }
1241 1302
1242 func (p *stdinPump) run() {1303 func (p *stdinPump) run() {
@@ -1249,6 +1310,9 @@ func (p *stdinPump) run() {
1249 send := func(frame ports.Frame) {1310 send := func(frame ports.Frame) {
1250 select {1311 select {
1251 case p.out <- frame:1312 case p.out <- frame:
1313+ if p.afterInputDelivered != nil {
1314+ p.afterInputDelivered()
1315+ }
1252 case <-p.ctx.Done():1316 case <-p.ctx.Done():
1253 sendOK.Store(false)1317 sendOK.Store(false)
1254 }1318 }
@@ -1297,6 +1361,15 @@ func (p *stdinPump) run() {
1297 consumer = input.claim()1361 consumer = input.claim()
1298 defer input.revoke(consumer)1362 defer input.revoke(consumer)
1299 }1363 }
1364+ // A marker prefix is ordinary terminal input until it becomes a complete
1365+ // DECRQM reply. If cancellation wins while it is withheld, preserve just
1366+ // that undecided suffix; the rest of its source read has already been
1367+ // delivered and must not be replayed by the next attempt.
1368+ defer func() {
1369+ if p.ctx.Err() != nil {
1370+ input.preserveResidual(consumer, markers.takePending())
1371+ }
1372+ }()
1300 1373
1301 var markerTimer ports.Timer1374 var markerTimer ports.Timer
1302 var markerTimerC <-chan time.Time1375 var markerTimerC <-chan time.Time
@@ -1371,7 +1444,14 @@ func (p *stdinPump) run() {
1371 }1444 }
1372 sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb})1445 sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb})
1373 }, func(slot int, rgb renderer.RGB) {1446 }, func(slot int, rgb renderer.RGB) {
1374- sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: uint8(slot), rgb: rgb})1447+ // Scanner implementations are independently testable; retain the
1448+ // protocol boundary here too so a malformed implementation cannot
1449+ // wrap a negative or oversized int into an ANSI palette slot.
1450+ slot8, ok := paletteSlot(slot)
1451+ if !ok {
1452+ return
1453+ }
1454+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: slot8, rgb: rgb})
1375 }, func(light bool) {1455 }, func(light bool) {
1376 sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light})1456 sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light})
1377 }, func(data []byte) {1457 }, func(data []byte) {
@@ -1396,10 +1476,17 @@ func (p *stdinPump) run() {
1396 p.cancel()1476 p.cancel()
1397 return1477 return
1398 }1478 }
1399- if !sendOK.Load() || p.ctx.Err() != nil {1479+ if !sendOK.Load() {
1400 return1480 return
1401 }1481 }
1482+ // Commit every callback from this raw read before observing a
1483+ // concurrent cancellation. Any remaining marker prefix is retained
1484+ // by the deferred handoff above, so reconnects never duplicate the
1485+ // delivered bytes or lose a standalone Escape.
1402 input.ack(consumer)1486 input.ack(consumer)
1487+ if p.ctx.Err() != nil {
1488+ return
1489+ }
1403 }1490 }
1404 }1491 }
1405 }1492 }
M internal/usecase/client/palette_attach_test.go +44 -6
@@ -30,19 +30,34 @@ func (paletteAttachTimer) Reset(time.Duration) bool { return false }
3030 func (paletteAttachTimer) Stop() bool { return true }
3131
3232 type paletteAttachReader struct {
33+ mu sync.Mutex
3334 data []byte
35+ done chan struct{}
3436 once sync.Once
3537 }
3638
39+func newPaletteAttachReader(data []byte) *paletteAttachReader {
40+ return &paletteAttachReader{data: append([]byte(nil), data...), done: make(chan struct{})}
41+}
42+
43+// Read retains unread bytes after a short read and blocks only until close.
44+// This keeps the test terminal reader faithful to io.Reader and makes a
45+// failed test teardown deterministic instead of leaving a goroutine parked.
3746 func (r *paletteAttachReader) Read(p []byte) (int, error) {
38- read := false
39- r.once.Do(func() { read = true })
40- if read {
41- return copy(p, r.data), nil
47+ r.mu.Lock()
48+ if len(r.data) != 0 {
49+ n := copy(p, r.data)
50+ r.data = r.data[n:]
51+ r.mu.Unlock()
52+ return n, nil
4253 }
43- select {}
54+ r.mu.Unlock()
55+ <-r.done
56+ return 0, io.EOF
4457 }
4558
59+func (r *paletteAttachReader) close() { r.once.Do(func() { close(r.done) }) }
60+
4661 type paletteAttachTerminal struct {
4762 in io.Reader
4863 out bytes.Buffer
@@ -100,8 +115,27 @@ func (t *paletteAttachTransport) Recv() (ports.Frame, error) {
100115 }
101116 func (*paletteAttachTransport) Close() error { return nil }
102117
118+func TestPaletteAttachReaderPreservesShortReadRemainderAndCloses(t *testing.T) {
119+ reader := newPaletteAttachReader([]byte("abcdef"))
120+ first := make([]byte, 2)
121+ n, err := reader.Read(first)
122+ require.NoError(t, err)
123+ require.Equal(t, "ab", string(first[:n]))
124+
125+ rest := make([]byte, 8)
126+ n, err = reader.Read(rest)
127+ require.NoError(t, err)
128+ require.Equal(t, "cdef", string(rest[:n]))
129+
130+ reader.close()
131+ n, err = reader.Read(rest)
132+ require.Zero(t, n)
133+ require.ErrorIs(t, err, io.EOF)
134+}
135+
103136 func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {
104- input := &paletteAttachReader{data: []byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y")}
137+ input := newPaletteAttachReader([]byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y"))
138+ t.Cleanup(input.close)
105139 term := &paletteAttachTerminal{in: input, resize: make(chan domain.Size)}
106140 transport := &paletteAttachTransport{finalSet: make(chan struct{})}
107141 ms := &milestones{}
@@ -119,6 +153,10 @@ func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {
119153
120154 transport.mu.Lock()
121155 defer transport.mu.Unlock()
156+ require.Len(t, transport.sent, 3)
157+ require.Equal(t, []ports.MsgType{ports.MsgHello, ports.MsgTheme, ports.MsgTheme}, []ports.MsgType{
158+ transport.sent[0].Type, transport.sent[1].Type, transport.sent[2].Type,
159+ }, "the protocol requires Hello before the two palette publications")
122160 require.Len(t, transport.themes, 2)
123161 cleared, definitive := transport.themes[0], transport.themes[1]
124162 require.Zero(t, cleared.PaletteKnown)
@@ -30,19 +30,34 @@ func (paletteAttachTimer) Reset(time.Duration) bool { return false }
30 func (paletteAttachTimer) Stop() bool { return true }30 func (paletteAttachTimer) Stop() bool { return true }
31 31
32 type paletteAttachReader struct {32 type paletteAttachReader struct {
33+ mu sync.Mutex
33 data []byte34 data []byte
35+ done chan struct{}
34 once sync.Once36 once sync.Once
35 }37 }
36 38
39+func newPaletteAttachReader(data []byte) *paletteAttachReader {
40+ return &paletteAttachReader{data: append([]byte(nil), data...), done: make(chan struct{})}
41+}
42+
43+// Read retains unread bytes after a short read and blocks only until close.
44+// This keeps the test terminal reader faithful to io.Reader and makes a
45+// failed test teardown deterministic instead of leaving a goroutine parked.
37 func (r *paletteAttachReader) Read(p []byte) (int, error) {46 func (r *paletteAttachReader) Read(p []byte) (int, error) {
38- read := false47+ r.mu.Lock()
39- r.once.Do(func() { read = true })48+ if len(r.data) != 0 {
40- if read {49+ n := copy(p, r.data)
41- return copy(p, r.data), nil50+ r.data = r.data[n:]
51+ r.mu.Unlock()
52+ return n, nil
42 }53 }
43- select {}54+ r.mu.Unlock()
55+ <-r.done
56+ return 0, io.EOF
44 }57 }
45 58
59+func (r *paletteAttachReader) close() { r.once.Do(func() { close(r.done) }) }
60+
46 type paletteAttachTerminal struct {61 type paletteAttachTerminal struct {
47 in io.Reader62 in io.Reader
48 out bytes.Buffer63 out bytes.Buffer
@@ -100,8 +115,27 @@ func (t *paletteAttachTransport) Recv() (ports.Frame, error) {
100 }115 }
101 func (*paletteAttachTransport) Close() error { return nil }116 func (*paletteAttachTransport) Close() error { return nil }
102 117
118+func TestPaletteAttachReaderPreservesShortReadRemainderAndCloses(t *testing.T) {
119+ reader := newPaletteAttachReader([]byte("abcdef"))
120+ first := make([]byte, 2)
121+ n, err := reader.Read(first)
122+ require.NoError(t, err)
123+ require.Equal(t, "ab", string(first[:n]))
124+
125+ rest := make([]byte, 8)
126+ n, err = reader.Read(rest)
127+ require.NoError(t, err)
128+ require.Equal(t, "cdef", string(rest[:n]))
129+
130+ reader.close()
131+ n, err = reader.Read(rest)
132+ require.Zero(t, n)
133+ require.ErrorIs(t, err, io.EOF)
134+}
135+
103 func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {136 func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {
104- input := &paletteAttachReader{data: []byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y")}137+ input := newPaletteAttachReader([]byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y"))
138+ t.Cleanup(input.close)
105 term := &paletteAttachTerminal{in: input, resize: make(chan domain.Size)}139 term := &paletteAttachTerminal{in: input, resize: make(chan domain.Size)}
106 transport := &paletteAttachTransport{finalSet: make(chan struct{})}140 transport := &paletteAttachTransport{finalSet: make(chan struct{})}
107 ms := &milestones{}141 ms := &milestones{}
@@ -119,6 +153,10 @@ func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {
119 153
120 transport.mu.Lock()154 transport.mu.Lock()
121 defer transport.mu.Unlock()155 defer transport.mu.Unlock()
156+ require.Len(t, transport.sent, 3)
157+ require.Equal(t, []ports.MsgType{ports.MsgHello, ports.MsgTheme, ports.MsgTheme}, []ports.MsgType{
158+ transport.sent[0].Type, transport.sent[1].Type, transport.sent[2].Type,
159+ }, "the protocol requires Hello before the two palette publications")
122 require.Len(t, transport.themes, 2)160 require.Len(t, transport.themes, 2)
123 cleared, definitive := transport.themes[0], transport.themes[1]161 cleared, definitive := transport.themes[0], transport.themes[1]
124 require.Zero(t, cleared.PaletteKnown)162 require.Zero(t, cleared.PaletteKnown)
M internal/usecase/client/palette_generation.go +9 -0
@@ -308,4 +308,13 @@ func (s *paletteMarkerScanner) flush(onBytes func([]byte)) {
308308 s.pending = nil
309309 }
310310
311+// takePending transfers only an undecided DECRQM prefix to a replacement
312+// scanner. Callers use it during cancellation after all other callbacks from
313+// the source read have been committed, preventing both loss and replay.
314+func (s *paletteMarkerScanner) takePending() []byte {
315+ pending := append([]byte(nil), s.pending...)
316+ s.pending = nil
317+ return pending
318+}
319+
311320 func (s *paletteMarkerScanner) hasPendingPrefix() bool { return len(s.pending) != 0 }
@@ -308,4 +308,13 @@ func (s *paletteMarkerScanner) flush(onBytes func([]byte)) {
308 s.pending = nil308 s.pending = nil
309 }309 }
310 310
311+// takePending transfers only an undecided DECRQM prefix to a replacement
312+// scanner. Callers use it during cancellation after all other callbacks from
313+// the source read have been committed, preventing both loss and replay.
314+func (s *paletteMarkerScanner) takePending() []byte {
315+ pending := append([]byte(nil), s.pending...)
316+ s.pending = nil
317+ return pending
318+}
319+
311 func (s *paletteMarkerScanner) hasPendingPrefix() bool { return len(s.pending) != 0 }320 func (s *paletteMarkerScanner) hasPendingPrefix() bool { return len(s.pending) != 0 }
M internal/usecase/client/reconnect_toast_test.go +62 -28
@@ -110,19 +110,6 @@ func reconnectToastDetach(reason uint8) ports.Frame {
110110 return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
111111 }
112112
113-func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport {
114- t.Helper()
115- tr := portsmocks.NewMockTransport(t)
116- tr.EXPECT().Send(mock.MatchedBy(func(f ports.Frame) bool { return f.Type == ports.MsgTheme })).Return(nil).Maybe()
117- tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
118- for _, recv := range recvs {
119- tr.EXPECT().Recv().Return(recv.frame, recv.err).Once()
120- }
121- tr.EXPECT().Recv().Return(ports.Frame{}, io.EOF).Maybe()
122- tr.EXPECT().Close().Return(nil).Maybe()
123- return tr
124-}
125-
126113 type reconnectToastRecv struct {
127114 frame ports.Frame
128115 err error
@@ -143,11 +130,16 @@ func (d *reconnectToastSequenceDialer) Dial(context.Context) (ports.Transport, e
143130 }
144131
145132 type reconnectToastRecordingTransport struct {
133+ mu sync.Mutex
146134 recvs []reconnectToastRecv
147135 sends []ports.Frame
148136 closed bool
149137 }
150138
139+func newReconnectToastRecordingTransport(recvs ...reconnectToastRecv) *reconnectToastRecordingTransport {
140+ return &reconnectToastRecordingTransport{recvs: append([]reconnectToastRecv(nil), recvs...)}
141+}
142+
151143 type reconnectToastLinkTransport struct {
152144 recvCh chan reconnectToastRecv
153145 sends []ports.Frame
@@ -180,11 +172,15 @@ func (t *reconnectToastLinkTransport) LinkEvents() <-chan ports.LinkEvent { retu
180172 func (t *reconnectToastLinkTransport) LinkState() ports.LinkState { return t.state }
181173
182174 func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error {
175+ t.mu.Lock()
176+ defer t.mu.Unlock()
183177 t.sends = append(t.sends, f)
184178 return nil
185179 }
186180
187181 func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {
182+ t.mu.Lock()
183+ defer t.mu.Unlock()
188184 if len(t.recvs) == 0 {
189185 return ports.Frame{}, io.EOF
190186 }
@@ -194,15 +190,47 @@ func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {
194190 }
195191
196192 func (t *reconnectToastRecordingTransport) Close() error {
193+ t.mu.Lock()
194+ defer t.mu.Unlock()
197195 t.closed = true
198196 return nil
199197 }
200198
199+func (t *reconnectToastRecordingTransport) sentFrames() []ports.Frame {
200+ t.mu.Lock()
201+ defer t.mu.Unlock()
202+ return append([]ports.Frame(nil), t.sends...)
203+}
204+
205+func (t *reconnectToastRecordingTransport) wasClosed() bool {
206+ t.mu.Lock()
207+ defer t.mu.Unlock()
208+ return t.closed
209+}
210+
211+// assertReconnectToastAttemptPublishesOnlyClearedTheme verifies the precise
212+// handshake publication for these no-OSC-response reconnect paths. A final
213+// Theme would be wrong here: no terminal palette response was supplied.
214+func assertReconnectToastAttemptPublishesOnlyClearedTheme(t *testing.T, tr *reconnectToastRecordingTransport) {
215+ t.Helper()
216+ frames := tr.sentFrames()
217+ require.Len(t, frames, 2)
218+ require.Equal(t, ports.MsgHello, frames[0].Type)
219+ require.Equal(t, ports.MsgTheme, frames[1].Type)
220+ theme, err := ports.UnmarshalTheme(frames[1].Payload)
221+ require.NoError(t, err)
222+ require.False(t, theme.HasForeground)
223+ require.False(t, theme.HasBackground)
224+ require.Zero(t, theme.PaletteKnown)
225+ require.True(t, tr.wasClosed())
226+}
227+
201228 func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello {
202229 t.Helper()
203- require.NotEmpty(t, tr.sends)
204- require.Equal(t, ports.MsgHello, tr.sends[0].Type)
205- hello, err := ports.UnmarshalHello(tr.sends[0].Payload)
230+ frames := tr.sentFrames()
231+ require.NotEmpty(t, frames)
232+ require.Equal(t, ports.MsgHello, frames[0].Type)
233+ hello, err := ports.UnmarshalHello(frames[0].Payload)
206234 require.NoError(t, err)
207235 return hello
208236 }
@@ -494,8 +522,8 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
494522
495523 err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
496524 require.NoError(t, err)
497- require.True(t, tr1.closed)
498- require.True(t, tr2.closed)
525+ require.True(t, tr1.wasClosed())
526+ require.True(t, tr2.wasClosed())
499527 require.Equal(t, 2, dialer.calls)
500528
501529 firstHello := reconnectToastHelloFromSend(t, tr1)
@@ -555,18 +583,19 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
555583 func TestRemoteReconnectToastLifecycle(t *testing.T) {
556584 tests := []struct {
557585 name string
558- configure func(t *testing.T, dialer *portsmocks.MockDialer)
586+ configure func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport
559587 sleep func(cancel context.CancelFunc) bool
560588 wantErr func(t *testing.T, err error)
561589 wantBlank bool
562590 }{
563591 {
564592 name: "clears on successful reconnect",
565- configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
566- tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
567- tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)})
593+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport {
594+ tr1 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
595+ tr2 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)})
568596 dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
569597 dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
598+ return []*reconnectToastRecordingTransport{tr1, tr2}
570599 },
571600 sleep: func(context.CancelFunc) bool { return true },
572601 wantErr: func(t *testing.T, err error) { require.NoError(t, err) },
@@ -574,9 +603,10 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
574603 },
575604 {
576605 name: "clears on cancellation",
577- configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
578- tr := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
606+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport {
607+ tr := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
579608 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
609+ return []*reconnectToastRecordingTransport{tr}
580610 },
581611 sleep: func(cancel context.CancelFunc) bool {
582612 cancel()
@@ -587,11 +617,12 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
587617 },
588618 {
589619 name: "clears on final exit",
590- configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
591- tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
592- tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)})
620+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport {
621+ tr1 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
622+ tr2 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)})
593623 dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
594624 dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
625+ return []*reconnectToastRecordingTransport{tr1, tr2}
595626 },
596627 sleep: func(context.CancelFunc) bool { return true },
597628 wantErr: func(t *testing.T, err error) {
@@ -619,10 +650,13 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
619650 term := newReconnectToastTerminalHarness(t)
620651 defer term.closeInput()
621652 dialer := portsmocks.NewMockDialer(t)
622- tt.configure(t, dialer)
653+ transports := tt.configure(t, dialer)
623654
624655 err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
625656 tt.wantErr(t, err)
657+ for _, transport := range transports {
658+ assertReconnectToastAttemptPublishesOnlyClearedTheme(t, transport)
659+ }
626660 out := term.out.String()
627661 require.Contains(t, out, reconnectToastMessage)
628662 if tt.wantBlank {
@@ -110,19 +110,6 @@ func reconnectToastDetach(reason uint8) ports.Frame {
110 return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}110 return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
111 }111 }
112 112
113-func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport {
114- t.Helper()
115- tr := portsmocks.NewMockTransport(t)
116- tr.EXPECT().Send(mock.MatchedBy(func(f ports.Frame) bool { return f.Type == ports.MsgTheme })).Return(nil).Maybe()
117- tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
118- for _, recv := range recvs {
119- tr.EXPECT().Recv().Return(recv.frame, recv.err).Once()
120- }
121- tr.EXPECT().Recv().Return(ports.Frame{}, io.EOF).Maybe()
122- tr.EXPECT().Close().Return(nil).Maybe()
123- return tr
124-}
125-
126 type reconnectToastRecv struct {113 type reconnectToastRecv struct {
127 frame ports.Frame114 frame ports.Frame
128 err error115 err error
@@ -143,11 +130,16 @@ func (d *reconnectToastSequenceDialer) Dial(context.Context) (ports.Transport, e
143 }130 }
144 131
145 type reconnectToastRecordingTransport struct {132 type reconnectToastRecordingTransport struct {
133+ mu sync.Mutex
146 recvs []reconnectToastRecv134 recvs []reconnectToastRecv
147 sends []ports.Frame135 sends []ports.Frame
148 closed bool136 closed bool
149 }137 }
150 138
139+func newReconnectToastRecordingTransport(recvs ...reconnectToastRecv) *reconnectToastRecordingTransport {
140+ return &reconnectToastRecordingTransport{recvs: append([]reconnectToastRecv(nil), recvs...)}
141+}
142+
151 type reconnectToastLinkTransport struct {143 type reconnectToastLinkTransport struct {
152 recvCh chan reconnectToastRecv144 recvCh chan reconnectToastRecv
153 sends []ports.Frame145 sends []ports.Frame
@@ -180,11 +172,15 @@ func (t *reconnectToastLinkTransport) LinkEvents() <-chan ports.LinkEvent { retu
180 func (t *reconnectToastLinkTransport) LinkState() ports.LinkState { return t.state }172 func (t *reconnectToastLinkTransport) LinkState() ports.LinkState { return t.state }
181 173
182 func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error {174 func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error {
175+ t.mu.Lock()
176+ defer t.mu.Unlock()
183 t.sends = append(t.sends, f)177 t.sends = append(t.sends, f)
184 return nil178 return nil
185 }179 }
186 180
187 func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {181 func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {
182+ t.mu.Lock()
183+ defer t.mu.Unlock()
188 if len(t.recvs) == 0 {184 if len(t.recvs) == 0 {
189 return ports.Frame{}, io.EOF185 return ports.Frame{}, io.EOF
190 }186 }
@@ -194,15 +190,47 @@ func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {
194 }190 }
195 191
196 func (t *reconnectToastRecordingTransport) Close() error {192 func (t *reconnectToastRecordingTransport) Close() error {
193+ t.mu.Lock()
194+ defer t.mu.Unlock()
197 t.closed = true195 t.closed = true
198 return nil196 return nil
199 }197 }
200 198
199+func (t *reconnectToastRecordingTransport) sentFrames() []ports.Frame {
200+ t.mu.Lock()
201+ defer t.mu.Unlock()
202+ return append([]ports.Frame(nil), t.sends...)
203+}
204+
205+func (t *reconnectToastRecordingTransport) wasClosed() bool {
206+ t.mu.Lock()
207+ defer t.mu.Unlock()
208+ return t.closed
209+}
210+
211+// assertReconnectToastAttemptPublishesOnlyClearedTheme verifies the precise
212+// handshake publication for these no-OSC-response reconnect paths. A final
213+// Theme would be wrong here: no terminal palette response was supplied.
214+func assertReconnectToastAttemptPublishesOnlyClearedTheme(t *testing.T, tr *reconnectToastRecordingTransport) {
215+ t.Helper()
216+ frames := tr.sentFrames()
217+ require.Len(t, frames, 2)
218+ require.Equal(t, ports.MsgHello, frames[0].Type)
219+ require.Equal(t, ports.MsgTheme, frames[1].Type)
220+ theme, err := ports.UnmarshalTheme(frames[1].Payload)
221+ require.NoError(t, err)
222+ require.False(t, theme.HasForeground)
223+ require.False(t, theme.HasBackground)
224+ require.Zero(t, theme.PaletteKnown)
225+ require.True(t, tr.wasClosed())
226+}
227+
201 func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello {228 func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello {
202 t.Helper()229 t.Helper()
203- require.NotEmpty(t, tr.sends)230+ frames := tr.sentFrames()
204- require.Equal(t, ports.MsgHello, tr.sends[0].Type)231+ require.NotEmpty(t, frames)
205- hello, err := ports.UnmarshalHello(tr.sends[0].Payload)232+ require.Equal(t, ports.MsgHello, frames[0].Type)
233+ hello, err := ports.UnmarshalHello(frames[0].Payload)
206 require.NoError(t, err)234 require.NoError(t, err)
207 return hello235 return hello
208 }236 }
@@ -494,8 +522,8 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
494 522
495 err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})523 err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
496 require.NoError(t, err)524 require.NoError(t, err)
497- require.True(t, tr1.closed)525+ require.True(t, tr1.wasClosed())
498- require.True(t, tr2.closed)526+ require.True(t, tr2.wasClosed())
499 require.Equal(t, 2, dialer.calls)527 require.Equal(t, 2, dialer.calls)
500 528
501 firstHello := reconnectToastHelloFromSend(t, tr1)529 firstHello := reconnectToastHelloFromSend(t, tr1)
@@ -555,18 +583,19 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
555 func TestRemoteReconnectToastLifecycle(t *testing.T) {583 func TestRemoteReconnectToastLifecycle(t *testing.T) {
556 tests := []struct {584 tests := []struct {
557 name string585 name string
558- configure func(t *testing.T, dialer *portsmocks.MockDialer)586+ configure func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport
559 sleep func(cancel context.CancelFunc) bool587 sleep func(cancel context.CancelFunc) bool
560 wantErr func(t *testing.T, err error)588 wantErr func(t *testing.T, err error)
561 wantBlank bool589 wantBlank bool
562 }{590 }{
563 {591 {
564 name: "clears on successful reconnect",592 name: "clears on successful reconnect",
565- configure: func(t *testing.T, dialer *portsmocks.MockDialer) {593+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport {
566- tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})594+ tr1 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
567- tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)})595+ tr2 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)})
568 dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()596 dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
569 dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()597 dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
598+ return []*reconnectToastRecordingTransport{tr1, tr2}
570 },599 },
571 sleep: func(context.CancelFunc) bool { return true },600 sleep: func(context.CancelFunc) bool { return true },
572 wantErr: func(t *testing.T, err error) { require.NoError(t, err) },601 wantErr: func(t *testing.T, err error) { require.NoError(t, err) },
@@ -574,9 +603,10 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
574 },603 },
575 {604 {
576 name: "clears on cancellation",605 name: "clears on cancellation",
577- configure: func(t *testing.T, dialer *portsmocks.MockDialer) {606+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport {
578- tr := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})607+ tr := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
579 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()608 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
609+ return []*reconnectToastRecordingTransport{tr}
580 },610 },
581 sleep: func(cancel context.CancelFunc) bool {611 sleep: func(cancel context.CancelFunc) bool {
582 cancel()612 cancel()
@@ -587,11 +617,12 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
587 },617 },
588 {618 {
589 name: "clears on final exit",619 name: "clears on final exit",
590- configure: func(t *testing.T, dialer *portsmocks.MockDialer) {620+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport {
591- tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})621+ tr1 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
592- tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)})622+ tr2 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)})
593 dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()623 dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
594 dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()624 dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
625+ return []*reconnectToastRecordingTransport{tr1, tr2}
595 },626 },
596 sleep: func(context.CancelFunc) bool { return true },627 sleep: func(context.CancelFunc) bool { return true },
597 wantErr: func(t *testing.T, err error) {628 wantErr: func(t *testing.T, err error) {
@@ -619,10 +650,13 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
619 term := newReconnectToastTerminalHarness(t)650 term := newReconnectToastTerminalHarness(t)
620 defer term.closeInput()651 defer term.closeInput()
621 dialer := portsmocks.NewMockDialer(t)652 dialer := portsmocks.NewMockDialer(t)
622- tt.configure(t, dialer)653+ transports := tt.configure(t, dialer)
623 654
624 err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})655 err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
625 tt.wantErr(t, err)656 tt.wantErr(t, err)
657+ for _, transport := range transports {
658+ assertReconnectToastAttemptPublishesOnlyClearedTheme(t, transport)
659+ }
626 out := term.out.String()660 out := term.out.String()
627 require.Contains(t, out, reconnectToastMessage)661 require.Contains(t, out, reconnectToastMessage)
628 if tt.wantBlank {662 if tt.wantBlank {
M internal/usecase/client/terminal_input_pump_test.go +99 -0
@@ -71,6 +71,26 @@ func (r *lateStopReader) Read(p []byte) (int, error) {
7171 return copy(p, result.data), result.err
7272 }
7373
74+func TestPaletteSlotRejectsOutOfRangeIntBeforeNarrowing(t *testing.T) {
75+ for _, tc := range []struct {
76+ slot int
77+ want uint8
78+ ok bool
79+ }{
80+ {slot: -1},
81+ {slot: 0, want: 0, ok: true},
82+ {slot: 15, want: 15, ok: true},
83+ {slot: 16},
84+ {slot: 256},
85+ } {
86+ got, ok := paletteSlot(tc.slot)
87+ require.Equal(t, tc.ok, ok, "slot %d", tc.slot)
88+ if ok {
89+ require.Equal(t, tc.want, got, "slot %d", tc.slot)
90+ }
91+ }
92+}
93+
7494 func TestTerminalInputPumpStopDropsLateReadWithoutStartingAnother(t *testing.T) {
7595 reader := newLateStopReader()
7696 input := newTerminalInputPump(reader)
@@ -201,6 +221,85 @@ func TestTerminalInputPumpCancellationAfterTakeRequeuesRawRead(t *testing.T) {
201221 input.revoke(replacement)
202222 }
203223
224+func TestTerminalInputPumpCancellationPreservesStandaloneEscapeForReplacement(t *testing.T) {
225+ input := newTerminalInputPump(nil)
226+ input.enqueue(terminalReadResult{data: []byte("\x1b")})
227+ var activeGeneration atomic.Uint64
228+
229+ oldCtx, cancelOld := context.WithCancel(context.Background())
230+ oldClock := newAttachPaletteClock()
231+ oldDone := make(chan struct{})
232+ go func() {
233+ (&stdinPump{
234+ ctx: oldCtx, cancel: cancelOld, input: input, out: make(chan ports.Frame, 1),
235+ clock: oldClock, logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
236+ activeGeneration: &activeGeneration,
237+ }).run()
238+ close(oldDone)
239+ }()
240+ // The ambiguity timer proves the ESC was scanned and its raw read was
241+ // committed; only the marker scanner's undecided suffix remains.
242+ <-oldClock.timers
243+ cancelOld()
244+ <-oldDone
245+
246+ newCtx, cancelNew := context.WithCancel(context.Background())
247+ defer cancelNew()
248+ newClock := newAttachPaletteClock()
249+ out := make(chan ports.Frame, 1)
250+ newDone := make(chan struct{})
251+ go func() {
252+ (&stdinPump{
253+ ctx: newCtx, cancel: cancelNew, input: input, out: out,
254+ clock: newClock, logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
255+ activeGeneration: &activeGeneration,
256+ }).run()
257+ close(newDone)
258+ }()
259+ (<-newClock.timers).fire() // marker ambiguity deadline
260+ (<-newClock.timers).fire() // paste coalescer's lone-ESC deadline
261+ frame := <-out
262+ got, err := ports.UnmarshalInput(frame.Payload)
263+ require.NoError(t, err)
264+ require.Equal(t, []byte("\x1b"), got.Data)
265+
266+ cancelNew()
267+ <-newDone
268+}
269+
270+func TestTerminalInputPumpCancellationAfterDeliveredCallbackDoesNotReplay(t *testing.T) {
271+ input := newTerminalInputPump(nil)
272+ input.enqueue(terminalReadResult{data: []byte("x")})
273+ var activeGeneration atomic.Uint64
274+ ctx, cancel := context.WithCancel(context.Background())
275+ delivered := make(chan struct{})
276+ done := make(chan struct{})
277+ out := make(chan ports.Frame, 1)
278+ go func() {
279+ (&stdinPump{
280+ ctx: ctx, cancel: cancel, input: input, out: out, clock: newAttachPaletteClock(),
281+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
282+ activeGeneration: &activeGeneration,
283+ afterInputDelivered: func() {
284+ cancel()
285+ close(delivered)
286+ },
287+ }).run()
288+ close(done)
289+ }()
290+ frame := <-out
291+ got, err := ports.UnmarshalInput(frame.Payload)
292+ require.NoError(t, err)
293+ require.Equal(t, []byte("x"), got.Data)
294+ <-delivered
295+ <-done
296+
297+ consumer := input.claim()
298+ _, ok := input.take(context.Background(), consumer)
299+ require.False(t, ok, "a callback already delivered to the sender must not replay after reconnect")
300+ input.revoke(consumer)
301+}
302+
204303 func TestTerminalInputPumpReusesOneReaderAcrossCancelledAttempts(t *testing.T) {
205304 reader := newLifecycleReader()
206305 input := newTerminalInputPump(reader)
@@ -71,6 +71,26 @@ func (r *lateStopReader) Read(p []byte) (int, error) {
71 return copy(p, result.data), result.err71 return copy(p, result.data), result.err
72 }72 }
73 73
74+func TestPaletteSlotRejectsOutOfRangeIntBeforeNarrowing(t *testing.T) {
75+ for _, tc := range []struct {
76+ slot int
77+ want uint8
78+ ok bool
79+ }{
80+ {slot: -1},
81+ {slot: 0, want: 0, ok: true},
82+ {slot: 15, want: 15, ok: true},
83+ {slot: 16},
84+ {slot: 256},
85+ } {
86+ got, ok := paletteSlot(tc.slot)
87+ require.Equal(t, tc.ok, ok, "slot %d", tc.slot)
88+ if ok {
89+ require.Equal(t, tc.want, got, "slot %d", tc.slot)
90+ }
91+ }
92+}
93+
74 func TestTerminalInputPumpStopDropsLateReadWithoutStartingAnother(t *testing.T) {94 func TestTerminalInputPumpStopDropsLateReadWithoutStartingAnother(t *testing.T) {
75 reader := newLateStopReader()95 reader := newLateStopReader()
76 input := newTerminalInputPump(reader)96 input := newTerminalInputPump(reader)
@@ -201,6 +221,85 @@ func TestTerminalInputPumpCancellationAfterTakeRequeuesRawRead(t *testing.T) {
201 input.revoke(replacement)221 input.revoke(replacement)
202 }222 }
203 223
224+func TestTerminalInputPumpCancellationPreservesStandaloneEscapeForReplacement(t *testing.T) {
225+ input := newTerminalInputPump(nil)
226+ input.enqueue(terminalReadResult{data: []byte("\x1b")})
227+ var activeGeneration atomic.Uint64
228+
229+ oldCtx, cancelOld := context.WithCancel(context.Background())
230+ oldClock := newAttachPaletteClock()
231+ oldDone := make(chan struct{})
232+ go func() {
233+ (&stdinPump{
234+ ctx: oldCtx, cancel: cancelOld, input: input, out: make(chan ports.Frame, 1),
235+ clock: oldClock, logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
236+ activeGeneration: &activeGeneration,
237+ }).run()
238+ close(oldDone)
239+ }()
240+ // The ambiguity timer proves the ESC was scanned and its raw read was
241+ // committed; only the marker scanner's undecided suffix remains.
242+ <-oldClock.timers
243+ cancelOld()
244+ <-oldDone
245+
246+ newCtx, cancelNew := context.WithCancel(context.Background())
247+ defer cancelNew()
248+ newClock := newAttachPaletteClock()
249+ out := make(chan ports.Frame, 1)
250+ newDone := make(chan struct{})
251+ go func() {
252+ (&stdinPump{
253+ ctx: newCtx, cancel: cancelNew, input: input, out: out,
254+ clock: newClock, logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
255+ activeGeneration: &activeGeneration,
256+ }).run()
257+ close(newDone)
258+ }()
259+ (<-newClock.timers).fire() // marker ambiguity deadline
260+ (<-newClock.timers).fire() // paste coalescer's lone-ESC deadline
261+ frame := <-out
262+ got, err := ports.UnmarshalInput(frame.Payload)
263+ require.NoError(t, err)
264+ require.Equal(t, []byte("\x1b"), got.Data)
265+
266+ cancelNew()
267+ <-newDone
268+}
269+
270+func TestTerminalInputPumpCancellationAfterDeliveredCallbackDoesNotReplay(t *testing.T) {
271+ input := newTerminalInputPump(nil)
272+ input.enqueue(terminalReadResult{data: []byte("x")})
273+ var activeGeneration atomic.Uint64
274+ ctx, cancel := context.WithCancel(context.Background())
275+ delivered := make(chan struct{})
276+ done := make(chan struct{})
277+ out := make(chan ports.Frame, 1)
278+ go func() {
279+ (&stdinPump{
280+ ctx: ctx, cancel: cancel, input: input, out: out, clock: newAttachPaletteClock(),
281+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
282+ activeGeneration: &activeGeneration,
283+ afterInputDelivered: func() {
284+ cancel()
285+ close(delivered)
286+ },
287+ }).run()
288+ close(done)
289+ }()
290+ frame := <-out
291+ got, err := ports.UnmarshalInput(frame.Payload)
292+ require.NoError(t, err)
293+ require.Equal(t, []byte("x"), got.Data)
294+ <-delivered
295+ <-done
296+
297+ consumer := input.claim()
298+ _, ok := input.take(context.Background(), consumer)
299+ require.False(t, ok, "a callback already delivered to the sender must not replay after reconnect")
300+ input.revoke(consumer)
301+}
302+
204 func TestTerminalInputPumpReusesOneReaderAcrossCancelledAttempts(t *testing.T) {303 func TestTerminalInputPumpReusesOneReaderAcrossCancelledAttempts(t *testing.T) {
205 reader := newLifecycleReader()304 reader := newLifecycleReader()
206 input := newTerminalInputPump(reader)305 input := newTerminalInputPump(reader)

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