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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 126 | 126 | }) | |
| 127 | 127 | lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump} | |
| 128 | 128 | clock := newSupervisorTestClock() | |
| 129 | - | watch := lifetime.watchResume(clock) | |
| 129 | + | watch := lifetime.watchResume(clock, nil) | |
| 130 | 130 | for _, read := range tt.reads { | |
| 131 | 131 | reader.chunks <- []byte(read) | |
| 132 | 132 | } | |
| @@ -187,7 +187,7 @@ func TestResumeWatchKeepsBacklogVerbatim(t *testing.T) { | |||
| 187 | 187 | }, 5*time.Second, time.Millisecond) | |
| 188 | 188 | } | |
| 189 | 189 | lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump} | |
| 190 | - | watch := lifetime.watchResume(newSupervisorTestClock()) | |
| 190 | + | watch := lifetime.watchResume(newSupervisorTestClock(), nil) | |
| 191 | 191 | require.Eventually(t, func() bool { return watch.heldLen() == len(tt.residual)+len(tt.pending) }, 5*time.Second, time.Millisecond) | |
| 192 | 192 | if tt.typed != "" { | |
| 193 | 193 | reader.chunks <- []byte(tt.typed) | |
| @@ -234,7 +234,7 @@ func TestResumeWatchResolvesEscapeAtRelease(t *testing.T) { | |||
| 234 | 234 | }) | |
| 235 | 235 | lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump} | |
| 236 | 236 | clock := newSupervisorTestClock() | |
| 237 | - | watch := lifetime.watchResume(clock) | |
| 237 | + | watch := lifetime.watchResume(clock, nil) | |
| 238 | 238 | reader.chunks <- []byte("\x1b") | |
| 239 | 239 | escape := clock.awaitTimer(t) | |
| 240 | 240 | released := make(chan struct{}) | |
| @@ -268,11 +268,18 @@ func TestResumeWatchResolvesEscapeAtRelease(t *testing.T) { | |||
| 268 | 268 | } | |
| 269 | 269 | ||
| 270 | 270 | func TestResumeWatchHeldOverflowIsSticky(t *testing.T) { | |
| 271 | - | w := &resumeWatch{} | |
| 271 | + | lifetime := &terminalInputLifetime{} | |
| 272 | + | w := &resumeWatch{lifetime: lifetime} | |
| 272 | 273 | w.hold(bytes.Repeat([]byte("a"), resumeHeldInputLimit-1)) | |
| 273 | 274 | w.hold([]byte("bb")) | |
| 274 | 275 | w.hold([]byte("c")) | |
| 275 | 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 | 285 | // 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; stalled | 285 | // stallingSessionStream passes writes through until stall is set; stalled | ||
M internal/usecase/client/supervisor.go +22 -11
| @@ -1387,6 +1387,10 @@ type terminalInputLifetime struct { | |||
| 1387 | 1387 | ||
| 1388 | 1388 | mu sync.Mutex | |
| 1389 | 1389 | 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 | 1396 | // startTerminalInputLifetime starts the single terminal read. A nil reader is | |
| @@ -1436,6 +1440,7 @@ func (l *terminalInputLifetime) acquirePicker() { | |||
| 1436 | 1440 | } | |
| 1437 | 1441 | if id, ok := l.pump.tryClaim(); ok { | |
| 1438 | 1442 | l.pickerID = id | |
| 1443 | + | l.resumeOverflowed.Store(false) | |
| 1439 | 1444 | l.mu.Unlock() | |
| 1440 | 1445 | return | |
| 1441 | 1446 | } | |
| @@ -1514,9 +1519,8 @@ func (l *terminalInputLifetime) stop() { | |||
| 1514 | 1519 | } | |
| 1515 | 1520 | ||
| 1516 | 1521 | // 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. | |
| 1520 | 1524 | const resumeHeldInputLimit = 64 << 10 | |
| 1521 | 1525 | ||
| 1522 | 1526 | // resumeWatch reads the terminal while a lost attachment waits to resume. | |
| @@ -1529,6 +1533,7 @@ type resumeWatch struct { | |||
| 1529 | 1533 | lifetime *terminalInputLifetime | |
| 1530 | 1534 | id uint64 | |
| 1531 | 1535 | clock ports.Clock | |
| 1536 | + | logger *slog.Logger | |
| 1532 | 1537 | ||
| 1533 | 1538 | // backlog counts deliveries already waiting at claim: session input the | |
| 1534 | 1539 | // lost attachment kept, held verbatim and never read as a cancel key. | |
| @@ -1537,9 +1542,6 @@ type resumeWatch struct { | |||
| 1537 | 1542 | ||
| 1538 | 1543 | mu sync.Mutex | |
| 1539 | 1544 | 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 | 1547 | // heldLen reports how many kept bytes the watch holds. | |
| @@ -1553,8 +1555,11 @@ func (w *resumeWatch) heldLen() int { | |||
| 1553 | 1555 | func (w *resumeWatch) hold(data []byte) { | |
| 1554 | 1556 | w.mu.Lock() | |
| 1555 | 1557 | 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 | + | } | |
| 1558 | 1563 | return | |
| 1559 | 1564 | } | |
| 1560 | 1565 | w.held = append(w.held, data...) | |
| @@ -1562,8 +1567,8 @@ func (w *resumeWatch) hold(data []byte) { | |||
| 1562 | 1567 | ||
| 1563 | 1568 | // watchResume claims terminal input for one resume backoff. When the claim | |
| 1564 | 1569 | // 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} | |
| 1567 | 1572 | if l == nil || l.pump == nil { | |
| 1568 | 1573 | close(w.done) | |
| 1569 | 1574 | return w | |
| @@ -1672,7 +1677,13 @@ func (w *resumeWatch) releaseClaim() { | |||
| 1672 | 1677 | case <-w.cancelled: | |
| 1673 | 1678 | default: | |
| 1674 | 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 | 1687 | w.mu.Unlock() | |
| 1677 | 1688 | } | |
| 1678 | 1689 | pump.revoke(w.id) | |
| @@ -1387,6 +1387,10 @@ type terminalInputLifetime struct { | |||||
| 1387 | 1387 | ||||
| 1388 | mu sync.Mutex | 1388 | mu sync.Mutex | ||
| 1389 | pickerID uint64 | 1389 | 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 is | 1396 | // 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 = id | 1442 | l.pickerID = id | ||
| 1443 | + | l.resumeOverflowed.Store(false) | |||
| 1439 | l.mu.Unlock() | 1444 | l.mu.Unlock() | ||
| 1440 | return | 1445 | 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 keys | 1523 | + | // picker takes input again. |
| 1519 | - | // again and leave a gap. Make it sticky on the input lifetime until attach. | |||
| 1520 | const resumeHeldInputLimit = 64 << 10 | 1524 | 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 *terminalInputLifetime | 1533 | lifetime *terminalInputLifetime | ||
| 1530 | id uint64 | 1534 | id uint64 | ||
| 1531 | clock ports.Clock | 1535 | clock ports.Clock | ||
| 1536 | + | logger *slog.Logger | |||
| 1532 | 1537 | ||||
| 1533 | // backlog counts deliveries already waiting at claim: session input the | 1538 | // 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.Mutex | 1543 | mu sync.Mutex | ||
| 1539 | held []byte | 1544 | 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) != 0 | 1559 | + | if overflowed.Load() || len(w.held)+len(data) > resumeHeldInputLimit { |
| 1560 | + | if len(data) != 0 { | |||
| 1561 | + | overflowed.Store(true) | |||
| 1562 | + | } | |||
| 1558 | return | 1563 | 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 claim | 1568 | // 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 w | 1574 | 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 | |||
| 744 | 744 | var cancelOpen context.CancelFunc | |
| 745 | 745 | var watchDone chan struct{} | |
| 746 | 746 | if resuming { | |
| 747 | - | keys = input.watchResume(s.cfg.Clock) | |
| 747 | + | keys = input.watchResume(s.cfg.Clock, s.logger) | |
| 748 | 748 | openCtx, cancelOpen = context.WithCancel(openCtx) | |
| 749 | 749 | watchDone = make(chan struct{}) | |
| 750 | 750 | go func() { | |
| @@ -779,6 +779,10 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif | |||
| 779 | 779 | if supervisorNil(stream) { | |
| 780 | 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 | 786 | if err := deadline.adopt(stream); err != nil { | |
| 783 | 787 | // The accepted absolute deadline already elapsed before this client | |
| 784 | 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 | 1047 | delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter) | |
| 1044 | 1048 | timer := s.cfg.Clock.NewTimer(delay) | |
| 1045 | 1049 | defer stopSupervisorTimer(timer) | |
| 1046 | - | keys := input.watchResume(s.cfg.Clock) | |
| 1050 | + | keys := input.watchResume(s.cfg.Clock, s.logger) | |
| 1047 | 1051 | defer keys.release() | |
| 1048 | 1052 | for { | |
| 1049 | 1053 | select { | |
| @@ -744,7 +744,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif | |||||
| 744 | var cancelOpen context.CancelFunc | 744 | 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 client | 787 | // 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 { | ||