detached aae2680

fix(client): preserve scanner residual before reconnect claim handoff

commit
aae268046728c77e40b7e63e3adf2b82b91c1640
author
brice · · 2026-07-18 11:50 UTC
parent
afb8c1a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +66 -5
M internal/usecase/client/client.go +10 -5
@@ -764,14 +764,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
764764 defer close(stdinDone)
765765 (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()
766766 }()
767- // Revoke this scanner's dequeue permission before waiting for it to exit.
768- // The lifecycle-owned reader retains queued bytes for the next attempt.
767+ // Scanner cancellation can leave an undecided marker suffix (including a
768+ // standalone Escape) that it must hand back to the lifecycle-owned reader.
769+ // Wait for that handoff before revoking the claim: otherwise a replacement
770+ // scanner could claim the reader between revocation and preservation and
771+ // lose the suffix.
769772 defer func() {
770- // Revoke before scanner teardown so replacement attempts can claim the
771- // lifecycle-owned reader as soon as this attempt releases it.
772- revokeInputClaim()
773773 cancel()
774774 <-stdinDone
775+ revokeInputClaim()
775776 }()
776777 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
777778
@@ -1007,6 +1008,7 @@ type terminalInputPump struct {
10071008 space chan struct{}
10081009 state chan struct{}
10091010 exited chan struct{}
1011+ afterRevoke func() // test synchronization hook
10101012 startMu sync.Once
10111013 stopMu sync.Once
10121014 }
@@ -1053,6 +1055,9 @@ func (p *terminalInputPump) revoke(consumer uint64) {
10531055 }
10541056 p.mu.Unlock()
10551057 p.signalReady()
1058+ if p.afterRevoke != nil {
1059+ p.afterRevoke()
1060+ }
10561061 }
10571062
10581063 func (p *terminalInputPump) signalReady() {
@@ -764,14 +764,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
764 defer close(stdinDone)764 defer close(stdinDone)
765 (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()765 (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()
766 }()766 }()
767- // Revoke this scanner's dequeue permission before waiting for it to exit.767+ // Scanner cancellation can leave an undecided marker suffix (including a
768- // The lifecycle-owned reader retains queued bytes for the next attempt.768+ // standalone Escape) that it must hand back to the lifecycle-owned reader.
769+ // Wait for that handoff before revoking the claim: otherwise a replacement
770+ // scanner could claim the reader between revocation and preservation and
771+ // lose the suffix.
769 defer func() {772 defer func() {
770- // Revoke before scanner teardown so replacement attempts can claim the
771- // lifecycle-owned reader as soon as this attempt releases it.
772- revokeInputClaim()
773 cancel()773 cancel()
774 <-stdinDone774 <-stdinDone
775+ revokeInputClaim()
775 }()776 }()
776 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)777 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
777 778
@@ -1007,6 +1008,7 @@ type terminalInputPump struct {
1007 space chan struct{}1008 space chan struct{}
1008 state chan struct{}1009 state chan struct{}
1009 exited chan struct{}1010 exited chan struct{}
1011+ afterRevoke func() // test synchronization hook
1010 startMu sync.Once1012 startMu sync.Once
1011 stopMu sync.Once1013 stopMu sync.Once
1012 }1014 }
@@ -1053,6 +1055,9 @@ func (p *terminalInputPump) revoke(consumer uint64) {
1053 }1055 }
1054 p.mu.Unlock()1056 p.mu.Unlock()
1055 p.signalReady()1057 p.signalReady()
1058+ if p.afterRevoke != nil {
1059+ p.afterRevoke()
1060+ }
1056 }1061 }
1057 1062
1058 func (p *terminalInputPump) signalReady() {1063 func (p *terminalInputPump) signalReady() {
M internal/usecase/client/palette_attach_generation_test.go +56 -0
@@ -255,6 +255,62 @@ func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) {
255255 require.Len(t, reconnected.transport.snapshots(), 2, "reconnect must not send a restore Theme before its cleared publication")
256256 }
257257
258+func TestAttachReconnectPreservesStandaloneEscapeBeforeClaimRevocation(t *testing.T) {
259+ t.Setenv("COLORTERM", "truecolor")
260+ reader := newAttachPaletteReader()
261+ input := newTerminalInputPump(reader)
262+ input.start()
263+ t.Cleanup(input.stop)
264+
265+ first := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
266+ require.Equal(t, paletteColorBatch, first.nextWrite(t))
267+ _ = first.nextTheme(t)
268+ _ = first.nextTimer(t) // initial palette completion deadline
269+
270+ // A standalone Escape is withheld as a possible DECRQM marker prefix. Its
271+ // raw read is committed, leaving only the scanner-local residual to hand
272+ // over when this attach is replaced.
273+ reader.send("\x1b")
274+ _ = first.nextTimer(t) // standalone Escape ambiguity deadline
275+
276+ revoked := make(chan []byte, 1)
277+ allowRevocationToReturn := make(chan struct{})
278+ var releaseRevocation sync.Once
279+ release := func() { releaseRevocation.Do(func() { close(allowRevocationToReturn) }) }
280+ defer release()
281+ input.afterRevoke = func() {
282+ input.mu.Lock()
283+ residual := append([]byte(nil), input.residual...)
284+ input.mu.Unlock()
285+ revoked <- residual
286+ <-allowRevocationToReturn
287+ }
288+
289+ // Detach starts reconnect teardown. The residual must already be owned by
290+ // the lifecycle reader at the precise point the old claim is revoked.
291+ first.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}
292+ require.Equal(t, []byte("\x1b"), <-revoked)
293+ release()
294+ require.NoError(t, (<-first.done).err)
295+ input.afterRevoke = nil
296+
297+ // The replacement attach receives the retained byte exactly once.
298+ second := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
299+ require.Equal(t, paletteColorBatch, second.nextWrite(t))
300+ _ = second.nextTheme(t)
301+ _ = second.nextTimer(t) // initial palette completion deadline
302+ second.nextTimer(t).fire()
303+ second.nextTimer(t).fire()
304+ got := <-second.transport.inputCh
305+ require.Equal(t, []byte("\x1b"), got.Data)
306+ select {
307+ case duplicate := <-second.transport.inputCh:
308+ require.Failf(t, "duplicate input", "unexpected duplicate %q", duplicate.Data)
309+ default:
310+ }
311+ second.detach(t)
312+}
313+
258314 func TestAttachSchemeReplacementAndDeadlinesProgressWhileReadBlocks(t *testing.T) {
259315 t.Setenv("COLORTERM", "truecolor")
260316 h := startAttachPaletteHarness(&terminalThemeState{})
@@ -255,6 +255,62 @@ func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) {
255 require.Len(t, reconnected.transport.snapshots(), 2, "reconnect must not send a restore Theme before its cleared publication")255 require.Len(t, reconnected.transport.snapshots(), 2, "reconnect must not send a restore Theme before its cleared publication")
256 }256 }
257 257
258+func TestAttachReconnectPreservesStandaloneEscapeBeforeClaimRevocation(t *testing.T) {
259+ t.Setenv("COLORTERM", "truecolor")
260+ reader := newAttachPaletteReader()
261+ input := newTerminalInputPump(reader)
262+ input.start()
263+ t.Cleanup(input.stop)
264+
265+ first := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
266+ require.Equal(t, paletteColorBatch, first.nextWrite(t))
267+ _ = first.nextTheme(t)
268+ _ = first.nextTimer(t) // initial palette completion deadline
269+
270+ // A standalone Escape is withheld as a possible DECRQM marker prefix. Its
271+ // raw read is committed, leaving only the scanner-local residual to hand
272+ // over when this attach is replaced.
273+ reader.send("\x1b")
274+ _ = first.nextTimer(t) // standalone Escape ambiguity deadline
275+
276+ revoked := make(chan []byte, 1)
277+ allowRevocationToReturn := make(chan struct{})
278+ var releaseRevocation sync.Once
279+ release := func() { releaseRevocation.Do(func() { close(allowRevocationToReturn) }) }
280+ defer release()
281+ input.afterRevoke = func() {
282+ input.mu.Lock()
283+ residual := append([]byte(nil), input.residual...)
284+ input.mu.Unlock()
285+ revoked <- residual
286+ <-allowRevocationToReturn
287+ }
288+
289+ // Detach starts reconnect teardown. The residual must already be owned by
290+ // the lifecycle reader at the precise point the old claim is revoked.
291+ first.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}
292+ require.Equal(t, []byte("\x1b"), <-revoked)
293+ release()
294+ require.NoError(t, (<-first.done).err)
295+ input.afterRevoke = nil
296+
297+ // The replacement attach receives the retained byte exactly once.
298+ second := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
299+ require.Equal(t, paletteColorBatch, second.nextWrite(t))
300+ _ = second.nextTheme(t)
301+ _ = second.nextTimer(t) // initial palette completion deadline
302+ second.nextTimer(t).fire()
303+ second.nextTimer(t).fire()
304+ got := <-second.transport.inputCh
305+ require.Equal(t, []byte("\x1b"), got.Data)
306+ select {
307+ case duplicate := <-second.transport.inputCh:
308+ require.Failf(t, "duplicate input", "unexpected duplicate %q", duplicate.Data)
309+ default:
310+ }
311+ second.detach(t)
312+}
313+
258 func TestAttachSchemeReplacementAndDeadlinesProgressWhileReadBlocks(t *testing.T) {314 func TestAttachSchemeReplacementAndDeadlinesProgressWhileReadBlocks(t *testing.T) {
259 t.Setenv("COLORTERM", "truecolor")315 t.Setenv("COLORTERM", "truecolor")
260 h := startAttachPaletteHarness(&terminalThemeState{})316 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