detached 4c50de8

fix(daemon): cancel tab opens with session

commit
4c50de83dda29e5c97d67fd16c63324297d2d662
author
brice · · 2026-07-12 14:20 UTC
parent
5f064ea
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +29 -21
M internal/usecase/daemon/session.go +1 -1
@@ -272,7 +272,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
272272 if err != nil {
273273 return err
274274 }
275- pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
275+ pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
276276 if err != nil {
277277 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
278278 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
@@ -272,7 +272,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
272 if err != nil {272 if err != nil {
273 return err273 return err
274 }274 }
275- pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)275+ pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
276 if err != nil {276 if err != nil {
277 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")277 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
278 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)278 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
M internal/usecase/daemon/session_test.go +28 -20
@@ -21,6 +21,11 @@ import (
2121 )
2222
2323 // --- test doubles -----------------------------------------------------------
24+func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize domain.Size, floating ports.PTY) {
25+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
26+ return got != normalSize && got.Valid()
27+ })).Return(floating, nil).Maybe()
28+}
2429
2530 // stubClock returns timers whose channel never fires, so a scheduler under it
2631 // blocks in its debounce loop until the session context is cancelled. Used by
@@ -44,9 +49,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
4449 },
4550 ).Twice()
4651 floating := newQuietPTY()
47- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
48- return got != normalSize && got.Valid()
49- })).Return(floating, nil).Maybe()
52+ expectFloatingPrewarmOpen(f, normalSize, floating)
5053
5154 d := newTestDaemon(t, f, stubClock{})
5255 tr := portsmocks.NewMockTransport(t)
@@ -199,7 +202,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
199202 p1, release1 := newBlockingPTY(t)
200203 defer release1()
201204 p2 := portsmocks.NewMockPTY(t)
202- opened := make(chan struct{})
205+ openCtx := make(chan context.Context, 1)
203206 releaseOpen := make(chan struct{})
204207 closed := make(chan struct{})
205208 p2.EXPECT().Close().RunAndReturn(func() error {
@@ -212,13 +215,14 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
212215 normalSize := domain.Size{Cols: 80, Rows: 22}
213216 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
214217 floating := newQuietPTY()
215- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
216- return got != normalSize && got.Valid()
217- })).Return(floating, nil).Maybe()
218+ expectFloatingPrewarmOpen(f, normalSize, floating)
218219 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
219- func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error) {
220- close(opened)
221- <-releaseOpen
220+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
221+ openCtx <- ctx
222+ select {
223+ case <-ctx.Done():
224+ case <-releaseOpen:
225+ }
222226 return p2, nil
223227 },
224228 ).Once()
@@ -234,10 +238,20 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
234238
235239 errCh := make(chan error, 1)
236240 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()
237- <-opened
241+ ctx := <-openCtx
238242 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
243+
244+ cancelled := true
245+ select {
246+ case <-ctx.Done():
247+ case <-time.After(time.Second):
248+ cancelled = false
249+ }
239250 close(releaseOpen)
240251
252+ if !cancelled {
253+ t.Error("killSession did not cancel the context passed to PTYFactory.Open")
254+ }
241255 require.Error(t, <-errCh)
242256 select {
243257 case <-closed:
@@ -1276,9 +1290,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12761290 },
12771291 ).Twice()
12781292 floating := newQuietPTY()
1279- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1280- return got != normalSize && got.Valid()
1281- })).Return(floating, nil).Maybe()
1293+ expectFloatingPrewarmOpen(f, normalSize, floating)
12821294
12831295 d := newTestDaemon(t, f, stubClock{})
12841296 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
@@ -1321,9 +1333,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
13211333 },
13221334 ).Twice()
13231335 floating := newQuietPTY()
1324- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1325- return got != normalSize && got.Valid()
1326- })).Return(floating, nil).Maybe()
1336+ expectFloatingPrewarmOpen(f, normalSize, floating)
13271337
13281338 d := newTestDaemon(t, f, stubClock{})
13291339 tr1 := portsmocks.NewMockTransport(t)
@@ -1368,9 +1378,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
13681378 },
13691379 ).Twice()
13701380 floating := newQuietPTY()
1371- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1372- return got != normalSize && got.Valid()
1373- })).Return(floating, nil).Maybe()
1381+ expectFloatingPrewarmOpen(f, normalSize, floating)
13741382 d := newTestDaemon(t, f, stubClock{})
13751383 tr := portsmocks.NewMockTransport(t)
13761384 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -21,6 +21,11 @@ import (
21 )21 )
22 22
23 // --- test doubles -----------------------------------------------------------23 // --- test doubles -----------------------------------------------------------
24+func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize domain.Size, floating ports.PTY) {
25+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
26+ return got != normalSize && got.Valid()
27+ })).Return(floating, nil).Maybe()
28+}
24 29
25 // stubClock returns timers whose channel never fires, so a scheduler under it30 // stubClock returns timers whose channel never fires, so a scheduler under it
26 // blocks in its debounce loop until the session context is cancelled. Used by31 // blocks in its debounce loop until the session context is cancelled. Used by
@@ -44,9 +49,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
44 },49 },
45 ).Twice()50 ).Twice()
46 floating := newQuietPTY()51 floating := newQuietPTY()
47- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {52+ expectFloatingPrewarmOpen(f, normalSize, floating)
48- return got != normalSize && got.Valid()
49- })).Return(floating, nil).Maybe()
50 53
51 d := newTestDaemon(t, f, stubClock{})54 d := newTestDaemon(t, f, stubClock{})
52 tr := portsmocks.NewMockTransport(t)55 tr := portsmocks.NewMockTransport(t)
@@ -199,7 +202,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
199 p1, release1 := newBlockingPTY(t)202 p1, release1 := newBlockingPTY(t)
200 defer release1()203 defer release1()
201 p2 := portsmocks.NewMockPTY(t)204 p2 := portsmocks.NewMockPTY(t)
202- opened := make(chan struct{})205+ openCtx := make(chan context.Context, 1)
203 releaseOpen := make(chan struct{})206 releaseOpen := make(chan struct{})
204 closed := make(chan struct{})207 closed := make(chan struct{})
205 p2.EXPECT().Close().RunAndReturn(func() error {208 p2.EXPECT().Close().RunAndReturn(func() error {
@@ -212,13 +215,14 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
212 normalSize := domain.Size{Cols: 80, Rows: 22}215 normalSize := domain.Size{Cols: 80, Rows: 22}
213 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()216 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
214 floating := newQuietPTY()217 floating := newQuietPTY()
215- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {218+ expectFloatingPrewarmOpen(f, normalSize, floating)
216- return got != normalSize && got.Valid()
217- })).Return(floating, nil).Maybe()
218 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(219 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
219- func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error) {220+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
220- close(opened)221+ openCtx <- ctx
221- <-releaseOpen222+ select {
223+ case <-ctx.Done():
224+ case <-releaseOpen:
225+ }
222 return p2, nil226 return p2, nil
223 },227 },
224 ).Once()228 ).Once()
@@ -234,10 +238,20 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
234 238
235 errCh := make(chan error, 1)239 errCh := make(chan error, 1)
236 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()240 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()
237- <-opened241+ ctx := <-openCtx
238 _ = d.killSession(sess, ports.ReasonSessionKilled, false)242 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
243+
244+ cancelled := true
245+ select {
246+ case <-ctx.Done():
247+ case <-time.After(time.Second):
248+ cancelled = false
249+ }
239 close(releaseOpen)250 close(releaseOpen)
240 251
252+ if !cancelled {
253+ t.Error("killSession did not cancel the context passed to PTYFactory.Open")
254+ }
241 require.Error(t, <-errCh)255 require.Error(t, <-errCh)
242 select {256 select {
243 case <-closed:257 case <-closed:
@@ -1276,9 +1290,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1276 },1290 },
1277 ).Twice()1291 ).Twice()
1278 floating := newQuietPTY()1292 floating := newQuietPTY()
1279- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1293+ expectFloatingPrewarmOpen(f, normalSize, floating)
1280- return got != normalSize && got.Valid()
1281- })).Return(floating, nil).Maybe()
1282 1294
1283 d := newTestDaemon(t, f, stubClock{})1295 d := newTestDaemon(t, f, stubClock{})
1284 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}1296 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
@@ -1321,9 +1333,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1321 },1333 },
1322 ).Twice()1334 ).Twice()
1323 floating := newQuietPTY()1335 floating := newQuietPTY()
1324- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1336+ expectFloatingPrewarmOpen(f, normalSize, floating)
1325- return got != normalSize && got.Valid()
1326- })).Return(floating, nil).Maybe()
1327 1337
1328 d := newTestDaemon(t, f, stubClock{})1338 d := newTestDaemon(t, f, stubClock{})
1329 tr1 := portsmocks.NewMockTransport(t)1339 tr1 := portsmocks.NewMockTransport(t)
@@ -1368,9 +1378,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1368 },1378 },
1369 ).Twice()1379 ).Twice()
1370 floating := newQuietPTY()1380 floating := newQuietPTY()
1371- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1381+ expectFloatingPrewarmOpen(f, normalSize, floating)
1372- return got != normalSize && got.Valid()
1373- })).Return(floating, nil).Maybe()
1374 d := newTestDaemon(t, f, stubClock{})1382 d := newTestDaemon(t, f, stubClock{})
1375 tr := portsmocks.NewMockTransport(t)1383 tr := portsmocks.NewMockTransport(t)
1376 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1384 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()

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