1package daemon
2
3import (
4 "context"
5 "errors"
6 "io"
7 "sync"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/domain/layout"
16 "github.com/bnema/vev/internal/ports"
17)
18
19type lifetimePTY struct {
20 ctx context.Context
21 closed chan struct{}
22 closeOnce sync.Once
23 closes atomic.Int32
24}
25
26func newLifetimePTY() *lifetimePTY {
27 return &lifetimePTY{closed: make(chan struct{})}
28}
29
30func (p *lifetimePTY) Read([]byte) (int, error) {
31 select {
32 case <-p.ctx.Done():
33 return 0, p.ctx.Err()
34 case <-p.closed:
35 return 0, io.EOF
36 }
37}
38
39func (*lifetimePTY) Write(b []byte) (int, error) { return len(b), nil }
40func (*lifetimePTY) Resize(domain.Geometry) error { return nil }
41func (*lifetimePTY) Pid() int { return 1 }
42func (*lifetimePTY) ForegroundPgid() (int, error) {
43 return 1, nil
44}
45func (p *lifetimePTY) Close() error {
46 p.closes.Add(1)
47 p.closeOnce.Do(func() { close(p.closed) })
48 return nil
49}
50
51type lifetimePTYFactory struct {
52 mu sync.Mutex
53 ptys []*lifetimePTY
54 contexts []context.Context
55}
56
57func (f *lifetimePTYFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
58 p := newLifetimePTY()
59 p.ctx = ctx
60 f.mu.Lock()
61 f.ptys = append(f.ptys, p)
62 f.contexts = append(f.contexts, ctx)
63 f.mu.Unlock()
64 return p, nil
65}
66
67func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
68 type openCase struct {
69 name string
70 wantCode domain.NoticeCode
71 run func(*testing.T, *Daemon) ([]*tab, func(), error)
72 }
73 tests := []openCase{
74 {
75 name: "initial pane",
76 wantCode: domain.NoticeSessionSpawn,
77 run: func(_ *testing.T, d *Daemon) ([]*tab, func(), error) {
78 sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
79 if sess != nil {
80 return sess.tabs, func() { closeTabs(sess.tabs) }, err
81 }
82 return nil, d.serveCancel, err
83 },
84 },
85 {
86 name: "created tab",
87 wantCode: domain.NoticeTabSpawn,
88 run: func(t *testing.T, d *Daemon) ([]*tab, func(), error) {
89 sessCtx, cancelSession := context.WithCancel(t.Context())
90 base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
91 base.ctx, base.cancel = context.WithCancel(sessCtx)
92 sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
93 d.mu.Lock()
94 d.sessions[sess.id] = sess
95 d.mu.Unlock()
96 err := d.createTab(sess, domain.Size{Cols: 80, Rows: 24})
97 return sess.tabs, func() {
98 cancelSession()
99 closeTabs(sess.tabs)
100 }, err
101 },
102 },
103 {
104 name: "split pane",
105 wantCode: domain.NoticePaneSpawn,
106 run: func(t *testing.T, d *Daemon) ([]*tab, func(), error) {
107 sessCtx, cancelSession := context.WithCancel(t.Context())
108 tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10})
109 tb.ctx, tb.cancel = context.WithCancel(sessCtx)
110 sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
111 _, err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right)
112 return []*tab{tb}, func() {
113 cancelSession()
114 tb.closeAllPanes()
115 }, err
116 },
117 },
118 {
119 name: "restored pane",
120 run: func(t *testing.T, d *Daemon) ([]*tab, func(), error) {
121 restoreCtx, cancelRestore := context.WithCancel(t.Context())
122 sessCtx, cancelSession := context.WithCancel(t.Context())
123 tabs, err := d.restoreSnapshotTabs(restoreCtx, sessCtx, restoreAcceptanceSession(t, "work"))
124 return tabs, func() {
125 cancelRestore()
126 cancelSession()
127 closeRestoredTabs(tabs)
128 }, err
129 },
130 },
131 }
132
133 for _, tt := range tests {
134 t.Run(tt.name, func(t *testing.T) {
135 cause := errors.New("open failed")
136 pty := newFailedOpenPTY()
137 cancelCallbacks := atomic.Int32{}
138 cancelled := make(chan struct{})
139 openFactory := paneErrorFactory{
140 pty: pty,
141 err: cause,
142 onOpen: func(ctx context.Context) {
143 pty.ctx = ctx
144 context.AfterFunc(ctx, func() {
145 cancelCallbacks.Add(1)
146 close(cancelled)
147 })
148 },
149 }
150 d := newTestDaemon(t, &openFactory, stubClock{})
151
152 tabs, cleanup, err := tt.run(t, d)
153 require.ErrorIs(t, err, cause)
154 if tt.wantCode != 0 {
155 var userErr *domain.UserError
156 require.ErrorAs(t, err, &userErr)
157 require.Equal(t, tt.wantCode, userErr.Code)
158 }
159 select {
160 case <-cancelled:
161 case <-time.After(time.Second):
162 t.Fatal("failed pane Open process context was not cancelled")
163 }
164
165 for _, tb := range tabs {
166 if tb == nil {
167 continue
168 }
169 tb.mu.Lock()
170 floating := tb.floating.pane
171 var floatingPTY ports.PTY
172 if floating != nil {
173 floating.mu.Lock()
174 floatingPTY = floating.pty
175 floating.mu.Unlock()
176 }
177 publishedFailedPTY := false
178 for _, published := range tb.panes {
179 publishedFailedPTY = publishedFailedPTY || published.pty == pty
180 }
181 tb.mu.Unlock()
182 require.NotEqual(t, pty, floatingPTY)
183 require.False(t, publishedFailedPTY, "failed Open must not publish a pane or owner")
184 }
185 require.Zero(t, pty.readerStarts.Load(), "failed Open must not start a reader")
186 require.Equal(t, int32(1), pty.closes.Load(), "failed Open PTY must close exactly once")
187 require.Equal(t, int32(1), cancelCallbacks.Load(), "failed Open context must cancel exactly once")
188
189 cleanup()
190 require.Equal(t, int32(1), pty.closes.Load(), "later teardown must not close an unpublished PTY again")
191 require.Equal(t, int32(1), cancelCallbacks.Load(), "later teardown must not recancel an unpublished process")
192 })
193 }
194}
195
196type paneErrorFactory struct {
197 pty ports.PTY
198 err error
199 onOpen func(context.Context)
200}
201
202func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
203 if f.onOpen != nil {
204 f.onOpen(ctx)
205 }
206 return f.pty, f.err
207}