detached bb3b15d

fix(floating): retain PTY context after launch

commit
bb3b15df4b520aeb414b1aab638c809dca818181
author
brice · · 2026-07-13 10:01 UTC
parent
0a42b0f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +88 -8
M internal/usecase/daemon/floating.go +15 -5
@@ -300,7 +300,7 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi
300300 // before a late Open completion has either been rejected or fully joined.
301301 d.sessWg.Go(func() {
302302 defer sess.finishFloatingLaunch(launch)
303- d.openAndInstallFloating(sess, tb, spec, generation, launch)
303+ d.openAndInstallFloating(sess, tb, spec, generation)
304304 })
305305 }
306306
@@ -357,7 +357,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
357357
358358 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
359359 // occurs under tb.mu; the pane is fully initialized before publication.
360-func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64, launch *floatingLaunch) {
360+func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {
361361 // Open is context-aware and is intentionally called without any daemon,
362362 // session, tab, or launch-ownership lock. Cancellation makes teardown
363363 // bounded even if the adapter is waiting to create the child.
@@ -366,9 +366,12 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
366366 }
367367 openCtx, cancelOpen := context.WithCancel(spec.parentCtx)
368368 stopSessionCancel := context.AfterFunc(sess.ctx, cancelOpen)
369+ ownedByPane := false
369370 defer func() {
370- stopSessionCancel()
371- cancelOpen()
371+ if !ownedByPane {
372+ stopSessionCancel()
373+ cancelOpen()
374+ }
372375 }()
373376 pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size)
374377 if err != nil {
@@ -379,7 +382,14 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
379382 p.rect = spec.geometry.Inner
380383 p.popupGeometry = spec.geometry
381384 p.title.displayFallback = spec.fallback
382- p.ctx, p.cancel = context.WithCancel(spec.parentCtx)
385+ // CommandContext owns the child through openCtx, so retain both this context
386+ // and its cancellation until the installed pane is reaped or torn down.
387+ p.ctx = openCtx
388+ p.cancel = func() {
389+ stopSessionCancel()
390+ cancelOpen()
391+ }
392+ ownedByPane = true
383393 // The reader may run as soon as install returns; make its exit policy
384394 // immutable before publishing the pane to the slot.
385395 p.onExit = func() { d.reapFloating(sess, tb, p, generation) }
@@ -300,7 +300,7 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi
300 // before a late Open completion has either been rejected or fully joined.300 // before a late Open completion has either been rejected or fully joined.
301 d.sessWg.Go(func() {301 d.sessWg.Go(func() {
302 defer sess.finishFloatingLaunch(launch)302 defer sess.finishFloatingLaunch(launch)
303- d.openAndInstallFloating(sess, tb, spec, generation, launch)303+ d.openAndInstallFloating(sess, tb, spec, generation)
304 })304 })
305 }305 }
306 306
@@ -357,7 +357,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
357 357
358 // openAndInstallFloating runs entirely in the launch worker. No PTY operation358 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
359 // occurs under tb.mu; the pane is fully initialized before publication.359 // occurs under tb.mu; the pane is fully initialized before publication.
360-func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64, launch *floatingLaunch) {360+func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {
361 // Open is context-aware and is intentionally called without any daemon,361 // Open is context-aware and is intentionally called without any daemon,
362 // session, tab, or launch-ownership lock. Cancellation makes teardown362 // session, tab, or launch-ownership lock. Cancellation makes teardown
363 // bounded even if the adapter is waiting to create the child.363 // bounded even if the adapter is waiting to create the child.
@@ -366,9 +366,12 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
366 }366 }
367 openCtx, cancelOpen := context.WithCancel(spec.parentCtx)367 openCtx, cancelOpen := context.WithCancel(spec.parentCtx)
368 stopSessionCancel := context.AfterFunc(sess.ctx, cancelOpen)368 stopSessionCancel := context.AfterFunc(sess.ctx, cancelOpen)
369+ ownedByPane := false
369 defer func() {370 defer func() {
370- stopSessionCancel()371+ if !ownedByPane {
371- cancelOpen()372+ stopSessionCancel()
373+ cancelOpen()
374+ }
372 }()375 }()
373 pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size)376 pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size)
374 if err != nil {377 if err != nil {
@@ -379,7 +382,14 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
379 p.rect = spec.geometry.Inner382 p.rect = spec.geometry.Inner
380 p.popupGeometry = spec.geometry383 p.popupGeometry = spec.geometry
381 p.title.displayFallback = spec.fallback384 p.title.displayFallback = spec.fallback
382- p.ctx, p.cancel = context.WithCancel(spec.parentCtx)385+ // CommandContext owns the child through openCtx, so retain both this context
386+ // and its cancellation until the installed pane is reaped or torn down.
387+ p.ctx = openCtx
388+ p.cancel = func() {
389+ stopSessionCancel()
390+ cancelOpen()
391+ }
392+ ownedByPane = true
383 // The reader may run as soon as install returns; make its exit policy393 // The reader may run as soon as install returns; make its exit policy
384 // immutable before publishing the pane to the slot.394 // immutable before publishing the pane to the slot.
385 p.onExit = func() { d.reapFloating(sess, tb, p, generation) }395 p.onExit = func() { d.reapFloating(sess, tb, p, generation) }
M internal/usecase/daemon/floating_open_test.go +73 -3
@@ -54,12 +54,19 @@ func (p *contextAwareFloatingPTY) Close() error {
5454 type contextAwareFloatingFactory struct {
5555 pty *contextAwareFloatingPTY
5656 opened chan context.Context
57+ err error
58+ onOpen func()
5759 }
5860
5961 func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
60- f.pty.ctx = ctx
62+ if f.pty != nil {
63+ f.pty.ctx = ctx
64+ }
6165 f.opened <- ctx
62- return f.pty, nil
66+ if f.onOpen != nil {
67+ f.onOpen()
68+ }
69+ return f.pty, f.err
6370 }
6471
6572 func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
@@ -109,7 +116,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
109116 geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
110117 paneStableID: "p_floating",
111118 parentCtx: tabCtx,
112- }, generation, nil)
119+ }, generation)
113120
114121 openCtx := <-factory.opened
115122 require.NoError(t, openCtx.Err(), "the PTY context must outlive the launch worker")
@@ -147,6 +154,69 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
147154 }
148155 }
149156
157+func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) {
158+ tests := []struct {
159+ name string
160+ setup func(*tab, *contextAwareFloatingFactory)
161+ }{
162+ {
163+ name: "failed Open",
164+ setup: func(_ *tab, factory *contextAwareFloatingFactory) {
165+ factory.err = context.Canceled
166+ },
167+ },
168+ {
169+ name: "stale install",
170+ setup: func(tb *tab, factory *contextAwareFloatingFactory) {
171+ factory.onOpen = func() {
172+ tb.mu.Lock()
173+ tb.takeFloatingLocked()
174+ tb.mu.Unlock()
175+ }
176+ },
177+ },
178+ }
179+ for _, tt := range tests {
180+ t.Run(tt.name, func(t *testing.T) {
181+ pty := newContextAwareFloatingPTY()
182+ factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)}
183+ d := newTestDaemon(t, factory, stubClock{})
184+ tb := newFloatingTestTab(t)
185+ sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()}
186+ tb.mu.Lock()
187+ generation := tb.beginFloatingWarmLocked(true)
188+ tb.mu.Unlock()
189+ tt.setup(tb, factory)
190+
191+ d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
192+ sessionName: "work",
193+ size: domain.Size{Cols: 20, Rows: 8},
194+ geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
195+ paneStableID: "p_floating",
196+ parentCtx: tb.ctx,
197+ }, generation)
198+
199+ openCtx := <-factory.opened
200+ select {
201+ case <-openCtx.Done():
202+ case <-time.After(time.Second):
203+ t.Fatal("failed or stale floating Open retained its context")
204+ }
205+ tb.mu.Lock()
206+ require.Nil(t, tb.floating.pane)
207+ tb.mu.Unlock()
208+ if tt.name == "stale install" {
209+ select {
210+ case <-pty.closed:
211+ case <-time.After(time.Second):
212+ t.Fatal("stale floating PTY was not closed")
213+ }
214+ require.Equal(t, 1, pty.closeCount)
215+ }
216+ })
217+ }
218+}
219+
150220 func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) {
151221 factory := portsmocks.NewMockPTYFactory(t)
152222 opened := make(chan struct{})
@@ -54,12 +54,19 @@ func (p *contextAwareFloatingPTY) Close() error {
54 type contextAwareFloatingFactory struct {54 type contextAwareFloatingFactory struct {
55 pty *contextAwareFloatingPTY55 pty *contextAwareFloatingPTY
56 opened chan context.Context56 opened chan context.Context
57+ err error
58+ onOpen func()
57 }59 }
58 60
59 func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {61 func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
60- f.pty.ctx = ctx62+ if f.pty != nil {
63+ f.pty.ctx = ctx
64+ }
61 f.opened <- ctx65 f.opened <- ctx
62- return f.pty, nil66+ if f.onOpen != nil {
67+ f.onOpen()
68+ }
69+ return f.pty, f.err
63 }70 }
64 71
65 func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {72 func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
@@ -109,7 +116,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
109 geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},116 geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
110 paneStableID: "p_floating",117 paneStableID: "p_floating",
111 parentCtx: tabCtx,118 parentCtx: tabCtx,
112- }, generation, nil)119+ }, generation)
113 120
114 openCtx := <-factory.opened121 openCtx := <-factory.opened
115 require.NoError(t, openCtx.Err(), "the PTY context must outlive the launch worker")122 require.NoError(t, openCtx.Err(), "the PTY context must outlive the launch worker")
@@ -147,6 +154,69 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
147 }154 }
148 }155 }
149 156
157+func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) {
158+ tests := []struct {
159+ name string
160+ setup func(*tab, *contextAwareFloatingFactory)
161+ }{
162+ {
163+ name: "failed Open",
164+ setup: func(_ *tab, factory *contextAwareFloatingFactory) {
165+ factory.err = context.Canceled
166+ },
167+ },
168+ {
169+ name: "stale install",
170+ setup: func(tb *tab, factory *contextAwareFloatingFactory) {
171+ factory.onOpen = func() {
172+ tb.mu.Lock()
173+ tb.takeFloatingLocked()
174+ tb.mu.Unlock()
175+ }
176+ },
177+ },
178+ }
179+ for _, tt := range tests {
180+ t.Run(tt.name, func(t *testing.T) {
181+ pty := newContextAwareFloatingPTY()
182+ factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)}
183+ d := newTestDaemon(t, factory, stubClock{})
184+ tb := newFloatingTestTab(t)
185+ sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()}
186+ tb.mu.Lock()
187+ generation := tb.beginFloatingWarmLocked(true)
188+ tb.mu.Unlock()
189+ tt.setup(tb, factory)
190+
191+ d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
192+ sessionName: "work",
193+ size: domain.Size{Cols: 20, Rows: 8},
194+ geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
195+ paneStableID: "p_floating",
196+ parentCtx: tb.ctx,
197+ }, generation)
198+
199+ openCtx := <-factory.opened
200+ select {
201+ case <-openCtx.Done():
202+ case <-time.After(time.Second):
203+ t.Fatal("failed or stale floating Open retained its context")
204+ }
205+ tb.mu.Lock()
206+ require.Nil(t, tb.floating.pane)
207+ tb.mu.Unlock()
208+ if tt.name == "stale install" {
209+ select {
210+ case <-pty.closed:
211+ case <-time.After(time.Second):
212+ t.Fatal("stale floating PTY was not closed")
213+ }
214+ require.Equal(t, 1, pty.closeCount)
215+ }
216+ })
217+ }
218+}
219+
150 func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) {220 func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) {
151 factory := portsmocks.NewMockPTYFactory(t)221 factory := portsmocks.NewMockPTYFactory(t)
152 opened := make(chan struct{})222 opened := make(chan struct{})

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