1package daemon
2
3import (
4 "context"
5 "io"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"
14)
15
16// spareOpen records one PTY Open issued for a floating shell.
17type spareOpen struct {
18 cwd string
19 geometry domain.Geometry
20 pty *sparePTY
21}
22
23type sparePTY struct {
24 ctx context.Context
25 mu sync.Mutex
26 resizes []domain.Geometry
27 closed chan struct{}
28 once sync.Once
29}
30
31func (p *sparePTY) Read([]byte) (int, error) {
32 select {
33 case <-p.ctx.Done():
34 return 0, p.ctx.Err()
35 case <-p.closed:
36 return 0, io.EOF
37 }
38}
39func (*sparePTY) Write(b []byte) (int, error) { return len(b), nil }
40func (p *sparePTY) Resize(g domain.Geometry) error {
41 p.mu.Lock()
42 p.resizes = append(p.resizes, g)
43 p.mu.Unlock()
44 return nil
45}
46func (*sparePTY) Pid() int { return 1 }
47func (*sparePTY) ForegroundPgid() (int, error) { return 1, nil }
48func (p *sparePTY) Close() error { p.once.Do(func() { close(p.closed) }); return nil }
49func (p *sparePTY) isClosed() bool {
50 select {
51 case <-p.closed:
52 return true
53 default:
54 return false
55 }
56}
57
58type spareFactory struct {
59 mu sync.Mutex
60 opens []spareOpen
61}
62
63func (f *spareFactory) Open(ctx context.Context, _ string, _ []string, _ []string, cwd string, g domain.Geometry) (ports.PTY, error) {
64 p := &sparePTY{ctx: ctx, closed: make(chan struct{})}
65 f.mu.Lock()
66 f.opens = append(f.opens, spareOpen{cwd: cwd, geometry: g, pty: p})
67 f.mu.Unlock()
68 return p, nil
69}
70
71func (f *spareFactory) snapshot() []spareOpen {
72 f.mu.Lock()
73 defer f.mu.Unlock()
74 return append([]spareOpen(nil), f.opens...)
75}
76
77// liveShells counts opened floating PTYs that are still running.
78func (f *spareFactory) liveShells() int {
79 n := 0
80 for _, open := range f.snapshot() {
81 if !open.pty.isClosed() {
82 n++
83 }
84 }
85 return n
86}
87
88func waitSpareReady(t *testing.T, sess *session) {
89 t.Helper()
90 require.Eventually(t, func() bool {
91 sess.floatingLaunchMu.Lock()
92 defer sess.floatingLaunchMu.Unlock()
93 return sess.floatingSpare != nil && !sess.floatingSpareStarting
94 }, time.Second, time.Millisecond)
95}
96
97func waitFloatingVisible(t *testing.T, tb *tab) *pane {
98 t.Helper()
99 var p *pane
100 require.Eventually(t, func() bool {
101 tb.mu.Lock()
102 defer tb.mu.Unlock()
103 p = tb.floating.pane
104 return tb.floating.state == floatingVisible && p != nil
105 }, time.Second, time.Millisecond)
106 return p
107}
108
109func TestFloatingSpareIsSharedPerSession(t *testing.T) {
110 cfg := domain.FloatingConfig{Width: 80, Height: 80}
111 tests := []struct {
112 name string
113 claimCwd string
114 wantReused bool
115 }{
116 {name: "same cwd claims the spare", claimCwd: "/work", wantReused: true},
117 {name: "other cwd launches a fresh shell", claimCwd: "/other", wantReused: false},
118 }
119 for _, tt := range tests {
120 t.Run(tt.name, func(t *testing.T) {
121 factory := &spareFactory{}
122 d := newTestDaemon(t, factory, stubClock{})
123 d.ApplyConfig(domain.Config{Floating: cfg})
124 cwds := map[int]string{1: "/work", 2: tt.claimCwd}
125 d.procCwd = func(pid int) (string, error) { return cwds[pid], nil }
126 d.dirOrHome = func(cwd string) string { return cwd }
127
128 first := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 120, Rows: 40})
129 second := newTab(&pidPTY{pid: 2}, domain.Size{Cols: 60, Rows: 20})
130 for _, tb := range []*tab{first, second} {
131 tb.ctx, tb.cancel = context.WithCancel(t.Context())
132 }
133 sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{first, second}, ctx: t.Context()}
134
135 // Activating both tabs starts exactly one session spare.
136 d.ensureFloatingWarm(sess, first)
137 d.ensureFloatingWarm(sess, second)
138 waitSpareReady(t, sess)
139 require.Len(t, factory.snapshot(), 1)
140 require.Equal(t, "/work", factory.snapshot()[0].cwd)
141
142 // The second (smaller) tab opens its floating pane and claims the
143 // spare that the first tab started.
144 second.mu.Lock()
145 generation := second.beginFloatingWarmLocked(true)
146 second.mu.Unlock()
147 d.launchFloating(sess, second, cfg, generation, true)
148 claimed := waitFloatingVisible(t, second)
149 waitSpareReady(t, sess)
150
151 want := calculateContentFloatingGeometry(domain.Size{Cols: 60, Rows: 20}, cfg)
152 claimed.mu.Lock()
153 require.Equal(t, want.ptyRect(), claimed.rect)
154 require.Equal(t, rectSize(want.ptyRect()), claimed.geometry.Size)
155 require.Equal(t, rectSize(want.ptyRect()).Cols, claimed.screen.Columns())
156 claimed.mu.Unlock()
157
158 opens := factory.snapshot()
159 if tt.wantReused {
160 require.Same(t, opens[0].pty, claimed.pty, "the claim must reuse the pre-started shell")
161 require.Equal(t, []domain.Geometry{claimed.geometry}, opens[0].pty.resizes, "the claim resizes the spare to the claiming tab")
162 require.Len(t, opens, 2, "one claimed shell plus one new spare")
163 } else {
164 require.True(t, opens[0].pty.isClosed(), "a spare from another cwd is discarded")
165 require.Equal(t, tt.claimCwd, opens[1].cwd)
166 require.Same(t, opens[1].pty, claimed.pty)
167 require.Len(t, opens, 3, "a fresh shell plus one new spare")
168 }
169 // Panes visible as floating plus exactly one idle spare.
170 require.Equal(t, 2, factory.liveShells())
171
172 sess.stopInMemoryLifecycle()
173 d.teardownFloating(second, nil)
174 d.sessWg.Wait()
175 require.Zero(t, factory.liveShells(), "teardown closes the spare")
176 })
177 }
178}
179
180type pidPTY struct {
181 transactionalResizePTY
182 pid int
183}
184
185func (p *pidPTY) Pid() int { return p.pid }