1package daemon
2
3import (
4 "context"
5 "io"
6 "sync"
7 "testing"
8 "time"
9
10 renderer "github.com/bnema/vev-vt"
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"
15 themeui "github.com/bnema/vev/internal/usecase/theme"
16)
17
18// spareOpen records one PTY Open issued for a floating shell.
19type spareOpen struct {
20 cwd string
21 geometry domain.Geometry
22 pty *sparePTY
23}
24
25type sparePTY struct {
26 ctx context.Context
27 mu sync.Mutex
28 resizes []domain.Geometry
29 closed chan struct{}
30 once sync.Once
31}
32
33func (p *sparePTY) Read([]byte) (int, error) {
34 select {
35 case <-p.ctx.Done():
36 return 0, p.ctx.Err()
37 case <-p.closed:
38 return 0, io.EOF
39 }
40}
41func (*sparePTY) Write(b []byte) (int, error) { return len(b), nil }
42func (p *sparePTY) Resize(g domain.Geometry) error {
43 p.mu.Lock()
44 p.resizes = append(p.resizes, g)
45 p.mu.Unlock()
46 return nil
47}
48func (*sparePTY) Pid() int { return 1 }
49func (*sparePTY) ForegroundPgid() (int, error) { return 1, nil }
50func (p *sparePTY) Close() error { p.once.Do(func() { close(p.closed) }); return nil }
51func (p *sparePTY) isClosed() bool {
52 select {
53 case <-p.closed:
54 return true
55 default:
56 return false
57 }
58}
59
60type spareFactory struct {
61 mu sync.Mutex
62 opens []spareOpen
63}
64
65func (f *spareFactory) Open(ctx context.Context, _ string, _ []string, _ []string, cwd string, g domain.Geometry) (ports.PTY, error) {
66 p := &sparePTY{ctx: ctx, closed: make(chan struct{})}
67 f.mu.Lock()
68 f.opens = append(f.opens, spareOpen{cwd: cwd, geometry: g, pty: p})
69 f.mu.Unlock()
70 return p, nil
71}
72
73func (f *spareFactory) snapshot() []spareOpen {
74 f.mu.Lock()
75 defer f.mu.Unlock()
76 return append([]spareOpen(nil), f.opens...)
77}
78
79// liveShells counts opened floating PTYs that are still running.
80func (f *spareFactory) liveShells() int {
81 n := 0
82 for _, open := range f.snapshot() {
83 if !open.pty.isClosed() {
84 n++
85 }
86 }
87 return n
88}
89
90func waitSpareReady(t *testing.T, sess *session) {
91 t.Helper()
92 require.Eventually(t, func() bool {
93 sess.floatingLaunchMu.Lock()
94 defer sess.floatingLaunchMu.Unlock()
95 return sess.floatingSpare != nil && !sess.floatingSpareStarting
96 }, time.Second, time.Millisecond)
97}
98
99func waitFloatingVisible(t *testing.T, tb *tab) *pane {
100 t.Helper()
101 var p *pane
102 require.Eventually(t, func() bool {
103 tb.mu.Lock()
104 defer tb.mu.Unlock()
105 p = tb.floating.pane
106 return tb.floating.state == floatingVisible && p != nil
107 }, time.Second, time.Millisecond)
108 return p
109}
110
111func TestFloatingSpareIsSharedPerSession(t *testing.T) {
112 cfg := domain.FloatingConfig{Width: 80, Height: 80}
113 tests := []struct {
114 name string
115 claimCwd string
116 wantReused bool
117 }{
118 {name: "same cwd claims the spare", claimCwd: "/work", wantReused: true},
119 {name: "other cwd launches a fresh shell", claimCwd: "/other", wantReused: false},
120 }
121 for _, tt := range tests {
122 t.Run(tt.name, func(t *testing.T) {
123 factory := &spareFactory{}
124 d := newTestDaemon(t, factory, stubClock{})
125 d.ApplyConfig(domain.Config{Floating: cfg})
126 cwds := map[int]string{1: "/work", 2: tt.claimCwd}
127 d.procCwd = func(pid int) (string, error) { return cwds[pid], nil }
128 d.dirOrHome = func(cwd string) string { return cwd }
129
130 first := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 120, Rows: 40})
131 second := newTab(&pidPTY{pid: 2}, domain.Size{Cols: 60, Rows: 20})
132 for _, tb := range []*tab{first, second} {
133 tb.ctx, tb.cancel = context.WithCancel(t.Context())
134 }
135 sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{first, second}, ctx: t.Context()}
136
137 // Activating both tabs starts exactly one session spare.
138 d.ensureFloatingWarm(sess, first)
139 d.ensureFloatingWarm(sess, second)
140 waitSpareReady(t, sess)
141 require.Len(t, factory.snapshot(), 1)
142 require.Equal(t, "/work", factory.snapshot()[0].cwd)
143
144 // The second (smaller) tab opens its floating pane and claims the
145 // spare that the first tab started.
146 second.mu.Lock()
147 generation := second.beginFloatingWarmLocked(true)
148 second.mu.Unlock()
149 d.launchFloating(sess, second, cfg, generation, true)
150 claimed := waitFloatingVisible(t, second)
151 waitSpareReady(t, sess)
152
153 want := calculateContentFloatingGeometry(domain.Size{Cols: 60, Rows: 20}, cfg)
154 claimed.mu.Lock()
155 require.Equal(t, want.ptyRect(), claimed.rect)
156 require.Equal(t, rectSize(want.ptyRect()), claimed.geometry.Size)
157 require.Equal(t, rectSize(want.ptyRect()).Cols, claimed.screen.Columns())
158 claimed.mu.Unlock()
159
160 opens := factory.snapshot()
161 if tt.wantReused {
162 require.Same(t, opens[0].pty, claimed.pty, "the claim must reuse the pre-started shell")
163 require.Equal(t, []domain.Geometry{claimed.geometry}, opens[0].pty.resizes, "the claim resizes the spare to the claiming tab")
164 require.Len(t, opens, 2, "one claimed shell plus one new spare")
165 } else {
166 require.True(t, opens[0].pty.isClosed(), "a spare from another cwd is discarded")
167 require.Equal(t, tt.claimCwd, opens[1].cwd)
168 require.Same(t, opens[1].pty, claimed.pty)
169 require.Len(t, opens, 3, "a fresh shell plus one new spare")
170 }
171 // Panes visible as floating plus exactly one idle spare.
172 require.Equal(t, 2, factory.liveShells())
173
174 sess.stopInMemoryLifecycle()
175 d.teardownFloating(second, nil)
176 d.sessWg.Wait()
177 require.Zero(t, factory.liveShells(), "teardown closes the spare")
178 })
179 }
180}
181
182type pidPTY struct {
183 transactionalResizePTY
184 pid int
185}
186
187func (p *pidPTY) Pid() int { return p.pid }
188
189// queryPTY is a floating shell that sends one terminal query at startup and
190// records what the daemon writes back.
191type queryPTY struct {
192 sparePTY
193 query string
194 sent chan struct{}
195 replied chan []byte
196}
197
198func (p *queryPTY) Read(b []byte) (int, error) {
199 select {
200 case <-p.sent:
201 return p.sparePTY.Read(b)
202 default:
203 close(p.sent)
204 return copy(b, p.query), nil
205 }
206}
207
208func (p *queryPTY) Write(b []byte) (int, error) {
209 p.replied <- append([]byte(nil), b...)
210 return len(b), nil
211}
212
213type queryFactory struct{ pty *queryPTY }
214
215func (f *queryFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
216 f.pty.ctx = ctx
217 return f.pty, nil
218}
219
220func TestFloatingSpareAnswersStartupQueriesBeforeClaim(t *testing.T) {
221 for _, tt := range []struct {
222 name string
223 query string
224 want string
225 }{
226 {name: "primary device attributes", query: "\x1b[c", want: "\x1b[?"},
227 {name: "background color from the session theme", query: "\x1b]11;?\x07", want: "\x1b]11;rgb:"},
228 } {
229 t.Run(tt.name, func(t *testing.T) {
230 pty := &queryPTY{sparePTY: sparePTY{closed: make(chan struct{})}, query: tt.query, sent: make(chan struct{}), replied: make(chan []byte, 4)}
231 d := newTestDaemon(t, &queryFactory{pty: pty}, stubClock{})
232 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}})
233 tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24})
234 tb.ctx, tb.cancel = context.WithCancel(t.Context())
235 ac := &attachedClient{}
236 ac.setClientTheme(themeui.Theme{Known: true, HasFG: true, HasBG: true,
237 Foreground: renderer.RGB{R: 0xee, G: 0xee, B: 0xee}, Background: renderer.RGB{R: 0x11, G: 0x11, B: 0x11}})
238 sess := &session{sessionCore: sessionCore{name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()}
239
240 d.ensureFloatingWarm(sess, tb)
241 select {
242 case reply := <-pty.replied:
243 require.Contains(t, string(reply), tt.want, "the unclaimed spare must answer its shell's startup query")
244 case <-time.After(5 * time.Second):
245 t.Fatal("the unclaimed spare never answered its shell's startup query")
246 }
247
248 sess.stopInMemoryLifecycle()
249 d.sessWg.Wait()
250 })
251 }
252}
253
254func TestFloatingSpareExitBeforeClaimDropsIt(t *testing.T) {
255 factory := &spareFactory{}
256 d := newTestDaemon(t, factory, stubClock{})
257 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}})
258 tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24})
259 tb.ctx, tb.cancel = context.WithCancel(t.Context())
260 sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
261
262 d.ensureFloatingWarm(sess, tb)
263 waitSpareReady(t, sess)
264 spare := sess.floatingSparePane()
265 require.NotNil(t, spare)
266 require.NoError(t, factory.snapshot()[0].pty.Close(), "the spare's shell exits")
267 require.Eventually(t, func() bool { return sess.floatingSparePane() == nil }, time.Second, time.Millisecond,
268 "an exited unclaimed spare must be dropped so the next claim launches a fresh shell")
269
270 sess.stopInMemoryLifecycle()
271 d.sessWg.Wait()
272}