1package daemon
2
3import (
4 "strings"
5
6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/domain/layout"
8)
9
10// floatingSpare is one pre-started floating shell owned by a session rather
11// than by a tab. The first tab that opens its floating pane claims it, and the
12// session then starts a new spare in the background, so a session keeps at
13// most one idle floating shell instead of one hidden shell per tab. A spare is
14// never persisted or restored. Its reader starts with the shell, so startup
15// terminal queries are answered before any claim; with no owner its screen
16// changes publish nothing. Its VEV identity names the tab that started it;
17// floating panes are not --self targets.
18//
19// A claim takes the claiming tab's geometry and requires the same cwd,
20// floating.command, shell and environment: a mismatched spare is discarded and
21// the claim launches a fresh shell instead.
22//
23// The spare is guarded by session.floatingLaunchMu, which is never held across
24// PTY operations or architecture locks.
25type floatingSpare struct {
26 pane *pane
27 key floatingSpareKey
28}
29
30// floatingSpareKey is every launch input a claim must share with the spare.
31// env excludes the per-launch VEV identity.
32type floatingSpareKey struct {
33 cwd, floatingCommand, command, args, env string
34}
35
36func spareKey(spec floatingLaunchSpec) floatingSpareKey {
37 env := make([]string, 0, len(spec.env))
38 for _, entry := range spec.env {
39 if key, _, _ := environmentEntry(entry); key != "VEV" {
40 env = append(env, entry)
41 }
42 }
43 return floatingSpareKey{
44 cwd: spec.cwd, floatingCommand: spec.floatingCommand, command: spec.command,
45 args: strings.Join(spec.args, "\x00"), env: strings.Join(env, "\x00"),
46 }
47}
48
49// ensureFloatingSpare starts the session's spare when none exists or is
50// starting. tb supplies the initial geometry and cwd; a claim from another tab
51// resizes it or, when the cwd differs, replaces it.
52func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
53 if d == nil || sess == nil || tb == nil || d.ptys == nil {
54 return
55 }
56 cfg := d.currentFloatingConfig()
57 sess.floatingLaunchMu.Lock()
58 if sess.floatingLaunchStopping || sess.floatingSpare != nil || sess.floatingSpareStarting {
59 sess.floatingLaunchMu.Unlock()
60 return
61 }
62 sess.floatingSpareStarting = true
63 sess.floatingLaunchMu.Unlock()
64
65 spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, false)
66 // The spare belongs to the session, so closing the tab that started it
67 // must not cancel it.
68 spec.parentCtx = sess.ctx
69 var launch *floatingLaunch
70 ok := err == nil && spec.parentCtx != nil
71 if ok {
72 launch, ok = sess.registerFloatingLaunch()
73 }
74 if !ok {
75 sess.floatingLaunchMu.Lock()
76 sess.floatingSpareStarting = false
77 sess.floatingLaunchMu.Unlock()
78 return
79 }
80 d.sessWg.Go(func() {
81 defer sess.finishFloatingLaunch(launch)
82 p := d.openFloatingPane(sess, spec)
83 if p != nil {
84 // The reader answers color and scheme queries, so the spare
85 // starts with the session theme, as a new tab does.
86 d.applySessionThemeToPane(sess, p)
87 }
88 sess.floatingLaunchMu.Lock()
89 sess.floatingSpareStarting = false
90 if p != nil && !sess.floatingLaunchStopping {
91 sess.floatingSpare = &floatingSpare{pane: p, key: spareKey(spec)}
92 p.prestarted = true
93 spare := p
94 spare.onExit = func() {
95 spare.spareExited.Store(true)
96 // Still unclaimed: drop it. A claim takes the spare under the
97 // same lock, then reaps after install when spareExited is set.
98 if sess.dropFloatingSpare(spare) {
99 return
100 }
101 d.reapInstalledFloating(spare)
102 }
103 d.sessWg.Add(1)
104 go d.readPanePTY(p)
105 p = nil
106 }
107 sess.floatingLaunchMu.Unlock()
108 if p != nil {
109 closeFloatingPane(p)
110 }
111 })
112}
113
114// takeFloatingSpare removes the session spare. It returns the pane when the
115// spare was started with the same launch inputs as spec; a mismatched spare is
116// closed because it can never serve this launch. had reports whether a spare
117// existed, so the caller knows to start its replacement.
118func (sess *session) takeFloatingSpare(spec floatingLaunchSpec) (p *pane, had bool) {
119 sess.floatingLaunchMu.Lock()
120 spare := sess.floatingSpare
121 sess.floatingSpare = nil
122 sess.floatingLaunchMu.Unlock()
123 if spare == nil {
124 return nil, false
125 }
126 if spare.key != spareKey(spec) {
127 closeFloatingPane(spare.pane)
128 return nil, true
129 }
130 return spare.pane, true
131}
132
133// dropFloatingSpare closes p when it is still the session's unclaimed spare
134// and reports whether it did. A claimed spare is left to its tab.
135func (sess *session) dropFloatingSpare(p *pane) bool {
136 sess.floatingLaunchMu.Lock()
137 unclaimed := sess.floatingSpare != nil && sess.floatingSpare.pane == p
138 if unclaimed {
139 sess.floatingSpare = nil
140 }
141 sess.floatingLaunchMu.Unlock()
142 if unclaimed {
143 closeFloatingPane(p)
144 }
145 return unclaimed
146}
147
148// applySessionThemeToPane gives a pane the theme of the session's first
149// attachment, as createTab does for a new tab. A headless session leaves the
150// pane unthemed until the next theme application.
151func (d *Daemon) applySessionThemeToPane(sess *session, p *pane) {
152 attachments := sess.snapshotAttachments()
153 if len(attachments) == 0 {
154 return
155 }
156 t := d.effectiveTheme(attachments[0].getClientTheme())
157 p.mu.Lock()
158 applyPaneThemeLocked(p, t, false)
159 p.mu.Unlock()
160}
161
162// floatingSparePane returns the session's unclaimed spare pane, if any.
163func (sess *session) floatingSparePane() *pane {
164 sess.floatingLaunchMu.Lock()
165 defer sess.floatingLaunchMu.Unlock()
166 if sess.floatingSpare == nil {
167 return nil
168 }
169 return sess.floatingSpare.pane
170}
171
172// closeFloatingSpare releases the spare on session teardown. The caller has
173// already set floatingLaunchStopping, so no launch can publish a new one.
174func (sess *session) closeFloatingSpare() {
175 sess.floatingLaunchMu.Lock()
176 spare := sess.floatingSpare
177 sess.floatingSpare = nil
178 sess.floatingLaunchMu.Unlock()
179 if spare != nil {
180 closeFloatingPane(spare.pane)
181 }
182}
183
184// openFloatingPane opens one floating PTY for spec and returns its initialized,
185// unpublished pane, or nil when the launch failed or was cancelled.
186func (d *Daemon) openFloatingPane(sess *session, spec floatingLaunchSpec) *pane {
187 if err := spec.parentCtx.Err(); err != nil {
188 return nil
189 }
190 lifetime := d.newPaneProcessLifetime(spec.parentCtx, sess.ctx)
191 pty, err := d.ptys.Open(lifetime.ctx, spec.command, spec.args, spec.env, spec.cwd, spec.ptyGeometry)
192 if err != nil {
193 lifetime.abort()
194 if pty != nil {
195 _ = pty.Close()
196 }
197 d.log.Debug("floating spare spawn failed", "err", err, "session", spec.sessionName)
198 return nil
199 }
200 p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback, d.currentHistoryConfig())
201 p.geometry = spec.ptyGeometry
202 setScreenGeometry(p.screen, spec.ptyGeometry)
203 p.rect = spec.geometry.ptyRect()
204 p.popupGeometry = spec.geometry
205 if !lifetime.publish(p) {
206 _ = pty.Close()
207 return nil
208 }
209 return p
210}
211
212// adoptFloatingSpare fits a claimed spare to the claiming tab's launch spec
213// and installs it in the current slot generation. It runs in the launch worker
214// without any architecture lock, because PTY.Resize is external.
215func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floatingLaunchSpec, generation uint64) {
216 p.resizeMu.Lock()
217 p.mu.Lock()
218 resize := p.geometry != spec.ptyGeometry
219 if resize {
220 // The spare's reader is running: buffer its output across the
221 // resize so the redraw is parsed at the new size.
222 p.resizeApplying = true
223 }
224 p.mu.Unlock()
225 if resize {
226 if err := resizePTYGeometry(p.pty, spec.ptyGeometry); err != nil {
227 d.replayResizePending(sess, tb, p, false, domain.Rect{})
228 p.resizeMu.Unlock()
229 closeFloatingPane(p)
230 d.failFloatingLaunch(sess, tb, generation, true, spec.sessionName, err)
231 return
232 }
233 }
234 p.mu.Lock()
235 p.geometry = spec.ptyGeometry
236 setScreenGeometry(p.screen, spec.ptyGeometry)
237 p.rect = spec.geometry.ptyRect()
238 p.popupGeometry = spec.geometry
239 p.mu.Unlock()
240 if resize {
241 d.replayResizePending(sess, tb, p, false, domain.Rect{})
242 }
243 p.resizeMu.Unlock()
244 d.installFloating(sess, tb, p, generation)
245 if p.spareExited.Load() {
246 // The shell exited before the owner was published, so its reader
247 // could not reap the slot.
248 d.reapInstalledFloating(p)
249 }
250}