detached 037ef43

fix(daemon): cancel floating PTY opens during teardown

commit
037ef43febdf4f76b546fb1d3e43aefb143e1ce0
author
brice · · 2026-07-12 12:20 UTC
parent
75a1afd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +204 -146
M internal/adapters/pty/pty_linux.go +6 -2
@@ -8,6 +8,7 @@ package pty
88 // x/sys/unix plus the standard library.
99
1010 import (
11+ "context"
1112 "errors"
1213 "fmt"
1314 "io"
@@ -38,7 +39,10 @@ func NewFactory() *Factory { return &Factory{} }
3839 // verbatim (nil means inherit the current process environment); the caller
3940 // decides TERM and friends. sz sets the terminal window size before the child
4041 // starts, so the child observes the correct dimensions on its first query.
41-func (Factory) Open(command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
42+func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
43+ if err := ctx.Err(); err != nil {
44+ return nil, err
45+ }
4246 masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
4347 if err != nil {
4448 return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)
@@ -79,7 +83,7 @@ func (Factory) Open(command string, args []string, env []string, dir string, sz
7983 }
8084 }
8185
82- cmd := exec.Command(command, args...)
86+ cmd := exec.CommandContext(ctx, command, args...)
8387 cmd.Env = env
8488 cmd.Dir = platform.DirOrHome(dir)
8589 cmd.Stdin = slave
@@ -8,6 +8,7 @@ package pty
8 // x/sys/unix plus the standard library.8 // x/sys/unix plus the standard library.
9 9
10 import (10 import (
11+ "context"
11 "errors"12 "errors"
12 "fmt"13 "fmt"
13 "io"14 "io"
@@ -38,7 +39,10 @@ func NewFactory() *Factory { return &Factory{} }
38 // verbatim (nil means inherit the current process environment); the caller39 // verbatim (nil means inherit the current process environment); the caller
39 // decides TERM and friends. sz sets the terminal window size before the child40 // decides TERM and friends. sz sets the terminal window size before the child
40 // starts, so the child observes the correct dimensions on its first query.41 // starts, so the child observes the correct dimensions on its first query.
41-func (Factory) Open(command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {42+func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
43+ if err := ctx.Err(); err != nil {
44+ return nil, err
45+ }
42 masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)46 masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
43 if err != nil {47 if err != nil {
44 return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)48 return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)
@@ -79,7 +83,7 @@ func (Factory) Open(command string, args []string, env []string, dir string, sz
79 }83 }
80 }84 }
81 85
82- cmd := exec.Command(command, args...)86+ cmd := exec.CommandContext(ctx, command, args...)
83 cmd.Env = env87 cmd.Env = env
84 cmd.Dir = platform.DirOrHome(dir)88 cmd.Dir = platform.DirOrHome(dir)
85 cmd.Stdin = slave89 cmd.Stdin = slave
M internal/adapters/pty/pty_linux_test.go +8 -7
@@ -4,6 +4,7 @@ package pty_test
44
55 import (
66 "bytes"
7+ "context"
78 "errors"
89 "io"
910 "os"
@@ -53,7 +54,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) {
5354 t.Skip("skipping pty integration test in -short mode")
5455 }
5556 f := newFactory()
56- p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
57+ p, err := f.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
5758 require.NoError(t, err)
5859 t.Cleanup(func() { _ = p.Close() })
5960
@@ -70,7 +71,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
7071 t.Skip("skipping pty integration test in -short mode")
7172 }
7273 f := newFactory()
73- p, err := f.Open("cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
74+ p, err := f.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
7475 require.NoError(t, err)
7576 t.Cleanup(func() { _ = p.Close() })
7677
@@ -103,7 +104,7 @@ func TestForegroundPgid(t *testing.T) {
103104 t.Skip("skipping pty integration test in -short mode")
104105 }
105106 f := newFactory()
106- p, err := f.Open("sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
107+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
107108 require.NoError(t, err)
108109 t.Cleanup(func() { _ = p.Close() })
109110
@@ -141,7 +142,7 @@ func TestResize_SttySize(t *testing.T) {
141142 for _, tt := range tests {
142143 t.Run(tt.name, func(t *testing.T) {
143144 f := newFactory()
144- p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)
145+ p, err := f.Open(context.Background(), "sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)
145146 require.NoError(t, err)
146147 t.Cleanup(func() { _ = p.Close() })
147148
@@ -160,7 +161,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) {
160161 t.Skip("skipping pty integration test in -short mode")
161162 }
162163 f := newFactory()
163- p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
164+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
164165 require.NoError(t, err)
165166
166167 pid := p.Pid()
@@ -191,7 +192,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) {
191192 t.Skip("skipping pty integration test in -short mode")
192193 }
193194 f := newFactory()
194- p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
195+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
195196 require.NoError(t, err)
196197 pid := p.Pid()
197198
@@ -212,7 +213,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
212213 // deliver SIGWINCH to the foreground process group.
213214 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`
214215 f := newFactory()
215- p, err := f.Open("sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
216+ p, err := f.Open(context.Background(), "sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
216217 require.NoError(t, err)
217218 t.Cleanup(func() { _ = p.Close() })
218219
@@ -4,6 +4,7 @@ package pty_test
4 4
5 import (5 import (
6 "bytes"6 "bytes"
7+ "context"
7 "errors"8 "errors"
8 "io"9 "io"
9 "os"10 "os"
@@ -53,7 +54,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) {
53 t.Skip("skipping pty integration test in -short mode")54 t.Skip("skipping pty integration test in -short mode")
54 }55 }
55 f := newFactory()56 f := newFactory()
56- p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})57+ p, err := f.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
57 require.NoError(t, err)58 require.NoError(t, err)
58 t.Cleanup(func() { _ = p.Close() })59 t.Cleanup(func() { _ = p.Close() })
59 60
@@ -70,7 +71,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
70 t.Skip("skipping pty integration test in -short mode")71 t.Skip("skipping pty integration test in -short mode")
71 }72 }
72 f := newFactory()73 f := newFactory()
73- p, err := f.Open("cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})74+ p, err := f.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
74 require.NoError(t, err)75 require.NoError(t, err)
75 t.Cleanup(func() { _ = p.Close() })76 t.Cleanup(func() { _ = p.Close() })
76 77
@@ -103,7 +104,7 @@ func TestForegroundPgid(t *testing.T) {
103 t.Skip("skipping pty integration test in -short mode")104 t.Skip("skipping pty integration test in -short mode")
104 }105 }
105 f := newFactory()106 f := newFactory()
106- p, err := f.Open("sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})107+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
107 require.NoError(t, err)108 require.NoError(t, err)
108 t.Cleanup(func() { _ = p.Close() })109 t.Cleanup(func() { _ = p.Close() })
109 110
@@ -141,7 +142,7 @@ func TestResize_SttySize(t *testing.T) {
141 for _, tt := range tests {142 for _, tt := range tests {
142 t.Run(tt.name, func(t *testing.T) {143 t.Run(tt.name, func(t *testing.T) {
143 f := newFactory()144 f := newFactory()
144- p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)145+ p, err := f.Open(context.Background(), "sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)
145 require.NoError(t, err)146 require.NoError(t, err)
146 t.Cleanup(func() { _ = p.Close() })147 t.Cleanup(func() { _ = p.Close() })
147 148
@@ -160,7 +161,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) {
160 t.Skip("skipping pty integration test in -short mode")161 t.Skip("skipping pty integration test in -short mode")
161 }162 }
162 f := newFactory()163 f := newFactory()
163- p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})164+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
164 require.NoError(t, err)165 require.NoError(t, err)
165 166
166 pid := p.Pid()167 pid := p.Pid()
@@ -191,7 +192,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) {
191 t.Skip("skipping pty integration test in -short mode")192 t.Skip("skipping pty integration test in -short mode")
192 }193 }
193 f := newFactory()194 f := newFactory()
194- p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})195+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
195 require.NoError(t, err)196 require.NoError(t, err)
196 pid := p.Pid()197 pid := p.Pid()
197 198
@@ -212,7 +213,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
212 // deliver SIGWINCH to the foreground process group.213 // deliver SIGWINCH to the foreground process group.
213 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`214 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`
214 f := newFactory()215 f := newFactory()
215- p, err := f.Open("sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})216+ p, err := f.Open(context.Background(), "sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
216 require.NoError(t, err)217 require.NoError(t, err)
217 t.Cleanup(func() { _ = p.Close() })218 t.Cleanup(func() { _ = p.Close() })
218 219
M internal/ports/mocks/mocks.go +26 -20
@@ -499,8 +499,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter {
499499 }
500500
501501 // Open provides a mock function for the type MockPTYFactory
502-func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
503- ret := _mock.Called(cmd, args, env, dir, sz)
502+func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
503+ ret := _mock.Called(ctx, cmd, args, env, dir, sz)
504504
505505 if len(ret) == 0 {
506506 panic("no return value specified for Open")
@@ -508,18 +508,18 @@ func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir s
508508
509509 var r0 ports.PTY
510510 var r1 error
511- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
512- return returnFunc(cmd, args, env, dir, sz)
511+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
512+ return returnFunc(ctx, cmd, args, env, dir, sz)
513513 }
514- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) ports.PTY); ok {
515- r0 = returnFunc(cmd, args, env, dir, sz)
514+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) ports.PTY); ok {
515+ r0 = returnFunc(ctx, cmd, args, env, dir, sz)
516516 } else {
517517 if ret.Get(0) != nil {
518518 r0 = ret.Get(0).(ports.PTY)
519519 }
520520 }
521- if returnFunc, ok := ret.Get(1).(func(string, []string, []string, string, domain.Size) error); ok {
522- r1 = returnFunc(cmd, args, env, dir, sz)
521+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Size) error); ok {
522+ r1 = returnFunc(ctx, cmd, args, env, dir, sz)
523523 } else {
524524 r1 = ret.Error(1)
525525 }
@@ -532,36 +532,41 @@ type MockPTYFactory_Open_Call struct {
532532 }
533533
534534 // Open is a helper method to define mock.On call
535+// - ctx context.Context
535536 // - cmd string
536537 // - args []string
537538 // - env []string
538539 // - dir string
539540 // - sz domain.Size
540-func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {
541- return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, dir, sz)}
541+func (_e *MockPTYFactory_Expecter) Open(ctx any, cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {
542+ return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, sz)}
542543 }
543544
544-func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {
545+func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {
545546 _c.Call.Run(func(args mock.Arguments) {
546- var arg0 string
547+ var arg0 context.Context
547548 if args[0] != nil {
548- arg0 = args[0].(string)
549+ arg0 = args[0].(context.Context)
549550 }
550- var arg1 []string
551+ var arg1 string
551552 if args[1] != nil {
552- arg1 = args[1].([]string)
553+ arg1 = args[1].(string)
553554 }
554555 var arg2 []string
555556 if args[2] != nil {
556557 arg2 = args[2].([]string)
557558 }
558- var arg3 string
559+ var arg3 []string
559560 if args[3] != nil {
560- arg3 = args[3].(string)
561+ arg3 = args[3].([]string)
561562 }
562- var arg4 domain.Size
563+ var arg4 string
563564 if args[4] != nil {
564- arg4 = args[4].(domain.Size)
565+ arg4 = args[4].(string)
566+ }
567+ var arg5 domain.Size
568+ if args[5] != nil {
569+ arg5 = args[5].(domain.Size)
565570 }
566571 run(
567572 arg0,
@@ -569,6 +574,7 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env
569574 arg2,
570575 arg3,
571576 arg4,
577+ arg5,
572578 )
573579 })
574580 return _c
@@ -579,7 +585,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac
579585 return _c
580586 }
581587
582-func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
588+func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
583589 _c.Call.Return(run)
584590 return _c
585591 }
@@ -499,8 +499,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter {
499 }499 }
500 500
501 // Open provides a mock function for the type MockPTYFactory501 // Open provides a mock function for the type MockPTYFactory
502-func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {502+func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
503- ret := _mock.Called(cmd, args, env, dir, sz)503+ ret := _mock.Called(ctx, cmd, args, env, dir, sz)
504 504
505 if len(ret) == 0 {505 if len(ret) == 0 {
506 panic("no return value specified for Open")506 panic("no return value specified for Open")
@@ -508,18 +508,18 @@ func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir s
508 508
509 var r0 ports.PTY509 var r0 ports.PTY
510 var r1 error510 var r1 error
511- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {511+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
512- return returnFunc(cmd, args, env, dir, sz)512+ return returnFunc(ctx, cmd, args, env, dir, sz)
513 }513 }
514- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) ports.PTY); ok {514+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) ports.PTY); ok {
515- r0 = returnFunc(cmd, args, env, dir, sz)515+ r0 = returnFunc(ctx, cmd, args, env, dir, sz)
516 } else {516 } else {
517 if ret.Get(0) != nil {517 if ret.Get(0) != nil {
518 r0 = ret.Get(0).(ports.PTY)518 r0 = ret.Get(0).(ports.PTY)
519 }519 }
520 }520 }
521- if returnFunc, ok := ret.Get(1).(func(string, []string, []string, string, domain.Size) error); ok {521+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Size) error); ok {
522- r1 = returnFunc(cmd, args, env, dir, sz)522+ r1 = returnFunc(ctx, cmd, args, env, dir, sz)
523 } else {523 } else {
524 r1 = ret.Error(1)524 r1 = ret.Error(1)
525 }525 }
@@ -532,36 +532,41 @@ type MockPTYFactory_Open_Call struct {
532 }532 }
533 533
534 // Open is a helper method to define mock.On call534 // Open is a helper method to define mock.On call
535+// - ctx context.Context
535 // - cmd string536 // - cmd string
536 // - args []string537 // - args []string
537 // - env []string538 // - env []string
538 // - dir string539 // - dir string
539 // - sz domain.Size540 // - sz domain.Size
540-func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {541+func (_e *MockPTYFactory_Expecter) Open(ctx any, cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {
541- return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, dir, sz)}542+ return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, sz)}
542 }543 }
543 544
544-func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {545+func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {
545 _c.Call.Run(func(args mock.Arguments) {546 _c.Call.Run(func(args mock.Arguments) {
546- var arg0 string547+ var arg0 context.Context
547 if args[0] != nil {548 if args[0] != nil {
548- arg0 = args[0].(string)549+ arg0 = args[0].(context.Context)
549 }550 }
550- var arg1 []string551+ var arg1 string
551 if args[1] != nil {552 if args[1] != nil {
552- arg1 = args[1].([]string)553+ arg1 = args[1].(string)
553 }554 }
554 var arg2 []string555 var arg2 []string
555 if args[2] != nil {556 if args[2] != nil {
556 arg2 = args[2].([]string)557 arg2 = args[2].([]string)
557 }558 }
558- var arg3 string559+ var arg3 []string
559 if args[3] != nil {560 if args[3] != nil {
560- arg3 = args[3].(string)561+ arg3 = args[3].([]string)
561 }562 }
562- var arg4 domain.Size563+ var arg4 string
563 if args[4] != nil {564 if args[4] != nil {
564- arg4 = args[4].(domain.Size)565+ arg4 = args[4].(string)
566+ }
567+ var arg5 domain.Size
568+ if args[5] != nil {
569+ arg5 = args[5].(domain.Size)
565 }570 }
566 run(571 run(
567 arg0,572 arg0,
@@ -569,6 +574,7 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env
569 arg2,574 arg2,
570 arg3,575 arg3,
571 arg4,576 arg4,
577+ arg5,
572 )578 )
573 })579 })
574 return _c580 return _c
@@ -579,7 +585,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac
579 return _c585 return _c
580 }586 }
581 587
582-func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {588+func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
583 _c.Call.Return(run)589 _c.Call.Return(run)
584 return _c590 return _c
585 }591 }
M internal/ports/ports.go +3 -1
@@ -24,7 +24,9 @@ type PTY interface {
2424
2525 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
2626 type PTYFactory interface {
27- Open(cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
27+ // Open creates a PTY while honoring ctx cancellation. Implementations must
28+ // return promptly once ctx is cancelled and must not leave a child running.
29+ Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
2830 }
2931
3032 // Terminal is the CLIENT-side controlling terminal.
@@ -24,7 +24,9 @@ type PTY interface {
24 24
25 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.25 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
26 type PTYFactory interface {26 type PTYFactory interface {
27- Open(cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)27+ // Open creates a PTY while honoring ctx cancellation. Implementations must
28+ // return promptly once ctx is cancelled and must not leave a child running.
29+ Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
28 }30 }
29 31
30 // Terminal is the CLIENT-side controlling terminal.32 // Terminal is the CLIENT-side controlling terminal.
M internal/usecase/daemon/daemon.go +0 -10
@@ -22,7 +22,6 @@ import (
2222 // sustained floods step toward the maximum to reduce frame/syscall pressure.
2323 const (
2424 minDebounceInterval = 2 * time.Millisecond
25- maxDebounceInterval = 16 * time.Millisecond
2625 maxSyncUpdateDuration = 500 * time.Millisecond
2726 )
2827
@@ -74,10 +73,6 @@ type Daemon struct {
7473 // under the same mutex — so a Hello racing shutdown can never insert a new
7574 // session that nobody would tear down.
7675 closing bool
77- // floatingLaunchGate serializes the irreversible shutdown decision with the
78- // final transition into PTYFactory.Open. It is intentionally not d.mu: no
79- // daemon, session, or tab lock is held while an external Open can block.
80- floatingLaunchGate sync.Mutex
8176 // notifies holds one completion channel per in-flight async Detached
8277 // notification (guarded by mu, pruned on insert). Channels rather than a
8378 // WaitGroup: notifications are spawned from arbitrary goroutines while
@@ -423,10 +418,6 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
423418 // inserted after the snapshot: route rejects once closing is set, and both run
424419 // under d.mu. killSession (which relocks) runs after the lock is released.
425420 func (d *Daemon) shutdownAll(reason uint8) {
426- // Take the launch gate before publishing closing. A launch either entered
427- // Open before this point (and is subsequently reaped), or observes closing
428- // and never starts an external child.
429- d.floatingLaunchGate.Lock()
430421 d.mu.Lock()
431422 d.closing = true
432423 for token, parked := range d.parked {
@@ -435,7 +426,6 @@ func (d *Daemon) shutdownAll(reason uint8) {
435426 snapshot := d.sessionsSnapshotLocked()
436427 empty := len(snapshot) == 0
437428 d.mu.Unlock()
438- d.floatingLaunchGate.Unlock()
439429 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
440430 if empty {
441431 d.doneOnce.Do(func() { close(d.done) })
@@ -22,7 +22,6 @@ import (
22 // sustained floods step toward the maximum to reduce frame/syscall pressure.22 // sustained floods step toward the maximum to reduce frame/syscall pressure.
23 const (23 const (
24 minDebounceInterval = 2 * time.Millisecond24 minDebounceInterval = 2 * time.Millisecond
25- maxDebounceInterval = 16 * time.Millisecond
26 maxSyncUpdateDuration = 500 * time.Millisecond25 maxSyncUpdateDuration = 500 * time.Millisecond
27 )26 )
28 27
@@ -74,10 +73,6 @@ type Daemon struct {
74 // under the same mutex — so a Hello racing shutdown can never insert a new73 // under the same mutex — so a Hello racing shutdown can never insert a new
75 // session that nobody would tear down.74 // session that nobody would tear down.
76 closing bool75 closing bool
77- // floatingLaunchGate serializes the irreversible shutdown decision with the
78- // final transition into PTYFactory.Open. It is intentionally not d.mu: no
79- // daemon, session, or tab lock is held while an external Open can block.
80- floatingLaunchGate sync.Mutex
81 // notifies holds one completion channel per in-flight async Detached76 // notifies holds one completion channel per in-flight async Detached
82 // notification (guarded by mu, pruned on insert). Channels rather than a77 // notification (guarded by mu, pruned on insert). Channels rather than a
83 // WaitGroup: notifications are spawned from arbitrary goroutines while78 // WaitGroup: notifications are spawned from arbitrary goroutines while
@@ -423,10 +418,6 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
423 // inserted after the snapshot: route rejects once closing is set, and both run418 // inserted after the snapshot: route rejects once closing is set, and both run
424 // under d.mu. killSession (which relocks) runs after the lock is released.419 // under d.mu. killSession (which relocks) runs after the lock is released.
425 func (d *Daemon) shutdownAll(reason uint8) {420 func (d *Daemon) shutdownAll(reason uint8) {
426- // Take the launch gate before publishing closing. A launch either entered
427- // Open before this point (and is subsequently reaped), or observes closing
428- // and never starts an external child.
429- d.floatingLaunchGate.Lock()
430 d.mu.Lock()421 d.mu.Lock()
431 d.closing = true422 d.closing = true
432 for token, parked := range d.parked {423 for token, parked := range d.parked {
@@ -435,7 +426,6 @@ func (d *Daemon) shutdownAll(reason uint8) {
435 snapshot := d.sessionsSnapshotLocked()426 snapshot := d.sessionsSnapshotLocked()
436 empty := len(snapshot) == 0427 empty := len(snapshot) == 0
437 d.mu.Unlock()428 d.mu.Unlock()
438- d.floatingLaunchGate.Unlock()
439 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))429 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
440 if empty {430 if empty {
441 d.doneOnce.Do(func() { close(d.done) })431 d.doneOnce.Do(func() { close(d.done) })
M internal/usecase/daemon/daemon_test.go +5 -5
@@ -142,7 +142,7 @@ func newBlockingOpenFactory(t *testing.T, d *Daemon) *blockingOpenFactory {
142142 return f
143143 }
144144
145-func (f *blockingOpenFactory) Open(string, []string, []string, string, domain.Size) (ports.PTY, error) {
145+func (f *blockingOpenFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
146146 <-f.release
147147 return nil, io.ErrClosedPipe
148148 }
@@ -154,8 +154,8 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
154154 t.Helper()
155155 f := portsmocks.NewMockPTYFactory(t)
156156 var normal domain.Size
157- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
158- func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
157+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
158+ func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
159159 if !normal.Valid() {
160160 normal = sz
161161 }
@@ -174,8 +174,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
174174 var mu sync.Mutex
175175 var normal domain.Size
176176 next := 0
177- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
178- func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
177+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
178+ func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
179179 mu.Lock()
180180 defer mu.Unlock()
181181 if !normal.Valid() {
@@ -142,7 +142,7 @@ func newBlockingOpenFactory(t *testing.T, d *Daemon) *blockingOpenFactory {
142 return f142 return f
143 }143 }
144 144
145-func (f *blockingOpenFactory) Open(string, []string, []string, string, domain.Size) (ports.PTY, error) {145+func (f *blockingOpenFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
146 <-f.release146 <-f.release
147 return nil, io.ErrClosedPipe147 return nil, io.ErrClosedPipe
148 }148 }
@@ -154,8 +154,8 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
154 t.Helper()154 t.Helper()
155 f := portsmocks.NewMockPTYFactory(t)155 f := portsmocks.NewMockPTYFactory(t)
156 var normal domain.Size156 var normal domain.Size
157- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(157+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
158- func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {158+ func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
159 if !normal.Valid() {159 if !normal.Valid() {
160 normal = sz160 normal = sz
161 }161 }
@@ -174,8 +174,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
174 var mu sync.Mutex174 var mu sync.Mutex
175 var normal domain.Size175 var normal domain.Size
176 next := 0176 next := 0
177- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(177+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
178- func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {178+ func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
179 mu.Lock()179 mu.Lock()
180 defer mu.Unlock()180 defer mu.Unlock()
181 if !normal.Valid() {181 if !normal.Valid() {
M internal/usecase/daemon/floating.go +12 -44
@@ -7,7 +7,6 @@ import (
77 "strings"
88
99 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
1110 "github.com/bnema/vev/internal/usecase/layout"
1211 )
1312
@@ -246,18 +245,6 @@ func (sess *session) registerFloatingLaunch() (*floatingLaunch, bool) {
246245 return launch, true
247246 }
248247
249-// openFloating runs fn while only the dedicated launch-ownership lock is
250-// held. It deliberately does not hold sess.mu, tb.mu, or d.mu across Open.
251-func (sess *session) openFloating(fn func() (ports.PTY, error)) (ports.PTY, error, bool) {
252- sess.floatingLaunchMu.Lock()
253- defer sess.floatingLaunchMu.Unlock()
254- if sess.floatingLaunchStopping {
255- return nil, nil, false
256- }
257- pty, err := fn()
258- return pty, err, true
259-}
260-
261248 func (sess *session) finishFloatingLaunch(launch *floatingLaunch) {
262249 sess.floatingLaunchMu.Lock()
263250 delete(sess.floatingLaunches, launch)
@@ -265,15 +252,10 @@ func (sess *session) finishFloatingLaunch(launch *floatingLaunch) {
265252 sess.floatingLaunchMu.Unlock()
266253 }
267254
268-func (sess *session) stopFloatingLaunches() []<-chan struct{} {
255+func (sess *session) stopFloatingLaunches() {
269256 sess.floatingLaunchMu.Lock()
270257 sess.floatingLaunchStopping = true
271- done := make([]<-chan struct{}, 0, len(sess.floatingLaunches))
272- for launch := range sess.floatingLaunches {
273- done = append(done, launch.done)
274- }
275258 sess.floatingLaunchMu.Unlock()
276- return done
277259 }
278260
279261 type floatingLaunchSpec struct {
@@ -366,33 +348,19 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
366348 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
367349 // occurs under tb.mu; the pane is fully initialized before publication.
368350 func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64, launch *floatingLaunch) {
369- // The gate makes the final ownership check and external Open one shutdown
370- // epoch: shutdown cannot become irreversible between them. Neither d.mu,
371- // sess.mu, nor tb.mu is held while the factory can block.
372- d.floatingLaunchGate.Lock()
373- select {
374- case <-spec.parentCtx.Done():
375- d.floatingLaunchGate.Unlock()
376- return
377- default:
378- }
379- d.mu.Lock()
380- // Unit-scoped launch paths may use a session without registering it in the
381- // daemon map. Production sessions have an ID and must still be owned by
382- // the map until teardown removes them.
383- live := !d.closing && (sess.id == "" || d.sessions[sess.id] == sess)
384- d.mu.Unlock()
385- if !live {
386- d.floatingLaunchGate.Unlock()
387- return
388- }
389- pty, err, opened := sess.openFloating(func() (ports.PTY, error) {
390- return d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)
391- })
392- d.floatingLaunchGate.Unlock()
393- if !opened {
351+ // Open is context-aware and is intentionally called without any daemon,
352+ // session, tab, or launch-ownership lock. Cancellation makes teardown
353+ // bounded even if the adapter is waiting to create the child.
354+ if err := spec.parentCtx.Err(); err != nil {
394355 return
395356 }
357+ openCtx, cancelOpen := context.WithCancel(spec.parentCtx)
358+ stopSessionCancel := context.AfterFunc(sess.ctx, cancelOpen)
359+ defer func() {
360+ stopSessionCancel()
361+ cancelOpen()
362+ }()
363+ pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size)
396364 if err != nil {
397365 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)
398366 return
@@ -7,7 +7,6 @@ import (
7 "strings"7 "strings"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
12 )11 )
13 12
@@ -246,18 +245,6 @@ func (sess *session) registerFloatingLaunch() (*floatingLaunch, bool) {
246 return launch, true245 return launch, true
247 }246 }
248 247
249-// openFloating runs fn while only the dedicated launch-ownership lock is
250-// held. It deliberately does not hold sess.mu, tb.mu, or d.mu across Open.
251-func (sess *session) openFloating(fn func() (ports.PTY, error)) (ports.PTY, error, bool) {
252- sess.floatingLaunchMu.Lock()
253- defer sess.floatingLaunchMu.Unlock()
254- if sess.floatingLaunchStopping {
255- return nil, nil, false
256- }
257- pty, err := fn()
258- return pty, err, true
259-}
260-
261 func (sess *session) finishFloatingLaunch(launch *floatingLaunch) {248 func (sess *session) finishFloatingLaunch(launch *floatingLaunch) {
262 sess.floatingLaunchMu.Lock()249 sess.floatingLaunchMu.Lock()
263 delete(sess.floatingLaunches, launch)250 delete(sess.floatingLaunches, launch)
@@ -265,15 +252,10 @@ func (sess *session) finishFloatingLaunch(launch *floatingLaunch) {
265 sess.floatingLaunchMu.Unlock()252 sess.floatingLaunchMu.Unlock()
266 }253 }
267 254
268-func (sess *session) stopFloatingLaunches() []<-chan struct{} {255+func (sess *session) stopFloatingLaunches() {
269 sess.floatingLaunchMu.Lock()256 sess.floatingLaunchMu.Lock()
270 sess.floatingLaunchStopping = true257 sess.floatingLaunchStopping = true
271- done := make([]<-chan struct{}, 0, len(sess.floatingLaunches))
272- for launch := range sess.floatingLaunches {
273- done = append(done, launch.done)
274- }
275 sess.floatingLaunchMu.Unlock()258 sess.floatingLaunchMu.Unlock()
276- return done
277 }259 }
278 260
279 type floatingLaunchSpec struct {261 type floatingLaunchSpec struct {
@@ -366,33 +348,19 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
366 // openAndInstallFloating runs entirely in the launch worker. No PTY operation348 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
367 // occurs under tb.mu; the pane is fully initialized before publication.349 // occurs under tb.mu; the pane is fully initialized before publication.
368 func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64, launch *floatingLaunch) {350 func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64, launch *floatingLaunch) {
369- // The gate makes the final ownership check and external Open one shutdown351+ // Open is context-aware and is intentionally called without any daemon,
370- // epoch: shutdown cannot become irreversible between them. Neither d.mu,352+ // session, tab, or launch-ownership lock. Cancellation makes teardown
371- // sess.mu, nor tb.mu is held while the factory can block.353+ // bounded even if the adapter is waiting to create the child.
372- d.floatingLaunchGate.Lock()354+ if err := spec.parentCtx.Err(); err != nil {
373- select {
374- case <-spec.parentCtx.Done():
375- d.floatingLaunchGate.Unlock()
376- return
377- default:
378- }
379- d.mu.Lock()
380- // Unit-scoped launch paths may use a session without registering it in the
381- // daemon map. Production sessions have an ID and must still be owned by
382- // the map until teardown removes them.
383- live := !d.closing && (sess.id == "" || d.sessions[sess.id] == sess)
384- d.mu.Unlock()
385- if !live {
386- d.floatingLaunchGate.Unlock()
387- return
388- }
389- pty, err, opened := sess.openFloating(func() (ports.PTY, error) {
390- return d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)
391- })
392- d.floatingLaunchGate.Unlock()
393- if !opened {
394 return355 return
395 }356 }
357+ openCtx, cancelOpen := context.WithCancel(spec.parentCtx)
358+ stopSessionCancel := context.AfterFunc(sess.ctx, cancelOpen)
359+ defer func() {
360+ stopSessionCancel()
361+ cancelOpen()
362+ }()
363+ pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size)
396 if err != nil {364 if err != nil {
397 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)365 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)
398 return366 return
A internal/usecase/daemon/floating_open_test.go +96 -0
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+)
16+
17+func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) {
18+ factory := portsmocks.NewMockPTYFactory(t)
19+ opened := make(chan struct{})
20+ latePTY := portsmocks.NewMockPTY(t)
21+ closed := make(chan struct{})
22+ latePTY.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
23+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
24+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
25+ close(opened)
26+ <-ctx.Done()
27+ return latePTY, nil
28+ }).Once()
29+ d := newTestDaemon(t, factory, stubClock{})
30+ tb := newFloatingTestTab(t)
31+ ctx, cancel := context.WithCancel(t.Context())
32+ sess := &session{id: "blocked-open", name: "work", tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
33+ d.mu.Lock()
34+ d.sessions[sess.id] = sess
35+ d.mu.Unlock()
36+ d.startFloating(sess, tb, true)
37+ select {
38+ case <-opened:
39+ case <-time.After(time.Second):
40+ t.Fatal("floating Open did not begin")
41+ }
42+
43+ killed := make(chan error, 1)
44+ go func() { killed <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
45+ select {
46+ case err := <-killed:
47+ require.NoError(t, err)
48+ case <-time.After(time.Second):
49+ t.Fatal("session teardown waited for blocked Open")
50+ }
51+ select {
52+ case <-closed:
53+ case <-time.After(time.Second):
54+ t.Fatal("late Open PTY was not closed")
55+ }
56+ tb.mu.Lock()
57+ require.Nil(t, tb.floating.pane, "late PTY must not be installed")
58+ tb.mu.Unlock()
59+}
60+
61+func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) {
62+ factory := portsmocks.NewMockPTYFactory(t)
63+ entered := make(chan struct{}, 2)
64+ release := make(chan struct{})
65+ var once sync.Once
66+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
67+ func(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
68+ entered <- struct{}{}
69+ <-release
70+ return newQuietPTY(), nil
71+ }).Twice()
72+ d := newTestDaemon(t, factory, stubClock{})
73+ tabs := make([]*tab, 0, 2)
74+ cancels := make([]context.CancelFunc, 0, 2)
75+ for _, id := range []domain.SessionID{"one", "two"} {
76+ tb := newFloatingTestTab(t)
77+ ctx, cancel := context.WithCancel(t.Context())
78+ sess := &session{id: id, name: string(id), tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
79+ tabs = append(tabs, tb)
80+ cancels = append(cancels, cancel)
81+ d.startFloating(sess, tb, false)
82+ }
83+ for range 2 {
84+ select {
85+ case <-entered:
86+ case <-time.After(time.Second):
87+ t.Fatal("unrelated floating launch was serialized")
88+ }
89+ }
90+ once.Do(func() { close(release) })
91+ for i, cancel := range cancels {
92+ cancel()
93+ d.teardownFloating(tabs[i], nil)
94+ }
95+ d.sessWg.Wait()
96+}
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+)
16+
17+func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) {
18+ factory := portsmocks.NewMockPTYFactory(t)
19+ opened := make(chan struct{})
20+ latePTY := portsmocks.NewMockPTY(t)
21+ closed := make(chan struct{})
22+ latePTY.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
23+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
24+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
25+ close(opened)
26+ <-ctx.Done()
27+ return latePTY, nil
28+ }).Once()
29+ d := newTestDaemon(t, factory, stubClock{})
30+ tb := newFloatingTestTab(t)
31+ ctx, cancel := context.WithCancel(t.Context())
32+ sess := &session{id: "blocked-open", name: "work", tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
33+ d.mu.Lock()
34+ d.sessions[sess.id] = sess
35+ d.mu.Unlock()
36+ d.startFloating(sess, tb, true)
37+ select {
38+ case <-opened:
39+ case <-time.After(time.Second):
40+ t.Fatal("floating Open did not begin")
41+ }
42+
43+ killed := make(chan error, 1)
44+ go func() { killed <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
45+ select {
46+ case err := <-killed:
47+ require.NoError(t, err)
48+ case <-time.After(time.Second):
49+ t.Fatal("session teardown waited for blocked Open")
50+ }
51+ select {
52+ case <-closed:
53+ case <-time.After(time.Second):
54+ t.Fatal("late Open PTY was not closed")
55+ }
56+ tb.mu.Lock()
57+ require.Nil(t, tb.floating.pane, "late PTY must not be installed")
58+ tb.mu.Unlock()
59+}
60+
61+func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) {
62+ factory := portsmocks.NewMockPTYFactory(t)
63+ entered := make(chan struct{}, 2)
64+ release := make(chan struct{})
65+ var once sync.Once
66+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
67+ func(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
68+ entered <- struct{}{}
69+ <-release
70+ return newQuietPTY(), nil
71+ }).Twice()
72+ d := newTestDaemon(t, factory, stubClock{})
73+ tabs := make([]*tab, 0, 2)
74+ cancels := make([]context.CancelFunc, 0, 2)
75+ for _, id := range []domain.SessionID{"one", "two"} {
76+ tb := newFloatingTestTab(t)
77+ ctx, cancel := context.WithCancel(t.Context())
78+ sess := &session{id: id, name: string(id), tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
79+ tabs = append(tabs, tb)
80+ cancels = append(cancels, cancel)
81+ d.startFloating(sess, tb, false)
82+ }
83+ for range 2 {
84+ select {
85+ case <-entered:
86+ case <-time.After(time.Second):
87+ t.Fatal("unrelated floating launch was serialized")
88+ }
89+ }
90+ once.Do(func() { close(release) })
91+ for i, cancel := range cancels {
92+ cancel()
93+ d.teardownFloating(tabs[i], nil)
94+ }
95+ d.sessWg.Wait()
96+}
M internal/usecase/daemon/floating_test.go +4 -4
@@ -36,8 +36,8 @@ func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsm
3636 var releaseOnce sync.Once
3737 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
3838 t.Cleanup(releaseOpen)
39- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
40- func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
39+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
40+ func(_ context.Context, command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
4141 opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size}
4242 <-release
4343 return result, openErr
@@ -467,8 +467,8 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
467467 t.Run(tt.name, func(t *testing.T) {
468468 factory := portsmocks.NewMockPTYFactory(t)
469469 opened := make(chan string, 1)
470- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
471- func(_ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {
470+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
471+ func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {
472472 opened <- cwd
473473 return nil, io.ErrUnexpectedEOF
474474 }).Once()
@@ -36,8 +36,8 @@ func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsm
36 var releaseOnce sync.Once36 var releaseOnce sync.Once
37 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }37 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
38 t.Cleanup(releaseOpen)38 t.Cleanup(releaseOpen)
39- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(39+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
40- func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {40+ func(_ context.Context, command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
41 opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size}41 opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size}
42 <-release42 <-release
43 return result, openErr43 return result, openErr
@@ -467,8 +467,8 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
467 t.Run(tt.name, func(t *testing.T) {467 t.Run(tt.name, func(t *testing.T) {
468 factory := portsmocks.NewMockPTYFactory(t)468 factory := portsmocks.NewMockPTYFactory(t)
469 opened := make(chan string, 1)469 opened := make(chan string, 1)
470- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(470+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
471- func(_ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {471+ func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {
472 opened <- cwd472 opened <- cwd
473 return nil, io.ErrUnexpectedEOF473 return nil, io.ErrUnexpectedEOF
474 }).Once()474 }).Once()
M internal/usecase/daemon/palette_test.go +1 -1
@@ -371,7 +371,7 @@ func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
371371 d, sess, ac, sends, releases := newManualTabSession(t, 1)
372372 defer releases[0]()
373373 ptys := portsmocks.NewMockPTYFactory(t)
374- ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
374+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
375375 d.ptys = ptys
376376
377377 d.handleInput(sess, ac, []byte("\x1b "))
@@ -371,7 +371,7 @@ func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
371 d, sess, ac, sends, releases := newManualTabSession(t, 1)371 d, sess, ac, sends, releases := newManualTabSession(t, 1)
372 defer releases[0]()372 defer releases[0]()
373 ptys := portsmocks.NewMockPTYFactory(t)373 ptys := portsmocks.NewMockPTYFactory(t)
374- ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))374+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
375 d.ptys = ptys375 d.ptys = ptys
376 376
377 d.handleInput(sess, ac, []byte("\x1b "))377 d.handleInput(sess, ac, []byte("\x1b "))
M internal/usecase/daemon/pane_actions.go +1 -1
@@ -69,7 +69,7 @@ func (d *Daemon) spawnPaneOp(
6969 tb.mu.Unlock()
7070 return fmt.Errorf("daemon: generating pane identity: %w", err)
7171 }
72- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
72+ pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
7373 if err != nil {
7474 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
7575 tb.mu.Lock()
@@ -69,7 +69,7 @@ func (d *Daemon) spawnPaneOp(
69 tb.mu.Unlock()69 tb.mu.Unlock()
70 return fmt.Errorf("daemon: generating pane identity: %w", err)70 return fmt.Errorf("daemon: generating pane identity: %w", err)
71 }71 }
72- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))72+ pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
73 if err != nil {73 if err != nil {
74 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")74 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
75 tb.mu.Lock()75 tb.mu.Lock()
M internal/usecase/daemon/pane_actions_test.go +9 -9
@@ -39,7 +39,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
3939 newPTY := portsmocks.NewMockPTY(t)
4040 oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()
4141 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
42- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()
42+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()
4343
4444 require.NoError(t, d.splitPane(sess, nil, tt.dir))
4545
@@ -68,7 +68,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
6868 newPTY := portsmocks.NewMockPTY(t)
6969 stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
7070 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
71- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
71+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
7272
7373 require.NoError(t, d.splitPane(sess, nil, layout.Right))
7474
@@ -84,7 +84,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
8484
8585 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
8686 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
87- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once()
87+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once()
8888
8989 tb := sess.activeTab()
9090 tb.mu.Lock()
@@ -231,7 +231,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
231231 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})
232232 newPTY := portsmocks.NewMockPTY(t)
233233 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
234- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once()
234+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once()
235235
236236 require.NoError(t, d.stackPane(sess, nil))
237237 tb := sess.activeTab()
@@ -251,7 +251,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
251251 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})
252252 newPTY := portsmocks.NewMockPTY(t)
253253 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
254- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once()
254+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once()
255255 require.NoError(t, d.stackPane(sess, nil))
256256 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once()
257257
@@ -261,7 +261,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
261261 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
262262 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)
263263 thirdPTY := portsmocks.NewMockPTY(t)
264- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()
264+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()
265265 require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall)
266266 require.Len(t, tb.panes, 2)
267267 }
@@ -291,7 +291,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
291291 defer releaseNew()
292292 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
293293 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
294- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
294+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
295295
296296 require.NoError(t, d.splitPane(sess, nil, layout.Right))
297297 tb := sess.activeTab()
@@ -350,8 +350,8 @@ func TestSplitPaneInheritsTerminalEnv(t *testing.T) {
350350 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
351351 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
352352 var gotEnv []string
353- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
354- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
353+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
354+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
355355 gotEnv = append([]string(nil), env...)
356356 return newPTY, nil
357357 },
@@ -39,7 +39,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
39 newPTY := portsmocks.NewMockPTY(t)39 newPTY := portsmocks.NewMockPTY(t)
40 oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()40 oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()
41 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()41 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
42- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()42+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()
43 43
44 require.NoError(t, d.splitPane(sess, nil, tt.dir))44 require.NoError(t, d.splitPane(sess, nil, tt.dir))
45 45
@@ -68,7 +68,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
68 newPTY := portsmocks.NewMockPTY(t)68 newPTY := portsmocks.NewMockPTY(t)
69 stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()69 stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
70 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()70 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
71- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()71+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
72 72
73 require.NoError(t, d.splitPane(sess, nil, layout.Right))73 require.NoError(t, d.splitPane(sess, nil, layout.Right))
74 74
@@ -84,7 +84,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
84 84
85 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {85 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
86 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})86 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
87- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once()87+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once()
88 88
89 tb := sess.activeTab()89 tb := sess.activeTab()
90 tb.mu.Lock()90 tb.mu.Lock()
@@ -231,7 +231,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
231 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})231 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})
232 newPTY := portsmocks.NewMockPTY(t)232 newPTY := portsmocks.NewMockPTY(t)
233 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()233 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
234- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once()234+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once()
235 235
236 require.NoError(t, d.stackPane(sess, nil))236 require.NoError(t, d.stackPane(sess, nil))
237 tb := sess.activeTab()237 tb := sess.activeTab()
@@ -251,7 +251,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
251 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})251 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})
252 newPTY := portsmocks.NewMockPTY(t)252 newPTY := portsmocks.NewMockPTY(t)
253 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()253 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
254- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once()254+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once()
255 require.NoError(t, d.stackPane(sess, nil))255 require.NoError(t, d.stackPane(sess, nil))
256 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once()256 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once()
257 257
@@ -261,7 +261,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
261 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)261 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
262 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)262 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)
263 thirdPTY := portsmocks.NewMockPTY(t)263 thirdPTY := portsmocks.NewMockPTY(t)
264- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()264+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()
265 require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall)265 require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall)
266 require.Len(t, tb.panes, 2)266 require.Len(t, tb.panes, 2)
267 }267 }
@@ -291,7 +291,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
291 defer releaseNew()291 defer releaseNew()
292 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()292 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
293 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()293 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
294- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()294+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
295 295
296 require.NoError(t, d.splitPane(sess, nil, layout.Right))296 require.NoError(t, d.splitPane(sess, nil, layout.Right))
297 tb := sess.activeTab()297 tb := sess.activeTab()
@@ -350,8 +350,8 @@ func TestSplitPaneInheritsTerminalEnv(t *testing.T) {
350 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()350 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
351 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()351 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
352 var gotEnv []string352 var gotEnv []string
353- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(353+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
354- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {354+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
355 gotEnv = append([]string(nil), env...)355 gotEnv = append([]string(nil), env...)
356 return newPTY, nil356 return newPTY, nil
357 },357 },
M internal/usecase/daemon/picker_test.go +5 -5
@@ -399,8 +399,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
399399 defer releasePTY3()
400400 var openedEnv []string
401401 f := portsmocks.NewMockPTYFactory(t)
402- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
403- func(_ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
402+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
403+ func(_ context.Context, _ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
404404 if sz != (domain.Size{Cols: 80, Rows: 22}) {
405405 return newQuietPTY(), nil
406406 }
@@ -514,8 +514,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
514514 var opens [][]string
515515 f := portsmocks.NewMockPTYFactory(t)
516516 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
517- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
518- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
517+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
518+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
519519 opens = append(opens, append([]string(nil), env...))
520520 if len(opens) == 1 {
521521 return p1, nil
@@ -524,7 +524,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
524524 },
525525 ).Twice()
526526 floating := newQuietPTY()
527- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
527+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
528528 return got != normalSize && got.Valid()
529529 })).Return(floating, nil).Once()
530530 d := newTestDaemon(t, f, stubClock{})
@@ -399,8 +399,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
399 defer releasePTY3()399 defer releasePTY3()
400 var openedEnv []string400 var openedEnv []string
401 f := portsmocks.NewMockPTYFactory(t)401 f := portsmocks.NewMockPTYFactory(t)
402- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(402+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
403- func(_ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {403+ func(_ context.Context, _ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
404 if sz != (domain.Size{Cols: 80, Rows: 22}) {404 if sz != (domain.Size{Cols: 80, Rows: 22}) {
405 return newQuietPTY(), nil405 return newQuietPTY(), nil
406 }406 }
@@ -514,8 +514,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
514 var opens [][]string514 var opens [][]string
515 f := portsmocks.NewMockPTYFactory(t)515 f := portsmocks.NewMockPTYFactory(t)
516 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}516 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
517- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(517+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
518- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {518+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
519 opens = append(opens, append([]string(nil), env...))519 opens = append(opens, append([]string(nil), env...))
520 if len(opens) == 1 {520 if len(opens) == 1 {
521 return p1, nil521 return p1, nil
@@ -524,7 +524,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
524 },524 },
525 ).Twice()525 ).Twice()
526 floating := newQuietPTY()526 floating := newQuietPTY()
527- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {527+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
528 return got != normalSize && got.Valid()528 return got != normalSize && got.Valid()
529 })).Return(floating, nil).Once()529 })).Return(floating, nil).Once()
530 d := newTestDaemon(t, f, stubClock{})530 d := newTestDaemon(t, f, stubClock{})
M internal/usecase/daemon/session.go +5 -14
@@ -131,7 +131,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
131131 closeTabs(tabs)
132132 return nil, err
133133 }
134- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
134+ pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
135135 if err != nil {
136136 closeTabs(tabs)
137137 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
@@ -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.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
275+ pty, err := d.ptys.Open(d.serveCtx, 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)
@@ -674,14 +674,9 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
674674 d.captureSession(sess)
675675 }
676676 }
677- // Serialize removal of the final session with the last transition into a
678- // floating Open. This establishes whether an Open is pre-shutdown work or
679- // is rejected, without holding d.mu across the external call.
680- d.floatingLaunchGate.Lock()
681677 d.mu.Lock()
682678 if _, ok := d.sessions[sess.id]; !ok {
683679 d.mu.Unlock()
684- d.floatingLaunchGate.Unlock()
685680 return nil
686681 }
687682 delete(d.sessions, sess.id)
@@ -706,7 +701,6 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
706701 d.closing = true
707702 }
708703 d.mu.Unlock()
709- d.floatingLaunchGate.Unlock()
710704 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
711705
712706 purgeErr := snapshotDeleteErr
@@ -743,9 +737,9 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
743737 }
744738
745739 // Prevent queued launches from entering Open, then cancel the parent
746- // context. In-flight Opens are joined below so a late result is always
747- // closed before teardown returns.
748- floatingLaunches := sess.stopFloatingLaunches()
740+ // context. The launch worker owns any late PTY result and closes it rather
741+ // than publishing it into the torn-down tab.
742+ sess.stopFloatingLaunches()
749743 sess.cancel()
750744 sess.mu.Lock()
751745 tabs := append([]*tab(nil), sess.tabs...)
@@ -757,9 +751,6 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
757751 d.teardownFloating(tb, ac)
758752 tb.closeAllPanes()
759753 }
760- for _, launchDone := range floatingLaunches {
761- <-launchDone
762- }
763754 for _, path := range clipFiles {
764755 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
765756 d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
@@ -131,7 +131,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
131 closeTabs(tabs)131 closeTabs(tabs)
132 return nil, err132 return nil, err
133 }133 }
134- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)134+ pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
135 if err != nil {135 if err != nil {
136 closeTabs(tabs)136 closeTabs(tabs)
137 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")137 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
@@ -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.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)275+ pty, err := d.ptys.Open(d.serveCtx, 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)
@@ -674,14 +674,9 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
674 d.captureSession(sess)674 d.captureSession(sess)
675 }675 }
676 }676 }
677- // Serialize removal of the final session with the last transition into a
678- // floating Open. This establishes whether an Open is pre-shutdown work or
679- // is rejected, without holding d.mu across the external call.
680- d.floatingLaunchGate.Lock()
681 d.mu.Lock()677 d.mu.Lock()
682 if _, ok := d.sessions[sess.id]; !ok {678 if _, ok := d.sessions[sess.id]; !ok {
683 d.mu.Unlock()679 d.mu.Unlock()
684- d.floatingLaunchGate.Unlock()
685 return nil680 return nil
686 }681 }
687 delete(d.sessions, sess.id)682 delete(d.sessions, sess.id)
@@ -706,7 +701,6 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
706 d.closing = true701 d.closing = true
707 }702 }
708 d.mu.Unlock()703 d.mu.Unlock()
709- d.floatingLaunchGate.Unlock()
710 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)704 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
711 705
712 purgeErr := snapshotDeleteErr706 purgeErr := snapshotDeleteErr
@@ -743,9 +737,9 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
743 }737 }
744 738
745 // Prevent queued launches from entering Open, then cancel the parent739 // Prevent queued launches from entering Open, then cancel the parent
746- // context. In-flight Opens are joined below so a late result is always740+ // context. The launch worker owns any late PTY result and closes it rather
747- // closed before teardown returns.741+ // than publishing it into the torn-down tab.
748- floatingLaunches := sess.stopFloatingLaunches()742+ sess.stopFloatingLaunches()
749 sess.cancel()743 sess.cancel()
750 sess.mu.Lock()744 sess.mu.Lock()
751 tabs := append([]*tab(nil), sess.tabs...)745 tabs := append([]*tab(nil), sess.tabs...)
@@ -757,9 +751,6 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
757 d.teardownFloating(tb, ac)751 d.teardownFloating(tb, ac)
758 tb.closeAllPanes()752 tb.closeAllPanes()
759 }753 }
760- for _, launchDone := range floatingLaunches {
761- <-launchDone
762- }
763 for _, path := range clipFiles {754 for _, path := range clipFiles {
764 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {755 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
765 d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)756 d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
M internal/usecase/daemon/session_test.go +20 -20
@@ -34,8 +34,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
3434 f := portsmocks.NewMockPTYFactory(t)
3535 var dirs []string
3636 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
37- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
38- func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
37+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
38+ func(_ context.Context, _ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
3939 dirs = append(dirs, dir)
4040 if len(dirs) == 1 {
4141 return first, nil
@@ -44,7 +44,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
4444 },
4545 ).Twice()
4646 floating := newQuietPTY()
47- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
47+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
4848 return got != normalSize && got.Valid()
4949 })).Return(floating, nil).Maybe()
5050
@@ -210,13 +210,13 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
210210
211211 f := portsmocks.NewMockPTYFactory(t)
212212 normalSize := domain.Size{Cols: 80, Rows: 22}
213- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
213+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
214214 floating := newQuietPTY()
215- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
215+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
216216 return got != normalSize && got.Valid()
217217 })).Return(floating, nil).Maybe()
218- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
219- func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
218+ 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) {
220220 close(opened)
221221 <-releaseOpen
222222 return p2, nil
@@ -516,8 +516,8 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
516516 preShutdownFloatingOpen := make(chan struct{}, 1)
517517 var opensAfterShutdown atomic.Int32
518518 d := newTestDaemon(t, f, stubClock{})
519- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
520- func(_ string, _ []string, _ []string, _ string, size domain.Size) (ports.PTY, error) {
519+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
520+ func(_ context.Context, _ string, _ []string, _ []string, _ string, size domain.Size) (ports.PTY, error) {
521521 select {
522522 case <-d.done:
523523 opensAfterShutdown.Add(1)
@@ -1113,8 +1113,8 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
11131113
11141114 var gotEnv []string
11151115 f := portsmocks.NewMockPTYFactory(t)
1116- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1117- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1116+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1117+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
11181118 gotEnv = append([]string(nil), env...)
11191119 return p, nil
11201120 },
@@ -1266,8 +1266,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12661266 var opens [][]string
12671267 f := portsmocks.NewMockPTYFactory(t)
12681268 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1269- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1270- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1269+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1270+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
12711271 opens = append(opens, append([]string(nil), env...))
12721272 if len(opens) == 1 {
12731273 return p1, nil
@@ -1276,7 +1276,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12761276 },
12771277 ).Twice()
12781278 floating := newQuietPTY()
1279- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1279+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
12801280 return got != normalSize && got.Valid()
12811281 })).Return(floating, nil).Maybe()
12821282
@@ -1311,8 +1311,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
13111311 var opens [][]string
13121312 f := portsmocks.NewMockPTYFactory(t)
13131313 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1314- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1315- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1314+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1315+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
13161316 opens = append(opens, append([]string(nil), env...))
13171317 if len(opens) == 1 {
13181318 return p1, nil
@@ -1321,7 +1321,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
13211321 },
13221322 ).Twice()
13231323 floating := newQuietPTY()
1324- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1324+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
13251325 return got != normalSize && got.Valid()
13261326 })).Return(floating, nil).Maybe()
13271327
@@ -1358,8 +1358,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
13581358 var opens [][]string
13591359 f := portsmocks.NewMockPTYFactory(t)
13601360 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1361- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1362- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1361+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1362+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
13631363 opens = append(opens, append([]string(nil), env...))
13641364 if len(opens) == 1 {
13651365 return p1, nil
@@ -1368,7 +1368,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
13681368 },
13691369 ).Twice()
13701370 floating := newQuietPTY()
1371- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1371+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
13721372 return got != normalSize && got.Valid()
13731373 })).Return(floating, nil).Maybe()
13741374 d := newTestDaemon(t, f, stubClock{})
@@ -34,8 +34,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
34 f := portsmocks.NewMockPTYFactory(t)34 f := portsmocks.NewMockPTYFactory(t)
35 var dirs []string35 var dirs []string
36 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}36 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
37- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(37+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
38- func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {38+ func(_ context.Context, _ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
39 dirs = append(dirs, dir)39 dirs = append(dirs, dir)
40 if len(dirs) == 1 {40 if len(dirs) == 1 {
41 return first, nil41 return first, nil
@@ -44,7 +44,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
44 },44 },
45 ).Twice()45 ).Twice()
46 floating := newQuietPTY()46 floating := newQuietPTY()
47- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {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()48 return got != normalSize && got.Valid()
49 })).Return(floating, nil).Maybe()49 })).Return(floating, nil).Maybe()
50 50
@@ -210,13 +210,13 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
210 210
211 f := portsmocks.NewMockPTYFactory(t)211 f := portsmocks.NewMockPTYFactory(t)
212 normalSize := domain.Size{Cols: 80, Rows: 22}212 normalSize := domain.Size{Cols: 80, Rows: 22}
213- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()213+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
214 floating := newQuietPTY()214 floating := newQuietPTY()
215- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {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()216 return got != normalSize && got.Valid()
217 })).Return(floating, nil).Maybe()217 })).Return(floating, nil).Maybe()
218- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(218+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
219- func(string, []string, []string, string, domain.Size) (ports.PTY, error) {219+ func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error) {
220 close(opened)220 close(opened)
221 <-releaseOpen221 <-releaseOpen
222 return p2, nil222 return p2, nil
@@ -516,8 +516,8 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
516 preShutdownFloatingOpen := make(chan struct{}, 1)516 preShutdownFloatingOpen := make(chan struct{}, 1)
517 var opensAfterShutdown atomic.Int32517 var opensAfterShutdown atomic.Int32
518 d := newTestDaemon(t, f, stubClock{})518 d := newTestDaemon(t, f, stubClock{})
519- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(519+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
520- func(_ string, _ []string, _ []string, _ string, size domain.Size) (ports.PTY, error) {520+ func(_ context.Context, _ string, _ []string, _ []string, _ string, size domain.Size) (ports.PTY, error) {
521 select {521 select {
522 case <-d.done:522 case <-d.done:
523 opensAfterShutdown.Add(1)523 opensAfterShutdown.Add(1)
@@ -1113,8 +1113,8 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
1113 1113
1114 var gotEnv []string1114 var gotEnv []string
1115 f := portsmocks.NewMockPTYFactory(t)1115 f := portsmocks.NewMockPTYFactory(t)
1116- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(1116+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1117- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1117+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1118 gotEnv = append([]string(nil), env...)1118 gotEnv = append([]string(nil), env...)
1119 return p, nil1119 return p, nil
1120 },1120 },
@@ -1266,8 +1266,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1266 var opens [][]string1266 var opens [][]string
1267 f := portsmocks.NewMockPTYFactory(t)1267 f := portsmocks.NewMockPTYFactory(t)
1268 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1268 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1269- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1269+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1270- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1270+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1271 opens = append(opens, append([]string(nil), env...))1271 opens = append(opens, append([]string(nil), env...))
1272 if len(opens) == 1 {1272 if len(opens) == 1 {
1273 return p1, nil1273 return p1, nil
@@ -1276,7 +1276,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1276 },1276 },
1277 ).Twice()1277 ).Twice()
1278 floating := newQuietPTY()1278 floating := newQuietPTY()
1279- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {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()1280 return got != normalSize && got.Valid()
1281 })).Return(floating, nil).Maybe()1281 })).Return(floating, nil).Maybe()
1282 1282
@@ -1311,8 +1311,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1311 var opens [][]string1311 var opens [][]string
1312 f := portsmocks.NewMockPTYFactory(t)1312 f := portsmocks.NewMockPTYFactory(t)
1313 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1313 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1314- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1314+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1315- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1315+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1316 opens = append(opens, append([]string(nil), env...))1316 opens = append(opens, append([]string(nil), env...))
1317 if len(opens) == 1 {1317 if len(opens) == 1 {
1318 return p1, nil1318 return p1, nil
@@ -1321,7 +1321,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1321 },1321 },
1322 ).Twice()1322 ).Twice()
1323 floating := newQuietPTY()1323 floating := newQuietPTY()
1324- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {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()1325 return got != normalSize && got.Valid()
1326 })).Return(floating, nil).Maybe()1326 })).Return(floating, nil).Maybe()
1327 1327
@@ -1358,8 +1358,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1358 var opens [][]string1358 var opens [][]string
1359 f := portsmocks.NewMockPTYFactory(t)1359 f := portsmocks.NewMockPTYFactory(t)
1360 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1360 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1361- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1361+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1362- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1362+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1363 opens = append(opens, append([]string(nil), env...))1363 opens = append(opens, append([]string(nil), env...))
1364 if len(opens) == 1 {1364 if len(opens) == 1 {
1365 return p1, nil1365 return p1, nil
@@ -1368,7 +1368,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1368 },1368 },
1369 ).Twice()1369 ).Twice()
1370 floating := newQuietPTY()1370 floating := newQuietPTY()
1371- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {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()1372 return got != normalSize && got.Valid()
1373 })).Return(floating, nil).Maybe()1373 })).Return(floating, nil).Maybe()
1374 d := newTestDaemon(t, f, stubClock{})1374 d := newTestDaemon(t, f, stubClock{})
M internal/usecase/daemon/snapshot.go +1 -1
@@ -141,7 +141,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
141141 return fmt.Errorf("snapshot: generating pane identity: %w", err)
142142 }
143143 }
144- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
144+ pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
145145 if err != nil {
146146 closeOpened()
147147 return err
@@ -141,7 +141,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
141 return fmt.Errorf("snapshot: generating pane identity: %w", err)141 return fmt.Errorf("snapshot: generating pane identity: %w", err)
142 }142 }
143 }143 }
144- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)144+ pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
145 if err != nil {145 if err != nil {
146 closeOpened()146 closeOpened()
147 return err147 return err
M internal/usecase/daemon/snapshot_restore_test.go +1 -1
@@ -444,7 +444,7 @@ type restorePTYOpen struct {
444444 pty *restorePTY
445445 }
446446
447-func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
447+func (f *restorePTYFactory) Open(_ context.Context, _ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
448448 f.mu.Lock()
449449 defer f.mu.Unlock()
450450 pty := newRestorePTY()
@@ -444,7 +444,7 @@ type restorePTYOpen struct {
444 pty *restorePTY444 pty *restorePTY
445 }445 }
446 446
447-func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {447+func (f *restorePTYFactory) Open(_ context.Context, _ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
448 f.mu.Lock()448 f.mu.Lock()
449 defer f.mu.Unlock()449 defer f.mu.Unlock()
450 pty := newRestorePTY()450 pty := newRestorePTY()
M internal/usecase/daemon/snapshot_test.go +1 -1
@@ -385,7 +385,7 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
385385 newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
386386 newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
387387 newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
388- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
388+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
389389
390390 require.NoError(t, d.splitPane(sess, nil, layout.Right))
391391 require.True(t, sess.snapDirty.Load())
@@ -385,7 +385,7 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
385 newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()385 newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
386 newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe()386 newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
387 newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe()387 newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
388- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()388+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
389 389
390 require.NoError(t, d.splitPane(sess, nil, layout.Right))390 require.NoError(t, d.splitPane(sess, nil, layout.Right))
391 require.True(t, sess.snapDirty.Load())391 require.True(t, sess.snapDirty.Load())

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