detached fe83619

fix(client): replay the held prefix after a resume input overflow

commit
fe83619b13f6f4d58b15c1b11d69a7d48cdb7a85
author
brice · · 2026-10-08 08:52 UTC
committer
brice · · 2026-10-08 09:31 UTC
parent
3a8b3d9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +69 -19
M internal/usecase/client/attachment_outbox_test.go +30 -0
@@ -282,6 +282,36 @@ func TestResumeWatchHeldOverflowIsSticky(t *testing.T) {
282282 require.Zero(t, next.heldLen(), "a later phase must not accept keys after an overflow")
283283 }
284284
285+// TestResumeWatchOverflowKeepsHeldPrefix overflows one phase and checks the
286+// next phase still replays the gap-free prefix the first phase handed back.
287+// TestResumeWatchHeldOverflowIsSticky covers dropping keys typed after it.
288+func TestResumeWatchOverflowKeepsHeldPrefix(t *testing.T) {
289+ reader := &inputTestReader{chunks: make(chan []byte, 2)}
290+ pump := newTerminalInputPump(reader)
291+ pump.start()
292+ t.Cleanup(func() {
293+ pump.stop()
294+ close(reader.chunks)
295+ })
296+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
297+ clock := newSupervisorTestClock()
298+
299+ first := lifetime.watchResume(clock, nil)
300+ reader.chunks <- []byte("ls")
301+ require.Eventually(t, func() bool { return first.heldLen() == 2 }, 5*time.Second, time.Millisecond)
302+ first.hold(bytes.Repeat([]byte("x"), resumeHeldInputLimit))
303+ require.True(t, lifetime.resumeOverflowed.Load())
304+ first.release()
305+
306+ second := lifetime.watchResume(clock, nil)
307+ require.Eventually(t, func() bool { return second.heldLen() == 2 }, 5*time.Second, time.Millisecond)
308+ second.release()
309+
310+ pump.mu.Lock()
311+ defer pump.mu.Unlock()
312+ require.Equal(t, "ls", string(pump.residual))
313+}
314+
285315 // stallingSessionStream passes writes through until stall is set; stalled
286316 // writes block until release closes, then fail with failErr when set.
287317 type stallingSessionStream struct {
@@ -282,6 +282,36 @@ func TestResumeWatchHeldOverflowIsSticky(t *testing.T) {
282 require.Zero(t, next.heldLen(), "a later phase must not accept keys after an overflow")282 require.Zero(t, next.heldLen(), "a later phase must not accept keys after an overflow")
283 }283 }
284 284
285+// TestResumeWatchOverflowKeepsHeldPrefix overflows one phase and checks the
286+// next phase still replays the gap-free prefix the first phase handed back.
287+// TestResumeWatchHeldOverflowIsSticky covers dropping keys typed after it.
288+func TestResumeWatchOverflowKeepsHeldPrefix(t *testing.T) {
289+ reader := &inputTestReader{chunks: make(chan []byte, 2)}
290+ pump := newTerminalInputPump(reader)
291+ pump.start()
292+ t.Cleanup(func() {
293+ pump.stop()
294+ close(reader.chunks)
295+ })
296+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
297+ clock := newSupervisorTestClock()
298+
299+ first := lifetime.watchResume(clock, nil)
300+ reader.chunks <- []byte("ls")
301+ require.Eventually(t, func() bool { return first.heldLen() == 2 }, 5*time.Second, time.Millisecond)
302+ first.hold(bytes.Repeat([]byte("x"), resumeHeldInputLimit))
303+ require.True(t, lifetime.resumeOverflowed.Load())
304+ first.release()
305+
306+ second := lifetime.watchResume(clock, nil)
307+ require.Eventually(t, func() bool { return second.heldLen() == 2 }, 5*time.Second, time.Millisecond)
308+ second.release()
309+
310+ pump.mu.Lock()
311+ defer pump.mu.Unlock()
312+ require.Equal(t, "ls", string(pump.residual))
313+}
314+
285 // stallingSessionStream passes writes through until stall is set; stalled315 // stallingSessionStream passes writes through until stall is set; stalled
286 // writes block until release closes, then fail with failErr when set.316 // writes block until release closes, then fail with failErr when set.
287 type stallingSessionStream struct {317 type stallingSessionStream struct {
M internal/usecase/client/supervisor.go +27 -7
@@ -1515,9 +1515,10 @@ type resumeWatch struct {
15151515 clock ports.Clock
15161516 logger *slog.Logger
15171517
1518- // backlog counts deliveries already waiting at claim: session input the
1519- // lost attachment kept, held verbatim and never read as a cancel key.
1520- backlog int
1518+ // backlog marks deliveries already waiting at claim: session input the
1519+ // lost attachment or an earlier phase kept, held verbatim and never read
1520+ // as a cancel key.
1521+ backlog claimBacklog
15211522 releaseOnce sync.Once
15221523
15231524 mu sync.Mutex
@@ -1531,12 +1532,24 @@ func (w *resumeWatch) heldLen() int {
15311532 return len(w.held)
15321533 }
15331534
1534-// hold keeps data within the bound.
1535+// hold keeps newly typed data within the bound. After an overflow in this
1536+// outage it drops every new key, so the session never sees a gap.
15351537 func (w *resumeWatch) hold(data []byte) {
1538+ w.holdInput(data, false)
1539+}
1540+
1541+// holdResidual keeps the residual an earlier owner handed back. It was kept
1542+// before any overflow, so it precedes the dropped keys and the sticky
1543+// overflow must not discard it.
1544+func (w *resumeWatch) holdResidual(data []byte) {
1545+ w.holdInput(data, true)
1546+}
1547+
1548+func (w *resumeWatch) holdInput(data []byte, residual bool) {
15361549 w.mu.Lock()
15371550 defer w.mu.Unlock()
15381551 overflowed := &w.lifetime.resumeOverflowed
1539- if overflowed.Load() || len(w.held)+len(data) > resumeHeldInputLimit {
1552+ if (!residual && overflowed.Load()) || len(w.held)+len(data) > resumeHeldInputLimit {
15401553 if len(data) != 0 {
15411554 overflowed.Store(true)
15421555 }
@@ -1615,8 +1628,15 @@ func (w *resumeWatch) run() {
16151628 return
16161629 }
16171630 pump.ack(w.id)
1618- if w.backlog > 0 {
1619- w.backlog--
1631+ if w.backlog.residual {
1632+ w.backlog.residual = false
1633+ w.holdResidual(result.data)
1634+ continue
1635+ }
1636+ if w.backlog.pending {
1637+ // Read after the earlier owner stopped: newly typed, so an
1638+ // overflow drops it, but it is still never a cancel key.
1639+ w.backlog.pending = false
16201640 w.hold(result.data)
16211641 continue
16221642 }
@@ -1515,9 +1515,10 @@ type resumeWatch struct {
1515 clock ports.Clock1515 clock ports.Clock
1516 logger *slog.Logger1516 logger *slog.Logger
1517 1517
1518- // backlog counts deliveries already waiting at claim: session input the1518+ // backlog marks deliveries already waiting at claim: session input the
1519- // lost attachment kept, held verbatim and never read as a cancel key.1519+ // lost attachment or an earlier phase kept, held verbatim and never read
1520- backlog int1520+ // as a cancel key.
1521+ backlog claimBacklog
1521 releaseOnce sync.Once1522 releaseOnce sync.Once
1522 1523
1523 mu sync.Mutex1524 mu sync.Mutex
@@ -1531,12 +1532,24 @@ func (w *resumeWatch) heldLen() int {
1531 return len(w.held)1532 return len(w.held)
1532 }1533 }
1533 1534
1534-// hold keeps data within the bound.1535+// hold keeps newly typed data within the bound. After an overflow in this
1536+// outage it drops every new key, so the session never sees a gap.
1535 func (w *resumeWatch) hold(data []byte) {1537 func (w *resumeWatch) hold(data []byte) {
1538+ w.holdInput(data, false)
1539+}
1540+
1541+// holdResidual keeps the residual an earlier owner handed back. It was kept
1542+// before any overflow, so it precedes the dropped keys and the sticky
1543+// overflow must not discard it.
1544+func (w *resumeWatch) holdResidual(data []byte) {
1545+ w.holdInput(data, true)
1546+}
1547+
1548+func (w *resumeWatch) holdInput(data []byte, residual bool) {
1536 w.mu.Lock()1549 w.mu.Lock()
1537 defer w.mu.Unlock()1550 defer w.mu.Unlock()
1538 overflowed := &w.lifetime.resumeOverflowed1551 overflowed := &w.lifetime.resumeOverflowed
1539- if overflowed.Load() || len(w.held)+len(data) > resumeHeldInputLimit {1552+ if (!residual && overflowed.Load()) || len(w.held)+len(data) > resumeHeldInputLimit {
1540 if len(data) != 0 {1553 if len(data) != 0 {
1541 overflowed.Store(true)1554 overflowed.Store(true)
1542 }1555 }
@@ -1615,8 +1628,15 @@ func (w *resumeWatch) run() {
1615 return1628 return
1616 }1629 }
1617 pump.ack(w.id)1630 pump.ack(w.id)
1618- if w.backlog > 0 {1631+ if w.backlog.residual {
1619- w.backlog--1632+ w.backlog.residual = false
1633+ w.holdResidual(result.data)
1634+ continue
1635+ }
1636+ if w.backlog.pending {
1637+ // Read after the earlier owner stopped: newly typed, so an
1638+ // overflow drops it, but it is still never a cancel key.
1639+ w.backlog.pending = false
1620 w.hold(result.data)1640 w.hold(result.data)
1621 continue1641 continue
1622 }1642 }
M internal/usecase/client/terminal_input_pump.go +12 -12
@@ -109,22 +109,22 @@ func (p *terminalInputPump) tryClaim() (uint64, bool) {
109109 return consumer, ok
110110 }
111111
112-// claimWithBacklog claims the pump and reports, atomically with the claim, how
113-// many deliveries were already waiting: the residual, then the pending read.
114-// They were read for an earlier owner, not typed for this one.
115-func (p *terminalInputPump) claimWithBacklog() (uint64, int, bool) {
112+// claimBacklog describes deliveries already waiting at claim. They are taken
113+// in this order: the residual first, then the pending read.
114+type claimBacklog struct {
115+ residual, pending bool
116+}
117+
118+// claimWithBacklog claims the pump and reports, atomically with the claim,
119+// which deliveries were already waiting. They were read for an earlier owner,
120+// not typed for this one.
121+func (p *terminalInputPump) claimWithBacklog() (uint64, claimBacklog, bool) {
116122 p.mu.Lock()
117123 if p.consumer != 0 {
118124 p.mu.Unlock()
119- return 0, 0, false
120- }
121- backlog := 0
122- if len(p.residual) != 0 {
123- backlog++
124- }
125- if p.pending != nil {
126- backlog++
125+ return 0, claimBacklog{}, false
127126 }
127+ backlog := claimBacklog{residual: len(p.residual) != 0, pending: p.pending != nil}
128128 p.nextID++
129129 p.consumer = p.nextID
130130 consumer := p.consumer
@@ -109,22 +109,22 @@ func (p *terminalInputPump) tryClaim() (uint64, bool) {
109 return consumer, ok109 return consumer, ok
110 }110 }
111 111
112-// claimWithBacklog claims the pump and reports, atomically with the claim, how112+// claimBacklog describes deliveries already waiting at claim. They are taken
113-// many deliveries were already waiting: the residual, then the pending read.113+// in this order: the residual first, then the pending read.
114-// They were read for an earlier owner, not typed for this one.114+type claimBacklog struct {
115-func (p *terminalInputPump) claimWithBacklog() (uint64, int, bool) {115+ residual, pending bool
116+}
117+
118+// claimWithBacklog claims the pump and reports, atomically with the claim,
119+// which deliveries were already waiting. They were read for an earlier owner,
120+// not typed for this one.
121+func (p *terminalInputPump) claimWithBacklog() (uint64, claimBacklog, bool) {
116 p.mu.Lock()122 p.mu.Lock()
117 if p.consumer != 0 {123 if p.consumer != 0 {
118 p.mu.Unlock()124 p.mu.Unlock()
119- return 0, 0, false125+ return 0, claimBacklog{}, false
120- }
121- backlog := 0
122- if len(p.residual) != 0 {
123- backlog++
124- }
125- if p.pending != nil {
126- backlog++
127 }126 }
127+ backlog := claimBacklog{residual: len(p.residual) != 0, pending: p.pending != nil}
128 p.nextID++128 p.nextID++
129 p.consumer = p.nextID129 p.consumer = p.nextID
130 consumer := p.consumer130 consumer := p.consumer

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