fix(daemon): answer floating spare shell queries before a tab claims it
The session spare started its PTY reader only on claim, so a shell's startup queries (fish's Primary Device Attribute query) went unanswered and fish disabled features after a 10 s timeout. The spare now reads from launch and writes VT replies while unowned; a claim reuses that reader. A spare is also claimed only by a launch with the same shell, args and environment, not just cwd and floating.command, so a re-attach with a new agent socket or display gets a fresh shell.
- commit
- 861e555dc0a9f01444fe661b411609b31148c034
- author
- brice · · 2026-10-08 05:57 UTC
- parent
- 03130e9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +129 -16
M internal/usecase/daemon/floating.go +1 -1
| @@ -407,7 +407,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||
| 407 | 407 | // Claim the session spare when it matches this launch; a missing or | |
| 408 | 408 | // mismatched spare falls through to a fresh launch. Once a spare was | |
| 409 | 409 | // used up, the session starts the next one in the background. | |
| 410 | - | p, had := sess.takeFloatingSpare(spec.cwd, spec.floatingCommand) | |
| 410 | + | p, had := sess.takeFloatingSpare(spec) | |
| 411 | 411 | if had { | |
| 412 | 412 | defer d.ensureFloatingSpare(sess, tb) | |
| 413 | 413 | } | |
| @@ -407,7 +407,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||||
| 407 | // Claim the session spare when it matches this launch; a missing or | 407 | // Claim the session spare when it matches this launch; a missing or | ||
| 408 | // mismatched spare falls through to a fresh launch. Once a spare was | 408 | // mismatched spare falls through to a fresh launch. Once a spare was | ||
| 409 | // used up, the session starts the next one in the background. | 409 | // used up, the session starts the next one in the background. | ||
| 410 | - | p, had := sess.takeFloatingSpare(spec.cwd, spec.floatingCommand) | 410 | + | p, had := sess.takeFloatingSpare(spec) |
| 411 | if had { | 411 | if had { | ||
| 412 | defer d.ensureFloatingSpare(sess, tb) | 412 | defer d.ensureFloatingSpare(sess, tb) | ||
| 413 | } | 413 | } | ||
M internal/usecase/daemon/floating_spare.go +62 -15
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/vev/internal/domain/layout" | |
| 5 | 7 | ) | |
| 6 | 8 | ||
| @@ -8,20 +10,39 @@ import ( | |||
| 8 | 10 | // than by a tab. The first tab that opens its floating pane claims it, and the | |
| 9 | 11 | // session then starts a new spare in the background, so a session keeps at | |
| 10 | 12 | // most one idle floating shell instead of one hidden shell per tab. A spare is | |
| 11 | - | // never persisted or restored. Its reader starts only once a tab installs it; | |
| 12 | - | // until then the kernel PTY buffer holds its first prompt. Its VEV identity | |
| 13 | - | // names the tab that started it; floating panes are not --self targets. | |
| 13 | + | // never persisted or restored. Its reader starts with the shell, so startup | |
| 14 | + | // terminal queries are answered before any claim; with no owner its screen | |
| 15 | + | // changes publish nothing. Its VEV identity names the tab that started it; | |
| 16 | + | // floating panes are not --self targets. | |
| 14 | 17 | // | |
| 15 | - | // A claim takes the claiming tab's geometry (resize before the reader starts) | |
| 16 | - | // and cwd: a spare started in another directory is discarded and the claim | |
| 17 | - | // launches a fresh shell instead. | |
| 18 | + | // A claim takes the claiming tab's geometry and requires the same cwd, | |
| 19 | + | // floating.command, shell and environment: a mismatched spare is discarded and | |
| 20 | + | // the claim launches a fresh shell instead. | |
| 18 | 21 | // | |
| 19 | 22 | // The spare is guarded by session.floatingLaunchMu, which is never held across | |
| 20 | 23 | // PTY operations or architecture locks. | |
| 21 | 24 | type floatingSpare struct { | |
| 22 | - | pane *pane | |
| 23 | - | cwd string | |
| 24 | - | command string | |
| 25 | + | pane *pane | |
| 26 | + | key floatingSpareKey | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // floatingSpareKey is every launch input a claim must share with the spare. | |
| 30 | + | // env excludes the per-launch VEV identity. | |
| 31 | + | type floatingSpareKey struct { | |
| 32 | + | cwd, floatingCommand, command, args, env string | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func spareKey(spec floatingLaunchSpec) floatingSpareKey { | |
| 36 | + | env := make([]string, 0, len(spec.env)) | |
| 37 | + | for _, entry := range spec.env { | |
| 38 | + | if key, _, _ := environmentEntry(entry); key != "VEV" { | |
| 39 | + | env = append(env, entry) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | return floatingSpareKey{ | |
| 43 | + | cwd: spec.cwd, floatingCommand: spec.floatingCommand, command: spec.command, | |
| 44 | + | args: strings.Join(spec.args, "\x00"), env: strings.Join(env, "\x00"), | |
| 45 | + | } | |
| 25 | 46 | } | |
| 26 | 47 | ||
| 27 | 48 | // ensureFloatingSpare starts the session's spare when none exists or is | |
| @@ -61,7 +82,19 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) { | |||
| 61 | 82 | sess.floatingLaunchMu.Lock() | |
| 62 | 83 | sess.floatingSpareStarting = false | |
| 63 | 84 | if p != nil && !sess.floatingLaunchStopping { | |
| 64 | - | sess.floatingSpare = &floatingSpare{pane: p, cwd: spec.cwd, command: cfg.Command} | |
| 85 | + | sess.floatingSpare = &floatingSpare{pane: p, key: spareKey(spec)} | |
| 86 | + | p.prestarted = true | |
| 87 | + | spare := p | |
| 88 | + | spare.onExit = func() { | |
| 89 | + | spare.spareExited.Store(true) | |
| 90 | + | if spare.ownerSnapshot() == nil { | |
| 91 | + | sess.dropFloatingSpare(spare) | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | d.reapInstalledFloating(spare) | |
| 95 | + | } | |
| 96 | + | d.sessWg.Add(1) | |
| 97 | + | go d.readPanePTY(p) | |
| 65 | 98 | p = nil | |
| 66 | 99 | } | |
| 67 | 100 | sess.floatingLaunchMu.Unlock() | |
| @@ -72,10 +105,10 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) { | |||
| 72 | 105 | } | |
| 73 | 106 | ||
| 74 | 107 | // takeFloatingSpare removes the session spare. It returns the pane when the | |
| 75 | - | // spare was started for cwd and command; a mismatched spare is closed because | |
| 76 | - | // it can never serve this directory or config. had reports whether a spare | |
| 108 | + | // spare was started with the same launch inputs as spec; a mismatched spare is | |
| 109 | + | // closed because it can never serve this launch. had reports whether a spare | |
| 77 | 110 | // existed, so the caller knows to start its replacement. | |
| 78 | - | func (sess *session) takeFloatingSpare(cwd, command string) (p *pane, had bool) { | |
| 111 | + | func (sess *session) takeFloatingSpare(spec floatingLaunchSpec) (p *pane, had bool) { | |
| 79 | 112 | sess.floatingLaunchMu.Lock() | |
| 80 | 113 | spare := sess.floatingSpare | |
| 81 | 114 | sess.floatingSpare = nil | |
| @@ -83,13 +116,23 @@ func (sess *session) takeFloatingSpare(cwd, command string) (p *pane, had bool) | |||
| 83 | 116 | if spare == nil { | |
| 84 | 117 | return nil, false | |
| 85 | 118 | } | |
| 86 | - | if spare.cwd != cwd || spare.command != command { | |
| 119 | + | if spare.key != spareKey(spec) { | |
| 87 | 120 | closeFloatingPane(spare.pane) | |
| 88 | 121 | return nil, true | |
| 89 | 122 | } | |
| 90 | 123 | return spare.pane, true | |
| 91 | 124 | } | |
| 92 | 125 | ||
| 126 | + | // dropFloatingSpare forgets an unclaimed spare whose shell exited. | |
| 127 | + | func (sess *session) dropFloatingSpare(p *pane) { | |
| 128 | + | sess.floatingLaunchMu.Lock() | |
| 129 | + | if sess.floatingSpare != nil && sess.floatingSpare.pane == p { | |
| 130 | + | sess.floatingSpare = nil | |
| 131 | + | } | |
| 132 | + | sess.floatingLaunchMu.Unlock() | |
| 133 | + | closeFloatingPane(p) | |
| 134 | + | } | |
| 135 | + | ||
| 93 | 136 | // closeFloatingSpare releases the spare on session teardown. The caller has | |
| 94 | 137 | // already set floatingLaunchStopping, so no launch can publish a new one. | |
| 95 | 138 | func (sess *session) closeFloatingSpare() { | |
| @@ -147,6 +190,10 @@ func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floati | |||
| 147 | 190 | p.rect = spec.geometry.ptyRect() | |
| 148 | 191 | p.popupGeometry = spec.geometry | |
| 149 | 192 | p.mu.Unlock() | |
| 150 | - | p.onExit = func() { d.reapInstalledFloating(p) } | |
| 151 | 193 | d.installFloating(sess, tb, p, generation) | |
| 194 | + | if p.spareExited.Load() { | |
| 195 | + | // The shell exited before the owner was published, so its reader | |
| 196 | + | // could not reap the slot. | |
| 197 | + | d.reapInstalledFloating(p) | |
| 198 | + | } | |
| 152 | 199 | } | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/vev/internal/domain/layout" | 6 | "github.com/bnema/vev/internal/domain/layout" | ||
| 5 | ) | 7 | ) | ||
| 6 | 8 | ||||
| @@ -8,20 +10,39 @@ import ( | |||||
| 8 | // than by a tab. The first tab that opens its floating pane claims it, and the | 10 | // than by a tab. The first tab that opens its floating pane claims it, and the | ||
| 9 | // session then starts a new spare in the background, so a session keeps at | 11 | // session then starts a new spare in the background, so a session keeps at | ||
| 10 | // most one idle floating shell instead of one hidden shell per tab. A spare is | 12 | // most one idle floating shell instead of one hidden shell per tab. A spare is | ||
| 11 | - | // never persisted or restored. Its reader starts only once a tab installs it; | 13 | + | // never persisted or restored. Its reader starts with the shell, so startup |
| 12 | - | // until then the kernel PTY buffer holds its first prompt. Its VEV identity | 14 | + | // terminal queries are answered before any claim; with no owner its screen |
| 13 | - | // names the tab that started it; floating panes are not --self targets. | 15 | + | // changes publish nothing. Its VEV identity names the tab that started it; |
| 16 | + | // floating panes are not --self targets. | |||
| 14 | // | 17 | // | ||
| 15 | - | // A claim takes the claiming tab's geometry (resize before the reader starts) | 18 | + | // A claim takes the claiming tab's geometry and requires the same cwd, |
| 16 | - | // and cwd: a spare started in another directory is discarded and the claim | 19 | + | // floating.command, shell and environment: a mismatched spare is discarded and |
| 17 | - | // launches a fresh shell instead. | 20 | + | // the claim launches a fresh shell instead. |
| 18 | // | 21 | // | ||
| 19 | // The spare is guarded by session.floatingLaunchMu, which is never held across | 22 | // The spare is guarded by session.floatingLaunchMu, which is never held across | ||
| 20 | // PTY operations or architecture locks. | 23 | // PTY operations or architecture locks. | ||
| 21 | type floatingSpare struct { | 24 | type floatingSpare struct { | ||
| 22 | - | pane *pane | 25 | + | pane *pane |
| 23 | - | cwd string | 26 | + | key floatingSpareKey |
| 24 | - | command string | 27 | + | } |
| 28 | + | ||||
| 29 | + | // floatingSpareKey is every launch input a claim must share with the spare. | |||
| 30 | + | // env excludes the per-launch VEV identity. | |||
| 31 | + | type floatingSpareKey struct { | |||
| 32 | + | cwd, floatingCommand, command, args, env string | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func spareKey(spec floatingLaunchSpec) floatingSpareKey { | |||
| 36 | + | env := make([]string, 0, len(spec.env)) | |||
| 37 | + | for _, entry := range spec.env { | |||
| 38 | + | if key, _, _ := environmentEntry(entry); key != "VEV" { | |||
| 39 | + | env = append(env, entry) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | return floatingSpareKey{ | |||
| 43 | + | cwd: spec.cwd, floatingCommand: spec.floatingCommand, command: spec.command, | |||
| 44 | + | args: strings.Join(spec.args, "\x00"), env: strings.Join(env, "\x00"), | |||
| 45 | + | } | |||
| 25 | } | 46 | } | ||
| 26 | 47 | ||||
| 27 | // ensureFloatingSpare starts the session's spare when none exists or is | 48 | // ensureFloatingSpare starts the session's spare when none exists or is | ||
| @@ -61,7 +82,19 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) { | |||||
| 61 | sess.floatingLaunchMu.Lock() | 82 | sess.floatingLaunchMu.Lock() | ||
| 62 | sess.floatingSpareStarting = false | 83 | sess.floatingSpareStarting = false | ||
| 63 | if p != nil && !sess.floatingLaunchStopping { | 84 | if p != nil && !sess.floatingLaunchStopping { | ||
| 64 | - | sess.floatingSpare = &floatingSpare{pane: p, cwd: spec.cwd, command: cfg.Command} | 85 | + | sess.floatingSpare = &floatingSpare{pane: p, key: spareKey(spec)} |
| 86 | + | p.prestarted = true | |||
| 87 | + | spare := p | |||
| 88 | + | spare.onExit = func() { | |||
| 89 | + | spare.spareExited.Store(true) | |||
| 90 | + | if spare.ownerSnapshot() == nil { | |||
| 91 | + | sess.dropFloatingSpare(spare) | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | d.reapInstalledFloating(spare) | |||
| 95 | + | } | |||
| 96 | + | d.sessWg.Add(1) | |||
| 97 | + | go d.readPanePTY(p) | |||
| 65 | p = nil | 98 | p = nil | ||
| 66 | } | 99 | } | ||
| 67 | sess.floatingLaunchMu.Unlock() | 100 | sess.floatingLaunchMu.Unlock() | ||
| @@ -72,10 +105,10 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) { | |||||
| 72 | } | 105 | } | ||
| 73 | 106 | ||||
| 74 | // takeFloatingSpare removes the session spare. It returns the pane when the | 107 | // takeFloatingSpare removes the session spare. It returns the pane when the | ||
| 75 | - | // spare was started for cwd and command; a mismatched spare is closed because | 108 | + | // spare was started with the same launch inputs as spec; a mismatched spare is |
| 76 | - | // it can never serve this directory or config. had reports whether a spare | 109 | + | // closed because it can never serve this launch. had reports whether a spare |
| 77 | // existed, so the caller knows to start its replacement. | 110 | // existed, so the caller knows to start its replacement. | ||
| 78 | - | func (sess *session) takeFloatingSpare(cwd, command string) (p *pane, had bool) { | 111 | + | func (sess *session) takeFloatingSpare(spec floatingLaunchSpec) (p *pane, had bool) { |
| 79 | sess.floatingLaunchMu.Lock() | 112 | sess.floatingLaunchMu.Lock() | ||
| 80 | spare := sess.floatingSpare | 113 | spare := sess.floatingSpare | ||
| 81 | sess.floatingSpare = nil | 114 | sess.floatingSpare = nil | ||
| @@ -83,13 +116,23 @@ func (sess *session) takeFloatingSpare(cwd, command string) (p *pane, had bool) | |||||
| 83 | if spare == nil { | 116 | if spare == nil { | ||
| 84 | return nil, false | 117 | return nil, false | ||
| 85 | } | 118 | } | ||
| 86 | - | if spare.cwd != cwd || spare.command != command { | 119 | + | if spare.key != spareKey(spec) { |
| 87 | closeFloatingPane(spare.pane) | 120 | closeFloatingPane(spare.pane) | ||
| 88 | return nil, true | 121 | return nil, true | ||
| 89 | } | 122 | } | ||
| 90 | return spare.pane, true | 123 | return spare.pane, true | ||
| 91 | } | 124 | } | ||
| 92 | 125 | ||||
| 126 | + | // dropFloatingSpare forgets an unclaimed spare whose shell exited. | |||
| 127 | + | func (sess *session) dropFloatingSpare(p *pane) { | |||
| 128 | + | sess.floatingLaunchMu.Lock() | |||
| 129 | + | if sess.floatingSpare != nil && sess.floatingSpare.pane == p { | |||
| 130 | + | sess.floatingSpare = nil | |||
| 131 | + | } | |||
| 132 | + | sess.floatingLaunchMu.Unlock() | |||
| 133 | + | closeFloatingPane(p) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 93 | // closeFloatingSpare releases the spare on session teardown. The caller has | 136 | // closeFloatingSpare releases the spare on session teardown. The caller has | ||
| 94 | // already set floatingLaunchStopping, so no launch can publish a new one. | 137 | // already set floatingLaunchStopping, so no launch can publish a new one. | ||
| 95 | func (sess *session) closeFloatingSpare() { | 138 | func (sess *session) closeFloatingSpare() { | ||
| @@ -147,6 +190,10 @@ func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floati | |||||
| 147 | p.rect = spec.geometry.ptyRect() | 190 | p.rect = spec.geometry.ptyRect() | ||
| 148 | p.popupGeometry = spec.geometry | 191 | p.popupGeometry = spec.geometry | ||
| 149 | p.mu.Unlock() | 192 | p.mu.Unlock() | ||
| 150 | - | p.onExit = func() { d.reapInstalledFloating(p) } | |||
| 151 | d.installFloating(sess, tb, p, generation) | 193 | d.installFloating(sess, tb, p, generation) | ||
| 194 | + | if p.spareExited.Load() { | |||
| 195 | + | // The shell exited before the owner was published, so its reader | |||
| 196 | + | // could not reap the slot. | |||
| 197 | + | d.reapInstalledFloating(p) | |||
| 198 | + | } | |||
| 152 | } | 199 | } | ||
M internal/usecase/daemon/floating_spare_test.go +50 -0
| @@ -183,3 +183,53 @@ type pidPTY struct { | |||
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | 185 | func (p *pidPTY) Pid() int { return p.pid } | |
| 186 | + | ||
| 187 | + | // queryPTY is a floating shell that sends one Primary Device Attribute query | |
| 188 | + | // at startup and records what the daemon writes back. | |
| 189 | + | type queryPTY struct { | |
| 190 | + | sparePTY | |
| 191 | + | sent chan struct{} | |
| 192 | + | replied chan []byte | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func (p *queryPTY) Read(b []byte) (int, error) { | |
| 196 | + | select { | |
| 197 | + | case <-p.sent: | |
| 198 | + | return p.sparePTY.Read(b) | |
| 199 | + | default: | |
| 200 | + | close(p.sent) | |
| 201 | + | return copy(b, "\x1b[c"), nil | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (p *queryPTY) Write(b []byte) (int, error) { | |
| 206 | + | p.replied <- append([]byte(nil), b...) | |
| 207 | + | return len(b), nil | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | type queryFactory struct{ pty *queryPTY } | |
| 211 | + | ||
| 212 | + | func (f *queryFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) { | |
| 213 | + | f.pty.ctx = ctx | |
| 214 | + | return f.pty, nil | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | 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{}) | |
| 220 | + | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}}) | |
| 221 | + | tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24}) | |
| 222 | + | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | |
| 223 | + | sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 224 | + | ||
| 225 | + | 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 | + | } | |
| 232 | + | ||
| 233 | + | sess.stopInMemoryLifecycle() | |
| 234 | + | d.sessWg.Wait() | |
| 235 | + | } | |
| @@ -183,3 +183,53 @@ type pidPTY struct { | |||||
| 183 | } | 183 | } | ||
| 184 | 184 | ||||
| 185 | func (p *pidPTY) Pid() int { return p.pid } | 185 | func (p *pidPTY) Pid() int { return p.pid } | ||
| 186 | + | ||||
| 187 | + | // queryPTY is a floating shell that sends one Primary Device Attribute query | |||
| 188 | + | // at startup and records what the daemon writes back. | |||
| 189 | + | type queryPTY struct { | |||
| 190 | + | sparePTY | |||
| 191 | + | sent chan struct{} | |||
| 192 | + | replied chan []byte | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func (p *queryPTY) Read(b []byte) (int, error) { | |||
| 196 | + | select { | |||
| 197 | + | case <-p.sent: | |||
| 198 | + | return p.sparePTY.Read(b) | |||
| 199 | + | default: | |||
| 200 | + | close(p.sent) | |||
| 201 | + | return copy(b, "\x1b[c"), nil | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (p *queryPTY) Write(b []byte) (int, error) { | |||
| 206 | + | p.replied <- append([]byte(nil), b...) | |||
| 207 | + | return len(b), nil | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | type queryFactory struct{ pty *queryPTY } | |||
| 211 | + | ||||
| 212 | + | func (f *queryFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) { | |||
| 213 | + | f.pty.ctx = ctx | |||
| 214 | + | return f.pty, nil | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | 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{}) | |||
| 220 | + | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}}) | |||
| 221 | + | tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24}) | |||
| 222 | + | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | |||
| 223 | + | sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()} | |||
| 224 | + | ||||
| 225 | + | 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 | + | } | |||
| 232 | + | ||||
| 233 | + | sess.stopInMemoryLifecycle() | |||
| 234 | + | d.sessWg.Wait() | |||
| 235 | + | } | |||
M internal/usecase/daemon/pane.go +7 -0
| @@ -145,6 +145,13 @@ type pane struct { | |||
| 145 | 145 | // onExit is set before the reader starts and never changed. Floating panes | |
| 146 | 146 | // use it to reap their independent slot without touching the layout tree. | |
| 147 | 147 | onExit func() | |
| 148 | + | // prestarted marks a floating spare whose reader runs before any owner is | |
| 149 | + | // published, so terminal queries from a starting shell are answered. | |
| 150 | + | // Set before the reader starts and never changed. | |
| 151 | + | prestarted bool | |
| 152 | + | // spareExited records that a prestarted spare's reader ended, so a claim | |
| 153 | + | // racing that exit still reaps the slot it installed. | |
| 154 | + | spareExited atomic.Bool | |
| 148 | 155 | } | |
| 149 | 156 | ||
| 150 | 157 | func closePaneProcess(p *pane) { | |
| @@ -145,6 +145,13 @@ type pane struct { | |||||
| 145 | // onExit is set before the reader starts and never changed. Floating panes | 145 | // onExit is set before the reader starts and never changed. Floating panes | ||
| 146 | // use it to reap their independent slot without touching the layout tree. | 146 | // use it to reap their independent slot without touching the layout tree. | ||
| 147 | onExit func() | 147 | onExit func() | ||
| 148 | + | // prestarted marks a floating spare whose reader runs before any owner is | |||
| 149 | + | // published, so terminal queries from a starting shell are answered. | |||
| 150 | + | // Set before the reader starts and never changed. | |||
| 151 | + | prestarted bool | |||
| 152 | + | // spareExited records that a prestarted spare's reader ended, so a claim | |||
| 153 | + | // racing that exit still reaps the slot it installed. | |||
| 154 | + | spareExited atomic.Bool | |||
| 148 | } | 155 | } | ||
| 149 | 156 | ||||
| 150 | func closePaneProcess(p *pane) { | 157 | func closePaneProcess(p *pane) { | ||
M internal/usecase/daemon/render.go +5 -0
| @@ -213,6 +213,11 @@ func filterUnsupportedKittyAnimationResponses(data []byte) []byte { | |||
| 213 | 213 | // bytes that belonged to the old owner. | |
| 214 | 214 | func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) { | |
| 215 | 215 | owner := effects.lease.owner | |
| 216 | + | if owner == nil && p.prestarted && len(effects.responses) > 0 { | |
| 217 | + | // An unclaimed spare still answers its shell's startup queries; a | |
| 218 | + | // closed spare only fails the write. | |
| 219 | + | _, _ = p.pty.Write(effects.responses) | |
| 220 | + | } | |
| 216 | 221 | if owner == nil || owner.session == nil || owner.tab == nil { | |
| 217 | 222 | return | |
| 218 | 223 | } | |
| @@ -213,6 +213,11 @@ func filterUnsupportedKittyAnimationResponses(data []byte) []byte { | |||||
| 213 | // bytes that belonged to the old owner. | 213 | // bytes that belonged to the old owner. | ||
| 214 | func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) { | 214 | func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) { | ||
| 215 | owner := effects.lease.owner | 215 | owner := effects.lease.owner | ||
| 216 | + | if owner == nil && p.prestarted && len(effects.responses) > 0 { | |||
| 217 | + | // An unclaimed spare still answers its shell's startup queries; a | |||
| 218 | + | // closed spare only fails the write. | |||
| 219 | + | _, _ = p.pty.Write(effects.responses) | |||
| 220 | + | } | |||
| 216 | if owner == nil || owner.session == nil || owner.tab == nil { | 221 | if owner == nil || owner.session == nil || owner.tab == nil { | ||
| 217 | return | 222 | return | ||
| 218 | } | 223 | } | ||
M internal/usecase/daemon/session.go +4 -0
| @@ -896,6 +896,10 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||
| 896 | 896 | name := sess.name | |
| 897 | 897 | sess.mu.Unlock() | |
| 898 | 898 | d.log.Info("pane created", "session", name, "pane", p.id) | |
| 899 | + | if p.prestarted { | |
| 900 | + | // A claimed spare keeps the reader it started with. | |
| 901 | + | return | |
| 902 | + | } | |
| 899 | 903 | // Scheduler ownership was removed; this launch creates exactly one reader. | |
| 900 | 904 | d.sessWg.Add(1) | |
| 901 | 905 | go d.readPanePTY(p) | |
| @@ -896,6 +896,10 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||||
| 896 | name := sess.name | 896 | name := sess.name | ||
| 897 | sess.mu.Unlock() | 897 | sess.mu.Unlock() | ||
| 898 | d.log.Info("pane created", "session", name, "pane", p.id) | 898 | d.log.Info("pane created", "session", name, "pane", p.id) | ||
| 899 | + | if p.prestarted { | |||
| 900 | + | // A claimed spare keeps the reader it started with. | |||
| 901 | + | return | |||
| 902 | + | } | |||
| 899 | // Scheduler ownership was removed; this launch creates exactly one reader. | 903 | // Scheduler ownership was removed; this launch creates exactly one reader. | ||
| 900 | d.sessWg.Add(1) | 904 | d.sessWg.Add(1) | ||
| 901 | go d.readPanePTY(p) | 905 | go d.readPanePTY(p) | ||