detached 3980b13

fix(client): never read kept session input as a resume cancel key

Input the lost attachment kept, or read before the loss was noticed, was typed for the remote shell: hold it verbatim instead of treating a Ctrl-C or trailing Esc in it as a cancel. Make the held-input bound sticky so the resumed session never receives input with a gap.
commit
3980b1367e2f9c86b47d585c8a042c62ed700411
author
brice · · 2026-10-07 07:20 UTC
parent
27f03fe
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +106 -2
M internal/usecase/client/attachment_outbox_test.go +70 -0
@@ -1,6 +1,7 @@
11 package client
22
33 import (
4+ "bytes"
45 "errors"
56 "strings"
67 "sync/atomic"
@@ -150,6 +151,75 @@ func TestResumeWatch(t *testing.T) {
150151 }
151152 }
152153
154+// TestResumeWatchKeepsBacklogVerbatim starts a watch while session input the
155+// lost attachment kept is still waiting: a Ctrl-C or trailing Esc in it was
156+// typed for the remote shell, so it is replayed, not read as a cancel key.
157+// Keys typed after the watch started still cancel.
158+func TestResumeWatchKeepsBacklogVerbatim(t *testing.T) {
159+ for _, tt := range []struct {
160+ name, residual, pending, typed string
161+ wantCancel bool
162+ wantResidual string
163+ }{
164+ {name: "kept ctrl-c", residual: "\x03", wantResidual: "\x03"},
165+ {name: "kept trailing esc", residual: "ihello\x1b", wantResidual: "ihello\x1b"},
166+ {name: "pending ctrl-c", pending: "x\x03", wantResidual: "x\x03"},
167+ {name: "residual then pending", residual: "a", pending: "\x03", wantResidual: "a\x03"},
168+ {name: "new ctrl-c still cancels", residual: "\x03", typed: "\x03", wantCancel: true},
169+ } {
170+ t.Run(tt.name, func(t *testing.T) {
171+ reader := &inputTestReader{chunks: make(chan []byte, 2)}
172+ pump := newTerminalInputPump(reader)
173+ pump.start()
174+ t.Cleanup(func() {
175+ pump.stop()
176+ close(reader.chunks)
177+ })
178+ pump.mu.Lock()
179+ pump.residual = []byte(tt.residual)
180+ pump.mu.Unlock()
181+ if tt.pending != "" {
182+ reader.chunks <- []byte(tt.pending)
183+ require.Eventually(t, func() bool {
184+ pump.mu.Lock()
185+ defer pump.mu.Unlock()
186+ return pump.pending != nil
187+ }, 5*time.Second, time.Millisecond)
188+ }
189+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
190+ watch := lifetime.watchResume(newSupervisorTestClock())
191+ require.Eventually(t, func() bool { return watch.heldLen() == len(tt.residual)+len(tt.pending) }, 5*time.Second, time.Millisecond)
192+ if tt.typed != "" {
193+ reader.chunks <- []byte(tt.typed)
194+ }
195+ if tt.wantCancel {
196+ select {
197+ case <-watch.cancelled:
198+ case <-time.After(5 * time.Second):
199+ t.Fatal("new cancel key ignored")
200+ }
201+ }
202+ watch.release()
203+ select {
204+ case <-watch.cancelled:
205+ require.True(t, tt.wantCancel, "backlog cancelled the resume")
206+ default:
207+ }
208+ pump.mu.Lock()
209+ defer pump.mu.Unlock()
210+ require.Equal(t, tt.wantResidual, string(pump.residual))
211+ })
212+ }
213+}
214+
215+func TestResumeWatchHeldOverflowIsSticky(t *testing.T) {
216+ w := &resumeWatch{}
217+ w.hold(bytes.Repeat([]byte("a"), resumeHeldInputLimit-1))
218+ w.hold([]byte("bb"))
219+ w.hold([]byte("c"))
220+ require.Equal(t, resumeHeldInputLimit-1, w.heldLen(), "keys after an overflow must be dropped too")
221+}
222+
153223 // stallingSessionStream passes writes through until stall is set; stalled
154224 // writes block until release closes, then fail with failErr when set.
155225 type stallingSessionStream struct {
@@ -1,6 +1,7 @@
1 package client1 package client
2 2
3 import (3 import (
4+ "bytes"
4 "errors"5 "errors"
5 "strings"6 "strings"
6 "sync/atomic"7 "sync/atomic"
@@ -150,6 +151,75 @@ func TestResumeWatch(t *testing.T) {
150 }151 }
151 }152 }
152 153
154+// TestResumeWatchKeepsBacklogVerbatim starts a watch while session input the
155+// lost attachment kept is still waiting: a Ctrl-C or trailing Esc in it was
156+// typed for the remote shell, so it is replayed, not read as a cancel key.
157+// Keys typed after the watch started still cancel.
158+func TestResumeWatchKeepsBacklogVerbatim(t *testing.T) {
159+ for _, tt := range []struct {
160+ name, residual, pending, typed string
161+ wantCancel bool
162+ wantResidual string
163+ }{
164+ {name: "kept ctrl-c", residual: "\x03", wantResidual: "\x03"},
165+ {name: "kept trailing esc", residual: "ihello\x1b", wantResidual: "ihello\x1b"},
166+ {name: "pending ctrl-c", pending: "x\x03", wantResidual: "x\x03"},
167+ {name: "residual then pending", residual: "a", pending: "\x03", wantResidual: "a\x03"},
168+ {name: "new ctrl-c still cancels", residual: "\x03", typed: "\x03", wantCancel: true},
169+ } {
170+ t.Run(tt.name, func(t *testing.T) {
171+ reader := &inputTestReader{chunks: make(chan []byte, 2)}
172+ pump := newTerminalInputPump(reader)
173+ pump.start()
174+ t.Cleanup(func() {
175+ pump.stop()
176+ close(reader.chunks)
177+ })
178+ pump.mu.Lock()
179+ pump.residual = []byte(tt.residual)
180+ pump.mu.Unlock()
181+ if tt.pending != "" {
182+ reader.chunks <- []byte(tt.pending)
183+ require.Eventually(t, func() bool {
184+ pump.mu.Lock()
185+ defer pump.mu.Unlock()
186+ return pump.pending != nil
187+ }, 5*time.Second, time.Millisecond)
188+ }
189+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
190+ watch := lifetime.watchResume(newSupervisorTestClock())
191+ require.Eventually(t, func() bool { return watch.heldLen() == len(tt.residual)+len(tt.pending) }, 5*time.Second, time.Millisecond)
192+ if tt.typed != "" {
193+ reader.chunks <- []byte(tt.typed)
194+ }
195+ if tt.wantCancel {
196+ select {
197+ case <-watch.cancelled:
198+ case <-time.After(5 * time.Second):
199+ t.Fatal("new cancel key ignored")
200+ }
201+ }
202+ watch.release()
203+ select {
204+ case <-watch.cancelled:
205+ require.True(t, tt.wantCancel, "backlog cancelled the resume")
206+ default:
207+ }
208+ pump.mu.Lock()
209+ defer pump.mu.Unlock()
210+ require.Equal(t, tt.wantResidual, string(pump.residual))
211+ })
212+ }
213+}
214+
215+func TestResumeWatchHeldOverflowIsSticky(t *testing.T) {
216+ w := &resumeWatch{}
217+ w.hold(bytes.Repeat([]byte("a"), resumeHeldInputLimit-1))
218+ w.hold([]byte("bb"))
219+ w.hold([]byte("c"))
220+ require.Equal(t, resumeHeldInputLimit-1, w.heldLen(), "keys after an overflow must be dropped too")
221+}
222+
153 // stallingSessionStream passes writes through until stall is set; stalled223 // stallingSessionStream passes writes through until stall is set; stalled
154 // writes block until release closes, then fail with failErr when set.224 // writes block until release closes, then fail with failErr when set.
155 type stallingSessionStream struct {225 type stallingSessionStream struct {
M internal/usecase/client/supervisor.go +17 -2
@@ -1505,8 +1505,15 @@ type resumeWatch struct {
15051505 id uint64
15061506 clock ports.Clock
15071507
1508+ // backlog counts deliveries already waiting at claim: session input the
1509+ // lost attachment kept, held verbatim and never read as a cancel key.
1510+ backlog int
1511+
15081512 mu sync.Mutex
15091513 held []byte
1514+ // overflowed drops every key after the first one past the bound, so the
1515+ // resumed session never receives input with a gap in the middle.
1516+ overflowed bool
15101517 }
15111518
15121519 // heldLen reports how many kept bytes the watch holds.
@@ -1520,9 +1527,11 @@ func (w *resumeWatch) heldLen() int {
15201527 func (w *resumeWatch) hold(data []byte) {
15211528 w.mu.Lock()
15221529 defer w.mu.Unlock()
1523- if len(w.held)+len(data) <= resumeHeldInputLimit {
1524- w.held = append(w.held, data...)
1530+ if w.overflowed || len(w.held)+len(data) > resumeHeldInputLimit {
1531+ w.overflowed = w.overflowed || len(data) != 0
1532+ return
15251533 }
1534+ w.held = append(w.held, data...)
15261535 }
15271536
15281537 // watchResume claims terminal input for one resume backoff. When the claim
@@ -1539,6 +1548,7 @@ func (l *terminalInputLifetime) watchResume(clock ports.Clock) *resumeWatch {
15391548 return w
15401549 }
15411550 w.id = id
1551+ w.backlog = l.pump.backlog(id)
15421552 go w.run()
15431553 return w
15441554 }
@@ -1582,6 +1592,11 @@ func (w *resumeWatch) run() {
15821592 return
15831593 }
15841594 pump.ack(w.id)
1595+ if w.backlog > 0 {
1596+ w.backlog--
1597+ w.hold(result.data)
1598+ continue
1599+ }
15851600 held, cancel := decoder.feed(result.data)
15861601 w.hold(held)
15871602 if cancel {
@@ -1505,8 +1505,15 @@ type resumeWatch struct {
1505 id uint641505 id uint64
1506 clock ports.Clock1506 clock ports.Clock
1507 1507
1508+ // backlog counts deliveries already waiting at claim: session input the
1509+ // lost attachment kept, held verbatim and never read as a cancel key.
1510+ backlog int
1511+
1508 mu sync.Mutex1512 mu sync.Mutex
1509 held []byte1513 held []byte
1514+ // overflowed drops every key after the first one past the bound, so the
1515+ // resumed session never receives input with a gap in the middle.
1516+ overflowed bool
1510 }1517 }
1511 1518
1512 // heldLen reports how many kept bytes the watch holds.1519 // heldLen reports how many kept bytes the watch holds.
@@ -1520,9 +1527,11 @@ func (w *resumeWatch) heldLen() int {
1520 func (w *resumeWatch) hold(data []byte) {1527 func (w *resumeWatch) hold(data []byte) {
1521 w.mu.Lock()1528 w.mu.Lock()
1522 defer w.mu.Unlock()1529 defer w.mu.Unlock()
1523- if len(w.held)+len(data) <= resumeHeldInputLimit {1530+ if w.overflowed || len(w.held)+len(data) > resumeHeldInputLimit {
1524- w.held = append(w.held, data...)1531+ w.overflowed = w.overflowed || len(data) != 0
1532+ return
1525 }1533 }
1534+ w.held = append(w.held, data...)
1526 }1535 }
1527 1536
1528 // watchResume claims terminal input for one resume backoff. When the claim1537 // watchResume claims terminal input for one resume backoff. When the claim
@@ -1539,6 +1548,7 @@ func (l *terminalInputLifetime) watchResume(clock ports.Clock) *resumeWatch {
1539 return w1548 return w
1540 }1549 }
1541 w.id = id1550 w.id = id
1551+ w.backlog = l.pump.backlog(id)
1542 go w.run()1552 go w.run()
1543 return w1553 return w
1544 }1554 }
@@ -1582,6 +1592,11 @@ func (w *resumeWatch) run() {
1582 return1592 return
1583 }1593 }
1584 pump.ack(w.id)1594 pump.ack(w.id)
1595+ if w.backlog > 0 {
1596+ w.backlog--
1597+ w.hold(result.data)
1598+ continue
1599+ }
1585 held, cancel := decoder.feed(result.data)1600 held, cancel := decoder.feed(result.data)
1586 w.hold(held)1601 w.hold(held)
1587 if cancel {1602 if cancel {
M internal/usecase/client/terminal_input_pump.go +19 -0
@@ -125,6 +125,25 @@ func (p *terminalInputPump) tryClaim() (uint64, bool) {
125125 return consumer, true
126126 }
127127
128+// backlog reports how many deliveries were already waiting when consumer
129+// claimed the pump: the residual, then the pending read. They were read for an
130+// earlier owner, not typed for consumer.
131+func (p *terminalInputPump) backlog(consumer uint64) int {
132+ p.mu.Lock()
133+ defer p.mu.Unlock()
134+ if p.consumer != consumer {
135+ return 0
136+ }
137+ n := 0
138+ if len(p.residual) != 0 {
139+ n++
140+ }
141+ if p.pending != nil {
142+ n++
143+ }
144+ return n
145+}
146+
128147 // revoke invalidates an attempt before its replacement is allowed to claim
129148 // input. Pending bytes are deliberately retained for that replacement.
130149 func (p *terminalInputPump) revoke(consumer uint64) {
@@ -125,6 +125,25 @@ func (p *terminalInputPump) tryClaim() (uint64, bool) {
125 return consumer, true125 return consumer, true
126 }126 }
127 127
128+// backlog reports how many deliveries were already waiting when consumer
129+// claimed the pump: the residual, then the pending read. They were read for an
130+// earlier owner, not typed for consumer.
131+func (p *terminalInputPump) backlog(consumer uint64) int {
132+ p.mu.Lock()
133+ defer p.mu.Unlock()
134+ if p.consumer != consumer {
135+ return 0
136+ }
137+ n := 0
138+ if len(p.residual) != 0 {
139+ n++
140+ }
141+ if p.pending != nil {
142+ n++
143+ }
144+ return n
145+}
146+
128 // revoke invalidates an attempt before its replacement is allowed to claim147 // revoke invalidates an attempt before its replacement is allowed to claim
129 // input. Pending bytes are deliberately retained for that replacement.148 // input. Pending bytes are deliberately retained for that replacement.
130 func (p *terminalInputPump) revoke(consumer uint64) {149 func (p *terminalInputPump) revoke(consumer uint64) {

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