fix(daemon): theme the floating spare and resize it under the apply gate
With its reader running from launch, an unclaimed spare answered color and scheme queries (OSC 10/11) without a theme, so they got no reply. The spare now takes the session theme before its reader starts and follows later theme changes. A claim resizes the running spare behind resizeApplying so its redraw is parsed at the new size. An exiting spare is dropped only while still unclaimed, under floatingLaunchMu, so an exit racing a claim no longer closes the claimed pane and leaves the slot without an owner.
- commit
- d2f974da47ef1eca2e7702740cf4b719de67cb6b
- author
- brice · · 2026-10-08 06:21 UTC
- parent
- 8dda6f5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +113 -19
M internal/usecase/daemon/floating_spare.go +59 -8
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain/layout" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| @@ -79,6 +80,11 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) { | |||
| 79 | 80 | d.sessWg.Go(func() { | |
| 80 | 81 | defer sess.finishFloatingLaunch(launch) | |
| 81 | 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 | + | } | |
| 82 | 88 | sess.floatingLaunchMu.Lock() | |
| 83 | 89 | sess.floatingSpareStarting = false | |
| 84 | 90 | if p != nil && !sess.floatingLaunchStopping { | |
| @@ -87,8 +93,9 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) { | |||
| 87 | 93 | spare := p | |
| 88 | 94 | spare.onExit = func() { | |
| 89 | 95 | spare.spareExited.Store(true) | |
| 90 | - | if spare.ownerSnapshot() == nil { | |
| 91 | - | sess.dropFloatingSpare(spare) | |
| 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) { | |
| 92 | 99 | return | |
| 93 | 100 | } | |
| 94 | 101 | d.reapInstalledFloating(spare) | |
| @@ -123,14 +130,43 @@ func (sess *session) takeFloatingSpare(spec floatingLaunchSpec) (p *pane, had bo | |||
| 123 | 130 | return spare.pane, true | |
| 124 | 131 | } | |
| 125 | 132 | ||
| 126 | - | // dropFloatingSpare forgets an unclaimed spare whose shell exited. | |
| 127 | - | func (sess *session) dropFloatingSpare(p *pane) { | |
| 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. | |
| 135 | + | func (sess *session) dropFloatingSpare(p *pane) bool { | |
| 128 | 136 | sess.floatingLaunchMu.Lock() | |
| 129 | - | if sess.floatingSpare != nil && sess.floatingSpare.pane == p { | |
| 137 | + | unclaimed := sess.floatingSpare != nil && sess.floatingSpare.pane == p | |
| 138 | + | if unclaimed { | |
| 130 | 139 | sess.floatingSpare = nil | |
| 131 | 140 | } | |
| 132 | 141 | sess.floatingLaunchMu.Unlock() | |
| 133 | - | closeFloatingPane(p) | |
| 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. | |
| 151 | + | func (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. | |
| 163 | + | func (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 | |
| 134 | 170 | } | |
| 135 | 171 | ||
| 136 | 172 | // closeFloatingSpare releases the spare on session teardown. The caller has | |
| @@ -177,8 +213,19 @@ func (d *Daemon) openFloatingPane(sess *session, spec floatingLaunchSpec) *pane | |||
| 177 | 213 | // and installs it in the current slot generation. It runs in the launch worker | |
| 178 | 214 | // without any architecture lock, because PTY.Resize is external. | |
| 179 | 215 | func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floatingLaunchSpec, generation uint64) { | |
| 180 | - | if p.geometry != spec.ptyGeometry { | |
| 181 | - | if err := p.pty.Resize(spec.ptyGeometry); err != nil { | |
| 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() | |
| 182 | 229 | closeFloatingPane(p) | |
| 183 | 230 | d.failFloatingLaunch(sess, tb, generation, true, spec.sessionName, err) | |
| 184 | 231 | return | |
| @@ -190,6 +237,10 @@ func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floati | |||
| 190 | 237 | p.rect = spec.geometry.ptyRect() | |
| 191 | 238 | p.popupGeometry = spec.geometry | |
| 192 | 239 | p.mu.Unlock() | |
| 240 | + | if resize { | |
| 241 | + | d.replayResizePending(sess, tb, p, false, domain.Rect{}) | |
| 242 | + | } | |
| 243 | + | p.resizeMu.Unlock() | |
| 193 | 244 | d.installFloating(sess, tb, p, generation) | |
| 194 | 245 | if p.spareExited.Load() { | |
| 195 | 246 | // The shell exited before the owner was published, so its reader | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "strings" | 4 | "strings" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 6 | "github.com/bnema/vev/internal/domain/layout" | 7 | "github.com/bnema/vev/internal/domain/layout" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| @@ -79,6 +80,11 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) { | |||||
| 79 | d.sessWg.Go(func() { | 80 | d.sessWg.Go(func() { | ||
| 80 | defer sess.finishFloatingLaunch(launch) | 81 | defer sess.finishFloatingLaunch(launch) | ||
| 81 | p := d.openFloatingPane(sess, spec) | 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 | + | } | |||
| 82 | sess.floatingLaunchMu.Lock() | 88 | sess.floatingLaunchMu.Lock() | ||
| 83 | sess.floatingSpareStarting = false | 89 | sess.floatingSpareStarting = false | ||
| 84 | if p != nil && !sess.floatingLaunchStopping { | 90 | if p != nil && !sess.floatingLaunchStopping { | ||
| @@ -87,8 +93,9 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) { | |||||
| 87 | spare := p | 93 | spare := p | ||
| 88 | spare.onExit = func() { | 94 | spare.onExit = func() { | ||
| 89 | spare.spareExited.Store(true) | 95 | spare.spareExited.Store(true) | ||
| 90 | - | if spare.ownerSnapshot() == nil { | 96 | + | // Still unclaimed: drop it. A claim takes the spare under the |
| 91 | - | sess.dropFloatingSpare(spare) | 97 | + | // same lock, then reaps after install when spareExited is set. |
| 98 | + | if sess.dropFloatingSpare(spare) { | |||
| 92 | return | 99 | return | ||
| 93 | } | 100 | } | ||
| 94 | d.reapInstalledFloating(spare) | 101 | d.reapInstalledFloating(spare) | ||
| @@ -123,14 +130,43 @@ func (sess *session) takeFloatingSpare(spec floatingLaunchSpec) (p *pane, had bo | |||||
| 123 | return spare.pane, true | 130 | return spare.pane, true | ||
| 124 | } | 131 | } | ||
| 125 | 132 | ||||
| 126 | - | // dropFloatingSpare forgets an unclaimed spare whose shell exited. | 133 | + | // dropFloatingSpare closes p when it is still the session's unclaimed spare |
| 127 | - | func (sess *session) dropFloatingSpare(p *pane) { | 134 | + | // and reports whether it did. A claimed spare is left to its tab. |
| 135 | + | func (sess *session) dropFloatingSpare(p *pane) bool { | |||
| 128 | sess.floatingLaunchMu.Lock() | 136 | sess.floatingLaunchMu.Lock() | ||
| 129 | - | if sess.floatingSpare != nil && sess.floatingSpare.pane == p { | 137 | + | unclaimed := sess.floatingSpare != nil && sess.floatingSpare.pane == p |
| 138 | + | if unclaimed { | |||
| 130 | sess.floatingSpare = nil | 139 | sess.floatingSpare = nil | ||
| 131 | } | 140 | } | ||
| 132 | sess.floatingLaunchMu.Unlock() | 141 | sess.floatingLaunchMu.Unlock() | ||
| 133 | - | closeFloatingPane(p) | 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. | |||
| 151 | + | func (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. | |||
| 163 | + | func (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 | |||
| 134 | } | 170 | } | ||
| 135 | 171 | ||||
| 136 | // closeFloatingSpare releases the spare on session teardown. The caller has | 172 | // closeFloatingSpare releases the spare on session teardown. The caller has | ||
| @@ -177,8 +213,19 @@ func (d *Daemon) openFloatingPane(sess *session, spec floatingLaunchSpec) *pane | |||||
| 177 | // and installs it in the current slot generation. It runs in the launch worker | 213 | // and installs it in the current slot generation. It runs in the launch worker | ||
| 178 | // without any architecture lock, because PTY.Resize is external. | 214 | // without any architecture lock, because PTY.Resize is external. | ||
| 179 | func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floatingLaunchSpec, generation uint64) { | 215 | func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floatingLaunchSpec, generation uint64) { | ||
| 180 | - | if p.geometry != spec.ptyGeometry { | 216 | + | p.resizeMu.Lock() |
| 181 | - | if err := p.pty.Resize(spec.ptyGeometry); err != nil { | 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() | |||
| 182 | closeFloatingPane(p) | 229 | closeFloatingPane(p) | ||
| 183 | d.failFloatingLaunch(sess, tb, generation, true, spec.sessionName, err) | 230 | d.failFloatingLaunch(sess, tb, generation, true, spec.sessionName, err) | ||
| 184 | return | 231 | return | ||
| @@ -190,6 +237,10 @@ func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floati | |||||
| 190 | p.rect = spec.geometry.ptyRect() | 237 | p.rect = spec.geometry.ptyRect() | ||
| 191 | p.popupGeometry = spec.geometry | 238 | p.popupGeometry = spec.geometry | ||
| 192 | p.mu.Unlock() | 239 | p.mu.Unlock() | ||
| 240 | + | if resize { | |||
| 241 | + | d.replayResizePending(sess, tb, p, false, domain.Rect{}) | |||
| 242 | + | } | |||
| 243 | + | p.resizeMu.Unlock() | |||
| 193 | d.installFloating(sess, tb, p, generation) | 244 | d.installFloating(sess, tb, p, generation) | ||
| 194 | if p.spareExited.Load() { | 245 | if p.spareExited.Load() { | ||
| 195 | // The shell exited before the owner was published, so its reader | 246 | // The shell exited before the owner was published, so its reader | ||
M internal/usecase/daemon/floating_spare_test.go +48 -11
| @@ -7,10 +7,12 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | renderer "github.com/bnema/vev-vt" | |
| 10 | 11 | "github.com/stretchr/testify/require" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| 15 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | // spareOpen records one PTY Open issued for a floating shell. | |
| @@ -184,10 +186,11 @@ type pidPTY struct { | |||
| 184 | 186 | ||
| 185 | 187 | func (p *pidPTY) Pid() int { return p.pid } | |
| 186 | 188 | ||
| 187 | - | // queryPTY is a floating shell that sends one Primary Device Attribute query | |
| 188 | - | // at startup and records what the daemon writes back. | |
| 189 | + | // queryPTY is a floating shell that sends one terminal query at startup and | |
| 190 | + | // records what the daemon writes back. | |
| 189 | 191 | type queryPTY struct { | |
| 190 | 192 | sparePTY | |
| 193 | + | query string | |
| 191 | 194 | sent chan struct{} | |
| 192 | 195 | replied chan []byte | |
| 193 | 196 | } | |
| @@ -198,7 +201,7 @@ func (p *queryPTY) Read(b []byte) (int, error) { | |||
| 198 | 201 | return p.sparePTY.Read(b) | |
| 199 | 202 | default: | |
| 200 | 203 | close(p.sent) | |
| 201 | - | return copy(b, "\x1b[c"), nil | |
| 204 | + | return copy(b, p.query), nil | |
| 202 | 205 | } | |
| 203 | 206 | } | |
| 204 | 207 | ||
| @@ -215,20 +218,54 @@ func (f *queryFactory) Open(ctx context.Context, _ string, _ []string, _ []strin | |||
| 215 | 218 | } | |
| 216 | 219 | ||
| 217 | 220 | func TestFloatingSpareAnswersStartupQueriesBeforeClaim(t *testing.T) { | |
| 218 | - | pty := &queryPTY{sparePTY: sparePTY{closed: make(chan struct{})}, sent: make(chan struct{}), replied: make(chan []byte, 4)} | |
| 219 | - | d := newTestDaemon(t, &queryFactory{pty: pty}, stubClock{}) | |
| 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 | + | ||
| 254 | + | func TestFloatingSpareExitBeforeClaimDropsIt(t *testing.T) { | |
| 255 | + | factory := &spareFactory{} | |
| 256 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 220 | 257 | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}}) | |
| 221 | 258 | tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24}) | |
| 222 | 259 | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | |
| 223 | 260 | sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 224 | 261 | ||
| 225 | 262 | d.ensureFloatingWarm(sess, tb) | |
| 226 | - | select { | |
| 227 | - | case reply := <-pty.replied: | |
| 228 | - | require.Contains(t, string(reply), "\x1b[?", "the spare must answer DA1 while unclaimed") | |
| 229 | - | case <-time.After(5 * time.Second): | |
| 230 | - | t.Fatal("the unclaimed spare never answered its shell's startup query") | |
| 231 | - | } | |
| 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") | |
| 232 | 269 | ||
| 233 | 270 | sess.stopInMemoryLifecycle() | |
| 234 | 271 | d.sessWg.Wait() | |
| @@ -7,10 +7,12 @@ import ( | |||||
| 7 | "testing" | 7 | "testing" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| 10 | + | renderer "github.com/bnema/vev-vt" | |||
| 10 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 11 | 12 | ||||
| 12 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 15 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | // spareOpen records one PTY Open issued for a floating shell. | 18 | // spareOpen records one PTY Open issued for a floating shell. | ||
| @@ -184,10 +186,11 @@ type pidPTY struct { | |||||
| 184 | 186 | ||||
| 185 | func (p *pidPTY) Pid() int { return p.pid } | 187 | func (p *pidPTY) Pid() int { return p.pid } | ||
| 186 | 188 | ||||
| 187 | - | // queryPTY is a floating shell that sends one Primary Device Attribute query | 189 | + | // queryPTY is a floating shell that sends one terminal query at startup and |
| 188 | - | // at startup and records what the daemon writes back. | 190 | + | // records what the daemon writes back. |
| 189 | type queryPTY struct { | 191 | type queryPTY struct { | ||
| 190 | sparePTY | 192 | sparePTY | ||
| 193 | + | query string | |||
| 191 | sent chan struct{} | 194 | sent chan struct{} | ||
| 192 | replied chan []byte | 195 | replied chan []byte | ||
| 193 | } | 196 | } | ||
| @@ -198,7 +201,7 @@ func (p *queryPTY) Read(b []byte) (int, error) { | |||||
| 198 | return p.sparePTY.Read(b) | 201 | return p.sparePTY.Read(b) | ||
| 199 | default: | 202 | default: | ||
| 200 | close(p.sent) | 203 | close(p.sent) | ||
| 201 | - | return copy(b, "\x1b[c"), nil | 204 | + | return copy(b, p.query), nil |
| 202 | } | 205 | } | ||
| 203 | } | 206 | } | ||
| 204 | 207 | ||||
| @@ -215,20 +218,54 @@ func (f *queryFactory) Open(ctx context.Context, _ string, _ []string, _ []strin | |||||
| 215 | } | 218 | } | ||
| 216 | 219 | ||||
| 217 | func TestFloatingSpareAnswersStartupQueriesBeforeClaim(t *testing.T) { | 220 | func TestFloatingSpareAnswersStartupQueriesBeforeClaim(t *testing.T) { | ||
| 218 | - | pty := &queryPTY{sparePTY: sparePTY{closed: make(chan struct{})}, sent: make(chan struct{}), replied: make(chan []byte, 4)} | 221 | + | for _, tt := range []struct { |
| 219 | - | d := newTestDaemon(t, &queryFactory{pty: pty}, stubClock{}) | 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 | + | ||||
| 254 | + | func TestFloatingSpareExitBeforeClaimDropsIt(t *testing.T) { | |||
| 255 | + | factory := &spareFactory{} | |||
| 256 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 220 | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}}) | 257 | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}}) | ||
| 221 | tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24}) | 258 | tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24}) | ||
| 222 | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | 259 | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | ||
| 223 | sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()} | 260 | sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()} | ||
| 224 | 261 | ||||
| 225 | d.ensureFloatingWarm(sess, tb) | 262 | d.ensureFloatingWarm(sess, tb) | ||
| 226 | - | select { | 263 | + | waitSpareReady(t, sess) |
| 227 | - | case reply := <-pty.replied: | 264 | + | spare := sess.floatingSparePane() |
| 228 | - | require.Contains(t, string(reply), "\x1b[?", "the spare must answer DA1 while unclaimed") | 265 | + | require.NotNil(t, spare) |
| 229 | - | case <-time.After(5 * time.Second): | 266 | + | require.NoError(t, factory.snapshot()[0].pty.Close(), "the spare's shell exits") |
| 230 | - | t.Fatal("the unclaimed spare never answered its shell's startup query") | 267 | + | require.Eventually(t, func() bool { return sess.floatingSparePane() == nil }, time.Second, time.Millisecond, |
| 231 | - | } | 268 | + | "an exited unclaimed spare must be dropped so the next claim launches a fresh shell") |
| 232 | 269 | ||||
| 233 | sess.stopInMemoryLifecycle() | 270 | sess.stopInMemoryLifecycle() | ||
| 234 | d.sessWg.Wait() | 271 | d.sessWg.Wait() | ||
M internal/usecase/daemon/theme_resolve.go +6 -0
| @@ -51,6 +51,12 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme | |||
| 51 | 51 | p.mu.Unlock() | |
| 52 | 52 | } | |
| 53 | 53 | } | |
| 54 | + | // An unclaimed spare answers color queries too, so it follows the theme. | |
| 55 | + | if spare := sess.floatingSparePane(); spare != nil { | |
| 56 | + | spare.mu.Lock() | |
| 57 | + | applyPaneThemeLocked(spare, t, clearUnknownScheme) | |
| 58 | + | spare.mu.Unlock() | |
| 59 | + | } | |
| 54 | 60 | return true | |
| 55 | 61 | } | |
| 56 | 62 | ||
| @@ -51,6 +51,12 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme | |||||
| 51 | p.mu.Unlock() | 51 | p.mu.Unlock() | ||
| 52 | } | 52 | } | ||
| 53 | } | 53 | } | ||
| 54 | + | // An unclaimed spare answers color queries too, so it follows the theme. | |||
| 55 | + | if spare := sess.floatingSparePane(); spare != nil { | |||
| 56 | + | spare.mu.Lock() | |||
| 57 | + | applyPaneThemeLocked(spare, t, clearUnknownScheme) | |||
| 58 | + | spare.mu.Unlock() | |||
| 59 | + | } | |||
| 54 | return true | 60 | return true | ||
| 55 | } | 61 | } | ||
| 56 | 62 | ||||