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 var published *pane
89 sess.floatingLaunchMu.Lock()
90 sess.floatingSpareStarting = false
91 if p != nil && !sess.floatingLaunchStopping {
92 published = p
93 sess.floatingSpare = &floatingSpare{pane: p, key: spareKey(spec)}
94 p.prestarted = true
95 spare := p
96 spare.onExit = func() {
97 spare.spareExited.Store(true)
98 // Still unclaimed: drop it. A claim takes the spare under the
99 // same lock, then reaps after install when spareExited is set.
100 if sess.dropFloatingSpare(spare) {
101 return
102 }
103 d.reapInstalledFloating(spare)
104 }
105 reader := p
106 d.sessWg.Go(func() { d.readPanePTY(reader) })
107 p = nil
108 }
109 sess.floatingLaunchMu.Unlock()
110 if p != nil {
111 closeFloatingPane(p)
112 }
113 if published != nil {
114 // A theme change between the first apply and publication could
115 // not see the spare; any later one does, so apply once more.
116 d.applySessionThemeToPane(sess, published)
117 }
118 })
119}
120
121// takeFloatingSpare removes the session spare. It returns the pane when the
122// spare was started with the same launch inputs as spec; a mismatched spare is
123// closed because it can never serve this launch. had reports whether a spare
124// existed, so the caller knows to start its replacement.
125func (sess *session) takeFloatingSpare(spec floatingLaunchSpec) (p *pane, had bool) {
126 sess.floatingLaunchMu.Lock()
127 spare := sess.floatingSpare
128 sess.floatingSpare = nil
129 sess.floatingLaunchMu.Unlock()
130 if spare == nil {
131 return nil, false
132 }
133 if spare.key != spareKey(spec) {
134 closeFloatingPane(spare.pane)
135 return nil, true
136 }
137 return spare.pane, true
138}
139
140// dropFloatingSpare closes p when it is still the session's unclaimed spare
141// and reports whether it did. A claimed spare is left to its tab.
142func (sess *session) dropFloatingSpare(p *pane) bool {
143 sess.floatingLaunchMu.Lock()
144 unclaimed := sess.floatingSpare != nil && sess.floatingSpare.pane == p
145 if unclaimed {
146 sess.floatingSpare = nil
147 }
148 sess.floatingLaunchMu.Unlock()
149 if unclaimed {
150 closeFloatingPane(p)
151 }
152 return unclaimed
153}
154
155// applySessionThemeToPane gives a pane the theme of the session's first
156// attachment, as createTab does for a new tab. A headless session leaves the
157// pane unthemed until the next theme application.
158func (d *Daemon) applySessionThemeToPane(sess *session, p *pane) {
159 attachments := sess.snapshotAttachments()
160 if len(attachments) == 0 {
161 return
162 }
163 applyClientThemeToPane(d, attachments[0], p)
164}
165
166// applyClientThemeToPane reads ac's theme while holding p.mu, so a concurrent
167// theme application either is seen here or writes the pane after it; a stale
168// theme can never overwrite a newer one. ac.themeMu is a leaf lock.
169func applyClientThemeToPane(d *Daemon, ac *attachedClient, p *pane) {
170 p.mu.Lock()
171 defer p.mu.Unlock()
172 applyPaneThemeLocked(p, d.effectiveTheme(ac.getClientTheme()), false)
173}
174
175// floatingSparePane returns the session's unclaimed spare pane, if any.
176func (sess *session) floatingSparePane() *pane {
177 sess.floatingLaunchMu.Lock()
178 defer sess.floatingLaunchMu.Unlock()
179 if sess.floatingSpare == nil {
180 return nil
181 }
182 return sess.floatingSpare.pane
183}
184
185// closeFloatingSpare releases the spare on session teardown. The caller has
186// already set floatingLaunchStopping, so no launch can publish a new one.
187func (sess *session) closeFloatingSpare() {
188 sess.floatingLaunchMu.Lock()
189 spare := sess.floatingSpare
190 sess.floatingSpare = nil
191 sess.floatingLaunchMu.Unlock()
192 if spare != nil {
193 closeFloatingPane(spare.pane)
194 }
195}
196
197// openFloatingPane opens one floating PTY for spec and returns its initialized,
198// unpublished pane, or nil when the launch failed or was cancelled.
199func (d *Daemon) openFloatingPane(sess *session, spec floatingLaunchSpec) *pane {
200 if err := spec.parentCtx.Err(); err != nil {
201 return nil
202 }
203 lifetime := d.newPaneProcessLifetime(spec.parentCtx, sess.ctx)
204 pty, err := d.ptys.Open(lifetime.ctx, spec.command, spec.args, spec.env, spec.cwd, spec.ptyGeometry)
205 if err != nil {
206 lifetime.abort()
207 if pty != nil {
208 _ = pty.Close()
209 }
210 d.log.Debug("floating spare spawn failed", "err", err, "session", spec.sessionName)
211 return nil
212 }
213 p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback, d.currentHistoryConfig())
214 p.geometry = spec.ptyGeometry
215 setScreenGeometry(p.screen, spec.ptyGeometry)
216 p.rect = spec.geometry.ptyRect()
217 p.popupGeometry = spec.geometry
218 if !lifetime.publish(p) {
219 _ = pty.Close()
220 return nil
221 }
222 return p
223}
224
225// adoptFloatingSpare fits a claimed spare to the claiming tab's launch spec
226// and installs it in the current slot generation. It runs in the launch worker
227// without any architecture lock, because PTY.Resize is external.
228func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floatingLaunchSpec, generation uint64) {
229 p.resizeMu.Lock()
230 p.mu.Lock()
231 resize := p.geometry != spec.ptyGeometry
232 if resize {
233 // The spare's reader is running: buffer its output across the
234 // resize so the redraw is parsed at the new size.
235 p.resizeApplying = true
236 }
237 p.mu.Unlock()
238 if resize {
239 if err := resizePTYGeometry(p.pty, spec.ptyGeometry); err != nil {
240 d.replayResizePending(sess, tb, p, false, domain.Rect{})
241 p.resizeMu.Unlock()
242 closeFloatingPane(p)
243 d.failFloatingLaunch(sess, tb, generation, true, spec.sessionName, err)
244 return
245 }
246 }
247 p.mu.Lock()
248 p.geometry = spec.ptyGeometry
249 setScreenGeometry(p.screen, spec.ptyGeometry)
250 p.rect = spec.geometry.ptyRect()
251 p.popupGeometry = spec.geometry
252 p.mu.Unlock()
253 if resize {
254 d.replayResizePending(sess, tb, p, false, domain.Rect{})
255 }
256 p.resizeMu.Unlock()
257 d.installFloating(sess, tb, p, generation)
258 if p.spareExited.Load() {
259 // The shell exited before the owner was published, so its reader
260 // could not reap the slot.
261 d.reapInstalledFloating(p)
262 }
263}