detached 337ac71

fix(client): resolve a pending Esc before a resume phase ends

Claim the pump and count its backlog atomically, so a key typed at claim time is never mistaken for kept input. When a backoff or stream-open phase ends with an undecided escape, wait for the escape delay so a lone Esc still cancels instead of reaching the session as backlog. release is now idempotent.
commit
337ac71345b2d7c68076d150a0279d1c81e74e2f
author
brice · · 2026-10-07 08:16 UTC
parent
55f7c78
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +122 -30
M internal/usecase/client/attachment_outbox_test.go +55 -0
@@ -212,6 +212,61 @@ func TestResumeWatchKeepsBacklogVerbatim(t *testing.T) {
212212 }
213213 }
214214
215+// TestResumeWatchResolvesEscapeAtRelease presses Esc just before the phase
216+// ends: release waits for the escape delay, so a lone Esc still cancels and an
217+// escape sequence completed in time is kept for the session.
218+func TestResumeWatchResolvesEscapeAtRelease(t *testing.T) {
219+ for _, tt := range []struct {
220+ name, after string
221+ wantCancel bool
222+ wantResidual string
223+ }{
224+ {name: "lone esc cancels", wantCancel: true},
225+ {name: "arrow key is kept", after: "[A", wantResidual: "\x1b[A"},
226+ } {
227+ t.Run(tt.name, func(t *testing.T) {
228+ reader := &inputTestReader{chunks: make(chan []byte, 2)}
229+ pump := newTerminalInputPump(reader)
230+ pump.start()
231+ t.Cleanup(func() {
232+ pump.stop()
233+ close(reader.chunks)
234+ })
235+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
236+ clock := newSupervisorTestClock()
237+ watch := lifetime.watchResume(clock)
238+ reader.chunks <- []byte("\x1b")
239+ escape := clock.awaitTimer(t)
240+ released := make(chan struct{})
241+ go func() { watch.release(); close(released) }()
242+ select {
243+ case <-released:
244+ t.Fatal("release returned before the escape was resolved")
245+ case <-time.After(50 * time.Millisecond):
246+ }
247+ if tt.after != "" {
248+ reader.chunks <- []byte(tt.after)
249+ } else {
250+ escape.fire()
251+ }
252+ select {
253+ case <-released:
254+ case <-time.After(5 * time.Second):
255+ t.Fatal("release never returned")
256+ }
257+ select {
258+ case <-watch.cancelled:
259+ require.True(t, tt.wantCancel, "escape sequence cancelled the resume")
260+ default:
261+ require.False(t, tt.wantCancel, "lone esc at release did not cancel")
262+ }
263+ pump.mu.Lock()
264+ defer pump.mu.Unlock()
265+ require.Equal(t, tt.wantResidual, string(pump.residual))
266+ })
267+ }
268+}
269+
215270 func TestResumeWatchHeldOverflowIsSticky(t *testing.T) {
216271 w := &resumeWatch{}
217272 w.hold(bytes.Repeat([]byte("a"), resumeHeldInputLimit-1))
@@ -212,6 +212,61 @@ func TestResumeWatchKeepsBacklogVerbatim(t *testing.T) {
212 }212 }
213 }213 }
214 214
215+// TestResumeWatchResolvesEscapeAtRelease presses Esc just before the phase
216+// ends: release waits for the escape delay, so a lone Esc still cancels and an
217+// escape sequence completed in time is kept for the session.
218+func TestResumeWatchResolvesEscapeAtRelease(t *testing.T) {
219+ for _, tt := range []struct {
220+ name, after string
221+ wantCancel bool
222+ wantResidual string
223+ }{
224+ {name: "lone esc cancels", wantCancel: true},
225+ {name: "arrow key is kept", after: "[A", wantResidual: "\x1b[A"},
226+ } {
227+ t.Run(tt.name, func(t *testing.T) {
228+ reader := &inputTestReader{chunks: make(chan []byte, 2)}
229+ pump := newTerminalInputPump(reader)
230+ pump.start()
231+ t.Cleanup(func() {
232+ pump.stop()
233+ close(reader.chunks)
234+ })
235+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
236+ clock := newSupervisorTestClock()
237+ watch := lifetime.watchResume(clock)
238+ reader.chunks <- []byte("\x1b")
239+ escape := clock.awaitTimer(t)
240+ released := make(chan struct{})
241+ go func() { watch.release(); close(released) }()
242+ select {
243+ case <-released:
244+ t.Fatal("release returned before the escape was resolved")
245+ case <-time.After(50 * time.Millisecond):
246+ }
247+ if tt.after != "" {
248+ reader.chunks <- []byte(tt.after)
249+ } else {
250+ escape.fire()
251+ }
252+ select {
253+ case <-released:
254+ case <-time.After(5 * time.Second):
255+ t.Fatal("release never returned")
256+ }
257+ select {
258+ case <-watch.cancelled:
259+ require.True(t, tt.wantCancel, "escape sequence cancelled the resume")
260+ default:
261+ require.False(t, tt.wantCancel, "lone esc at release did not cancel")
262+ }
263+ pump.mu.Lock()
264+ defer pump.mu.Unlock()
265+ require.Equal(t, tt.wantResidual, string(pump.residual))
266+ })
267+ }
268+}
269+
215 func TestResumeWatchHeldOverflowIsSticky(t *testing.T) {270 func TestResumeWatchHeldOverflowIsSticky(t *testing.T) {
216 w := &resumeWatch{}271 w := &resumeWatch{}
217 w.hold(bytes.Repeat([]byte("a"), resumeHeldInputLimit-1))272 w.hold(bytes.Repeat([]byte("a"), resumeHeldInputLimit-1))
M internal/usecase/client/resume_input.go +10 -0
@@ -2,12 +2,17 @@ package client
22
33 import (
44 "bytes"
5+ "time"
56
67 "github.com/bnema/vev/internal/usecase/keys/kittykey"
78 )
89
910 const resumeEscapeLimit = 256
1011
12+// resumeEscapeDelay is how long a lone Esc stays undecided before it counts
13+// as a cancel rather than the start of an escape sequence.
14+const resumeEscapeDelay = 50 * time.Millisecond
15+
1116 // resumeInputDecoder retains only an undecided escape prefix. Held bytes stay
1217 // in their original terminal encoding; paste content is never a cancel key.
1318 type resumeInputDecoder struct {
@@ -75,6 +80,11 @@ func (d *resumeInputDecoder) feed(data []byte) (held []byte, cancel bool) {
7580 return held, false
7681 }
7782
83+// undecided reports whether an escape prefix is still waiting for more bytes.
84+func (d *resumeInputDecoder) undecided() bool {
85+ return len(d.prefix) != 0
86+}
87+
7888 func (d *resumeInputDecoder) loneEscape() bool {
7989 return !d.paste && bytes.Equal(d.prefix, []byte{0x1b})
8090 }
@@ -2,12 +2,17 @@ package client
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "time"
5 6
6 "github.com/bnema/vev/internal/usecase/keys/kittykey"7 "github.com/bnema/vev/internal/usecase/keys/kittykey"
7 )8 )
8 9
9 const resumeEscapeLimit = 25610 const resumeEscapeLimit = 256
10 11
12+// resumeEscapeDelay is how long a lone Esc stays undecided before it counts
13+// as a cancel rather than the start of an escape sequence.
14+const resumeEscapeDelay = 50 * time.Millisecond
15+
11 // resumeInputDecoder retains only an undecided escape prefix. Held bytes stay16 // resumeInputDecoder retains only an undecided escape prefix. Held bytes stay
12 // in their original terminal encoding; paste content is never a cancel key.17 // in their original terminal encoding; paste content is never a cancel key.
13 type resumeInputDecoder struct {18 type resumeInputDecoder struct {
@@ -75,6 +80,11 @@ func (d *resumeInputDecoder) feed(data []byte) (held []byte, cancel bool) {
75 return held, false80 return held, false
76 }81 }
77 82
83+// undecided reports whether an escape prefix is still waiting for more bytes.
84+func (d *resumeInputDecoder) undecided() bool {
85+ return len(d.prefix) != 0
86+}
87+
78 func (d *resumeInputDecoder) loneEscape() bool {88 func (d *resumeInputDecoder) loneEscape() bool {
79 return !d.paste && bytes.Equal(d.prefix, []byte{0x1b})89 return !d.paste && bytes.Equal(d.prefix, []byte{0x1b})
80 }90 }
M internal/usecase/client/supervisor.go +30 -9
@@ -1507,7 +1507,8 @@ type resumeWatch struct {
15071507
15081508 // backlog counts deliveries already waiting at claim: session input the
15091509 // lost attachment kept, held verbatim and never read as a cancel key.
1510- backlog int
1510+ backlog int
1511+ releaseOnce sync.Once
15111512
15121513 mu sync.Mutex
15131514 held []byte
@@ -1542,13 +1543,13 @@ func (l *terminalInputLifetime) watchResume(clock ports.Clock) *resumeWatch {
15421543 close(w.done)
15431544 return w
15441545 }
1545- id, ok := l.pump.tryClaim()
1546+ id, backlog, ok := l.pump.claimWithBacklog()
15461547 if !ok {
15471548 close(w.done)
15481549 return w
15491550 }
15501551 w.id = id
1551- w.backlog = l.pump.backlog(id)
1552+ w.backlog = backlog
15521553 go w.run()
15531554 return w
15541555 }
@@ -1564,6 +1565,7 @@ func (w *resumeWatch) run() {
15641565 }
15651566 w.hold(decoder.flush())
15661567 }()
1568+ stop := w.stop
15671569 for {
15681570 var escapeC <-chan time.Time
15691571 if escape != nil {
@@ -1574,10 +1576,21 @@ func (w *resumeWatch) run() {
15741576 select {
15751577 case <-pump.readyFor(w.id):
15761578 case <-escapeC:
1577- close(w.cancelled)
1578- return
1579- case <-w.stop:
1579+ if decoder.loneEscape() {
1580+ close(w.cancelled)
1581+ }
15801582 return
1583+ case <-stop:
1584+ if !decoder.undecided() {
1585+ return
1586+ }
1587+ // Resolve an escape typed just before the phase ended: the
1588+ // next watch would hold it as backlog, so a cancel pressed at
1589+ // the boundary would reach the session instead.
1590+ stop = nil
1591+ if escape == nil {
1592+ escape = w.clock.NewTimer(resumeEscapeDelay)
1593+ }
15811594 case <-pump.done:
15821595 return
15831596 }
@@ -1607,18 +1620,26 @@ func (w *resumeWatch) run() {
16071620 escape.Stop()
16081621 escape = nil
16091622 }
1610- if decoder.loneEscape() {
1611- escape = w.clock.NewTimer(50 * time.Millisecond)
1623+ if stop == nil && !decoder.undecided() {
1624+ return
1625+ }
1626+ if decoder.loneEscape() || stop == nil {
1627+ escape = w.clock.NewTimer(resumeEscapeDelay)
16121628 }
16131629 }
16141630 }
16151631
16161632 // release stops the watch and hands kept keys back to the pump, unless the
1617-// user cancelled, in which case they are dropped with the session.
1633+// user cancelled, in which case they are dropped with the session. An escape
1634+// still undecided is resolved first, so it may cancel. Later calls are no-ops.
16181635 func (w *resumeWatch) release() {
16191636 if w.id == 0 {
16201637 return
16211638 }
1639+ w.releaseOnce.Do(w.releaseClaim)
1640+}
1641+
1642+func (w *resumeWatch) releaseClaim() {
16221643 close(w.stop)
16231644 <-w.done
16241645 pump := w.lifetime.pump
@@ -1507,7 +1507,8 @@ type resumeWatch struct {
1507 1507
1508 // backlog counts deliveries already waiting at claim: session input the1508 // backlog counts deliveries already waiting at claim: session input the
1509 // lost attachment kept, held verbatim and never read as a cancel key.1509 // lost attachment kept, held verbatim and never read as a cancel key.
1510- backlog int1510+ backlog int
1511+ releaseOnce sync.Once
1511 1512
1512 mu sync.Mutex1513 mu sync.Mutex
1513 held []byte1514 held []byte
@@ -1542,13 +1543,13 @@ func (l *terminalInputLifetime) watchResume(clock ports.Clock) *resumeWatch {
1542 close(w.done)1543 close(w.done)
1543 return w1544 return w
1544 }1545 }
1545- id, ok := l.pump.tryClaim()1546+ id, backlog, ok := l.pump.claimWithBacklog()
1546 if !ok {1547 if !ok {
1547 close(w.done)1548 close(w.done)
1548 return w1549 return w
1549 }1550 }
1550 w.id = id1551 w.id = id
1551- w.backlog = l.pump.backlog(id)1552+ w.backlog = backlog
1552 go w.run()1553 go w.run()
1553 return w1554 return w
1554 }1555 }
@@ -1564,6 +1565,7 @@ func (w *resumeWatch) run() {
1564 }1565 }
1565 w.hold(decoder.flush())1566 w.hold(decoder.flush())
1566 }()1567 }()
1568+ stop := w.stop
1567 for {1569 for {
1568 var escapeC <-chan time.Time1570 var escapeC <-chan time.Time
1569 if escape != nil {1571 if escape != nil {
@@ -1574,10 +1576,21 @@ func (w *resumeWatch) run() {
1574 select {1576 select {
1575 case <-pump.readyFor(w.id):1577 case <-pump.readyFor(w.id):
1576 case <-escapeC:1578 case <-escapeC:
1577- close(w.cancelled)1579+ if decoder.loneEscape() {
1578- return1580+ close(w.cancelled)
1579- case <-w.stop:1581+ }
1580 return1582 return
1583+ case <-stop:
1584+ if !decoder.undecided() {
1585+ return
1586+ }
1587+ // Resolve an escape typed just before the phase ended: the
1588+ // next watch would hold it as backlog, so a cancel pressed at
1589+ // the boundary would reach the session instead.
1590+ stop = nil
1591+ if escape == nil {
1592+ escape = w.clock.NewTimer(resumeEscapeDelay)
1593+ }
1581 case <-pump.done:1594 case <-pump.done:
1582 return1595 return
1583 }1596 }
@@ -1607,18 +1620,26 @@ func (w *resumeWatch) run() {
1607 escape.Stop()1620 escape.Stop()
1608 escape = nil1621 escape = nil
1609 }1622 }
1610- if decoder.loneEscape() {1623+ if stop == nil && !decoder.undecided() {
1611- escape = w.clock.NewTimer(50 * time.Millisecond)1624+ return
1625+ }
1626+ if decoder.loneEscape() || stop == nil {
1627+ escape = w.clock.NewTimer(resumeEscapeDelay)
1612 }1628 }
1613 }1629 }
1614 }1630 }
1615 1631
1616 // release stops the watch and hands kept keys back to the pump, unless the1632 // release stops the watch and hands kept keys back to the pump, unless the
1617-// user cancelled, in which case they are dropped with the session.1633+// user cancelled, in which case they are dropped with the session. An escape
1634+// still undecided is resolved first, so it may cancel. Later calls are no-ops.
1618 func (w *resumeWatch) release() {1635 func (w *resumeWatch) release() {
1619 if w.id == 0 {1636 if w.id == 0 {
1620 return1637 return
1621 }1638 }
1639+ w.releaseOnce.Do(w.releaseClaim)
1640+}
1641+
1642+func (w *resumeWatch) releaseClaim() {
1622 close(w.stop)1643 close(w.stop)
1623 <-w.done1644 <-w.done
1624 pump := w.lifetime.pump1645 pump := w.lifetime.pump
M internal/usecase/client/supervisor_attach.go +10 -0
@@ -1011,6 +1011,16 @@ func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetim
10111011 s.transition(supervisorEvent{kind: supervisorAttachEnded})
10121012 return false, nil, true
10131013 case <-timer.C():
1014+ // Release resolves an Esc typed just before the backoff ended,
1015+ // which may still cancel.
1016+ keys.release()
1017+ select {
1018+ case <-keys.cancelled:
1019+ s.logger.Info("client_attachment_resume_cancelled", "attempt", attempt)
1020+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
1021+ return false, nil, true
1022+ default:
1023+ }
10141024 return false, nil, false
10151025 case <-ctx.Done():
10161026 return true, ctx.Err(), false
@@ -1011,6 +1011,16 @@ func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetim
1011 s.transition(supervisorEvent{kind: supervisorAttachEnded})1011 s.transition(supervisorEvent{kind: supervisorAttachEnded})
1012 return false, nil, true1012 return false, nil, true
1013 case <-timer.C():1013 case <-timer.C():
1014+ // Release resolves an Esc typed just before the backoff ended,
1015+ // which may still cancel.
1016+ keys.release()
1017+ select {
1018+ case <-keys.cancelled:
1019+ s.logger.Info("client_attachment_resume_cancelled", "attempt", attempt)
1020+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
1021+ return false, nil, true
1022+ default:
1023+ }
1014 return false, nil, false1024 return false, nil, false
1015 case <-ctx.Done():1025 case <-ctx.Done():
1016 return true, ctx.Err(), false1026 return true, ctx.Err(), false
M internal/usecase/client/terminal_input_pump.go +17 -21
@@ -105,10 +105,25 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
105105
106106 // tryClaim lets competing foreground owners decline admission without panicking.
107107 func (p *terminalInputPump) tryClaim() (uint64, bool) {
108+ consumer, _, ok := p.claimWithBacklog()
109+ return consumer, ok
110+}
111+
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) {
108116 p.mu.Lock()
109117 if p.consumer != 0 {
110118 p.mu.Unlock()
111- return 0, false
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++
112127 }
113128 p.nextID++
114129 p.consumer = p.nextID
@@ -122,26 +137,7 @@ func (p *terminalInputPump) tryClaim() (uint64, bool) {
122137 if ready {
123138 p.signalReady(consumer)
124139 }
125- return consumer, true
126-}
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
140+ return consumer, backlog, true
145141 }
146142
147143 // revoke invalidates an attempt before its replacement is allowed to claim
@@ -105,10 +105,25 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
105 105
106 // tryClaim lets competing foreground owners decline admission without panicking.106 // tryClaim lets competing foreground owners decline admission without panicking.
107 func (p *terminalInputPump) tryClaim() (uint64, bool) {107 func (p *terminalInputPump) tryClaim() (uint64, bool) {
108+ consumer, _, ok := p.claimWithBacklog()
109+ return consumer, ok
110+}
111+
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) {
108 p.mu.Lock()116 p.mu.Lock()
109 if p.consumer != 0 {117 if p.consumer != 0 {
110 p.mu.Unlock()118 p.mu.Unlock()
111- return 0, false119+ return 0, 0, false
120+ }
121+ backlog := 0
122+ if len(p.residual) != 0 {
123+ backlog++
124+ }
125+ if p.pending != nil {
126+ backlog++
112 }127 }
113 p.nextID++128 p.nextID++
114 p.consumer = p.nextID129 p.consumer = p.nextID
@@ -122,26 +137,7 @@ func (p *terminalInputPump) tryClaim() (uint64, bool) {
122 if ready {137 if ready {
123 p.signalReady(consumer)138 p.signalReady(consumer)
124 }139 }
125- return consumer, true140+ return consumer, backlog, true
126-}
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 }141 }
146 142
147 // revoke invalidates an attempt before its replacement is allowed to claim143 // revoke invalidates an attempt before its replacement is allowed to claim

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