detached fd82dd0

fix(client): keep the held-input overflow sticky for a whole outage

Each resume phase started a new watch with a fresh overflow flag, so after one phase dropped keys a later phase accepted them again and the resumed session got input with a gap. The flag now lives on the terminal input lifetime until a stream opens or the picker takes input. A residual replay that exceeds the bound now logs client_resume_input_dropped and sets the same flag instead of dropping silently.
commit
fd82dd0f0dd4ed7a8368a6c83921a1fb9c6bc2db
author
brice · · 2026-10-08 06:00 UTC
parent
7f72ec6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +39 -17
M internal/usecase/client/attachment_outbox_test.go +11 -4
@@ -126,7 +126,7 @@ func TestResumeWatch(t *testing.T) {
126126 })
127127 lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
128128 clock := newSupervisorTestClock()
129- watch := lifetime.watchResume(clock)
129+ watch := lifetime.watchResume(clock, nil)
130130 for _, read := range tt.reads {
131131 reader.chunks <- []byte(read)
132132 }
@@ -187,7 +187,7 @@ func TestResumeWatchKeepsBacklogVerbatim(t *testing.T) {
187187 }, 5*time.Second, time.Millisecond)
188188 }
189189 lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
190- watch := lifetime.watchResume(newSupervisorTestClock())
190+ watch := lifetime.watchResume(newSupervisorTestClock(), nil)
191191 require.Eventually(t, func() bool { return watch.heldLen() == len(tt.residual)+len(tt.pending) }, 5*time.Second, time.Millisecond)
192192 if tt.typed != "" {
193193 reader.chunks <- []byte(tt.typed)
@@ -234,7 +234,7 @@ func TestResumeWatchResolvesEscapeAtRelease(t *testing.T) {
234234 })
235235 lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
236236 clock := newSupervisorTestClock()
237- watch := lifetime.watchResume(clock)
237+ watch := lifetime.watchResume(clock, nil)
238238 reader.chunks <- []byte("\x1b")
239239 escape := clock.awaitTimer(t)
240240 released := make(chan struct{})
@@ -268,11 +268,18 @@ func TestResumeWatchResolvesEscapeAtRelease(t *testing.T) {
268268 }
269269
270270 func TestResumeWatchHeldOverflowIsSticky(t *testing.T) {
271- w := &resumeWatch{}
271+ lifetime := &terminalInputLifetime{}
272+ w := &resumeWatch{lifetime: lifetime}
272273 w.hold(bytes.Repeat([]byte("a"), resumeHeldInputLimit-1))
273274 w.hold([]byte("bb"))
274275 w.hold([]byte("c"))
275276 require.Equal(t, resumeHeldInputLimit-1, w.heldLen(), "keys after an overflow must be dropped too")
277+
278+ // The next resume phase of the same outage starts a new watch; it must
279+ // keep dropping so the session never receives input with a gap.
280+ next := &resumeWatch{lifetime: lifetime}
281+ next.hold([]byte("d"))
282+ require.Zero(t, next.heldLen(), "a later phase must not accept keys after an overflow")
276283 }
277284
278285 // stallingSessionStream passes writes through until stall is set; stalled
@@ -126,7 +126,7 @@ func TestResumeWatch(t *testing.T) {
126 })126 })
127 lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}127 lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
128 clock := newSupervisorTestClock()128 clock := newSupervisorTestClock()
129- watch := lifetime.watchResume(clock)129+ watch := lifetime.watchResume(clock, nil)
130 for _, read := range tt.reads {130 for _, read := range tt.reads {
131 reader.chunks <- []byte(read)131 reader.chunks <- []byte(read)
132 }132 }
@@ -187,7 +187,7 @@ func TestResumeWatchKeepsBacklogVerbatim(t *testing.T) {
187 }, 5*time.Second, time.Millisecond)187 }, 5*time.Second, time.Millisecond)
188 }188 }
189 lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}189 lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
190- watch := lifetime.watchResume(newSupervisorTestClock())190+ watch := lifetime.watchResume(newSupervisorTestClock(), nil)
191 require.Eventually(t, func() bool { return watch.heldLen() == len(tt.residual)+len(tt.pending) }, 5*time.Second, time.Millisecond)191 require.Eventually(t, func() bool { return watch.heldLen() == len(tt.residual)+len(tt.pending) }, 5*time.Second, time.Millisecond)
192 if tt.typed != "" {192 if tt.typed != "" {
193 reader.chunks <- []byte(tt.typed)193 reader.chunks <- []byte(tt.typed)
@@ -234,7 +234,7 @@ func TestResumeWatchResolvesEscapeAtRelease(t *testing.T) {
234 })234 })
235 lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}235 lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
236 clock := newSupervisorTestClock()236 clock := newSupervisorTestClock()
237- watch := lifetime.watchResume(clock)237+ watch := lifetime.watchResume(clock, nil)
238 reader.chunks <- []byte("\x1b")238 reader.chunks <- []byte("\x1b")
239 escape := clock.awaitTimer(t)239 escape := clock.awaitTimer(t)
240 released := make(chan struct{})240 released := make(chan struct{})
@@ -268,11 +268,18 @@ func TestResumeWatchResolvesEscapeAtRelease(t *testing.T) {
268 }268 }
269 269
270 func TestResumeWatchHeldOverflowIsSticky(t *testing.T) {270 func TestResumeWatchHeldOverflowIsSticky(t *testing.T) {
271- w := &resumeWatch{}271+ lifetime := &terminalInputLifetime{}
272+ w := &resumeWatch{lifetime: lifetime}
272 w.hold(bytes.Repeat([]byte("a"), resumeHeldInputLimit-1))273 w.hold(bytes.Repeat([]byte("a"), resumeHeldInputLimit-1))
273 w.hold([]byte("bb"))274 w.hold([]byte("bb"))
274 w.hold([]byte("c"))275 w.hold([]byte("c"))
275 require.Equal(t, resumeHeldInputLimit-1, w.heldLen(), "keys after an overflow must be dropped too")276 require.Equal(t, resumeHeldInputLimit-1, w.heldLen(), "keys after an overflow must be dropped too")
277+
278+ // The next resume phase of the same outage starts a new watch; it must
279+ // keep dropping so the session never receives input with a gap.
280+ next := &resumeWatch{lifetime: lifetime}
281+ next.hold([]byte("d"))
282+ require.Zero(t, next.heldLen(), "a later phase must not accept keys after an overflow")
276 }283 }
277 284
278 // stallingSessionStream passes writes through until stall is set; stalled285 // stallingSessionStream passes writes through until stall is set; stalled
M internal/usecase/client/supervisor.go +22 -11
@@ -1387,6 +1387,10 @@ type terminalInputLifetime struct {
13871387
13881388 mu sync.Mutex
13891389 pickerID uint64
1390+ // resumeOverflowed spans every watch of one outage: once held keys
1391+ // overflowed, later phases drop keys too, so the resumed session never
1392+ // receives input with a gap. The picker taking input clears it.
1393+ resumeOverflowed atomic.Bool
13901394 }
13911395
13921396 // startTerminalInputLifetime starts the single terminal read. A nil reader is
@@ -1436,6 +1440,7 @@ func (l *terminalInputLifetime) acquirePicker() {
14361440 }
14371441 if id, ok := l.pump.tryClaim(); ok {
14381442 l.pickerID = id
1443+ l.resumeOverflowed.Store(false)
14391444 l.mu.Unlock()
14401445 return
14411446 }
@@ -1514,9 +1519,8 @@ func (l *terminalInputLifetime) stop() {
15141519 }
15151520
15161521 // resumeHeldInputLimit bounds keys kept while a lost attachment resumes.
1517-// Past it further keys are dropped, like a full terminal buffer.
1518-// TODO: the overflow flag is per resume watch; a later phase can accept keys
1519-// again and leave a gap. Make it sticky on the input lifetime until attach.
1522+// Past it further keys are dropped, like a full terminal buffer, until the
1523+// picker takes input again.
15201524 const resumeHeldInputLimit = 64 << 10
15211525
15221526 // resumeWatch reads the terminal while a lost attachment waits to resume.
@@ -1529,6 +1533,7 @@ type resumeWatch struct {
15291533 lifetime *terminalInputLifetime
15301534 id uint64
15311535 clock ports.Clock
1536+ logger *slog.Logger
15321537
15331538 // backlog counts deliveries already waiting at claim: session input the
15341539 // lost attachment kept, held verbatim and never read as a cancel key.
@@ -1537,9 +1542,6 @@ type resumeWatch struct {
15371542
15381543 mu sync.Mutex
15391544 held []byte
1540- // overflowed drops every key after the first one past the bound, so the
1541- // resumed session never receives input with a gap in the middle.
1542- overflowed bool
15431545 }
15441546
15451547 // heldLen reports how many kept bytes the watch holds.
@@ -1553,8 +1555,11 @@ func (w *resumeWatch) heldLen() int {
15531555 func (w *resumeWatch) hold(data []byte) {
15541556 w.mu.Lock()
15551557 defer w.mu.Unlock()
1556- if w.overflowed || len(w.held)+len(data) > resumeHeldInputLimit {
1557- w.overflowed = w.overflowed || len(data) != 0
1558+ overflowed := &w.lifetime.resumeOverflowed
1559+ if overflowed.Load() || len(w.held)+len(data) > resumeHeldInputLimit {
1560+ if len(data) != 0 {
1561+ overflowed.Store(true)
1562+ }
15581563 return
15591564 }
15601565 w.held = append(w.held, data...)
@@ -1562,8 +1567,8 @@ func (w *resumeWatch) hold(data []byte) {
15621567
15631568 // watchResume claims terminal input for one resume backoff. When the claim
15641569 // is unavailable it returns a watch that never cancels.
1565-func (l *terminalInputLifetime) watchResume(clock ports.Clock) *resumeWatch {
1566- w := &resumeWatch{cancelled: make(chan struct{}), stop: make(chan struct{}), done: make(chan struct{}), lifetime: l, clock: clock}
1570+func (l *terminalInputLifetime) watchResume(clock ports.Clock, logger *slog.Logger) *resumeWatch {
1571+ w := &resumeWatch{cancelled: make(chan struct{}), stop: make(chan struct{}), done: make(chan struct{}), lifetime: l, clock: clock, logger: logger}
15671572 if l == nil || l.pump == nil {
15681573 close(w.done)
15691574 return w
@@ -1672,7 +1677,13 @@ func (w *resumeWatch) releaseClaim() {
16721677 case <-w.cancelled:
16731678 default:
16741679 w.mu.Lock()
1675- pump.appendResidual(w.id, w.held, resumeHeldInputLimit)
1680+ if !pump.appendResidual(w.id, w.held, resumeHeldInputLimit) && len(w.held) != 0 {
1681+ // Replaying only part of the held keys would leave a gap.
1682+ w.lifetime.resumeOverflowed.Store(true)
1683+ if w.logger != nil {
1684+ w.logger.Warn("client_resume_input_dropped", "bytes", len(w.held))
1685+ }
1686+ }
16761687 w.mu.Unlock()
16771688 }
16781689 pump.revoke(w.id)
@@ -1387,6 +1387,10 @@ type terminalInputLifetime struct {
1387 1387
1388 mu sync.Mutex1388 mu sync.Mutex
1389 pickerID uint641389 pickerID uint64
1390+ // resumeOverflowed spans every watch of one outage: once held keys
1391+ // overflowed, later phases drop keys too, so the resumed session never
1392+ // receives input with a gap. The picker taking input clears it.
1393+ resumeOverflowed atomic.Bool
1390 }1394 }
1391 1395
1392 // startTerminalInputLifetime starts the single terminal read. A nil reader is1396 // startTerminalInputLifetime starts the single terminal read. A nil reader is
@@ -1436,6 +1440,7 @@ func (l *terminalInputLifetime) acquirePicker() {
1436 }1440 }
1437 if id, ok := l.pump.tryClaim(); ok {1441 if id, ok := l.pump.tryClaim(); ok {
1438 l.pickerID = id1442 l.pickerID = id
1443+ l.resumeOverflowed.Store(false)
1439 l.mu.Unlock()1444 l.mu.Unlock()
1440 return1445 return
1441 }1446 }
@@ -1514,9 +1519,8 @@ func (l *terminalInputLifetime) stop() {
1514 }1519 }
1515 1520
1516 // resumeHeldInputLimit bounds keys kept while a lost attachment resumes.1521 // resumeHeldInputLimit bounds keys kept while a lost attachment resumes.
1517-// Past it further keys are dropped, like a full terminal buffer.1522+// Past it further keys are dropped, like a full terminal buffer, until the
1518-// TODO: the overflow flag is per resume watch; a later phase can accept keys1523+// picker takes input again.
1519-// again and leave a gap. Make it sticky on the input lifetime until attach.
1520 const resumeHeldInputLimit = 64 << 101524 const resumeHeldInputLimit = 64 << 10
1521 1525
1522 // resumeWatch reads the terminal while a lost attachment waits to resume.1526 // resumeWatch reads the terminal while a lost attachment waits to resume.
@@ -1529,6 +1533,7 @@ type resumeWatch struct {
1529 lifetime *terminalInputLifetime1533 lifetime *terminalInputLifetime
1530 id uint641534 id uint64
1531 clock ports.Clock1535 clock ports.Clock
1536+ logger *slog.Logger
1532 1537
1533 // backlog counts deliveries already waiting at claim: session input the1538 // backlog counts deliveries already waiting at claim: session input the
1534 // lost attachment kept, held verbatim and never read as a cancel key.1539 // lost attachment kept, held verbatim and never read as a cancel key.
@@ -1537,9 +1542,6 @@ type resumeWatch struct {
1537 1542
1538 mu sync.Mutex1543 mu sync.Mutex
1539 held []byte1544 held []byte
1540- // overflowed drops every key after the first one past the bound, so the
1541- // resumed session never receives input with a gap in the middle.
1542- overflowed bool
1543 }1545 }
1544 1546
1545 // heldLen reports how many kept bytes the watch holds.1547 // heldLen reports how many kept bytes the watch holds.
@@ -1553,8 +1555,11 @@ func (w *resumeWatch) heldLen() int {
1553 func (w *resumeWatch) hold(data []byte) {1555 func (w *resumeWatch) hold(data []byte) {
1554 w.mu.Lock()1556 w.mu.Lock()
1555 defer w.mu.Unlock()1557 defer w.mu.Unlock()
1556- if w.overflowed || len(w.held)+len(data) > resumeHeldInputLimit {1558+ overflowed := &w.lifetime.resumeOverflowed
1557- w.overflowed = w.overflowed || len(data) != 01559+ if overflowed.Load() || len(w.held)+len(data) > resumeHeldInputLimit {
1560+ if len(data) != 0 {
1561+ overflowed.Store(true)
1562+ }
1558 return1563 return
1559 }1564 }
1560 w.held = append(w.held, data...)1565 w.held = append(w.held, data...)
@@ -1562,8 +1567,8 @@ func (w *resumeWatch) hold(data []byte) {
1562 1567
1563 // watchResume claims terminal input for one resume backoff. When the claim1568 // watchResume claims terminal input for one resume backoff. When the claim
1564 // is unavailable it returns a watch that never cancels.1569 // is unavailable it returns a watch that never cancels.
1565-func (l *terminalInputLifetime) watchResume(clock ports.Clock) *resumeWatch {1570+func (l *terminalInputLifetime) watchResume(clock ports.Clock, logger *slog.Logger) *resumeWatch {
1566- w := &resumeWatch{cancelled: make(chan struct{}), stop: make(chan struct{}), done: make(chan struct{}), lifetime: l, clock: clock}1571+ w := &resumeWatch{cancelled: make(chan struct{}), stop: make(chan struct{}), done: make(chan struct{}), lifetime: l, clock: clock, logger: logger}
1567 if l == nil || l.pump == nil {1572 if l == nil || l.pump == nil {
1568 close(w.done)1573 close(w.done)
1569 return w1574 return w
@@ -1672,7 +1677,13 @@ func (w *resumeWatch) releaseClaim() {
1672 case <-w.cancelled:1677 case <-w.cancelled:
1673 default:1678 default:
1674 w.mu.Lock()1679 w.mu.Lock()
1675- pump.appendResidual(w.id, w.held, resumeHeldInputLimit)1680+ if !pump.appendResidual(w.id, w.held, resumeHeldInputLimit) && len(w.held) != 0 {
1681+ // Replaying only part of the held keys would leave a gap.
1682+ w.lifetime.resumeOverflowed.Store(true)
1683+ if w.logger != nil {
1684+ w.logger.Warn("client_resume_input_dropped", "bytes", len(w.held))
1685+ }
1686+ }
1676 w.mu.Unlock()1687 w.mu.Unlock()
1677 }1688 }
1678 pump.revoke(w.id)1689 pump.revoke(w.id)
M internal/usecase/client/supervisor_attach.go +6 -2
@@ -744,7 +744,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
744744 var cancelOpen context.CancelFunc
745745 var watchDone chan struct{}
746746 if resuming {
747- keys = input.watchResume(s.cfg.Clock)
747+ keys = input.watchResume(s.cfg.Clock, s.logger)
748748 openCtx, cancelOpen = context.WithCancel(openCtx)
749749 watchDone = make(chan struct{})
750750 go func() {
@@ -779,6 +779,10 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
779779 if supervisorNil(stream) {
780780 return s.attemptFailed(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker returned no stream"}, resuming)
781781 }
782+ if input != nil {
783+ // The outage ended: keys typed from now on reach the session again.
784+ input.resumeOverflowed.Store(false)
785+ }
782786 if err := deadline.adopt(stream); err != nil {
783787 // The accepted absolute deadline already elapsed before this client
784788 // saw the stream. Fail the attachment instead of restarting a budget.
@@ -1043,7 +1047,7 @@ func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetim
10431047 delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter)
10441048 timer := s.cfg.Clock.NewTimer(delay)
10451049 defer stopSupervisorTimer(timer)
1046- keys := input.watchResume(s.cfg.Clock)
1050+ keys := input.watchResume(s.cfg.Clock, s.logger)
10471051 defer keys.release()
10481052 for {
10491053 select {
@@ -744,7 +744,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
744 var cancelOpen context.CancelFunc744 var cancelOpen context.CancelFunc
745 var watchDone chan struct{}745 var watchDone chan struct{}
746 if resuming {746 if resuming {
747- keys = input.watchResume(s.cfg.Clock)747+ keys = input.watchResume(s.cfg.Clock, s.logger)
748 openCtx, cancelOpen = context.WithCancel(openCtx)748 openCtx, cancelOpen = context.WithCancel(openCtx)
749 watchDone = make(chan struct{})749 watchDone = make(chan struct{})
750 go func() {750 go func() {
@@ -779,6 +779,10 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
779 if supervisorNil(stream) {779 if supervisorNil(stream) {
780 return s.attemptFailed(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker returned no stream"}, resuming)780 return s.attemptFailed(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker returned no stream"}, resuming)
781 }781 }
782+ if input != nil {
783+ // The outage ended: keys typed from now on reach the session again.
784+ input.resumeOverflowed.Store(false)
785+ }
782 if err := deadline.adopt(stream); err != nil {786 if err := deadline.adopt(stream); err != nil {
783 // The accepted absolute deadline already elapsed before this client787 // The accepted absolute deadline already elapsed before this client
784 // saw the stream. Fail the attachment instead of restarting a budget.788 // saw the stream. Fail the attachment instead of restarting a budget.
@@ -1043,7 +1047,7 @@ func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetim
1043 delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter)1047 delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter)
1044 timer := s.cfg.Clock.NewTimer(delay)1048 timer := s.cfg.Clock.NewTimer(delay)
1045 defer stopSupervisorTimer(timer)1049 defer stopSupervisorTimer(timer)
1046- keys := input.watchResume(s.cfg.Clock)1050+ keys := input.watchResume(s.cfg.Clock, s.logger)
1047 defer keys.release()1051 defer keys.release()
1048 for {1052 for {
1049 select {1053 select {

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