detached 80d04fe

fix(client): keep picker input released when an attachment run is cancelled (#303)

A cancelled run could settle through the attachment context (derived from
the parent) before the ctx.Done case was selected. It was then treated as a
plain end, and the deferred teardown handed terminal input back to the
retired picker. Parent cancellation now wins after settlement, and a
terminated run no longer re-acquires the picker. The test asserts ownership
after Run returns so the regression is deterministic.
commit
80d04fec7fbed319a77f2dcda333898a0ca76ca9
author
bnema · · 2026-09-29 19:42 UTC
committer
GitHub · · 2026-09-29 19:42 UTC
parent
b90b5f2
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +18 -1
M internal/usecase/client/supervisor_attach.go +15 -1
@@ -560,7 +560,7 @@ func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminal
560560 return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request, tab: tab}, SessionEnvironmentLocalPicker)
561561 }
562562
563-func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) {
563+func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (terminated bool, termErr error) {
564564 picker := s.cfg.Picker
565565 if picker == nil {
566566 return false, nil
@@ -568,6 +568,11 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
568568 picker.SetOwnsInput(false)
569569 input.releasePicker()
570570 defer func() {
571+ // A terminated run never returns to the picker: handing it input back
572+ // during teardown would let a retired picker consume keys.
573+ if terminated {
574+ return
575+ }
571576 // Whoever owned the terminal since the last picker frame, the next
572577 // frame must draw the whole box again.
573578 s.invalidatePickerPresentation()
@@ -811,6 +816,15 @@ settlement:
811816 }
812817 }
813818
819+ // Parent cancellation wins over a settlement that raced it: the attachment
820+ // context derives from ctx, so cancellation can settle the run before the
821+ // ctx.Done case is selected.
822+ if !terminated {
823+ if cause := ctx.Err(); cause != nil {
824+ terminated, termErr = true, cause
825+ }
826+ }
827+
814828 // The attachment settled, so its foreground (and overlay slot) is gone.
815829 overlay.drop()
816830 if !terminated && !brokerLost {
@@ -560,7 +560,7 @@ func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminal
560 return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request, tab: tab}, SessionEnvironmentLocalPicker)560 return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request, tab: tab}, SessionEnvironmentLocalPicker)
561 }561 }
562 562
563-func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) {563+func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (terminated bool, termErr error) {
564 picker := s.cfg.Picker564 picker := s.cfg.Picker
565 if picker == nil {565 if picker == nil {
566 return false, nil566 return false, nil
@@ -568,6 +568,11 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
568 picker.SetOwnsInput(false)568 picker.SetOwnsInput(false)
569 input.releasePicker()569 input.releasePicker()
570 defer func() {570 defer func() {
571+ // A terminated run never returns to the picker: handing it input back
572+ // during teardown would let a retired picker consume keys.
573+ if terminated {
574+ return
575+ }
571 // Whoever owned the terminal since the last picker frame, the next576 // Whoever owned the terminal since the last picker frame, the next
572 // frame must draw the whole box again.577 // frame must draw the whole box again.
573 s.invalidatePickerPresentation()578 s.invalidatePickerPresentation()
@@ -811,6 +816,15 @@ settlement:
811 }816 }
812 }817 }
813 818
819+ // Parent cancellation wins over a settlement that raced it: the attachment
820+ // context derives from ctx, so cancellation can settle the run before the
821+ // ctx.Done case is selected.
822+ if !terminated {
823+ if cause := ctx.Err(); cause != nil {
824+ terminated, termErr = true, cause
825+ }
826+ }
827+
814 // The attachment settled, so its foreground (and overlay slot) is gone.828 // The attachment settled, so its foreground (and overlay slot) is gone.
815 overlay.drop()829 overlay.drop()
816 if !terminated && !brokerLost {830 if !terminated && !brokerLost {
M internal/usecase/client/supervisor_attach_test.go +3 -0
@@ -1729,6 +1729,9 @@ func TestSupervisorAttachmentConcurrentCommitAndCancellation(t *testing.T) {
17291729 require.Equal(t, resolved, picker.resolveCount(), "revoked picker operations must not resolve")
17301730 require.Equal(t, consumed, picker.consumedCount(), "revoked input must not be consumed")
17311731 require.ErrorIs(t, harness.waitRun(t), context.Canceled)
1732+ // Teardown has fully run: a cancelled attachment must not hand input
1733+ // back to the picker on its way out.
1734+ require.False(t, picker.owns(), "a terminated run must not re-acquire the picker")
17321735 require.True(t, stream.closedNow(), "the admitted stream must be released")
17331736 require.NotEqual(t, PresentAttached, harness.sup.State().Presentation)
17341737 })
@@ -1729,6 +1729,9 @@ func TestSupervisorAttachmentConcurrentCommitAndCancellation(t *testing.T) {
1729 require.Equal(t, resolved, picker.resolveCount(), "revoked picker operations must not resolve")1729 require.Equal(t, resolved, picker.resolveCount(), "revoked picker operations must not resolve")
1730 require.Equal(t, consumed, picker.consumedCount(), "revoked input must not be consumed")1730 require.Equal(t, consumed, picker.consumedCount(), "revoked input must not be consumed")
1731 require.ErrorIs(t, harness.waitRun(t), context.Canceled)1731 require.ErrorIs(t, harness.waitRun(t), context.Canceled)
1732+ // Teardown has fully run: a cancelled attachment must not hand input
1733+ // back to the picker on its way out.
1734+ require.False(t, picker.owns(), "a terminated run must not re-acquire the picker")
1732 require.True(t, stream.closedNow(), "the admitted stream must be released")1735 require.True(t, stream.closedNow(), "the admitted stream must be released")
1733 require.NotEqual(t, PresentAttached, harness.sup.State().Presentation)1736 require.NotEqual(t, PresentAttached, harness.sup.State().Presentation)
1734 })1737 })

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