detached 1c3372f

Merge pull request 'refactor(daemon): extract config snapshot, snapshot worker, graphics pool and palette history' (#13) from refactor/daemon-split into main

commit
1c3372f6f5615f54f213e9d4e9d063b9bcfb5794
author
bnema · · 2026-10-08 08:22 UTC
merge of
da8b91e + b5fd841 · diff against the first parent
signature
unsigned
50 files · +1026 -629
M internal/usecase/daemon/attachment_suspension_expiry_test.go +1 -1
@@ -142,7 +142,7 @@ func TestSuspendedAttachmentSafetyExpiryEvictsAfterTimer(t *testing.T) {
142142
143143 func TestSuspendedAttachmentExpiryLeavesEphemeralSessionRegistered(t *testing.T) {
144144 d, sess, ac, timer, _ := newSuspendedExpiryFixture(t)
145- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: false})
145+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: false} })
146146 sess.mu.Lock()
147147 sess.ephemeral = true
148148 sess.mu.Unlock()
@@ -142,7 +142,7 @@ func TestSuspendedAttachmentSafetyExpiryEvictsAfterTimer(t *testing.T) {
142 142
143 func TestSuspendedAttachmentExpiryLeavesEphemeralSessionRegistered(t *testing.T) {143 func TestSuspendedAttachmentExpiryLeavesEphemeralSessionRegistered(t *testing.T) {
144 d, sess, ac, timer, _ := newSuspendedExpiryFixture(t)144 d, sess, ac, timer, _ := newSuspendedExpiryFixture(t)
145- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: false})145+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: false} })
146 sess.mu.Lock()146 sess.mu.Lock()
147 sess.ephemeral = true147 sess.ephemeral = true
148 sess.mu.Unlock()148 sess.mu.Unlock()
M internal/usecase/daemon/bar_refresh.go +4 -3
@@ -136,13 +136,13 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
136136 pid := 0
137137 if p := tb.focusedPane(); p != nil {
138138 ctx.Pane = p.stableID
139- if d != nil && d.procCwd != nil && p.pty != nil {
139+ if d != nil && d.proc != nil && p.pty != nil {
140140 pid = p.pty.Pid()
141141 }
142142 }
143143 tb.mu.Unlock()
144- if pid != 0 && d != nil && d.procCwd != nil {
145- if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
144+ if pid != 0 && d != nil && d.proc != nil {
145+ if cwd, err := d.proc.Cwd(pid); err == nil && cwd != "" {
146146 ctx.PaneCWD = cwd
147147 }
148148 }
@@ -316,6 +316,7 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
316316 return true
317317 }
318318
319+// scheduleBarScriptRefreshLocked requires d.barScripts.mu, not d.mu.
319320 func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
320321 if d.barScripts.pending[sess.id] {
321322 return
@@ -136,13 +136,13 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
136 pid := 0136 pid := 0
137 if p := tb.focusedPane(); p != nil {137 if p := tb.focusedPane(); p != nil {
138 ctx.Pane = p.stableID138 ctx.Pane = p.stableID
139- if d != nil && d.procCwd != nil && p.pty != nil {139+ if d != nil && d.proc != nil && p.pty != nil {
140 pid = p.pty.Pid()140 pid = p.pty.Pid()
141 }141 }
142 }142 }
143 tb.mu.Unlock()143 tb.mu.Unlock()
144- if pid != 0 && d != nil && d.procCwd != nil {144+ if pid != 0 && d != nil && d.proc != nil {
145- if cwd, err := d.procCwd(pid); err == nil && cwd != "" {145+ if cwd, err := d.proc.Cwd(pid); err == nil && cwd != "" {
146 ctx.PaneCWD = cwd146 ctx.PaneCWD = cwd
147 }147 }
148 }148 }
@@ -316,6 +316,7 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
316 return true316 return true
317 }317 }
318 318
319+// scheduleBarScriptRefreshLocked requires d.barScripts.mu, not d.mu.
319 func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {320 func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
320 if d.barScripts.pending[sess.id] {321 if d.barScripts.pending[sess.id] {
321 return322 return
M internal/usecase/daemon/bar_refresh_test.go +2 -2
@@ -96,10 +96,10 @@ func TestBarScriptContextUsesFirstOrderedPaneAndClientCols(t *testing.T) {
9696 active := sess.tabs[0].focusedPane()
9797 active.stableID = "pane-default"
9898 active.pty = newScriptPTY(nil)
99- d.procCwd = func(pid int) (string, error) {
99+ WithCwdReader(func(pid int) (string, error) {
100100 require.Equal(t, 4242, pid)
101101 return "/pane-repo", nil
102- }
102+ })(d)
103103
104104 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
105105 waitBarRefreshIdle(t, d)
@@ -96,10 +96,10 @@ func TestBarScriptContextUsesFirstOrderedPaneAndClientCols(t *testing.T) {
96 active := sess.tabs[0].focusedPane()96 active := sess.tabs[0].focusedPane()
97 active.stableID = "pane-default"97 active.stableID = "pane-default"
98 active.pty = newScriptPTY(nil)98 active.pty = newScriptPTY(nil)
99- d.procCwd = func(pid int) (string, error) {99+ WithCwdReader(func(pid int) (string, error) {
100 require.Equal(t, 4242, pid)100 require.Equal(t, 4242, pid)
101 return "/pane-repo", nil101 return "/pane-repo", nil
102- }102+ })(d)
103 103
104 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))104 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
105 waitBarRefreshIdle(t, d)105 waitBarRefreshIdle(t, d)
M internal/usecase/daemon/client.go +36 -0
@@ -18,6 +18,42 @@
1818 // moveLifecycleReservation.Release must never be called with d.mu held: it takes
1919 // moveLifecycleMu first, releases both the move-gate and purge-gate reservations
2020 // in that order, and only then drops moveLifecycleMu.
21+//
22+// Snapshot worker locks (snapshotWorker in snapshot_worker_state.go):
23+// snapshotWorker.mu (d.snapshots.mu) is a leaf with respect to d.mu and the
24+// session (mu), tab, and pane locks: no path acquires any of them while holding
25+// it, and the capture, queue, and worker code never takes d.mu. The only nested
26+// acquisition is a session's snapshotMu inside it (enqueueFinalSnapshotCapture
27+// finishes a replaced capture), so snapshotMu holders must release snapshotMu
28+// before taking snapshotWorker.mu, as finishSnapshotCapture does. That nested
29+// finishSnapshotCapture(replaced, false) cannot re-enter snapshotWorker.mu:
30+// final-queue captures are never normalWorkerAdmitted, and succeeded=false
31+// suppresses the forced-successor scheduling path.
32+// snapshotWorker.noticeMu guards the active persistence-failure signature; it
33+// is a leaf and is never held together with snapshotWorker.mu. Restoration
34+// takes d.mu only outside snapshotWorker.mu.
35+//
36+// paletteHistory.mu guards the daemon-wide recent-command list. It is a leaf:
37+// it is only held to copy or rewrite that slice, never while calling out.
38+//
39+// purgeAllMu serializes whole KillAll purges (purgeAllSessions). It is the
40+// outermost daemon-level lock on that path: the owner takes d.mu (briefly, to
41+// open and close the purge admission gate) and later per-session teardown locks
42+// while holding it. No path acquires purgeAllMu while holding d.mu,
43+// moveLifecycleMu, a session lock, or a pane lock. It is not an admission gate;
44+// admission is the d.mu-guarded purgeAdmission* state.
45+//
46+// attnMu guards only the attention animation frame counter (animFrame). It is
47+// a leaf: attentionFrame, advanceAttentionFrame, and setAttentionFrame take it
48+// for the read or write and call nothing while holding it.
49+//
50+// Naming rule for the Locked suffix: on a Daemon method (or a free function
51+// taking a Daemon-owned structure) a ...Locked suffix means the caller holds
52+// d.mu. On a method of any other type, it means the caller holds that
53+// receiver's own mutex (session.mu, tab.mu, pane.mu, and so on). Whenever a
54+// ...Locked function instead requires a different lock (for example
55+// overlayRuntime.copyMu, snapshotWorker.mu, moveLifecycleMu, or
56+// barScriptState.mu), its doc comment names that lock explicitly.
2157 package daemon
2258
2359 import (
@@ -18,6 +18,42 @@
18 // moveLifecycleReservation.Release must never be called with d.mu held: it takes18 // moveLifecycleReservation.Release must never be called with d.mu held: it takes
19 // moveLifecycleMu first, releases both the move-gate and purge-gate reservations19 // moveLifecycleMu first, releases both the move-gate and purge-gate reservations
20 // in that order, and only then drops moveLifecycleMu.20 // in that order, and only then drops moveLifecycleMu.
21+//
22+// Snapshot worker locks (snapshotWorker in snapshot_worker_state.go):
23+// snapshotWorker.mu (d.snapshots.mu) is a leaf with respect to d.mu and the
24+// session (mu), tab, and pane locks: no path acquires any of them while holding
25+// it, and the capture, queue, and worker code never takes d.mu. The only nested
26+// acquisition is a session's snapshotMu inside it (enqueueFinalSnapshotCapture
27+// finishes a replaced capture), so snapshotMu holders must release snapshotMu
28+// before taking snapshotWorker.mu, as finishSnapshotCapture does. That nested
29+// finishSnapshotCapture(replaced, false) cannot re-enter snapshotWorker.mu:
30+// final-queue captures are never normalWorkerAdmitted, and succeeded=false
31+// suppresses the forced-successor scheduling path.
32+// snapshotWorker.noticeMu guards the active persistence-failure signature; it
33+// is a leaf and is never held together with snapshotWorker.mu. Restoration
34+// takes d.mu only outside snapshotWorker.mu.
35+//
36+// paletteHistory.mu guards the daemon-wide recent-command list. It is a leaf:
37+// it is only held to copy or rewrite that slice, never while calling out.
38+//
39+// purgeAllMu serializes whole KillAll purges (purgeAllSessions). It is the
40+// outermost daemon-level lock on that path: the owner takes d.mu (briefly, to
41+// open and close the purge admission gate) and later per-session teardown locks
42+// while holding it. No path acquires purgeAllMu while holding d.mu,
43+// moveLifecycleMu, a session lock, or a pane lock. It is not an admission gate;
44+// admission is the d.mu-guarded purgeAdmission* state.
45+//
46+// attnMu guards only the attention animation frame counter (animFrame). It is
47+// a leaf: attentionFrame, advanceAttentionFrame, and setAttentionFrame take it
48+// for the read or write and call nothing while holding it.
49+//
50+// Naming rule for the Locked suffix: on a Daemon method (or a free function
51+// taking a Daemon-owned structure) a ...Locked suffix means the caller holds
52+// d.mu. On a method of any other type, it means the caller holds that
53+// receiver's own mutex (session.mu, tab.mu, pane.mu, and so on). Whenever a
54+// ...Locked function instead requires a different lock (for example
55+// overlayRuntime.copyMu, snapshotWorker.mu, moveLifecycleMu, or
56+// barScriptState.mu), its doc comment names that lock explicitly.
21 package daemon57 package daemon
22 58
23 import (59 import (
M internal/usecase/daemon/config.go +68 -61
@@ -18,7 +18,7 @@ import (
1818
1919 var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
2020
21-// themeConfigSnapshot is published as one immutable value. Its zero value is
21+// themeConfigSnapshot is the theme slice of runtimeConfig. Its zero value is
2222 // the default theme configuration: automatic mode with palette inheritance on.
2323 type themeConfigSnapshot struct {
2424 mode domain.ThemeMode
@@ -26,17 +26,54 @@ type themeConfigSnapshot struct {
2626 accent domain.ThemeAccent
2727 }
2828
29-func (d *Daemon) storeThemeConfig(cfg domain.Config) {
30- snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
31- d.themeConfig.Store(&snapshot)
29+// runtimeConfig is the immutable derived configuration published by
30+// ApplyConfig as one atomic snapshot, so a reader never observes values from
31+// two different reloads. Never mutate a published snapshot; build a new one.
32+//
33+// The key bindings are deliberately not part of the snapshot: keys.Router reads
34+// them through its own *atomic.Pointer[keys.Bindings] (see Daemon.bindings).
35+type runtimeConfig struct {
36+ codeOverrides map[string]string
37+ restoreProcessAllowlist map[string]struct{}
38+ floating domain.FloatingConfig
39+ copy domain.CopyConfig
40+ palette domain.PaletteConfig
41+ nav domain.NavConfig
42+ tabs domain.TabsConfig
43+ ephemeral domain.EphemeralConfig
44+ scrollback domain.ScrollbackConfig
45+ theme themeConfigSnapshot
3246 }
3347
34-func (d *Daemon) currentThemeConfig() themeConfigSnapshot {
35- snapshot := d.themeConfig.Load()
36- if snapshot == nil {
37- return themeConfigSnapshot{}
48+// defaultRuntimeConfig is the configuration in force before the first
49+// ApplyConfig: vev defaults for every section, no code overrides, and the
50+// default theme snapshot.
51+func defaultRuntimeConfig() *runtimeConfig {
52+ defaults := domain.Defaults()
53+ return &runtimeConfig{
54+ codeOverrides: map[string]string{},
55+ restoreProcessAllowlist: buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses()),
56+ floating: defaults.Floating,
57+ copy: defaults.Copy,
58+ palette: defaults.Palette,
59+ nav: defaults.Nav,
60+ tabs: defaults.Tabs,
61+ ephemeral: defaults.Ephemeral,
62+ scrollback: domain.DefaultScrollbackConfig(),
3863 }
39- return *snapshot
64+}
65+
66+// currentRuntimeConfig returns the current snapshot, or the defaults when none has
67+// been published (zero-value Daemon). The result is never nil.
68+func (d *Daemon) currentRuntimeConfig() *runtimeConfig {
69+ if cfg := d.config.Load(); cfg != nil {
70+ return cfg
71+ }
72+ return defaultRuntimeConfig()
73+}
74+
75+func (d *Daemon) currentThemeConfig() themeConfigSnapshot {
76+ return d.currentRuntimeConfig().theme
4077 }
4178
4279 // ApplyConfig validates and atomically swaps daemon runtime configuration.
@@ -49,27 +86,23 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
4986 scrollback = domain.DefaultScrollbackConfig()
5087 allWarnings = append(allWarnings, domain.Warning{Msg: "invalid scrollback limits; using vev defaults"})
5188 }
52- d.scrollbackConfig.Store(&scrollback)
89+ snapshot := &runtimeConfig{
90+ codeOverrides: overrides,
91+ restoreProcessAllowlist: restoreProcessAllowlistFromConfig(cfg.Snapshot),
92+ floating: cfg.Floating,
93+ copy: cfg.Copy,
94+ palette: cfg.Palette,
95+ nav: cfg.Nav,
96+ tabs: cfg.Tabs,
97+ ephemeral: cfg.Ephemeral,
98+ scrollback: scrollback,
99+ theme: themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent},
100+ }
101+ d.bindings.Store(bindings)
102+ d.config.Store(snapshot)
53103 for _, warning := range allWarnings {
54104 d.logConfigWarning(warning)
55105 }
56- allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot)
57- d.bindings.Store(bindings)
58- d.codeOverrides.Store(&overrides)
59- d.restoreProcessAllowlist.Store(&allowlist)
60- floating := cfg.Floating
61- d.floatingConfig.Store(&floating)
62- copyConfig := cfg.Copy
63- d.copyConfig.Store(&copyConfig)
64- palette := cfg.Palette
65- d.paletteConfig.Store(&palette)
66- nav := cfg.Nav
67- d.navConfig.Store(&nav)
68- tabs := cfg.Tabs
69- d.tabsConfig.Store(&tabs)
70- ephemeral := cfg.Ephemeral
71- d.ephemeralConfig.Store(&ephemeral)
72- d.storeThemeConfig(cfg)
73106 d.applyHistoryLimits()
74107 barChanged := false
75108 if d.barScripts != nil {
@@ -245,11 +278,7 @@ func buildRestoreProcessAllowlist(values []string) map[string]struct{} {
245278 }
246279
247280 func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} {
248- allow := d.restoreProcessAllowlist.Load()
249- if allow == nil {
250- return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
251- }
252- return *allow
281+ return d.currentRuntimeConfig().restoreProcessAllowlist
253282 }
254283
255284 func (d *Daemon) logConfigWarning(w domain.Warning) {
@@ -261,53 +290,31 @@ func (d *Daemon) logConfigWarning(w domain.Warning) {
261290 }
262291
263292 func (d *Daemon) currentCopyConfig() domain.CopyConfig {
264- if cfg := d.copyConfig.Load(); cfg != nil {
265- return *cfg
266- }
267- return domain.Defaults().Copy
293+ return d.currentRuntimeConfig().copy
268294 }
269295
270296 func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
271- if cfg := d.paletteConfig.Load(); cfg != nil {
272- return *cfg
273- }
274- return domain.Defaults().Palette
297+ return d.currentRuntimeConfig().palette
275298 }
276299
277300 func (d *Daemon) currentNavConfig() domain.NavConfig {
278- if cfg := d.navConfig.Load(); cfg != nil {
279- return *cfg
280- }
281- return domain.Defaults().Nav
301+ return d.currentRuntimeConfig().nav
282302 }
283303
284304 func (d *Daemon) currentTabsConfig() domain.TabsConfig {
285- if cfg := d.tabsConfig.Load(); cfg != nil {
286- return *cfg
287- }
288- return domain.Defaults().Tabs
305+ return d.currentRuntimeConfig().tabs
289306 }
290307
291308 func (d *Daemon) currentEphemeralConfig() domain.EphemeralConfig {
292- if cfg := d.ephemeralConfig.Load(); cfg != nil {
293- return *cfg
294- }
295- return domain.Defaults().Ephemeral
309+ return d.currentRuntimeConfig().ephemeral
296310 }
297311
298312 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
299- if cfg := d.floatingConfig.Load(); cfg != nil {
300- return *cfg
301- }
302- return domain.Defaults().Floating
313+ return d.currentRuntimeConfig().floating
303314 }
304315
305316 func (d *Daemon) codeOverrideSnapshot() map[string]string {
306- overrides := d.codeOverrides.Load()
307- if overrides == nil {
308- return nil
309- }
310- return *overrides
317+ return d.currentRuntimeConfig().codeOverrides
311318 }
312319
313320 func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {
@@ -18,7 +18,7 @@ import (
18 18
19 var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)19 var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
20 20
21-// themeConfigSnapshot is published as one immutable value. Its zero value is21+// themeConfigSnapshot is the theme slice of runtimeConfig. Its zero value is
22 // the default theme configuration: automatic mode with palette inheritance on.22 // the default theme configuration: automatic mode with palette inheritance on.
23 type themeConfigSnapshot struct {23 type themeConfigSnapshot struct {
24 mode domain.ThemeMode24 mode domain.ThemeMode
@@ -26,17 +26,54 @@ type themeConfigSnapshot struct {
26 accent domain.ThemeAccent26 accent domain.ThemeAccent
27 }27 }
28 28
29-func (d *Daemon) storeThemeConfig(cfg domain.Config) {29+// runtimeConfig is the immutable derived configuration published by
30- snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}30+// ApplyConfig as one atomic snapshot, so a reader never observes values from
31- d.themeConfig.Store(&snapshot)31+// two different reloads. Never mutate a published snapshot; build a new one.
32+//
33+// The key bindings are deliberately not part of the snapshot: keys.Router reads
34+// them through its own *atomic.Pointer[keys.Bindings] (see Daemon.bindings).
35+type runtimeConfig struct {
36+ codeOverrides map[string]string
37+ restoreProcessAllowlist map[string]struct{}
38+ floating domain.FloatingConfig
39+ copy domain.CopyConfig
40+ palette domain.PaletteConfig
41+ nav domain.NavConfig
42+ tabs domain.TabsConfig
43+ ephemeral domain.EphemeralConfig
44+ scrollback domain.ScrollbackConfig
45+ theme themeConfigSnapshot
32 }46 }
33 47
34-func (d *Daemon) currentThemeConfig() themeConfigSnapshot {48+// defaultRuntimeConfig is the configuration in force before the first
35- snapshot := d.themeConfig.Load()49+// ApplyConfig: vev defaults for every section, no code overrides, and the
36- if snapshot == nil {50+// default theme snapshot.
37- return themeConfigSnapshot{}51+func defaultRuntimeConfig() *runtimeConfig {
52+ defaults := domain.Defaults()
53+ return &runtimeConfig{
54+ codeOverrides: map[string]string{},
55+ restoreProcessAllowlist: buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses()),
56+ floating: defaults.Floating,
57+ copy: defaults.Copy,
58+ palette: defaults.Palette,
59+ nav: defaults.Nav,
60+ tabs: defaults.Tabs,
61+ ephemeral: defaults.Ephemeral,
62+ scrollback: domain.DefaultScrollbackConfig(),
38 }63 }
39- return *snapshot64+}
65+
66+// currentRuntimeConfig returns the current snapshot, or the defaults when none has
67+// been published (zero-value Daemon). The result is never nil.
68+func (d *Daemon) currentRuntimeConfig() *runtimeConfig {
69+ if cfg := d.config.Load(); cfg != nil {
70+ return cfg
71+ }
72+ return defaultRuntimeConfig()
73+}
74+
75+func (d *Daemon) currentThemeConfig() themeConfigSnapshot {
76+ return d.currentRuntimeConfig().theme
40 }77 }
41 78
42 // ApplyConfig validates and atomically swaps daemon runtime configuration.79 // ApplyConfig validates and atomically swaps daemon runtime configuration.
@@ -49,27 +86,23 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
49 scrollback = domain.DefaultScrollbackConfig()86 scrollback = domain.DefaultScrollbackConfig()
50 allWarnings = append(allWarnings, domain.Warning{Msg: "invalid scrollback limits; using vev defaults"})87 allWarnings = append(allWarnings, domain.Warning{Msg: "invalid scrollback limits; using vev defaults"})
51 }88 }
52- d.scrollbackConfig.Store(&scrollback)89+ snapshot := &runtimeConfig{
90+ codeOverrides: overrides,
91+ restoreProcessAllowlist: restoreProcessAllowlistFromConfig(cfg.Snapshot),
92+ floating: cfg.Floating,
93+ copy: cfg.Copy,
94+ palette: cfg.Palette,
95+ nav: cfg.Nav,
96+ tabs: cfg.Tabs,
97+ ephemeral: cfg.Ephemeral,
98+ scrollback: scrollback,
99+ theme: themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent},
100+ }
101+ d.bindings.Store(bindings)
102+ d.config.Store(snapshot)
53 for _, warning := range allWarnings {103 for _, warning := range allWarnings {
54 d.logConfigWarning(warning)104 d.logConfigWarning(warning)
55 }105 }
56- allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot)
57- d.bindings.Store(bindings)
58- d.codeOverrides.Store(&overrides)
59- d.restoreProcessAllowlist.Store(&allowlist)
60- floating := cfg.Floating
61- d.floatingConfig.Store(&floating)
62- copyConfig := cfg.Copy
63- d.copyConfig.Store(&copyConfig)
64- palette := cfg.Palette
65- d.paletteConfig.Store(&palette)
66- nav := cfg.Nav
67- d.navConfig.Store(&nav)
68- tabs := cfg.Tabs
69- d.tabsConfig.Store(&tabs)
70- ephemeral := cfg.Ephemeral
71- d.ephemeralConfig.Store(&ephemeral)
72- d.storeThemeConfig(cfg)
73 d.applyHistoryLimits()106 d.applyHistoryLimits()
74 barChanged := false107 barChanged := false
75 if d.barScripts != nil {108 if d.barScripts != nil {
@@ -245,11 +278,7 @@ func buildRestoreProcessAllowlist(values []string) map[string]struct{} {
245 }278 }
246 279
247 func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} {280 func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} {
248- allow := d.restoreProcessAllowlist.Load()281+ return d.currentRuntimeConfig().restoreProcessAllowlist
249- if allow == nil {
250- return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
251- }
252- return *allow
253 }282 }
254 283
255 func (d *Daemon) logConfigWarning(w domain.Warning) {284 func (d *Daemon) logConfigWarning(w domain.Warning) {
@@ -261,53 +290,31 @@ func (d *Daemon) logConfigWarning(w domain.Warning) {
261 }290 }
262 291
263 func (d *Daemon) currentCopyConfig() domain.CopyConfig {292 func (d *Daemon) currentCopyConfig() domain.CopyConfig {
264- if cfg := d.copyConfig.Load(); cfg != nil {293+ return d.currentRuntimeConfig().copy
265- return *cfg
266- }
267- return domain.Defaults().Copy
268 }294 }
269 295
270 func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {296 func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
271- if cfg := d.paletteConfig.Load(); cfg != nil {297+ return d.currentRuntimeConfig().palette
272- return *cfg
273- }
274- return domain.Defaults().Palette
275 }298 }
276 299
277 func (d *Daemon) currentNavConfig() domain.NavConfig {300 func (d *Daemon) currentNavConfig() domain.NavConfig {
278- if cfg := d.navConfig.Load(); cfg != nil {301+ return d.currentRuntimeConfig().nav
279- return *cfg
280- }
281- return domain.Defaults().Nav
282 }302 }
283 303
284 func (d *Daemon) currentTabsConfig() domain.TabsConfig {304 func (d *Daemon) currentTabsConfig() domain.TabsConfig {
285- if cfg := d.tabsConfig.Load(); cfg != nil {305+ return d.currentRuntimeConfig().tabs
286- return *cfg
287- }
288- return domain.Defaults().Tabs
289 }306 }
290 307
291 func (d *Daemon) currentEphemeralConfig() domain.EphemeralConfig {308 func (d *Daemon) currentEphemeralConfig() domain.EphemeralConfig {
292- if cfg := d.ephemeralConfig.Load(); cfg != nil {309+ return d.currentRuntimeConfig().ephemeral
293- return *cfg
294- }
295- return domain.Defaults().Ephemeral
296 }310 }
297 311
298 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {312 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
299- if cfg := d.floatingConfig.Load(); cfg != nil {313+ return d.currentRuntimeConfig().floating
300- return *cfg
301- }
302- return domain.Defaults().Floating
303 }314 }
304 315
305 func (d *Daemon) codeOverrideSnapshot() map[string]string {316 func (d *Daemon) codeOverrideSnapshot() map[string]string {
306- overrides := d.codeOverrides.Load()317+ return d.currentRuntimeConfig().codeOverrides
307- if overrides == nil {
308- return nil
309- }
310- return *overrides
311 }318 }
312 319
313 func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {320 func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {
M internal/usecase/daemon/config_test.go +20 -6
@@ -164,6 +164,20 @@ func TestEffectiveThemePaletteGate(t *testing.T) {
164164 }
165165 }
166166
167+// updateRuntimeConfig publishes a modified copy of the current runtime config
168+// snapshot, the way ApplyConfig does, without touching unrelated sections.
169+func updateRuntimeConfig(d *Daemon, mutate func(*runtimeConfig)) {
170+ next := *d.currentRuntimeConfig()
171+ mutate(&next)
172+ d.config.Store(&next)
173+}
174+
175+func storeThemeForTest(d *Daemon, cfg domain.Config) {
176+ updateRuntimeConfig(d, func(rc *runtimeConfig) {
177+ rc.theme = themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
178+ })
179+}
180+
167181 func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
168182 d := newTestDaemon(t, nil, stubClock{})
169183
@@ -184,7 +198,7 @@ func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
184198 {name: "light palette disabled", mode: domain.ThemeLight, gate: false},
185199 } {
186200 t.Run(tt.name, func(t *testing.T) {
187- d.storeThemeConfig(domain.Config{Theme: tt.mode, ThemePalette: tt.gate})
201+ storeThemeForTest(d, domain.Config{Theme: tt.mode, ThemePalette: tt.gate})
188202 require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig())
189203 })
190204 }
@@ -209,15 +223,15 @@ func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
209223 wg.Add(2)
210224 go func() {
211225 defer wg.Done()
212- d.storeThemeConfig(first)
226+ storeThemeForTest(d, first)
213227 close(firstPublished)
214228 <-firstObserved
215- d.storeThemeConfig(second)
229+ storeThemeForTest(d, second)
216230 close(secondPublished)
217231 <-secondObserved
218232 for range 10_000 {
219- d.storeThemeConfig(second)
220- d.storeThemeConfig(first)
233+ storeThemeForTest(d, second)
234+ storeThemeForTest(d, first)
221235 }
222236 }()
223237 go func() {
@@ -478,7 +492,7 @@ func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) {
478492 "split-right": "SRX",
479493 },
480494 })
481- d.recordPaletteUse("SRX")
495+ d.paletteHistory.record("SRX")
482496
483497 commands := d.paletteCommands()
484498 require.NotEmpty(t, commands)
@@ -164,6 +164,20 @@ func TestEffectiveThemePaletteGate(t *testing.T) {
164 }164 }
165 }165 }
166 166
167+// updateRuntimeConfig publishes a modified copy of the current runtime config
168+// snapshot, the way ApplyConfig does, without touching unrelated sections.
169+func updateRuntimeConfig(d *Daemon, mutate func(*runtimeConfig)) {
170+ next := *d.currentRuntimeConfig()
171+ mutate(&next)
172+ d.config.Store(&next)
173+}
174+
175+func storeThemeForTest(d *Daemon, cfg domain.Config) {
176+ updateRuntimeConfig(d, func(rc *runtimeConfig) {
177+ rc.theme = themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
178+ })
179+}
180+
167 func TestThemeConfigSnapshotIsAtomic(t *testing.T) {181 func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
168 d := newTestDaemon(t, nil, stubClock{})182 d := newTestDaemon(t, nil, stubClock{})
169 183
@@ -184,7 +198,7 @@ func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
184 {name: "light palette disabled", mode: domain.ThemeLight, gate: false},198 {name: "light palette disabled", mode: domain.ThemeLight, gate: false},
185 } {199 } {
186 t.Run(tt.name, func(t *testing.T) {200 t.Run(tt.name, func(t *testing.T) {
187- d.storeThemeConfig(domain.Config{Theme: tt.mode, ThemePalette: tt.gate})201+ storeThemeForTest(d, domain.Config{Theme: tt.mode, ThemePalette: tt.gate})
188 require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig())202 require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig())
189 })203 })
190 }204 }
@@ -209,15 +223,15 @@ func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
209 wg.Add(2)223 wg.Add(2)
210 go func() {224 go func() {
211 defer wg.Done()225 defer wg.Done()
212- d.storeThemeConfig(first)226+ storeThemeForTest(d, first)
213 close(firstPublished)227 close(firstPublished)
214 <-firstObserved228 <-firstObserved
215- d.storeThemeConfig(second)229+ storeThemeForTest(d, second)
216 close(secondPublished)230 close(secondPublished)
217 <-secondObserved231 <-secondObserved
218 for range 10_000 {232 for range 10_000 {
219- d.storeThemeConfig(second)233+ storeThemeForTest(d, second)
220- d.storeThemeConfig(first)234+ storeThemeForTest(d, first)
221 }235 }
222 }()236 }()
223 go func() {237 go func() {
@@ -478,7 +492,7 @@ func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) {
478 "split-right": "SRX",492 "split-right": "SRX",
479 },493 },
480 })494 })
481- d.recordPaletteUse("SRX")495+ d.paletteHistory.record("SRX")
482 496
483 commands := d.paletteCommands()497 commands := d.paletteCommands()
484 require.NotEmpty(t, commands)498 require.NotEmpty(t, commands)
M internal/usecase/daemon/control.go +2 -2
@@ -964,8 +964,8 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
964964 }
965965 pane.mu.Unlock()
966966 cwd := fallbackCWD
967- if e.d.procCwd != nil && pid > 0 {
968- if live, err := e.d.procCwd(pid); err == nil && live != "" {
967+ if e.d.proc != nil && pid > 0 {
968+ if live, err := e.d.proc.Cwd(pid); err == nil && live != "" {
969969 cwd = live
970970 }
971971 }
@@ -964,8 +964,8 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
964 }964 }
965 pane.mu.Unlock()965 pane.mu.Unlock()
966 cwd := fallbackCWD966 cwd := fallbackCWD
967- if e.d.procCwd != nil && pid > 0 {967+ if e.d.proc != nil && pid > 0 {
968- if live, err := e.d.procCwd(pid); err == nil && live != "" {968+ if live, err := e.d.proc.Cwd(pid); err == nil && live != "" {
969 cwd = live969 cwd = live
970 }970 }
971 }971 }
M internal/usecase/daemon/control_test.go +2 -2
@@ -868,10 +868,10 @@ func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
868868 pty := portsmocks.NewMockPTY(t)
869869 pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
870870 p.pty = pty
871- d.procComm = func(pid int) (string, error) {
871+ d.proc = &processInspectorFake{comm: func(pid int) (string, error) {
872872 require.Equal(t, 1234, pid)
873873 return "fish", nil
874- }
874+ }}
875875
876876 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
877877 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
@@ -868,10 +868,10 @@ func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
868 pty := portsmocks.NewMockPTY(t)868 pty := portsmocks.NewMockPTY(t)
869 pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()869 pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
870 p.pty = pty870 p.pty = pty
871- d.procComm = func(pid int) (string, error) {871+ d.proc = &processInspectorFake{comm: func(pid int) (string, error) {
872 require.Equal(t, 1234, pid)872 require.Equal(t, 1234, pid)
873 return "fish", nil873 return "fish", nil
874- }874+ }}
875 875
876 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})876 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
877 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)877 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
M internal/usecase/daemon/copy_scroll_animation.go +1 -0
@@ -66,6 +66,7 @@ func (d *Daemon) smoothCopyWheel(sess *session, ac *attachedClient, delta int) {
6666 }
6767 }
6868
69+// advanceCopyScrollLocked requires ac.overlays.copyMu, not d.mu.
6970 func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (bool, bool) {
7071 rt := ac.overlays
7172 motion := &rt.copyScroll
@@ -66,6 +66,7 @@ func (d *Daemon) smoothCopyWheel(sess *session, ac *attachedClient, delta int) {
66 }66 }
67 }67 }
68 68
69+// advanceCopyScrollLocked requires ac.overlays.copyMu, not d.mu.
69 func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (bool, bool) {70 func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (bool, bool) {
70 rt := ac.overlays71 rt := ac.overlays
71 motion := &rt.copyScroll72 motion := &rt.copyScroll
M internal/usecase/daemon/copy_scroll_animation_test.go +2 -2
@@ -113,13 +113,13 @@ func TestCopyScrollReducedMotionAndKeyboardCancellation(t *testing.T) {
113113 f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
114114 clock := newCoordinatorMockClock(t, 8)
115115 f.d.clock = clock.clock
116- f.d.copyConfig.Store(&domain.CopyConfig{ReduceMotion: true})
116+ updateRuntimeConfig(f.d, func(rc *runtimeConfig) { rc.copy = domain.CopyConfig{ReduceMotion: true} })
117117 rt := f.ac.overlays
118118 start := rt.copyMode.ViewportTop
119119 f.d.smoothCopyWheel(f.sess, f.ac, -3)
120120 require.Equal(t, start-3, rt.copyMode.ViewportTop)
121121 require.Empty(t, clock.timers, "reduced motion must not arm an animation")
122- f.d.copyConfig.Store(&domain.CopyConfig{})
122+ updateRuntimeConfig(f.d, func(rc *runtimeConfig) { rc.copy = domain.CopyConfig{} })
123123 f.d.smoothCopyWheel(f.sess, f.ac, -3)
124124 timer := awaitCoordinatorScheduledTimer(t, clock)
125125 require.True(t, rt.HandleInput(f.d, []byte("k")))
@@ -113,13 +113,13 @@ func TestCopyScrollReducedMotionAndKeyboardCancellation(t *testing.T) {
113 f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)113 f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
114 clock := newCoordinatorMockClock(t, 8)114 clock := newCoordinatorMockClock(t, 8)
115 f.d.clock = clock.clock115 f.d.clock = clock.clock
116- f.d.copyConfig.Store(&domain.CopyConfig{ReduceMotion: true})116+ updateRuntimeConfig(f.d, func(rc *runtimeConfig) { rc.copy = domain.CopyConfig{ReduceMotion: true} })
117 rt := f.ac.overlays117 rt := f.ac.overlays
118 start := rt.copyMode.ViewportTop118 start := rt.copyMode.ViewportTop
119 f.d.smoothCopyWheel(f.sess, f.ac, -3)119 f.d.smoothCopyWheel(f.sess, f.ac, -3)
120 require.Equal(t, start-3, rt.copyMode.ViewportTop)120 require.Equal(t, start-3, rt.copyMode.ViewportTop)
121 require.Empty(t, clock.timers, "reduced motion must not arm an animation")121 require.Empty(t, clock.timers, "reduced motion must not arm an animation")
122- f.d.copyConfig.Store(&domain.CopyConfig{})122+ updateRuntimeConfig(f.d, func(rc *runtimeConfig) { rc.copy = domain.CopyConfig{} })
123 f.d.smoothCopyWheel(f.sess, f.ac, -3)123 f.d.smoothCopyWheel(f.sess, f.ac, -3)
124 timer := awaitCoordinatorScheduledTimer(t, clock)124 timer := awaitCoordinatorScheduledTimer(t, clock)
125 require.True(t, rt.HandleInput(f.d, []byte("k")))125 require.True(t, rt.HandleInput(f.d, []byte("k")))
M internal/usecase/daemon/copymode.go +9 -0
@@ -22,6 +22,7 @@ const copyDoubleClickInterval = 500 * time.Millisecond
2222
2323 func sameCopyPos(a, b scopy.Pos) bool { return a.Row == b.Row && a.Col == b.Col }
2424
25+// isCopyDoubleClickLocked requires rt.copyMu, not d.mu.
2526 func (d *Daemon) isCopyDoubleClickLocked(rt *overlayRuntime, pane *pane, pos scopy.Pos, now time.Time) bool {
2627 candidate := rt.copyClick
2728 return candidate.valid && !candidate.dragged && candidate.pane == pane &&
@@ -342,6 +343,8 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
342343 d.invalidateRender(sess, ac, true, "copymode.go")
343344 }
344345 }
346+
347+// routeCopySearchInputLocked requires rt.copyMu, not d.mu.
345348 func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, data []byte) (changed, closeSearch, accepted bool) {
346349 routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{rune: func(r rune) { rt.copySearch.Insert(r); changed = true }, backspace: func() { rt.copySearch.Backspace(); changed = true }, enter: func() {
347350 if _, ok := rt.copySearch.Selected(); ok {
@@ -358,6 +361,8 @@ func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, data []byte) (ch
358361 }
359362 return
360363 }
364+
365+// previewCopySearchSelectionLocked requires rt.copyMu, not d.mu.
361366 func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime) {
362367 if rt == nil || rt.copyMode == nil || rt.copySearch == nil {
363368 return
@@ -366,6 +371,8 @@ func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime) {
366371 rt.copyMode.SetPosition(scopy.Pos{Row: match.Row, Col: match.Start})
367372 }
368373 }
374+
375+// retainCopyESCLocked requires ac.overlays.copyMu, not d.mu.
369376 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
370377 rt := ac.overlays
371378 mode := rt.copyMode
@@ -390,6 +397,8 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
390397 }
391398 })
392399 }
400+
401+// stopCopyPendingTimerLocked requires ac.overlays.copyMu, not d.mu.
393402 func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) { ac.overlays.copyESC.stop() }
394403 func routeCopyEscape(m *scopy.Mode, data []byte) (int, bool) {
395404 if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
@@ -22,6 +22,7 @@ const copyDoubleClickInterval = 500 * time.Millisecond
22 22
23 func sameCopyPos(a, b scopy.Pos) bool { return a.Row == b.Row && a.Col == b.Col }23 func sameCopyPos(a, b scopy.Pos) bool { return a.Row == b.Row && a.Col == b.Col }
24 24
25+// isCopyDoubleClickLocked requires rt.copyMu, not d.mu.
25 func (d *Daemon) isCopyDoubleClickLocked(rt *overlayRuntime, pane *pane, pos scopy.Pos, now time.Time) bool {26 func (d *Daemon) isCopyDoubleClickLocked(rt *overlayRuntime, pane *pane, pos scopy.Pos, now time.Time) bool {
26 candidate := rt.copyClick27 candidate := rt.copyClick
27 return candidate.valid && !candidate.dragged && candidate.pane == pane &&28 return candidate.valid && !candidate.dragged && candidate.pane == pane &&
@@ -342,6 +343,8 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
342 d.invalidateRender(sess, ac, true, "copymode.go")343 d.invalidateRender(sess, ac, true, "copymode.go")
343 }344 }
344 }345 }
346+
347+// routeCopySearchInputLocked requires rt.copyMu, not d.mu.
345 func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, data []byte) (changed, closeSearch, accepted bool) {348 func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, data []byte) (changed, closeSearch, accepted bool) {
346 routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{rune: func(r rune) { rt.copySearch.Insert(r); changed = true }, backspace: func() { rt.copySearch.Backspace(); changed = true }, enter: func() {349 routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{rune: func(r rune) { rt.copySearch.Insert(r); changed = true }, backspace: func() { rt.copySearch.Backspace(); changed = true }, enter: func() {
347 if _, ok := rt.copySearch.Selected(); ok {350 if _, ok := rt.copySearch.Selected(); ok {
@@ -358,6 +361,8 @@ func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, data []byte) (ch
358 }361 }
359 return362 return
360 }363 }
364+
365+// previewCopySearchSelectionLocked requires rt.copyMu, not d.mu.
361 func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime) {366 func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime) {
362 if rt == nil || rt.copyMode == nil || rt.copySearch == nil {367 if rt == nil || rt.copyMode == nil || rt.copySearch == nil {
363 return368 return
@@ -366,6 +371,8 @@ func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime) {
366 rt.copyMode.SetPosition(scopy.Pos{Row: match.Row, Col: match.Start})371 rt.copyMode.SetPosition(scopy.Pos{Row: match.Row, Col: match.Start})
367 }372 }
368 }373 }
374+
375+// retainCopyESCLocked requires ac.overlays.copyMu, not d.mu.
369 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {376 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
370 rt := ac.overlays377 rt := ac.overlays
371 mode := rt.copyMode378 mode := rt.copyMode
@@ -390,6 +397,8 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
390 }397 }
391 })398 })
392 }399 }
400+
401+// stopCopyPendingTimerLocked requires ac.overlays.copyMu, not d.mu.
393 func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) { ac.overlays.copyESC.stop() }402 func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) { ac.overlays.copyESC.stop() }
394 func routeCopyEscape(m *scopy.Mode, data []byte) (int, bool) {403 func routeCopyEscape(m *scopy.Mode, data []byte) (int, bool) {
395 if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {404 if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
M internal/usecase/daemon/daemon.go +59 -121
@@ -148,25 +148,16 @@ type Daemon struct {
148148 // re-suspension, detach, or session teardown can never be retired by a
149149 // stale timer. Guarded by mu.
150150 suspended map[*attachedClient]*suspendedAttachmentRetention
151- // graphicsNamespaces reserves deterministic, attachment/session-scoped Kitty
152- // ID blocks. Once a block may have reached an outer terminal it remains in
153- // this bounded table for the daemon lifetime: side-effect Output frames have
154- // no terminal ACK. Pool exhaustion disables graphics for new attachments and
155- // leaves their ordinary text output intact.
156- graphicsNamespaces map[uint64]struct{}
157- graphicsNamespaceFences map[uint64]uint64
158- graphicsNamespaceQuarantines map[uint64]*graphicsNamespaceQuarantine
159- // graphicsNamespaceSalt separates daemon lifetimes before attachment keys
160- // are hashed. Kitty IDs are terminal-global, so a restarted or neighboring
161- // daemon must not deterministically reopen the previous daemon's block.
162- graphicsNamespaceSalt uint64
151+ // graphics reserves attachment/session-scoped Kitty ID blocks. Guarded by
152+ // mu; see graphicsNamespacePool.
153+ graphics graphicsNamespacePool
163154
164155 attnMu sync.Mutex
165156 animFrame int
166157 animWake chan struct{}
167158
168- paletteRecentMu sync.Mutex
169- paletteRecent []string
159+ // paletteHistory is the daemon-wide recent-command order; its mu is a leaf.
160+ paletteHistory paletteHistory
170161 // daemonTestHooks holds deterministic test seams; production leaves every
171162 // hook nil.
172163 daemonTestHooks
@@ -186,65 +177,30 @@ type Daemon struct {
186177 snapshotRepository ports.SnapshotRepository
187178 recovery *recoveryusecase.Coordinator
188179 snapshotGarbageCollection bool
189- maintenanceWorkerCancel context.CancelFunc
190- maintenanceWorkerDone chan struct{}
191- // restoreWorkerDone is the restoration goroutine's ownership signal. Startup
192- // restoration reconciles durable checkpoints, so it is a durable writer and
193- // is guarded by snapshotWorkerMu with the other two.
194- restoreWorkerDone chan struct{}
195- snapsEnabled bool
196- noticeStore ports.NoticeStore
197- snapshotJobs chan *snapshotCapture
198- // snapshotAdmitted contains every capture accepted by either worker queue,
199- // including captures buffered in snapshotJobs. Guarded by snapshotWorkerMu.
200- snapshotAdmitted map[*snapshotCapture]struct{}
201- // snapshotWake wakes the repository scheduler when a session becomes dirty
202- // or an attempt completes. It is never closed and producers only send
203- // non-blockingly.
204- snapshotWake chan struct{}
205- snapshotWorkerMu sync.Mutex
206- snapshotWorkerID uint64
207- snapshotWorkerCtx context.Context
208- snapshotWorkerCancel context.CancelFunc
209- snapshotWorkerDone chan struct{}
210- snapshotWorkerFlush chan struct{}
211- snapshotWorkerFinalWake chan struct{}
212- // snapshotFinalJobs coalesces terminal captures by session when the bounded
213- // regular queue is full. It retains at most snapshotFinalQueueCapacity named
214- // sessions, each with only its newest terminal state while the worker blocks.
215- snapshotFinalJobs map[*session]*snapshotCapture
216- snapshotFinalOrder []*session
217- snapshotWorkerClosing bool
218- snapshotWorkerInFlight *snapshotCapture
219- // snapshotNoticeMu guards the active global persistence failure signature.
220- // It is separate from snapshotWorkerMu so notice routing cannot block a
221- // producer or a repository worker.
222- snapshotNoticeMu sync.Mutex
223- snapshotActiveFailureSignature string
224- shutdownNoticeMu sync.Mutex
225- shutdownNoticedSessions map[string]struct{}
226- restoreDone chan struct{}
227- restoreOnce sync.Once
228- procCwd func(int) (string, error)
229- procComm func(int) (string, error)
230- procArgv func(int) ([]string, error)
231- procGroupArgv func(int, int) ([]string, error)
232- dirOrHome func(string) string
233- bindings atomic.Pointer[keys.Bindings]
234- codeOverrides atomic.Pointer[map[string]string]
235- restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
236- floatingConfig atomic.Pointer[domain.FloatingConfig]
237- copyConfig atomic.Pointer[domain.CopyConfig]
238- paletteConfig atomic.Pointer[domain.PaletteConfig]
239- navConfig atomic.Pointer[domain.NavConfig]
240- tabsConfig atomic.Pointer[domain.TabsConfig]
241- ephemeralConfig atomic.Pointer[domain.EphemeralConfig]
242- scrollbackConfig atomic.Pointer[domain.ScrollbackConfig]
243- themeConfig atomic.Pointer[themeConfigSnapshot]
244- barScripts *barScriptState
245- notices *noticeCenter
246- resumeParkGrace time.Duration
247- suspendedSafetyExpiry time.Duration
180+ snapsEnabled bool
181+ noticeStore ports.NoticeStore
182+ // snapshots owns the snapshot worker, the durable-writer lifecycle
183+ // signals, and the global persistence-failure notice state.
184+ snapshots snapshotWorker
185+ shutdownNoticeMu sync.Mutex
186+ shutdownNoticedSessions map[string]struct{}
187+ restoreDone chan struct{}
188+ restoreOnce sync.Once
189+ // proc inspects pane processes (cwd, foreground command, argv). Nil means
190+ // no inspection is available; see WithProcessInspector and WithCwdReader.
191+ proc ports.ProcessInspector
192+ dirOrHome func(string) string
193+ // bindings is read by keys.Router through its own atomic pointer, so it is
194+ // published next to (not inside) the runtime config snapshot.
195+ bindings atomic.Pointer[keys.Bindings]
196+ // config is the immutable runtime configuration snapshot, published with
197+ // defaults by New and replaced by every ApplyConfig; read it through
198+ // currentRuntimeConfig.
199+ config atomic.Pointer[runtimeConfig]
200+ barScripts *barScriptState
201+ notices *noticeCenter
202+ resumeParkGrace time.Duration
203+ suspendedSafetyExpiry time.Duration
248204 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
249205 // (see clipboard.go); empty means use os.TempDir().
250206 tempDir string
@@ -572,10 +528,12 @@ func WithSnapshotGarbageCollection() Option {
572528 }
573529
574530 // WithCwdReader overrides the process cwd reader used for persistence tests.
531+// Every other inspection keeps using the installed process inspector, or stays
532+// unavailable when none is installed.
575533 func WithCwdReader(fn func(int) (string, error)) Option {
576534 return func(d *Daemon) {
577535 if fn != nil {
578- d.procCwd = fn
536+ d.proc = cwdOverrideInspector{base: unwrapCwdOverride(d.proc), cwd: fn}
579537 }
580538 }
581539 }
@@ -586,10 +544,7 @@ func WithProcessInspector(ins ports.ProcessInspector) Option {
586544 if ins == nil {
587545 return
588546 }
589- d.procCwd = ins.Cwd
590- d.procComm = ins.Comm
591- d.procArgv = ins.Argv
592- d.procGroupArgv = ins.GroupArgv
547+ d.proc = ins
593548 }
594549 }
595550
@@ -715,32 +670,31 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
715670 }
716671 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
717672 d := &Daemon{
718- sessions: make(map[domain.SessionID]*session),
719- inactive: make(map[string]inactiveSession),
720- creating: make(map[string]struct{}),
721- resume: newResumeCredentials(),
722- suspended: make(map[*attachedClient]*suspendedAttachmentRetention),
723- graphicsNamespaces: make(map[uint64]struct{}),
724- graphicsNamespaceFences: make(map[uint64]uint64),
725- graphicsNamespaceQuarantines: make(map[uint64]*graphicsNamespaceQuarantine),
726- graphicsNamespaceSalt: newGraphicsNamespaceSalt(),
727- paneProcessCtx: paneProcessCtx,
728- paneProcessCancel: paneProcessCancel,
729- ptys: ptys,
730- clock: clock,
731- log: log,
732- baseEnv: os.Environ(),
733- shell: defaultShellCommand,
734- dirOrHome: dirOrHome,
735- done: make(chan struct{}),
736- restoreDone: make(chan struct{}),
737- animWake: make(chan struct{}, 1),
738- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
739- snapshotAdmitted: make(map[*snapshotCapture]struct{}),
740- snapshotWake: make(chan struct{}, 1),
741- notices: newNoticeCenter(),
742- resumeParkGrace: defaultResumeParkGrace,
743- suspendedSafetyExpiry: defaultSuspendedSafetyExpiry,
673+ sessions: make(map[domain.SessionID]*session),
674+ inactive: make(map[string]inactiveSession),
675+ creating: make(map[string]struct{}),
676+ resume: newResumeCredentials(),
677+ suspended: make(map[*attachedClient]*suspendedAttachmentRetention),
678+ graphics: newGraphicsNamespacePool(),
679+ paneProcessCtx: paneProcessCtx,
680+ paneProcessCancel: paneProcessCancel,
681+ ptys: ptys,
682+ clock: clock,
683+ log: log,
684+ baseEnv: os.Environ(),
685+ shell: defaultShellCommand,
686+ dirOrHome: dirOrHome,
687+ done: make(chan struct{}),
688+ restoreDone: make(chan struct{}),
689+ animWake: make(chan struct{}, 1),
690+ snapshots: snapshotWorker{
691+ jobs: make(chan *snapshotCapture, snapshotQueueCapacity),
692+ admitted: make(map[*snapshotCapture]struct{}),
693+ wake: make(chan struct{}, 1),
694+ },
695+ notices: newNoticeCenter(),
696+ resumeParkGrace: defaultResumeParkGrace,
697+ suspendedSafetyExpiry: defaultSuspendedSafetyExpiry,
744698 barScripts: &barScriptState{
745699 cfg: barConfigFromDomain(domain.Defaults().Bar),
746700 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -750,15 +704,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
750704 reload: make(chan struct{}, 1),
751705 },
752706 }
753- defaults := domain.Defaults()
754- defaultFloating := defaults.Floating
755- d.floatingConfig.Store(&defaultFloating)
756- defaultCopy := defaults.Copy
757- d.copyConfig.Store(&defaultCopy)
758- defaultPalette := defaults.Palette
759- d.paletteConfig.Store(&defaultPalette)
760- defaultTabs := defaults.Tabs
761- d.tabsConfig.Store(&defaultTabs)
707+ d.config.Store(defaultRuntimeConfig())
762708 for _, o := range opts {
763709 o(d)
764710 }
@@ -768,14 +714,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
768714 if d.bindings.Load() == nil {
769715 d.bindings.Store(keys.DefaultBindings())
770716 }
771- if d.codeOverrides.Load() == nil {
772- empty := map[string]string{}
773- d.codeOverrides.Store(&empty)
774- }
775- if d.restoreProcessAllowlist.Load() == nil {
776- allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
777- d.restoreProcessAllowlist.Store(&allow)
778- }
779717 records := d.catalogueRecords
780718 var maxSeq uint64
781719 var maxCreatedAt int64
@@ -148,25 +148,16 @@ type Daemon struct {
148 // re-suspension, detach, or session teardown can never be retired by a148 // re-suspension, detach, or session teardown can never be retired by a
149 // stale timer. Guarded by mu.149 // stale timer. Guarded by mu.
150 suspended map[*attachedClient]*suspendedAttachmentRetention150 suspended map[*attachedClient]*suspendedAttachmentRetention
151- // graphicsNamespaces reserves deterministic, attachment/session-scoped Kitty151+ // graphics reserves attachment/session-scoped Kitty ID blocks. Guarded by
152- // ID blocks. Once a block may have reached an outer terminal it remains in152+ // mu; see graphicsNamespacePool.
153- // this bounded table for the daemon lifetime: side-effect Output frames have153+ graphics graphicsNamespacePool
154- // no terminal ACK. Pool exhaustion disables graphics for new attachments and
155- // leaves their ordinary text output intact.
156- graphicsNamespaces map[uint64]struct{}
157- graphicsNamespaceFences map[uint64]uint64
158- graphicsNamespaceQuarantines map[uint64]*graphicsNamespaceQuarantine
159- // graphicsNamespaceSalt separates daemon lifetimes before attachment keys
160- // are hashed. Kitty IDs are terminal-global, so a restarted or neighboring
161- // daemon must not deterministically reopen the previous daemon's block.
162- graphicsNamespaceSalt uint64
163 154
164 attnMu sync.Mutex155 attnMu sync.Mutex
165 animFrame int156 animFrame int
166 animWake chan struct{}157 animWake chan struct{}
167 158
168- paletteRecentMu sync.Mutex159+ // paletteHistory is the daemon-wide recent-command order; its mu is a leaf.
169- paletteRecent []string160+ paletteHistory paletteHistory
170 // daemonTestHooks holds deterministic test seams; production leaves every161 // daemonTestHooks holds deterministic test seams; production leaves every
171 // hook nil.162 // hook nil.
172 daemonTestHooks163 daemonTestHooks
@@ -186,65 +177,30 @@ type Daemon struct {
186 snapshotRepository ports.SnapshotRepository177 snapshotRepository ports.SnapshotRepository
187 recovery *recoveryusecase.Coordinator178 recovery *recoveryusecase.Coordinator
188 snapshotGarbageCollection bool179 snapshotGarbageCollection bool
189- maintenanceWorkerCancel context.CancelFunc180+ snapsEnabled bool
190- maintenanceWorkerDone chan struct{}181+ noticeStore ports.NoticeStore
191- // restoreWorkerDone is the restoration goroutine's ownership signal. Startup182+ // snapshots owns the snapshot worker, the durable-writer lifecycle
192- // restoration reconciles durable checkpoints, so it is a durable writer and183+ // signals, and the global persistence-failure notice state.
193- // is guarded by snapshotWorkerMu with the other two.184+ snapshots snapshotWorker
194- restoreWorkerDone chan struct{}185+ shutdownNoticeMu sync.Mutex
195- snapsEnabled bool186+ shutdownNoticedSessions map[string]struct{}
196- noticeStore ports.NoticeStore187+ restoreDone chan struct{}
197- snapshotJobs chan *snapshotCapture188+ restoreOnce sync.Once
198- // snapshotAdmitted contains every capture accepted by either worker queue,189+ // proc inspects pane processes (cwd, foreground command, argv). Nil means
199- // including captures buffered in snapshotJobs. Guarded by snapshotWorkerMu.190+ // no inspection is available; see WithProcessInspector and WithCwdReader.
200- snapshotAdmitted map[*snapshotCapture]struct{}191+ proc ports.ProcessInspector
201- // snapshotWake wakes the repository scheduler when a session becomes dirty192+ dirOrHome func(string) string
202- // or an attempt completes. It is never closed and producers only send193+ // bindings is read by keys.Router through its own atomic pointer, so it is
203- // non-blockingly.194+ // published next to (not inside) the runtime config snapshot.
204- snapshotWake chan struct{}195+ bindings atomic.Pointer[keys.Bindings]
205- snapshotWorkerMu sync.Mutex196+ // config is the immutable runtime configuration snapshot, published with
206- snapshotWorkerID uint64197+ // defaults by New and replaced by every ApplyConfig; read it through
207- snapshotWorkerCtx context.Context198+ // currentRuntimeConfig.
208- snapshotWorkerCancel context.CancelFunc199+ config atomic.Pointer[runtimeConfig]
209- snapshotWorkerDone chan struct{}200+ barScripts *barScriptState
210- snapshotWorkerFlush chan struct{}201+ notices *noticeCenter
211- snapshotWorkerFinalWake chan struct{}202+ resumeParkGrace time.Duration
212- // snapshotFinalJobs coalesces terminal captures by session when the bounded203+ suspendedSafetyExpiry time.Duration
213- // regular queue is full. It retains at most snapshotFinalQueueCapacity named
214- // sessions, each with only its newest terminal state while the worker blocks.
215- snapshotFinalJobs map[*session]*snapshotCapture
216- snapshotFinalOrder []*session
217- snapshotWorkerClosing bool
218- snapshotWorkerInFlight *snapshotCapture
219- // snapshotNoticeMu guards the active global persistence failure signature.
220- // It is separate from snapshotWorkerMu so notice routing cannot block a
221- // producer or a repository worker.
222- snapshotNoticeMu sync.Mutex
223- snapshotActiveFailureSignature string
224- shutdownNoticeMu sync.Mutex
225- shutdownNoticedSessions map[string]struct{}
226- restoreDone chan struct{}
227- restoreOnce sync.Once
228- procCwd func(int) (string, error)
229- procComm func(int) (string, error)
230- procArgv func(int) ([]string, error)
231- procGroupArgv func(int, int) ([]string, error)
232- dirOrHome func(string) string
233- bindings atomic.Pointer[keys.Bindings]
234- codeOverrides atomic.Pointer[map[string]string]
235- restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
236- floatingConfig atomic.Pointer[domain.FloatingConfig]
237- copyConfig atomic.Pointer[domain.CopyConfig]
238- paletteConfig atomic.Pointer[domain.PaletteConfig]
239- navConfig atomic.Pointer[domain.NavConfig]
240- tabsConfig atomic.Pointer[domain.TabsConfig]
241- ephemeralConfig atomic.Pointer[domain.EphemeralConfig]
242- scrollbackConfig atomic.Pointer[domain.ScrollbackConfig]
243- themeConfig atomic.Pointer[themeConfigSnapshot]
244- barScripts *barScriptState
245- notices *noticeCenter
246- resumeParkGrace time.Duration
247- suspendedSafetyExpiry time.Duration
248 // tempDir overrides os.TempDir() for clipboard-image-transfer writes204 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
249 // (see clipboard.go); empty means use os.TempDir().205 // (see clipboard.go); empty means use os.TempDir().
250 tempDir string206 tempDir string
@@ -572,10 +528,12 @@ func WithSnapshotGarbageCollection() Option {
572 }528 }
573 529
574 // WithCwdReader overrides the process cwd reader used for persistence tests.530 // WithCwdReader overrides the process cwd reader used for persistence tests.
531+// Every other inspection keeps using the installed process inspector, or stays
532+// unavailable when none is installed.
575 func WithCwdReader(fn func(int) (string, error)) Option {533 func WithCwdReader(fn func(int) (string, error)) Option {
576 return func(d *Daemon) {534 return func(d *Daemon) {
577 if fn != nil {535 if fn != nil {
578- d.procCwd = fn536+ d.proc = cwdOverrideInspector{base: unwrapCwdOverride(d.proc), cwd: fn}
579 }537 }
580 }538 }
581 }539 }
@@ -586,10 +544,7 @@ func WithProcessInspector(ins ports.ProcessInspector) Option {
586 if ins == nil {544 if ins == nil {
587 return545 return
588 }546 }
589- d.procCwd = ins.Cwd547+ d.proc = ins
590- d.procComm = ins.Comm
591- d.procArgv = ins.Argv
592- d.procGroupArgv = ins.GroupArgv
593 }548 }
594 }549 }
595 550
@@ -715,32 +670,31 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
715 }670 }
716 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())671 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
717 d := &Daemon{672 d := &Daemon{
718- sessions: make(map[domain.SessionID]*session),673+ sessions: make(map[domain.SessionID]*session),
719- inactive: make(map[string]inactiveSession),674+ inactive: make(map[string]inactiveSession),
720- creating: make(map[string]struct{}),675+ creating: make(map[string]struct{}),
721- resume: newResumeCredentials(),676+ resume: newResumeCredentials(),
722- suspended: make(map[*attachedClient]*suspendedAttachmentRetention),677+ suspended: make(map[*attachedClient]*suspendedAttachmentRetention),
723- graphicsNamespaces: make(map[uint64]struct{}),678+ graphics: newGraphicsNamespacePool(),
724- graphicsNamespaceFences: make(map[uint64]uint64),679+ paneProcessCtx: paneProcessCtx,
725- graphicsNamespaceQuarantines: make(map[uint64]*graphicsNamespaceQuarantine),680+ paneProcessCancel: paneProcessCancel,
726- graphicsNamespaceSalt: newGraphicsNamespaceSalt(),681+ ptys: ptys,
727- paneProcessCtx: paneProcessCtx,682+ clock: clock,
728- paneProcessCancel: paneProcessCancel,683+ log: log,
729- ptys: ptys,684+ baseEnv: os.Environ(),
730- clock: clock,685+ shell: defaultShellCommand,
731- log: log,686+ dirOrHome: dirOrHome,
732- baseEnv: os.Environ(),687+ done: make(chan struct{}),
733- shell: defaultShellCommand,688+ restoreDone: make(chan struct{}),
734- dirOrHome: dirOrHome,689+ animWake: make(chan struct{}, 1),
735- done: make(chan struct{}),690+ snapshots: snapshotWorker{
736- restoreDone: make(chan struct{}),691+ jobs: make(chan *snapshotCapture, snapshotQueueCapacity),
737- animWake: make(chan struct{}, 1),692+ admitted: make(map[*snapshotCapture]struct{}),
738- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),693+ wake: make(chan struct{}, 1),
739- snapshotAdmitted: make(map[*snapshotCapture]struct{}),694+ },
740- snapshotWake: make(chan struct{}, 1),695+ notices: newNoticeCenter(),
741- notices: newNoticeCenter(),696+ resumeParkGrace: defaultResumeParkGrace,
742- resumeParkGrace: defaultResumeParkGrace,697+ suspendedSafetyExpiry: defaultSuspendedSafetyExpiry,
743- suspendedSafetyExpiry: defaultSuspendedSafetyExpiry,
744 barScripts: &barScriptState{698 barScripts: &barScriptState{
745 cfg: barConfigFromDomain(domain.Defaults().Bar),699 cfg: barConfigFromDomain(domain.Defaults().Bar),
746 outputs: make(map[domain.SessionID]barScriptOutputs),700 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -750,15 +704,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
750 reload: make(chan struct{}, 1),704 reload: make(chan struct{}, 1),
751 },705 },
752 }706 }
753- defaults := domain.Defaults()707+ d.config.Store(defaultRuntimeConfig())
754- defaultFloating := defaults.Floating
755- d.floatingConfig.Store(&defaultFloating)
756- defaultCopy := defaults.Copy
757- d.copyConfig.Store(&defaultCopy)
758- defaultPalette := defaults.Palette
759- d.paletteConfig.Store(&defaultPalette)
760- defaultTabs := defaults.Tabs
761- d.tabsConfig.Store(&defaultTabs)
762 for _, o := range opts {708 for _, o := range opts {
763 o(d)709 o(d)
764 }710 }
@@ -768,14 +714,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
768 if d.bindings.Load() == nil {714 if d.bindings.Load() == nil {
769 d.bindings.Store(keys.DefaultBindings())715 d.bindings.Store(keys.DefaultBindings())
770 }716 }
771- if d.codeOverrides.Load() == nil {
772- empty := map[string]string{}
773- d.codeOverrides.Store(&empty)
774- }
775- if d.restoreProcessAllowlist.Load() == nil {
776- allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
777- d.restoreProcessAllowlist.Store(&allow)
778- }
779 records := d.catalogueRecords717 records := d.catalogueRecords
780 var maxSeq uint64718 var maxSeq uint64
781 var maxCreatedAt int64719 var maxCreatedAt int64
M internal/usecase/daemon/ephemeral_close_test.go +2 -2
@@ -30,7 +30,7 @@ func TestEphemeralCloseOnExit(t *testing.T) {
3030 pty, release := newBlockingPTY(t)
3131 defer release()
3232 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
33- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: tt.closeOnExit})
33+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: tt.closeOnExit} })
3434
3535 tr := &closeTrackingTransport{}
3636 sess, ac, err := d.route(helloResumeCapable(tt.intent, tt.sessionName, 0), tr)
@@ -122,7 +122,7 @@ func TestDetachFrameEphemeralCloseOnExit(t *testing.T) {
122122 pty, release := newBlockingPTY(t)
123123 defer release()
124124 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
125- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: tt.closeOnExit})
125+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: tt.closeOnExit} })
126126 tr, sends, _ := newConn(t,
127127 mustHello(tt.intent, tt.sessionName, domain.Size{Cols: 80, Rows: 24}),
128128 mustClientEnvelope(tt.detach),
@@ -30,7 +30,7 @@ func TestEphemeralCloseOnExit(t *testing.T) {
30 pty, release := newBlockingPTY(t)30 pty, release := newBlockingPTY(t)
31 defer release()31 defer release()
32 d := newTestDaemon(t, newFactory(t, pty), stubClock{})32 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
33- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: tt.closeOnExit})33+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: tt.closeOnExit} })
34 34
35 tr := &closeTrackingTransport{}35 tr := &closeTrackingTransport{}
36 sess, ac, err := d.route(helloResumeCapable(tt.intent, tt.sessionName, 0), tr)36 sess, ac, err := d.route(helloResumeCapable(tt.intent, tt.sessionName, 0), tr)
@@ -122,7 +122,7 @@ func TestDetachFrameEphemeralCloseOnExit(t *testing.T) {
122 pty, release := newBlockingPTY(t)122 pty, release := newBlockingPTY(t)
123 defer release()123 defer release()
124 d := newTestDaemon(t, newFactory(t, pty), stubClock{})124 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
125- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: tt.closeOnExit})125+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: tt.closeOnExit} })
126 tr, sends, _ := newConn(t,126 tr, sends, _ := newConn(t,
127 mustHello(tt.intent, tt.sessionName, domain.Size{Cols: 80, Rows: 24}),127 mustHello(tt.intent, tt.sessionName, domain.Size{Cols: 80, Rows: 24}),
128 mustClientEnvelope(tt.detach),128 mustClientEnvelope(tt.detach),
M internal/usecase/daemon/floating.go +2 -2
@@ -361,8 +361,8 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
361361 focused := tb.focusedPane()
362362 tabStableID, tabCtx := tb.stableID, tb.ctx
363363 tb.mu.Unlock()
364- if focused != nil && d.procCwd != nil && focused.pty != nil {
365- if live, err := d.procCwd(focused.pty.Pid()); err == nil && live != "" {
364+ if focused != nil && d.proc != nil && focused.pty != nil {
365+ if live, err := d.proc.Cwd(focused.pty.Pid()); err == nil && live != "" {
366366 cwd = live
367367 }
368368 }
@@ -361,8 +361,8 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
361 focused := tb.focusedPane()361 focused := tb.focusedPane()
362 tabStableID, tabCtx := tb.stableID, tb.ctx362 tabStableID, tabCtx := tb.stableID, tb.ctx
363 tb.mu.Unlock()363 tb.mu.Unlock()
364- if focused != nil && d.procCwd != nil && focused.pty != nil {364+ if focused != nil && d.proc != nil && focused.pty != nil {
365- if live, err := d.procCwd(focused.pty.Pid()); err == nil && live != "" {365+ if live, err := d.proc.Cwd(focused.pty.Pid()); err == nil && live != "" {
366 cwd = live366 cwd = live
367 }367 }
368 }368 }
M internal/usecase/daemon/floating_spare.go +2 -2
@@ -100,8 +100,8 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
100100 }
101101 d.reapInstalledFloating(spare)
102102 }
103- d.sessWg.Add(1)
104- go d.readPanePTY(p)
103+ reader := p
104+ d.sessWg.Go(func() { d.readPanePTY(reader) })
105105 p = nil
106106 }
107107 sess.floatingLaunchMu.Unlock()
@@ -100,8 +100,8 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
100 }100 }
101 d.reapInstalledFloating(spare)101 d.reapInstalledFloating(spare)
102 }102 }
103- d.sessWg.Add(1)103+ reader := p
104- go d.readPanePTY(p)104+ d.sessWg.Go(func() { d.readPanePTY(reader) })
105 p = nil105 p = nil
106 }106 }
107 sess.floatingLaunchMu.Unlock()107 sess.floatingLaunchMu.Unlock()
M internal/usecase/daemon/floating_spare_test.go +1 -1
@@ -124,7 +124,7 @@ func TestFloatingSpareIsSharedPerSession(t *testing.T) {
124124 d := newTestDaemon(t, factory, stubClock{})
125125 d.ApplyConfig(domain.Config{Floating: cfg})
126126 cwds := map[int]string{1: "/work", 2: tt.claimCwd}
127- d.procCwd = func(pid int) (string, error) { return cwds[pid], nil }
127+ WithCwdReader(func(pid int) (string, error) { return cwds[pid], nil })(d)
128128 d.dirOrHome = func(cwd string) string { return cwd }
129129
130130 first := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 120, Rows: 40})
@@ -124,7 +124,7 @@ func TestFloatingSpareIsSharedPerSession(t *testing.T) {
124 d := newTestDaemon(t, factory, stubClock{})124 d := newTestDaemon(t, factory, stubClock{})
125 d.ApplyConfig(domain.Config{Floating: cfg})125 d.ApplyConfig(domain.Config{Floating: cfg})
126 cwds := map[int]string{1: "/work", 2: tt.claimCwd}126 cwds := map[int]string{1: "/work", 2: tt.claimCwd}
127- d.procCwd = func(pid int) (string, error) { return cwds[pid], nil }127+ WithCwdReader(func(pid int) (string, error) { return cwds[pid], nil })(d)
128 d.dirOrHome = func(cwd string) string { return cwd }128 d.dirOrHome = func(cwd string) string { return cwd }
129 129
130 first := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 120, Rows: 40})130 first := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 120, Rows: 40})
M internal/usecase/daemon/floating_test.go +1 -1
@@ -627,7 +627,7 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
627627 return cwd
628628 }
629629 if tt.liveCwd != "" {
630- d.procCwd = func(int) (string, error) { return tt.liveCwd, nil }
630+ WithCwdReader(func(int) (string, error) { return tt.liveCwd, nil })(d)
631631 }
632632 tb := newFloatingTestTab(t)
633633 sess := &session{sessionCore: sessionCore{name: "work"}, cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()}
@@ -627,7 +627,7 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
627 return cwd627 return cwd
628 }628 }
629 if tt.liveCwd != "" {629 if tt.liveCwd != "" {
630- d.procCwd = func(int) (string, error) { return tt.liveCwd, nil }630+ WithCwdReader(func(int) (string, error) { return tt.liveCwd, nil })(d)
631 }631 }
632 tb := newFloatingTestTab(t)632 tb := newFloatingTestTab(t)
633 sess := &session{sessionCore: sessionCore{name: "work"}, cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()}633 sess := &session{sessionCore: sessionCore{name: "work"}, cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()}
A internal/usecase/daemon/graphics_namespace_pool.go +116 -0
@@ -0,0 +1,116 @@
1+package daemon
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/binary"
6+)
7+
8+type graphicsNamespaceQuarantine struct {
9+ base uint64
10+ fence uint64
11+}
12+
13+// graphicsNamespacePool reserves deterministic, attachment/session-scoped Kitty
14+// ID blocks. Once a block may have reached an outer terminal it remains in
15+// this bounded table for the daemon lifetime: side-effect Output frames have
16+// no terminal ACK. Pool exhaustion disables graphics for new attachments and
17+// leaves their ordinary text output intact.
18+//
19+// The pool is guarded by Daemon.mu and has no lock of its own: every method
20+// requires the caller to hold it. The zero value is usable; the maps are
21+// created lazily and a zero salt simply skips salting.
22+type graphicsNamespacePool struct {
23+ reserved map[uint64]struct{}
24+ fences map[uint64]uint64
25+ quarantines map[uint64]*graphicsNamespaceQuarantine
26+ // salt separates daemon lifetimes before attachment keys are hashed. Kitty
27+ // IDs are terminal-global, so a restarted or neighboring daemon must not
28+ // deterministically reopen the previous daemon's block.
29+ salt uint64
30+}
31+
32+func newGraphicsNamespacePool() graphicsNamespacePool {
33+ p := graphicsNamespacePool{salt: newGraphicsNamespaceSalt()}
34+ p.init()
35+ return p
36+}
37+
38+func (p *graphicsNamespacePool) init() {
39+ if p.reserved == nil {
40+ p.reserved = make(map[uint64]struct{})
41+ }
42+ if p.fences == nil {
43+ p.fences = make(map[uint64]uint64)
44+ }
45+ if p.quarantines == nil {
46+ p.quarantines = make(map[uint64]*graphicsNamespaceQuarantine)
47+ }
48+}
49+
50+func (p *graphicsNamespacePool) reserveLease(key string) (uint64, uint64) {
51+ p.init()
52+ hashInput := []byte(key)
53+ if p.salt != 0 {
54+ salt := make([]byte, 8, 8+len(key))
55+ binary.BigEndian.PutUint64(salt, p.salt)
56+ hashInput = append(salt, hashInput...)
57+ }
58+ digest := sha256.Sum256(hashInput)
59+ preferred := binary.BigEndian.Uint64(digest[:8]) % graphicsIDNamespaceCount
60+ for offset := uint64(0); offset < graphicsIDNamespaceCount; offset++ {
61+ block := (preferred + offset) % graphicsIDNamespaceCount
62+ if _, exists := p.reserved[block]; exists {
63+ continue
64+ }
65+ fence := nextGraphicsNamespaceFence()
66+ p.reserved[block] = struct{}{}
67+ p.fences[block] = fence
68+ return block*graphicsIDNamespaceSize + 1, fence
69+ }
70+ return 0, 0
71+}
72+
73+// releaseBase is the legacy base-only release. A quarantined block is never
74+// released by it.
75+func (p *graphicsNamespacePool) releaseBase(base uint64) {
76+ if base == 0 || base%graphicsIDNamespaceSize != 1 {
77+ return
78+ }
79+ block := (base - 1) / graphicsIDNamespaceSize
80+ if _, quarantined := p.quarantines[block]; quarantined {
81+ return
82+ }
83+ delete(p.reserved, block)
84+ delete(p.fences, block)
85+}
86+
87+func (p *graphicsNamespacePool) releaseLease(state *graphicsOutputState) {
88+ if state == nil || state.namespaceBase == 0 || state.namespaceBase%graphicsIDNamespaceSize != 1 {
89+ return
90+ }
91+ block := (state.namespaceBase - 1) / graphicsIDNamespaceSize
92+ if _, quarantined := p.quarantines[block]; quarantined {
93+ return
94+ }
95+ if current := p.fences[block]; current != 0 && current != state.namespaceFence {
96+ return
97+ }
98+ delete(p.reserved, block)
99+ delete(p.fences, block)
100+}
101+
102+func (p *graphicsNamespacePool) quarantine(state *graphicsOutputState) *graphicsNamespaceQuarantine {
103+ if state == nil || state.namespaceBase == 0 || state.namespaceBase%graphicsIDNamespaceSize != 1 {
104+ return nil
105+ }
106+ block := (state.namespaceBase - 1) / graphicsIDNamespaceSize
107+ p.init()
108+ if _, exists := p.quarantines[block]; exists {
109+ return nil
110+ }
111+ p.reserved[block] = struct{}{}
112+ p.fences[block] = state.namespaceFence
113+ q := &graphicsNamespaceQuarantine{base: state.namespaceBase, fence: state.namespaceFence}
114+ p.quarantines[block] = q
115+ return q
116+}
@@ -0,0 +1,116 @@
1+package daemon
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/binary"
6+)
7+
8+type graphicsNamespaceQuarantine struct {
9+ base uint64
10+ fence uint64
11+}
12+
13+// graphicsNamespacePool reserves deterministic, attachment/session-scoped Kitty
14+// ID blocks. Once a block may have reached an outer terminal it remains in
15+// this bounded table for the daemon lifetime: side-effect Output frames have
16+// no terminal ACK. Pool exhaustion disables graphics for new attachments and
17+// leaves their ordinary text output intact.
18+//
19+// The pool is guarded by Daemon.mu and has no lock of its own: every method
20+// requires the caller to hold it. The zero value is usable; the maps are
21+// created lazily and a zero salt simply skips salting.
22+type graphicsNamespacePool struct {
23+ reserved map[uint64]struct{}
24+ fences map[uint64]uint64
25+ quarantines map[uint64]*graphicsNamespaceQuarantine
26+ // salt separates daemon lifetimes before attachment keys are hashed. Kitty
27+ // IDs are terminal-global, so a restarted or neighboring daemon must not
28+ // deterministically reopen the previous daemon's block.
29+ salt uint64
30+}
31+
32+func newGraphicsNamespacePool() graphicsNamespacePool {
33+ p := graphicsNamespacePool{salt: newGraphicsNamespaceSalt()}
34+ p.init()
35+ return p
36+}
37+
38+func (p *graphicsNamespacePool) init() {
39+ if p.reserved == nil {
40+ p.reserved = make(map[uint64]struct{})
41+ }
42+ if p.fences == nil {
43+ p.fences = make(map[uint64]uint64)
44+ }
45+ if p.quarantines == nil {
46+ p.quarantines = make(map[uint64]*graphicsNamespaceQuarantine)
47+ }
48+}
49+
50+func (p *graphicsNamespacePool) reserveLease(key string) (uint64, uint64) {
51+ p.init()
52+ hashInput := []byte(key)
53+ if p.salt != 0 {
54+ salt := make([]byte, 8, 8+len(key))
55+ binary.BigEndian.PutUint64(salt, p.salt)
56+ hashInput = append(salt, hashInput...)
57+ }
58+ digest := sha256.Sum256(hashInput)
59+ preferred := binary.BigEndian.Uint64(digest[:8]) % graphicsIDNamespaceCount
60+ for offset := uint64(0); offset < graphicsIDNamespaceCount; offset++ {
61+ block := (preferred + offset) % graphicsIDNamespaceCount
62+ if _, exists := p.reserved[block]; exists {
63+ continue
64+ }
65+ fence := nextGraphicsNamespaceFence()
66+ p.reserved[block] = struct{}{}
67+ p.fences[block] = fence
68+ return block*graphicsIDNamespaceSize + 1, fence
69+ }
70+ return 0, 0
71+}
72+
73+// releaseBase is the legacy base-only release. A quarantined block is never
74+// released by it.
75+func (p *graphicsNamespacePool) releaseBase(base uint64) {
76+ if base == 0 || base%graphicsIDNamespaceSize != 1 {
77+ return
78+ }
79+ block := (base - 1) / graphicsIDNamespaceSize
80+ if _, quarantined := p.quarantines[block]; quarantined {
81+ return
82+ }
83+ delete(p.reserved, block)
84+ delete(p.fences, block)
85+}
86+
87+func (p *graphicsNamespacePool) releaseLease(state *graphicsOutputState) {
88+ if state == nil || state.namespaceBase == 0 || state.namespaceBase%graphicsIDNamespaceSize != 1 {
89+ return
90+ }
91+ block := (state.namespaceBase - 1) / graphicsIDNamespaceSize
92+ if _, quarantined := p.quarantines[block]; quarantined {
93+ return
94+ }
95+ if current := p.fences[block]; current != 0 && current != state.namespaceFence {
96+ return
97+ }
98+ delete(p.reserved, block)
99+ delete(p.fences, block)
100+}
101+
102+func (p *graphicsNamespacePool) quarantine(state *graphicsOutputState) *graphicsNamespaceQuarantine {
103+ if state == nil || state.namespaceBase == 0 || state.namespaceBase%graphicsIDNamespaceSize != 1 {
104+ return nil
105+ }
106+ block := (state.namespaceBase - 1) / graphicsIDNamespaceSize
107+ p.init()
108+ if _, exists := p.quarantines[block]; exists {
109+ return nil
110+ }
111+ p.reserved[block] = struct{}{}
112+ p.fences[block] = state.namespaceFence
113+ q := &graphicsNamespaceQuarantine{base: state.namespaceBase, fence: state.namespaceFence}
114+ p.quarantines[block] = q
115+ return q
116+}
M internal/usecase/daemon/graphics_output.go +7 -70
@@ -5,7 +5,6 @@ import (
55 "compress/zlib"
66 "crypto/sha256"
77 "encoding/base64"
8- "encoding/binary"
98 "errors"
109 "fmt"
1110 "sort"
@@ -106,11 +105,6 @@ var (
106105 graphicsNamespaceFence atomic.Uint64
107106 )
108107
109-type graphicsNamespaceQuarantine struct {
110- base uint64
111- fence uint64
112-}
113-
114108 func nextGraphicsNamespaceFence() uint64 {
115109 fence := graphicsNamespaceFence.Add(1)
116110 if fence == 0 {
@@ -167,34 +161,7 @@ func (d *Daemon) reserveGraphicsNamespaceLeaseLocked(key string) (uint64, uint64
167161 if d == nil {
168162 return 0, 0
169163 }
170- if d.graphicsNamespaces == nil {
171- d.graphicsNamespaces = make(map[uint64]struct{})
172- }
173- if d.graphicsNamespaceFences == nil {
174- d.graphicsNamespaceFences = make(map[uint64]uint64)
175- }
176- if d.graphicsNamespaceQuarantines == nil {
177- d.graphicsNamespaceQuarantines = make(map[uint64]*graphicsNamespaceQuarantine)
178- }
179- hashInput := []byte(key)
180- if d.graphicsNamespaceSalt != 0 {
181- salt := make([]byte, 8, 8+len(key))
182- binary.BigEndian.PutUint64(salt, d.graphicsNamespaceSalt)
183- hashInput = append(salt, hashInput...)
184- }
185- digest := sha256.Sum256(hashInput)
186- preferred := binary.BigEndian.Uint64(digest[:8]) % graphicsIDNamespaceCount
187- for offset := uint64(0); offset < graphicsIDNamespaceCount; offset++ {
188- block := (preferred + offset) % graphicsIDNamespaceCount
189- if _, exists := d.graphicsNamespaces[block]; exists {
190- continue
191- }
192- fence := nextGraphicsNamespaceFence()
193- d.graphicsNamespaces[block] = struct{}{}
194- d.graphicsNamespaceFences[block] = fence
195- return block*graphicsIDNamespaceSize + 1, fence
196- }
197- return 0, 0
164+ return d.graphics.reserveLease(key)
198165 }
199166
200167 func (d *Daemon) releaseGraphicsNamespace(base uint64) {
@@ -210,30 +177,17 @@ func (d *Daemon) releaseGraphicsNamespace(base uint64) {
210177 // already held. A quarantined block is intentionally not released by this
211178 // legacy base-only helper; only its fenced cleanup lifecycle may retire it.
212179 func (d *Daemon) releaseGraphicsNamespaceLocked(base uint64) {
213- if d == nil || base == 0 || base%graphicsIDNamespaceSize != 1 {
214- return
215- }
216- block := (base - 1) / graphicsIDNamespaceSize
217- if _, quarantined := d.graphicsNamespaceQuarantines[block]; quarantined {
180+ if d == nil {
218181 return
219182 }
220- delete(d.graphicsNamespaces, block)
221- delete(d.graphicsNamespaceFences, block)
183+ d.graphics.releaseBase(base)
222184 }
223185
224186 func (d *Daemon) releaseGraphicsNamespaceLeaseLocked(state *graphicsOutputState) {
225- if d == nil || state == nil || state.namespaceBase == 0 || state.namespaceBase%graphicsIDNamespaceSize != 1 {
226- return
227- }
228- block := (state.namespaceBase - 1) / graphicsIDNamespaceSize
229- if _, quarantined := d.graphicsNamespaceQuarantines[block]; quarantined {
230- return
231- }
232- if current := d.graphicsNamespaceFences[block]; current != 0 && current != state.namespaceFence {
187+ if d == nil {
233188 return
234189 }
235- delete(d.graphicsNamespaces, block)
236- delete(d.graphicsNamespaceFences, block)
190+ d.graphics.releaseLease(state)
237191 }
238192
239193 // quarantineGraphicsNamespaceLocked fences one exact namespace instance for
@@ -241,27 +195,10 @@ func (d *Daemon) releaseGraphicsNamespaceLeaseLocked(state *graphicsOutputState)
241195 // completion must never release a block that may have reached an outer terminal.
242196 // The caller holds d.mu.
243197 func (d *Daemon) quarantineGraphicsNamespaceLocked(state *graphicsOutputState) *graphicsNamespaceQuarantine {
244- if d == nil || state == nil || state.namespaceBase == 0 || state.namespaceBase%graphicsIDNamespaceSize != 1 {
245- return nil
246- }
247- block := (state.namespaceBase - 1) / graphicsIDNamespaceSize
248- if d.graphicsNamespaces == nil {
249- d.graphicsNamespaces = make(map[uint64]struct{})
250- }
251- if d.graphicsNamespaceFences == nil {
252- d.graphicsNamespaceFences = make(map[uint64]uint64)
253- }
254- if d.graphicsNamespaceQuarantines == nil {
255- d.graphicsNamespaceQuarantines = make(map[uint64]*graphicsNamespaceQuarantine)
256- }
257- if _, exists := d.graphicsNamespaceQuarantines[block]; exists {
198+ if d == nil {
258199 return nil
259200 }
260- d.graphicsNamespaces[block] = struct{}{}
261- d.graphicsNamespaceFences[block] = state.namespaceFence
262- q := &graphicsNamespaceQuarantine{base: state.namespaceBase, fence: state.namespaceFence}
263- d.graphicsNamespaceQuarantines[block] = q
264- return q
201+ return d.graphics.quarantine(state)
265202 }
266203
267204 func cloneGraphicsOutputState(in *graphicsOutputState) graphicsOutputState {
@@ -5,7 +5,6 @@ import (
5 "compress/zlib"5 "compress/zlib"
6 "crypto/sha256"6 "crypto/sha256"
7 "encoding/base64"7 "encoding/base64"
8- "encoding/binary"
9 "errors"8 "errors"
10 "fmt"9 "fmt"
11 "sort"10 "sort"
@@ -106,11 +105,6 @@ var (
106 graphicsNamespaceFence atomic.Uint64105 graphicsNamespaceFence atomic.Uint64
107 )106 )
108 107
109-type graphicsNamespaceQuarantine struct {
110- base uint64
111- fence uint64
112-}
113-
114 func nextGraphicsNamespaceFence() uint64 {108 func nextGraphicsNamespaceFence() uint64 {
115 fence := graphicsNamespaceFence.Add(1)109 fence := graphicsNamespaceFence.Add(1)
116 if fence == 0 {110 if fence == 0 {
@@ -167,34 +161,7 @@ func (d *Daemon) reserveGraphicsNamespaceLeaseLocked(key string) (uint64, uint64
167 if d == nil {161 if d == nil {
168 return 0, 0162 return 0, 0
169 }163 }
170- if d.graphicsNamespaces == nil {164+ return d.graphics.reserveLease(key)
171- d.graphicsNamespaces = make(map[uint64]struct{})
172- }
173- if d.graphicsNamespaceFences == nil {
174- d.graphicsNamespaceFences = make(map[uint64]uint64)
175- }
176- if d.graphicsNamespaceQuarantines == nil {
177- d.graphicsNamespaceQuarantines = make(map[uint64]*graphicsNamespaceQuarantine)
178- }
179- hashInput := []byte(key)
180- if d.graphicsNamespaceSalt != 0 {
181- salt := make([]byte, 8, 8+len(key))
182- binary.BigEndian.PutUint64(salt, d.graphicsNamespaceSalt)
183- hashInput = append(salt, hashInput...)
184- }
185- digest := sha256.Sum256(hashInput)
186- preferred := binary.BigEndian.Uint64(digest[:8]) % graphicsIDNamespaceCount
187- for offset := uint64(0); offset < graphicsIDNamespaceCount; offset++ {
188- block := (preferred + offset) % graphicsIDNamespaceCount
189- if _, exists := d.graphicsNamespaces[block]; exists {
190- continue
191- }
192- fence := nextGraphicsNamespaceFence()
193- d.graphicsNamespaces[block] = struct{}{}
194- d.graphicsNamespaceFences[block] = fence
195- return block*graphicsIDNamespaceSize + 1, fence
196- }
197- return 0, 0
198 }165 }
199 166
200 func (d *Daemon) releaseGraphicsNamespace(base uint64) {167 func (d *Daemon) releaseGraphicsNamespace(base uint64) {
@@ -210,30 +177,17 @@ func (d *Daemon) releaseGraphicsNamespace(base uint64) {
210 // already held. A quarantined block is intentionally not released by this177 // already held. A quarantined block is intentionally not released by this
211 // legacy base-only helper; only its fenced cleanup lifecycle may retire it.178 // legacy base-only helper; only its fenced cleanup lifecycle may retire it.
212 func (d *Daemon) releaseGraphicsNamespaceLocked(base uint64) {179 func (d *Daemon) releaseGraphicsNamespaceLocked(base uint64) {
213- if d == nil || base == 0 || base%graphicsIDNamespaceSize != 1 {180+ if d == nil {
214- return
215- }
216- block := (base - 1) / graphicsIDNamespaceSize
217- if _, quarantined := d.graphicsNamespaceQuarantines[block]; quarantined {
218 return181 return
219 }182 }
220- delete(d.graphicsNamespaces, block)183+ d.graphics.releaseBase(base)
221- delete(d.graphicsNamespaceFences, block)
222 }184 }
223 185
224 func (d *Daemon) releaseGraphicsNamespaceLeaseLocked(state *graphicsOutputState) {186 func (d *Daemon) releaseGraphicsNamespaceLeaseLocked(state *graphicsOutputState) {
225- if d == nil || state == nil || state.namespaceBase == 0 || state.namespaceBase%graphicsIDNamespaceSize != 1 {187+ if d == nil {
226- return
227- }
228- block := (state.namespaceBase - 1) / graphicsIDNamespaceSize
229- if _, quarantined := d.graphicsNamespaceQuarantines[block]; quarantined {
230- return
231- }
232- if current := d.graphicsNamespaceFences[block]; current != 0 && current != state.namespaceFence {
233 return188 return
234 }189 }
235- delete(d.graphicsNamespaces, block)190+ d.graphics.releaseLease(state)
236- delete(d.graphicsNamespaceFences, block)
237 }191 }
238 192
239 // quarantineGraphicsNamespaceLocked fences one exact namespace instance for193 // quarantineGraphicsNamespaceLocked fences one exact namespace instance for
@@ -241,27 +195,10 @@ func (d *Daemon) releaseGraphicsNamespaceLeaseLocked(state *graphicsOutputState)
241 // completion must never release a block that may have reached an outer terminal.195 // completion must never release a block that may have reached an outer terminal.
242 // The caller holds d.mu.196 // The caller holds d.mu.
243 func (d *Daemon) quarantineGraphicsNamespaceLocked(state *graphicsOutputState) *graphicsNamespaceQuarantine {197 func (d *Daemon) quarantineGraphicsNamespaceLocked(state *graphicsOutputState) *graphicsNamespaceQuarantine {
244- if d == nil || state == nil || state.namespaceBase == 0 || state.namespaceBase%graphicsIDNamespaceSize != 1 {198+ if d == nil {
245- return nil
246- }
247- block := (state.namespaceBase - 1) / graphicsIDNamespaceSize
248- if d.graphicsNamespaces == nil {
249- d.graphicsNamespaces = make(map[uint64]struct{})
250- }
251- if d.graphicsNamespaceFences == nil {
252- d.graphicsNamespaceFences = make(map[uint64]uint64)
253- }
254- if d.graphicsNamespaceQuarantines == nil {
255- d.graphicsNamespaceQuarantines = make(map[uint64]*graphicsNamespaceQuarantine)
256- }
257- if _, exists := d.graphicsNamespaceQuarantines[block]; exists {
258 return nil199 return nil
259 }200 }
260- d.graphicsNamespaces[block] = struct{}{}201+ return d.graphics.quarantine(state)
261- d.graphicsNamespaceFences[block] = state.namespaceFence
262- q := &graphicsNamespaceQuarantine{base: state.namespaceBase, fence: state.namespaceFence}
263- d.graphicsNamespaceQuarantines[block] = q
264- return q
265 }202 }
266 203
267 func cloneGraphicsOutputState(in *graphicsOutputState) graphicsOutputState {204 func cloneGraphicsOutputState(in *graphicsOutputState) graphicsOutputState {
M internal/usecase/daemon/graphics_output_test.go +9 -9
@@ -398,13 +398,13 @@ func TestGraphicsNamespaceQuarantinesOnParkExpiryAndFailedCleanup(t *testing.T)
398398 done: make(chan struct{}),
399399 }
400400 d.mu.Lock()
401- d.graphicsNamespaces[block] = struct{}{}
401+ d.graphics.reserved[block] = struct{}{}
402402 d.resume.parked[1] = parked
403403 d.mu.Unlock()
404404 d.expireParked(1, parked)
405405 require.Nil(t, parked.ac.output.graphicsOutput)
406406 d.mu.Lock()
407- _, reserved := d.graphicsNamespaces[block]
407+ _, reserved := d.graphics.reserved[block]
408408 d.mu.Unlock()
409409 require.False(t, reserved, "park expiry with no outer objects may release the attachment namespace")
410410
@@ -417,12 +417,12 @@ func TestGraphicsNamespaceQuarantinesOnParkExpiryAndFailedCleanup(t *testing.T)
417417 output: attachmentOutputWithGraphics(failedState),
418418 }
419419 d.mu.Lock()
420- d.graphicsNamespaces[block] = struct{}{}
420+ d.graphics.reserved[block] = struct{}{}
421421 d.mu.Unlock()
422422 require.Error(t, d.cleanupAttachmentOutput(failed), "failing transport must surface the cleanup send error")
423423 require.Nil(t, failed.output.graphicsOutput)
424424 d.mu.Lock()
425- _, reserved = d.graphicsNamespaces[block]
425+ _, reserved = d.graphics.reserved[block]
426426 d.mu.Unlock()
427427 require.True(t, reserved, "failed terminal cleanup must quarantine the namespace")
428428 }
@@ -453,7 +453,7 @@ func TestGraphicsNamespaceStaysQuarantinedAfterSocketSendSuccessWithoutTerminalF
453453 require.Zero(t, cleanup.New, "graphics cleanup is an unacknowledged side-effect frame")
454454
455455 d.mu.Lock()
456- _, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
456+ _, quarantined := d.graphics.quarantines[(oldBase-1)/graphicsIDNamespaceSize]
457457 newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
458458 d.mu.Unlock()
459459 require.True(t, quarantined, "socket-send success cannot prove terminal cleanup")
@@ -491,7 +491,7 @@ func TestGraphicsNamespaceStaysQuarantinedAfterFinalDeleteAndParkExpiry(t *testi
491491 d.expireParked(1, parked)
492492
493493 d.mu.Lock()
494- _, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
494+ _, quarantined := d.graphics.quarantines[(oldBase-1)/graphicsIDNamespaceSize]
495495 newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
496496 d.mu.Unlock()
497497 require.True(t, quarantined, "an apparently empty parked state still has unacknowledged terminal history")
@@ -502,7 +502,7 @@ func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) {
502502 d := newTestDaemon(t, nil, stubClock{})
503503 d.mu.Lock()
504504 for block := uint64(0); block < graphicsIDNamespaceCount; block++ {
505- d.graphicsNamespaces[block] = struct{}{}
505+ d.graphics.reserved[block] = struct{}{}
506506 }
507507 ac := d.prepareAttachedClientLocked(
508508 &session{sessionCore: sessionCore{id: "work"}},
@@ -552,7 +552,7 @@ func TestGraphicsNamespaceQuarantineFencesTimedOutLateDelete(t *testing.T) {
552552
553553 d.mu.Lock()
554554 newBase, newFence := d.reserveGraphicsNamespaceLeaseLocked(key)
555- _, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
555+ _, quarantined := d.graphics.quarantines[(oldBase-1)/graphicsIDNamespaceSize]
556556 d.mu.Unlock()
557557 require.True(t, quarantined, "timed-out cleanup must keep its namespace quarantined")
558558 require.NotEqual(t, oldBase, newBase, "new allocation must skip the quarantined namespace")
@@ -575,7 +575,7 @@ func TestGraphicsNamespaceQuarantineFencesTimedOutLateDelete(t *testing.T) {
575575 require.Contains(t, string(staleOutput.Data), "i="+strconv.FormatUint(oldID, 10))
576576 require.NotContains(t, string(staleOutput.Data), "i="+strconv.FormatUint(newID, 10), "late cleanup must never target the fresh attachment")
577577 d.mu.Lock()
578- _, retained := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
578+ _, retained := d.graphics.quarantines[(oldBase-1)/graphicsIDNamespaceSize]
579579 d.mu.Unlock()
580580 require.True(t, retained, "late transport success is not a terminal ACK and must not release the namespace")
581581 }
@@ -398,13 +398,13 @@ func TestGraphicsNamespaceQuarantinesOnParkExpiryAndFailedCleanup(t *testing.T)
398 done: make(chan struct{}),398 done: make(chan struct{}),
399 }399 }
400 d.mu.Lock()400 d.mu.Lock()
401- d.graphicsNamespaces[block] = struct{}{}401+ d.graphics.reserved[block] = struct{}{}
402 d.resume.parked[1] = parked402 d.resume.parked[1] = parked
403 d.mu.Unlock()403 d.mu.Unlock()
404 d.expireParked(1, parked)404 d.expireParked(1, parked)
405 require.Nil(t, parked.ac.output.graphicsOutput)405 require.Nil(t, parked.ac.output.graphicsOutput)
406 d.mu.Lock()406 d.mu.Lock()
407- _, reserved := d.graphicsNamespaces[block]407+ _, reserved := d.graphics.reserved[block]
408 d.mu.Unlock()408 d.mu.Unlock()
409 require.False(t, reserved, "park expiry with no outer objects may release the attachment namespace")409 require.False(t, reserved, "park expiry with no outer objects may release the attachment namespace")
410 410
@@ -417,12 +417,12 @@ func TestGraphicsNamespaceQuarantinesOnParkExpiryAndFailedCleanup(t *testing.T)
417 output: attachmentOutputWithGraphics(failedState),417 output: attachmentOutputWithGraphics(failedState),
418 }418 }
419 d.mu.Lock()419 d.mu.Lock()
420- d.graphicsNamespaces[block] = struct{}{}420+ d.graphics.reserved[block] = struct{}{}
421 d.mu.Unlock()421 d.mu.Unlock()
422 require.Error(t, d.cleanupAttachmentOutput(failed), "failing transport must surface the cleanup send error")422 require.Error(t, d.cleanupAttachmentOutput(failed), "failing transport must surface the cleanup send error")
423 require.Nil(t, failed.output.graphicsOutput)423 require.Nil(t, failed.output.graphicsOutput)
424 d.mu.Lock()424 d.mu.Lock()
425- _, reserved = d.graphicsNamespaces[block]425+ _, reserved = d.graphics.reserved[block]
426 d.mu.Unlock()426 d.mu.Unlock()
427 require.True(t, reserved, "failed terminal cleanup must quarantine the namespace")427 require.True(t, reserved, "failed terminal cleanup must quarantine the namespace")
428 }428 }
@@ -453,7 +453,7 @@ func TestGraphicsNamespaceStaysQuarantinedAfterSocketSendSuccessWithoutTerminalF
453 require.Zero(t, cleanup.New, "graphics cleanup is an unacknowledged side-effect frame")453 require.Zero(t, cleanup.New, "graphics cleanup is an unacknowledged side-effect frame")
454 454
455 d.mu.Lock()455 d.mu.Lock()
456- _, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]456+ _, quarantined := d.graphics.quarantines[(oldBase-1)/graphicsIDNamespaceSize]
457 newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)457 newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
458 d.mu.Unlock()458 d.mu.Unlock()
459 require.True(t, quarantined, "socket-send success cannot prove terminal cleanup")459 require.True(t, quarantined, "socket-send success cannot prove terminal cleanup")
@@ -491,7 +491,7 @@ func TestGraphicsNamespaceStaysQuarantinedAfterFinalDeleteAndParkExpiry(t *testi
491 d.expireParked(1, parked)491 d.expireParked(1, parked)
492 492
493 d.mu.Lock()493 d.mu.Lock()
494- _, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]494+ _, quarantined := d.graphics.quarantines[(oldBase-1)/graphicsIDNamespaceSize]
495 newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)495 newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
496 d.mu.Unlock()496 d.mu.Unlock()
497 require.True(t, quarantined, "an apparently empty parked state still has unacknowledged terminal history")497 require.True(t, quarantined, "an apparently empty parked state still has unacknowledged terminal history")
@@ -502,7 +502,7 @@ func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) {
502 d := newTestDaemon(t, nil, stubClock{})502 d := newTestDaemon(t, nil, stubClock{})
503 d.mu.Lock()503 d.mu.Lock()
504 for block := uint64(0); block < graphicsIDNamespaceCount; block++ {504 for block := uint64(0); block < graphicsIDNamespaceCount; block++ {
505- d.graphicsNamespaces[block] = struct{}{}505+ d.graphics.reserved[block] = struct{}{}
506 }506 }
507 ac := d.prepareAttachedClientLocked(507 ac := d.prepareAttachedClientLocked(
508 &session{sessionCore: sessionCore{id: "work"}},508 &session{sessionCore: sessionCore{id: "work"}},
@@ -552,7 +552,7 @@ func TestGraphicsNamespaceQuarantineFencesTimedOutLateDelete(t *testing.T) {
552 552
553 d.mu.Lock()553 d.mu.Lock()
554 newBase, newFence := d.reserveGraphicsNamespaceLeaseLocked(key)554 newBase, newFence := d.reserveGraphicsNamespaceLeaseLocked(key)
555- _, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]555+ _, quarantined := d.graphics.quarantines[(oldBase-1)/graphicsIDNamespaceSize]
556 d.mu.Unlock()556 d.mu.Unlock()
557 require.True(t, quarantined, "timed-out cleanup must keep its namespace quarantined")557 require.True(t, quarantined, "timed-out cleanup must keep its namespace quarantined")
558 require.NotEqual(t, oldBase, newBase, "new allocation must skip the quarantined namespace")558 require.NotEqual(t, oldBase, newBase, "new allocation must skip the quarantined namespace")
@@ -575,7 +575,7 @@ func TestGraphicsNamespaceQuarantineFencesTimedOutLateDelete(t *testing.T) {
575 require.Contains(t, string(staleOutput.Data), "i="+strconv.FormatUint(oldID, 10))575 require.Contains(t, string(staleOutput.Data), "i="+strconv.FormatUint(oldID, 10))
576 require.NotContains(t, string(staleOutput.Data), "i="+strconv.FormatUint(newID, 10), "late cleanup must never target the fresh attachment")576 require.NotContains(t, string(staleOutput.Data), "i="+strconv.FormatUint(newID, 10), "late cleanup must never target the fresh attachment")
577 d.mu.Lock()577 d.mu.Lock()
578- _, retained := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]578+ _, retained := d.graphics.quarantines[(oldBase-1)/graphicsIDNamespaceSize]
579 d.mu.Unlock()579 d.mu.Unlock()
580 require.True(t, retained, "late transport success is not a terminal ACK and must not release the namespace")580 require.True(t, retained, "late transport success is not a terminal ACK and must not release the namespace")
581 }581 }
M internal/usecase/daemon/history_policy.go +1 -5
@@ -16,11 +16,7 @@ func historyConfigFor(policy domain.ScrollbackConfig) vt.HistoryConfig {
1616 }
1717
1818 func (d *Daemon) currentHistoryConfig() vt.HistoryConfig {
19- policy := d.scrollbackConfig.Load()
20- if policy == nil {
21- return historyConfigFor(domain.DefaultScrollbackConfig())
22- }
23- return historyConfigFor(*policy)
19+ return historyConfigFor(d.currentRuntimeConfig().scrollback)
2420 }
2521
2622 // historyPanes gathers references without holding a session/tab lock while
@@ -16,11 +16,7 @@ func historyConfigFor(policy domain.ScrollbackConfig) vt.HistoryConfig {
16 }16 }
17 17
18 func (d *Daemon) currentHistoryConfig() vt.HistoryConfig {18 func (d *Daemon) currentHistoryConfig() vt.HistoryConfig {
19- policy := d.scrollbackConfig.Load()19+ return historyConfigFor(d.currentRuntimeConfig().scrollback)
20- if policy == nil {
21- return historyConfigFor(domain.DefaultScrollbackConfig())
22- }
23- return historyConfigFor(*policy)
24 }20 }
25 21
26 // historyPanes gathers references without holding a session/tab lock while22 // historyPanes gathers references without holding a session/tab lock while
M internal/usecase/daemon/input_test.go +6 -6
@@ -1262,7 +1262,7 @@ func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing
12621262 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
12631263 p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
12641264 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1265- d.procComm = nil
1265+ d.proc = nil
12661266 tb := testAttachmentTab(sess)
12671267 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
12681268 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
@@ -1444,7 +1444,7 @@ func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
14441444 p2 := portsmocks.NewMockPTY(t)
14451445 p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
14461446 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1447- d.procComm = nil
1447+ d.proc = nil
14481448 tb := testAttachmentTab(sess)
14491449 tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
14501450 tb.mu.Lock()
@@ -1467,7 +1467,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
14671467 p2 := portsmocks.NewMockPTY(t)
14681468 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Maybe()
14691469 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1470- d.procComm = nil
1470+ d.proc = nil
14711471 tb := testAttachmentTab(sess)
14721472 for i := range 10 {
14731473 writeTestRow(tb.focusedPane().screen, i, string(rune('a'+i)))
@@ -1501,7 +1501,7 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
15011501 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
15021502 p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
15031503 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1504- d.procComm = nil
1504+ d.proc = nil
15051505 tb := testAttachmentTab(sess)
15061506 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
15071507 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
@@ -1528,7 +1528,7 @@ func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
15281528 p2 := portsmocks.NewMockPTY(t)
15291529 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
15301530 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1531- d.procComm = nil
1531+ d.proc = nil
15321532 tb := testAttachmentTab(sess)
15331533 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
15341534 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
@@ -1558,7 +1558,7 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
15581558 p2 := portsmocks.NewMockPTY(t)
15591559 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(nil).Maybe()
15601560 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1561- d.procComm = nil
1561+ d.proc = nil
15621562 tb := testAttachmentTab(sess)
15631563 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3})
15641564 tb.mu.Lock()
@@ -1262,7 +1262,7 @@ func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing
1262 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()1262 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1263 p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()1263 p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
1264 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)1264 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1265- d.procComm = nil1265+ d.proc = nil
1266 tb := testAttachmentTab(sess)1266 tb := testAttachmentTab(sess)
1267 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})1267 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1268 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))1268 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
@@ -1444,7 +1444,7 @@ func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
1444 p2 := portsmocks.NewMockPTY(t)1444 p2 := portsmocks.NewMockPTY(t)
1445 p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()1445 p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
1446 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)1446 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1447- d.procComm = nil1447+ d.proc = nil
1448 tb := testAttachmentTab(sess)1448 tb := testAttachmentTab(sess)
1449 tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))1449 tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1450 tb.mu.Lock()1450 tb.mu.Lock()
@@ -1467,7 +1467,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
1467 p2 := portsmocks.NewMockPTY(t)1467 p2 := portsmocks.NewMockPTY(t)
1468 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Maybe()1468 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Maybe()
1469 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)1469 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1470- d.procComm = nil1470+ d.proc = nil
1471 tb := testAttachmentTab(sess)1471 tb := testAttachmentTab(sess)
1472 for i := range 10 {1472 for i := range 10 {
1473 writeTestRow(tb.focusedPane().screen, i, string(rune('a'+i)))1473 writeTestRow(tb.focusedPane().screen, i, string(rune('a'+i)))
@@ -1501,7 +1501,7 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
1501 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()1501 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1502 p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()1502 p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
1503 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)1503 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1504- d.procComm = nil1504+ d.proc = nil
1505 tb := testAttachmentTab(sess)1505 tb := testAttachmentTab(sess)
1506 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})1506 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1507 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))1507 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
@@ -1528,7 +1528,7 @@ func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
1528 p2 := portsmocks.NewMockPTY(t)1528 p2 := portsmocks.NewMockPTY(t)
1529 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()1529 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1530 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)1530 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1531- d.procComm = nil1531+ d.proc = nil
1532 tb := testAttachmentTab(sess)1532 tb := testAttachmentTab(sess)
1533 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})1533 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1534 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))1534 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
@@ -1558,7 +1558,7 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
1558 p2 := portsmocks.NewMockPTY(t)1558 p2 := portsmocks.NewMockPTY(t)
1559 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(nil).Maybe()1559 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(nil).Maybe()
1560 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)1560 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1561- d.procComm = nil1561+ d.proc = nil
1562 tb := testAttachmentTab(sess)1562 tb := testAttachmentTab(sess)
1563 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3})1563 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3})
1564 tb.mu.Lock()1564 tb.mu.Lock()
M internal/usecase/daemon/maintenance.go +11 -11
@@ -19,16 +19,16 @@ func (d *Daemon) startDurableMaintenance() {
1919 if d == nil || d.recovery == nil || !d.snapshotGarbageCollection {
2020 return
2121 }
22- d.snapshotWorkerMu.Lock()
23- if d.maintenanceWorkerCancel != nil {
24- d.snapshotWorkerMu.Unlock()
22+ d.snapshots.mu.Lock()
23+ if d.snapshots.maintenanceWorkerCancel != nil {
24+ d.snapshots.mu.Unlock()
2525 return
2626 }
2727 ctx, cancel := context.WithCancel(d.serveCtx)
28- d.maintenanceWorkerCancel = cancel
28+ d.snapshots.maintenanceWorkerCancel = cancel
2929 done := make(chan struct{})
30- d.maintenanceWorkerDone = done
31- d.snapshotWorkerMu.Unlock()
30+ d.snapshots.maintenanceWorkerDone = done
31+ d.snapshots.mu.Unlock()
3232
3333 go func() {
3434 defer close(done)
@@ -39,15 +39,15 @@ func (d *Daemon) startDurableMaintenance() {
3939 // cancelDurableMaintenance stops the GC worker without waiting for it;
4040 // WaitDurableWriters owns the join.
4141 func (d *Daemon) cancelDurableMaintenance() {
42- d.snapshotWorkerMu.Lock()
43- defer d.snapshotWorkerMu.Unlock()
42+ d.snapshots.mu.Lock()
43+ defer d.snapshots.mu.Unlock()
4444 d.cancelDurableMaintenanceLocked()
4545 }
4646
47-// cancelDurableMaintenanceLocked requires snapshotWorkerMu.
47+// cancelDurableMaintenanceLocked requires snapshotWorker.mu (d.snapshots.mu).
4848 func (d *Daemon) cancelDurableMaintenanceLocked() {
49- if d.maintenanceWorkerCancel != nil {
50- d.maintenanceWorkerCancel()
49+ if d.snapshots.maintenanceWorkerCancel != nil {
50+ d.snapshots.maintenanceWorkerCancel()
5151 }
5252 }
5353
@@ -19,16 +19,16 @@ func (d *Daemon) startDurableMaintenance() {
19 if d == nil || d.recovery == nil || !d.snapshotGarbageCollection {19 if d == nil || d.recovery == nil || !d.snapshotGarbageCollection {
20 return20 return
21 }21 }
22- d.snapshotWorkerMu.Lock()22+ d.snapshots.mu.Lock()
23- if d.maintenanceWorkerCancel != nil {23+ if d.snapshots.maintenanceWorkerCancel != nil {
24- d.snapshotWorkerMu.Unlock()24+ d.snapshots.mu.Unlock()
25 return25 return
26 }26 }
27 ctx, cancel := context.WithCancel(d.serveCtx)27 ctx, cancel := context.WithCancel(d.serveCtx)
28- d.maintenanceWorkerCancel = cancel28+ d.snapshots.maintenanceWorkerCancel = cancel
29 done := make(chan struct{})29 done := make(chan struct{})
30- d.maintenanceWorkerDone = done30+ d.snapshots.maintenanceWorkerDone = done
31- d.snapshotWorkerMu.Unlock()31+ d.snapshots.mu.Unlock()
32 32
33 go func() {33 go func() {
34 defer close(done)34 defer close(done)
@@ -39,15 +39,15 @@ func (d *Daemon) startDurableMaintenance() {
39 // cancelDurableMaintenance stops the GC worker without waiting for it;39 // cancelDurableMaintenance stops the GC worker without waiting for it;
40 // WaitDurableWriters owns the join.40 // WaitDurableWriters owns the join.
41 func (d *Daemon) cancelDurableMaintenance() {41 func (d *Daemon) cancelDurableMaintenance() {
42- d.snapshotWorkerMu.Lock()42+ d.snapshots.mu.Lock()
43- defer d.snapshotWorkerMu.Unlock()43+ defer d.snapshots.mu.Unlock()
44 d.cancelDurableMaintenanceLocked()44 d.cancelDurableMaintenanceLocked()
45 }45 }
46 46
47-// cancelDurableMaintenanceLocked requires snapshotWorkerMu.47+// cancelDurableMaintenanceLocked requires snapshotWorker.mu (d.snapshots.mu).
48 func (d *Daemon) cancelDurableMaintenanceLocked() {48 func (d *Daemon) cancelDurableMaintenanceLocked() {
49- if d.maintenanceWorkerCancel != nil {49+ if d.snapshots.maintenanceWorkerCancel != nil {
50- d.maintenanceWorkerCancel()50+ d.snapshots.maintenanceWorkerCancel()
51 }51 }
52 }52 }
53 53
M internal/usecase/daemon/maintenance_test.go +3 -3
@@ -78,7 +78,7 @@ func TestSnapshotGarbageCollectionRequiresExplicitOption(t *testing.T) {
7878 WithRecoveryCoordinator(coordinator)(d)
7979 d.startDurableMaintenance()
8080
81- d.snapshotWorkerMu.Lock()
82- defer d.snapshotWorkerMu.Unlock()
83- require.Nil(t, d.maintenanceWorkerDone)
81+ d.snapshots.mu.Lock()
82+ defer d.snapshots.mu.Unlock()
83+ require.Nil(t, d.snapshots.maintenanceWorkerDone)
8484 }
@@ -78,7 +78,7 @@ func TestSnapshotGarbageCollectionRequiresExplicitOption(t *testing.T) {
78 WithRecoveryCoordinator(coordinator)(d)78 WithRecoveryCoordinator(coordinator)(d)
79 d.startDurableMaintenance()79 d.startDurableMaintenance()
80 80
81- d.snapshotWorkerMu.Lock()81+ d.snapshots.mu.Lock()
82- defer d.snapshotWorkerMu.Unlock()82+ defer d.snapshots.mu.Unlock()
83- require.Nil(t, d.maintenanceWorkerDone)83+ require.Nil(t, d.snapshots.maintenanceWorkerDone)
84 }84 }
M internal/usecase/daemon/move_lifecycle.go +2 -0
@@ -179,6 +179,7 @@ func (d *Daemon) releaseMoveAdmissionLocked() {
179179 d.mu.Unlock()
180180 }
181181
182+// moveLifecycleChangeLocked requires moveLifecycleMu, not d.mu.
182183 func (d *Daemon) moveLifecycleChangeLocked() chan struct{} {
183184 if d.moveLifecycleChanged == nil {
184185 d.moveLifecycleChanged = make(chan struct{})
@@ -186,6 +187,7 @@ func (d *Daemon) moveLifecycleChangeLocked() chan struct{} {
186187 return d.moveLifecycleChanged
187188 }
188189
190+// signalMoveLifecycleChangedLocked requires moveLifecycleMu, not d.mu.
189191 func (d *Daemon) signalMoveLifecycleChangedLocked() {
190192 close(d.moveLifecycleChangeLocked())
191193 d.moveLifecycleChanged = make(chan struct{})
@@ -179,6 +179,7 @@ func (d *Daemon) releaseMoveAdmissionLocked() {
179 d.mu.Unlock()179 d.mu.Unlock()
180 }180 }
181 181
182+// moveLifecycleChangeLocked requires moveLifecycleMu, not d.mu.
182 func (d *Daemon) moveLifecycleChangeLocked() chan struct{} {183 func (d *Daemon) moveLifecycleChangeLocked() chan struct{} {
183 if d.moveLifecycleChanged == nil {184 if d.moveLifecycleChanged == nil {
184 d.moveLifecycleChanged = make(chan struct{})185 d.moveLifecycleChanged = make(chan struct{})
@@ -186,6 +187,7 @@ func (d *Daemon) moveLifecycleChangeLocked() chan struct{} {
186 return d.moveLifecycleChanged187 return d.moveLifecycleChanged
187 }188 }
188 189
190+// signalMoveLifecycleChangedLocked requires moveLifecycleMu, not d.mu.
189 func (d *Daemon) signalMoveLifecycleChangedLocked() {191 func (d *Daemon) signalMoveLifecycleChangedLocked() {
190 close(d.moveLifecycleChangeLocked())192 close(d.moveLifecycleChangeLocked())
191 d.moveLifecycleChanged = make(chan struct{})193 d.moveLifecycleChanged = make(chan struct{})
M internal/usecase/daemon/move_persistence_test.go +3 -3
@@ -183,14 +183,14 @@ func TestMoveTabPersistenceMatrix(t *testing.T) {
183183 source.incarnation = domain.IncarnationID{1}
184184 source.ephemeral = !tt.sourceNamed
185185 source.snapEligible.Store(tt.sourceNamed)
186- source.snapshotWake = d.snapshotWake
186+ source.snapshotWake = d.snapshots.wake
187187 moved := source.tabs[0]
188188 moved.stableID = "moved-tab"
189189
190190 destination := addMoveTabTestSession(d, "destination", "destination-tab")
191191 destination.ephemeral = !tt.destinationNamed
192192 destination.snapEligible.Store(tt.destinationNamed)
193- destination.snapshotWake = d.snapshotWake
193+ destination.snapshotWake = d.snapshots.wake
194194
195195 catalogue := &movePersistenceCatalogue{
196196 records: map[string]domain.CatalogueRecord{},
@@ -251,7 +251,7 @@ func TestMoveSnapshotPublicationFailureReportsWithoutRollback(t *testing.T) {
251251 destination := addMoveTabTestSession(d, "destination", "destination-tab")
252252 destination.ephemeral = false
253253 destination.snapEligible.Store(true)
254- destination.snapshotWake = d.snapshotWake
254+ destination.snapshotWake = d.snapshots.wake
255255
256256 repository := &movePersistenceRepository{
257257 publish: errors.New("injected publication failure"),
@@ -183,14 +183,14 @@ func TestMoveTabPersistenceMatrix(t *testing.T) {
183 source.incarnation = domain.IncarnationID{1}183 source.incarnation = domain.IncarnationID{1}
184 source.ephemeral = !tt.sourceNamed184 source.ephemeral = !tt.sourceNamed
185 source.snapEligible.Store(tt.sourceNamed)185 source.snapEligible.Store(tt.sourceNamed)
186- source.snapshotWake = d.snapshotWake186+ source.snapshotWake = d.snapshots.wake
187 moved := source.tabs[0]187 moved := source.tabs[0]
188 moved.stableID = "moved-tab"188 moved.stableID = "moved-tab"
189 189
190 destination := addMoveTabTestSession(d, "destination", "destination-tab")190 destination := addMoveTabTestSession(d, "destination", "destination-tab")
191 destination.ephemeral = !tt.destinationNamed191 destination.ephemeral = !tt.destinationNamed
192 destination.snapEligible.Store(tt.destinationNamed)192 destination.snapEligible.Store(tt.destinationNamed)
193- destination.snapshotWake = d.snapshotWake193+ destination.snapshotWake = d.snapshots.wake
194 194
195 catalogue := &movePersistenceCatalogue{195 catalogue := &movePersistenceCatalogue{
196 records: map[string]domain.CatalogueRecord{},196 records: map[string]domain.CatalogueRecord{},
@@ -251,7 +251,7 @@ func TestMoveSnapshotPublicationFailureReportsWithoutRollback(t *testing.T) {
251 destination := addMoveTabTestSession(d, "destination", "destination-tab")251 destination := addMoveTabTestSession(d, "destination", "destination-tab")
252 destination.ephemeral = false252 destination.ephemeral = false
253 destination.snapEligible.Store(true)253 destination.snapEligible.Store(true)
254- destination.snapshotWake = d.snapshotWake254+ destination.snapshotWake = d.snapshots.wake
255 255
256 repository := &movePersistenceRepository{256 repository := &movePersistenceRepository{
257 publish: errors.New("injected publication failure"),257 publish: errors.New("injected publication failure"),
M internal/usecase/daemon/palette.go +241 -145
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "errors"
55 "sort"
6+ "sync"
67 "time"
78
89 renderer "github.com/bnema/vev-vt"
@@ -292,11 +293,38 @@ func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) pale
292293 return hints
293294 }
294295
296+// paletteHistory is the daemon-wide most-recently-used command order. mu is a
297+// leaf lock: nothing is acquired while holding it. The zero value is usable.
298+type paletteHistory struct {
299+ mu sync.Mutex
300+ recent []string
301+}
302+
303+// snapshot returns a copy of the recent command codes, newest first.
304+func (h *paletteHistory) snapshot() []string {
305+ h.mu.Lock()
306+ defer h.mu.Unlock()
307+ return append([]string(nil), h.recent...)
308+}
309+
310+// record moves code to the front of the recent list.
311+func (h *paletteHistory) record(code string) {
312+ h.mu.Lock()
313+ defer h.mu.Unlock()
314+
315+ recent := make([]string, 0, len(h.recent)+1)
316+ recent = append(recent, code)
317+ for _, existing := range h.recent {
318+ if existing != code {
319+ recent = append(recent, existing)
320+ }
321+ }
322+ h.recent = recent
323+}
324+
295325 func (d *Daemon) paletteCommands() []command.Command {
296326 commands := command.PaletteRegistry()
297- d.paletteRecentMu.Lock()
298- recent := append([]string(nil), d.paletteRecent...)
299- d.paletteRecentMu.Unlock()
327+ recent := d.paletteHistory.snapshot()
300328 overrides := d.codeOverrideSnapshot()
301329
302330 byCode := make(map[string]command.Command, len(commands))
@@ -323,26 +351,43 @@ func (d *Daemon) paletteCommands() []command.Command {
323351 return out
324352 }
325353
326-func (d *Daemon) recordPaletteUse(code string) {
327- d.paletteRecentMu.Lock()
328- defer d.paletteRecentMu.Unlock()
354+// paletteDecisionKind names what handlePaletteInput must do once the palette
355+// lock is released.
356+type paletteDecisionKind int
357+
358+const (
359+ paletteDecisionNone paletteDecisionKind = iota
360+ paletteDecisionRedraw
361+ paletteDecisionCancel
362+ paletteDecisionCreateDestination
363+ paletteDecisionImportedSession
364+ paletteDecisionRouteNavigation
365+ paletteDecisionSessionTarget
366+ paletteDecisionCommand
367+)
329368
330- recent := make([]string, 0, len(d.paletteRecent)+1)
331- recent = append(recent, code)
332- for _, existing := range d.paletteRecent {
333- if existing != code {
334- recent = append(recent, existing)
335- }
336- }
337- d.paletteRecent = recent
369+// paletteDecision is the immutable outcome of routing one input chunk through
370+// the palette under paletteMu. Executing it never touches palette state without
371+// retaking the lock.
372+type paletteDecision struct {
373+ kind paletteDecisionKind
374+ generation uint64
375+ rawQuery string
376+ cmd command.Command
377+ args []string
378+ routeSnapshot protocol.RecentRouteSnapshot
379+ sessionTarget palette.Result
380+ importedSourceKey string
381+ importedEntryKey string
382+ routeTarget protocol.RouteNavigationAction
383+ createDestination palette.Result
384+ cancelInventoryInteraction uint64
385+ hasCancelInventory bool
338386 }
339387
340-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
341- entry := ac.currentAttachmentSession()
342- if entry == nil {
343- return
344- }
345- sess := entry
388+// decidePaletteInput takes paletteMu, applies data to the attachment's palette,
389+// and returns the action to run after unlocking.
390+func decidePaletteInput(ac *attachedClient, data []byte) paletteDecision {
346391 var cmd command.Command
347392 var sessionTarget palette.Result
348393 var hasSessionTarget bool
@@ -359,16 +404,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
359404 var cancelInventoryInteraction uint64
360405 var hasCancelInventory bool
361406 changed, cancel, execute, chooseDestination := false, false, false, false
362- var effect *attachmentEffect
363- if len(effects) != 0 {
364- effect = effects[0]
365- }
366407
367408 ac.overlays.paletteMu.Lock()
368409 if ac.overlays.palette == nil {
369410 ac.overlays.palettePending = nil
370411 ac.overlays.paletteMu.Unlock()
371- return
412+ return paletteDecision{}
372413 }
373414 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
374415 rune: func(r rune) {
@@ -478,139 +519,193 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
478519 ac.clearPaletteLocked()
479520 }
480521 ac.overlays.paletteMu.Unlock()
481- if cancel {
482- if hasCancelInventory {
483- d.sendPaletteInventoryDemand(ac, effect, false, cancelInventoryInteraction)
522+
523+ switch {
524+ case cancel:
525+ return paletteDecision{kind: paletteDecisionCancel, cancelInventoryInteraction: cancelInventoryInteraction, hasCancelInventory: hasCancelInventory}
526+ case !execute && changed:
527+ return paletteDecision{kind: paletteDecisionRedraw}
528+ case !execute:
529+ return paletteDecision{}
530+ }
531+ decision := paletteDecision{generation: generation, rawQuery: rawQuery}
532+ switch {
533+ case hasCreateDestination:
534+ decision.kind = paletteDecisionCreateDestination
535+ decision.createDestination = createDestination
536+ case hasImportedTarget:
537+ decision.kind = paletteDecisionImportedSession
538+ decision.importedSourceKey = importedSourceKey
539+ decision.importedEntryKey = importedEntryKey
540+ case hasRouteTarget:
541+ decision.kind = paletteDecisionRouteNavigation
542+ decision.routeTarget = routeTarget
543+ case hasSessionTarget:
544+ decision.kind = paletteDecisionSessionTarget
545+ decision.sessionTarget = sessionTarget
546+ default:
547+ decision.kind = paletteDecisionCommand
548+ decision.cmd = cmd
549+ decision.args = args
550+ decision.routeSnapshot = routeSnapshot
551+ }
552+ return decision
553+}
554+
555+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
556+ entry := ac.currentAttachmentSession()
557+ if entry == nil {
558+ return
559+ }
560+ var effect *attachmentEffect
561+ if len(effects) != 0 {
562+ effect = effects[0]
563+ }
564+ d.executePaletteDecision(ac, entry, effect, decidePaletteInput(ac, data))
565+}
566+
567+// executePaletteDecision runs the outcome of decidePaletteInput with no palette
568+// lock held.
569+func (d *Daemon) executePaletteDecision(ac *attachedClient, entry *session, effect *attachmentEffect, decision paletteDecision) {
570+ generation, rawQuery := decision.generation, decision.rawQuery
571+ switch decision.kind {
572+ case paletteDecisionNone:
573+ case paletteDecisionCancel:
574+ if decision.hasCancelInventory {
575+ d.sendPaletteInventoryDemand(ac, effect, false, decision.cancelInventoryInteraction)
484576 }
485577 d.invalidateRender(entry, ac, true, "palette.go")
578+ case paletteDecisionRedraw:
579+ d.invalidateRender(entry, ac, true, "palette.go")
580+ case paletteDecisionCreateDestination:
581+ d.executePaletteCreateDestination(ac, entry, effect, generation, rawQuery, decision.createDestination)
582+ case paletteDecisionImportedSession:
583+ d.executePaletteImportedSession(ac, entry, effect, generation, rawQuery, decision.importedSourceKey, decision.importedEntryKey)
584+ case paletteDecisionRouteNavigation:
585+ d.executePaletteRouteNavigation(ac, entry, effect, generation, rawQuery, decision.routeTarget)
586+ case paletteDecisionSessionTarget:
587+ d.executePaletteSessionTarget(ac, entry, effect, generation, rawQuery, decision.sessionTarget)
588+ case paletteDecisionCommand:
589+ d.executePaletteCommand(ac, entry, effect, generation, rawQuery, decision.cmd, decision.args, decision.routeSnapshot)
590+ }
591+}
592+
593+func (d *Daemon) executePaletteCreateDestination(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, createDestination palette.Result) {
594+ name, _, _, _, _, _ := createDestination.CreateSessionDestination()
595+ exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, effect: effect}
596+ if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {
597+ ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
598+ d.invalidateRender(entry, ac, true, "palette.go")
486599 return
487600 }
488- if !execute {
489- if changed {
490- d.invalidateRender(entry, ac, true, "palette.go")
491- }
601+ if !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
492602 return
493603 }
494-
495- if hasCreateDestination {
496- name, _, _, _, _, _ := createDestination.CreateSessionDestination()
497- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
498- if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {
499- ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
500- d.invalidateRender(entry, ac, true, "palette.go")
501- return
502- }
503- if !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
504- return
505- }
506- if name == "" {
507- d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
508- return exec.createSessionOnDestination(promptEffect, createDestination, name)
509- })
510- d.invalidateRender(entry, ac, true, "palette.go")
511- return
512- }
513- err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
514- if err != nil && !errors.Is(err, errAttachmentTransition) {
515- d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
516- return
517- }
518- if current := ac.currentAttachmentSession(); current != nil {
519- d.invalidateRender(current, ac, true, "palette.go")
520- }
604+ if name == "" {
605+ d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
606+ return exec.createSessionOnDestination(promptEffect, createDestination, name)
607+ })
608+ d.invalidateRender(entry, ac, true, "palette.go")
521609 return
522610 }
523-
524- if hasImportedTarget {
525- if effect == nil {
526- ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
527- d.invalidateRender(entry, ac, true, "palette.go")
528- return
529- }
530- if importedSourceKey != protocol.NavigationInventoryLocalSourceKey {
531- // Remote vision is read-only: imported remote routes surface
532- // availability but never attach.
533- ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached")
534- d.invalidateRender(entry, ac, true, "palette.go")
535- return
536- }
537- ac.overlays.paletteMu.Lock()
538- open := ac.overlays.paletteInventoryOpen
539- selection := protocol.NavigationInventorySelection{
540- CauseActionID: effect.uiActionID,
541- InteractionGeneration: ac.overlays.paletteInventoryInteraction,
542- PublicationGeneration: ac.overlays.paletteInventoryPublication,
543- SourceKey: importedSourceKey,
544- EntryKey: importedEntryKey,
545- }
546- if open {
547- freezePaletteInventorySelection(ac.overlays, selection)
548- }
549- ac.overlays.paletteMu.Unlock()
550- if !open || protocol.ValidateNavigationInventorySelection(selection) != nil {
551- ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
552- d.invalidateRender(entry, ac, true, "palette.go")
553- return
554- }
555- // Selection crosses before the close demand on the guarded serving
556- // connection; closing after selection must not cancel the client's
557- // pending navigation operation.
558- if err := effect.sendControl(selection); err != nil {
559- ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
560- d.invalidateRender(entry, ac, true, "palette.go")
561- return
562- }
563- d.closeExecutedPalette(ac, effect, generation, rawQuery)
611+ err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
612+ if err != nil && !errors.Is(err, errAttachmentTransition) {
613+ d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
564614 return
565615 }
616+ if current := ac.currentAttachmentSession(); current != nil {
617+ d.invalidateRender(current, ac, true, "palette.go")
618+ }
619+}
566620
567- if hasRouteTarget {
568- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
569- if err := exec.NavigateRecentRoute(routeTarget); err != nil {
570- if errors.Is(err, errAttachmentTransition) {
571- return
572- }
573- ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
574- d.invalidateRender(entry, ac, true, "palette.go")
575- return
576- }
577- if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
578- d.invalidateRender(entry, ac, true, "palette.go")
579- }
621+func (d *Daemon) executePaletteImportedSession(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery, importedSourceKey, importedEntryKey string) {
622+ if effect == nil {
623+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
624+ d.invalidateRender(entry, ac, true, "palette.go")
625+ return
626+ }
627+ if importedSourceKey != protocol.NavigationInventoryLocalSourceKey {
628+ // Remote vision is read-only: imported remote routes surface
629+ // availability but never attach.
630+ ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached")
631+ d.invalidateRender(entry, ac, true, "palette.go")
632+ return
633+ }
634+ ac.overlays.paletteMu.Lock()
635+ open := ac.overlays.paletteInventoryOpen
636+ selection := protocol.NavigationInventorySelection{
637+ CauseActionID: effect.uiActionID,
638+ InteractionGeneration: ac.overlays.paletteInventoryInteraction,
639+ PublicationGeneration: ac.overlays.paletteInventoryPublication,
640+ SourceKey: importedSourceKey,
641+ EntryKey: importedEntryKey,
642+ }
643+ if open {
644+ freezePaletteInventorySelection(ac.overlays, selection)
645+ }
646+ ac.overlays.paletteMu.Unlock()
647+ if !open || protocol.ValidateNavigationInventorySelection(selection) != nil {
648+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
649+ d.invalidateRender(entry, ac, true, "palette.go")
580650 return
581651 }
652+ // Selection crosses before the close demand on the guarded serving
653+ // connection; closing after selection must not cancel the client's
654+ // pending navigation operation.
655+ if err := effect.sendControl(selection); err != nil {
656+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
657+ d.invalidateRender(entry, ac, true, "palette.go")
658+ return
659+ }
660+ d.closeExecutedPalette(ac, effect, generation, rawQuery)
661+}
582662
583- if hasSessionTarget {
584- createdAt, _ := sessionTarget.SessionCreatedAt()
585- expectedCreatedAt := createdAt.UnixNano()
586- name, _ := sessionTarget.SessionName()
587- exactTarget, _ := sessionTarget.SessionTarget()
588- target := picker.Target{
589- Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
590- ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
591- }
592- var err error
593- if effect != nil {
594- err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
595- } else {
596- err = d.switchToTarget(sess, ac, target)
597- }
663+func (d *Daemon) executePaletteRouteNavigation(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, routeTarget protocol.RouteNavigationAction) {
664+ exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, effect: effect}
665+ if err := exec.NavigateRecentRoute(routeTarget); err != nil {
598666 if errors.Is(err, errAttachmentTransition) {
599667 return
600668 }
601- if err != nil {
602- ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
603- d.invalidateRender(entry, ac, true, "palette.go")
604- return
605- }
606- if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
607- if current := ac.currentAttachmentSession(); current != nil {
608- d.invalidateRender(current, ac, true, "palette.go")
609- }
610- }
669+ ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
670+ d.invalidateRender(entry, ac, true, "palette.go")
611671 return
612672 }
673+ if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
674+ d.invalidateRender(entry, ac, true, "palette.go")
675+ }
676+}
677+
678+func (d *Daemon) executePaletteSessionTarget(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, sessionTarget palette.Result) {
679+ createdAt, _ := sessionTarget.SessionCreatedAt()
680+ expectedCreatedAt := createdAt.UnixNano()
681+ name, _ := sessionTarget.SessionName()
682+ exactTarget, _ := sessionTarget.SessionTarget()
683+ target := picker.Target{
684+ Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
685+ ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
686+ }
687+ var err error
688+ if effect != nil {
689+ err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
690+ } else {
691+ err = d.switchToTarget(entry, ac, target)
692+ }
693+ if errors.Is(err, errAttachmentTransition) {
694+ return
695+ }
696+ if err != nil {
697+ ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
698+ d.invalidateRender(entry, ac, true, "palette.go")
699+ return
700+ }
701+ if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
702+ if current := ac.currentAttachmentSession(); current != nil {
703+ d.invalidateRender(current, ac, true, "palette.go")
704+ }
705+ }
706+}
613707
708+func (d *Daemon) executePaletteCommand(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, cmd command.Command, args []string, routeSnapshot protocol.RecentRouteSnapshot) {
614709 if cmd.Slug == "jump-recent-session" {
615710 rank, err := command.ParsePositiveDecimal(args)
616711 if err != nil {
@@ -618,7 +713,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
618713 d.invalidateRender(entry, ac, true, "palette.go")
619714 return
620715 }
621- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect}
716+ exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect}
622717 if err := exec.JumpRecentSession(rank); err != nil {
623718 if errors.Is(err, errAttachmentTransition) {
624719 return
@@ -628,7 +723,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
628723 return
629724 }
630725 if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
631- d.recordPaletteUse(cmd.Code)
726+ d.paletteHistory.record(cmd.Code)
632727 if current := ac.currentAttachmentSession(); current != nil {
633728 d.invalidateRender(current, ac, true, "palette.go")
634729 }
@@ -639,9 +734,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
639734 if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
640735 return
641736 }
642- sess.dispatchMu.Lock()
643- err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
644- sess.dispatchMu.Unlock()
737+ entry.dispatchMu.Lock()
738+ err := cmd.Run(paletteExec{d: d, sess: entry, ac: ac, effect: effect, redrawClosedPalette: true}, args)
739+ entry.dispatchMu.Unlock()
645740 if attachmentHandoff {
646741 if current := ac.currentSession(); current != nil {
647742 currentToken := current.captureAttachmentCapability(ac, ac.transport())
@@ -661,9 +756,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
661756 }
662757 if err != nil {
663758 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
664- d.reportError(sess, paletteCommandNoticeError(cmd, err))
759+ d.reportError(entry, paletteCommandNoticeError(cmd, err))
665760 } else {
666- d.recordPaletteUse(cmd.Code)
761+ d.paletteHistory.record(cmd.Code)
667762 }
668763 }
669764
@@ -682,6 +777,7 @@ func paletteArgs(query string, cmd command.Command) []string {
682777 return action.Args
683778 }
684779
780+// clearPaletteLocked requires ac.overlays.paletteMu.
685781 func (ac *attachedClient) clearPaletteLocked() {
686782 ac.overlays.paletteGeneration++
687783 ac.overlays.palette = nil
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "errors"4 "errors"
5 "sort"5 "sort"
6+ "sync"
6 "time"7 "time"
7 8
8 renderer "github.com/bnema/vev-vt"9 renderer "github.com/bnema/vev-vt"
@@ -292,11 +293,38 @@ func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) pale
292 return hints293 return hints
293 }294 }
294 295
296+// paletteHistory is the daemon-wide most-recently-used command order. mu is a
297+// leaf lock: nothing is acquired while holding it. The zero value is usable.
298+type paletteHistory struct {
299+ mu sync.Mutex
300+ recent []string
301+}
302+
303+// snapshot returns a copy of the recent command codes, newest first.
304+func (h *paletteHistory) snapshot() []string {
305+ h.mu.Lock()
306+ defer h.mu.Unlock()
307+ return append([]string(nil), h.recent...)
308+}
309+
310+// record moves code to the front of the recent list.
311+func (h *paletteHistory) record(code string) {
312+ h.mu.Lock()
313+ defer h.mu.Unlock()
314+
315+ recent := make([]string, 0, len(h.recent)+1)
316+ recent = append(recent, code)
317+ for _, existing := range h.recent {
318+ if existing != code {
319+ recent = append(recent, existing)
320+ }
321+ }
322+ h.recent = recent
323+}
324+
295 func (d *Daemon) paletteCommands() []command.Command {325 func (d *Daemon) paletteCommands() []command.Command {
296 commands := command.PaletteRegistry()326 commands := command.PaletteRegistry()
297- d.paletteRecentMu.Lock()327+ recent := d.paletteHistory.snapshot()
298- recent := append([]string(nil), d.paletteRecent...)
299- d.paletteRecentMu.Unlock()
300 overrides := d.codeOverrideSnapshot()328 overrides := d.codeOverrideSnapshot()
301 329
302 byCode := make(map[string]command.Command, len(commands))330 byCode := make(map[string]command.Command, len(commands))
@@ -323,26 +351,43 @@ func (d *Daemon) paletteCommands() []command.Command {
323 return out351 return out
324 }352 }
325 353
326-func (d *Daemon) recordPaletteUse(code string) {354+// paletteDecisionKind names what handlePaletteInput must do once the palette
327- d.paletteRecentMu.Lock()355+// lock is released.
328- defer d.paletteRecentMu.Unlock()356+type paletteDecisionKind int
357+
358+const (
359+ paletteDecisionNone paletteDecisionKind = iota
360+ paletteDecisionRedraw
361+ paletteDecisionCancel
362+ paletteDecisionCreateDestination
363+ paletteDecisionImportedSession
364+ paletteDecisionRouteNavigation
365+ paletteDecisionSessionTarget
366+ paletteDecisionCommand
367+)
329 368
330- recent := make([]string, 0, len(d.paletteRecent)+1)369+// paletteDecision is the immutable outcome of routing one input chunk through
331- recent = append(recent, code)370+// the palette under paletteMu. Executing it never touches palette state without
332- for _, existing := range d.paletteRecent {371+// retaking the lock.
333- if existing != code {372+type paletteDecision struct {
334- recent = append(recent, existing)373+ kind paletteDecisionKind
335- }374+ generation uint64
336- }375+ rawQuery string
337- d.paletteRecent = recent376+ cmd command.Command
377+ args []string
378+ routeSnapshot protocol.RecentRouteSnapshot
379+ sessionTarget palette.Result
380+ importedSourceKey string
381+ importedEntryKey string
382+ routeTarget protocol.RouteNavigationAction
383+ createDestination palette.Result
384+ cancelInventoryInteraction uint64
385+ hasCancelInventory bool
338 }386 }
339 387
340-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {388+// decidePaletteInput takes paletteMu, applies data to the attachment's palette,
341- entry := ac.currentAttachmentSession()389+// and returns the action to run after unlocking.
342- if entry == nil {390+func decidePaletteInput(ac *attachedClient, data []byte) paletteDecision {
343- return
344- }
345- sess := entry
346 var cmd command.Command391 var cmd command.Command
347 var sessionTarget palette.Result392 var sessionTarget palette.Result
348 var hasSessionTarget bool393 var hasSessionTarget bool
@@ -359,16 +404,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
359 var cancelInventoryInteraction uint64404 var cancelInventoryInteraction uint64
360 var hasCancelInventory bool405 var hasCancelInventory bool
361 changed, cancel, execute, chooseDestination := false, false, false, false406 changed, cancel, execute, chooseDestination := false, false, false, false
362- var effect *attachmentEffect
363- if len(effects) != 0 {
364- effect = effects[0]
365- }
366 407
367 ac.overlays.paletteMu.Lock()408 ac.overlays.paletteMu.Lock()
368 if ac.overlays.palette == nil {409 if ac.overlays.palette == nil {
369 ac.overlays.palettePending = nil410 ac.overlays.palettePending = nil
370 ac.overlays.paletteMu.Unlock()411 ac.overlays.paletteMu.Unlock()
371- return412+ return paletteDecision{}
372 }413 }
373 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{414 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
374 rune: func(r rune) {415 rune: func(r rune) {
@@ -478,139 +519,193 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
478 ac.clearPaletteLocked()519 ac.clearPaletteLocked()
479 }520 }
480 ac.overlays.paletteMu.Unlock()521 ac.overlays.paletteMu.Unlock()
481- if cancel {522+
482- if hasCancelInventory {523+ switch {
483- d.sendPaletteInventoryDemand(ac, effect, false, cancelInventoryInteraction)524+ case cancel:
525+ return paletteDecision{kind: paletteDecisionCancel, cancelInventoryInteraction: cancelInventoryInteraction, hasCancelInventory: hasCancelInventory}
526+ case !execute && changed:
527+ return paletteDecision{kind: paletteDecisionRedraw}
528+ case !execute:
529+ return paletteDecision{}
530+ }
531+ decision := paletteDecision{generation: generation, rawQuery: rawQuery}
532+ switch {
533+ case hasCreateDestination:
534+ decision.kind = paletteDecisionCreateDestination
535+ decision.createDestination = createDestination
536+ case hasImportedTarget:
537+ decision.kind = paletteDecisionImportedSession
538+ decision.importedSourceKey = importedSourceKey
539+ decision.importedEntryKey = importedEntryKey
540+ case hasRouteTarget:
541+ decision.kind = paletteDecisionRouteNavigation
542+ decision.routeTarget = routeTarget
543+ case hasSessionTarget:
544+ decision.kind = paletteDecisionSessionTarget
545+ decision.sessionTarget = sessionTarget
546+ default:
547+ decision.kind = paletteDecisionCommand
548+ decision.cmd = cmd
549+ decision.args = args
550+ decision.routeSnapshot = routeSnapshot
551+ }
552+ return decision
553+}
554+
555+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
556+ entry := ac.currentAttachmentSession()
557+ if entry == nil {
558+ return
559+ }
560+ var effect *attachmentEffect
561+ if len(effects) != 0 {
562+ effect = effects[0]
563+ }
564+ d.executePaletteDecision(ac, entry, effect, decidePaletteInput(ac, data))
565+}
566+
567+// executePaletteDecision runs the outcome of decidePaletteInput with no palette
568+// lock held.
569+func (d *Daemon) executePaletteDecision(ac *attachedClient, entry *session, effect *attachmentEffect, decision paletteDecision) {
570+ generation, rawQuery := decision.generation, decision.rawQuery
571+ switch decision.kind {
572+ case paletteDecisionNone:
573+ case paletteDecisionCancel:
574+ if decision.hasCancelInventory {
575+ d.sendPaletteInventoryDemand(ac, effect, false, decision.cancelInventoryInteraction)
484 }576 }
485 d.invalidateRender(entry, ac, true, "palette.go")577 d.invalidateRender(entry, ac, true, "palette.go")
578+ case paletteDecisionRedraw:
579+ d.invalidateRender(entry, ac, true, "palette.go")
580+ case paletteDecisionCreateDestination:
581+ d.executePaletteCreateDestination(ac, entry, effect, generation, rawQuery, decision.createDestination)
582+ case paletteDecisionImportedSession:
583+ d.executePaletteImportedSession(ac, entry, effect, generation, rawQuery, decision.importedSourceKey, decision.importedEntryKey)
584+ case paletteDecisionRouteNavigation:
585+ d.executePaletteRouteNavigation(ac, entry, effect, generation, rawQuery, decision.routeTarget)
586+ case paletteDecisionSessionTarget:
587+ d.executePaletteSessionTarget(ac, entry, effect, generation, rawQuery, decision.sessionTarget)
588+ case paletteDecisionCommand:
589+ d.executePaletteCommand(ac, entry, effect, generation, rawQuery, decision.cmd, decision.args, decision.routeSnapshot)
590+ }
591+}
592+
593+func (d *Daemon) executePaletteCreateDestination(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, createDestination palette.Result) {
594+ name, _, _, _, _, _ := createDestination.CreateSessionDestination()
595+ exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, effect: effect}
596+ if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {
597+ ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
598+ d.invalidateRender(entry, ac, true, "palette.go")
486 return599 return
487 }600 }
488- if !execute {601+ if !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
489- if changed {
490- d.invalidateRender(entry, ac, true, "palette.go")
491- }
492 return602 return
493 }603 }
494-604+ if name == "" {
495- if hasCreateDestination {605+ d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
496- name, _, _, _, _, _ := createDestination.CreateSessionDestination()606+ return exec.createSessionOnDestination(promptEffect, createDestination, name)
497- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}607+ })
498- if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {608+ d.invalidateRender(entry, ac, true, "palette.go")
499- ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
500- d.invalidateRender(entry, ac, true, "palette.go")
501- return
502- }
503- if !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
504- return
505- }
506- if name == "" {
507- d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
508- return exec.createSessionOnDestination(promptEffect, createDestination, name)
509- })
510- d.invalidateRender(entry, ac, true, "palette.go")
511- return
512- }
513- err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
514- if err != nil && !errors.Is(err, errAttachmentTransition) {
515- d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
516- return
517- }
518- if current := ac.currentAttachmentSession(); current != nil {
519- d.invalidateRender(current, ac, true, "palette.go")
520- }
521 return609 return
522 }610 }
523-611+ err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
524- if hasImportedTarget {612+ if err != nil && !errors.Is(err, errAttachmentTransition) {
525- if effect == nil {613+ d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
526- ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
527- d.invalidateRender(entry, ac, true, "palette.go")
528- return
529- }
530- if importedSourceKey != protocol.NavigationInventoryLocalSourceKey {
531- // Remote vision is read-only: imported remote routes surface
532- // availability but never attach.
533- ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached")
534- d.invalidateRender(entry, ac, true, "palette.go")
535- return
536- }
537- ac.overlays.paletteMu.Lock()
538- open := ac.overlays.paletteInventoryOpen
539- selection := protocol.NavigationInventorySelection{
540- CauseActionID: effect.uiActionID,
541- InteractionGeneration: ac.overlays.paletteInventoryInteraction,
542- PublicationGeneration: ac.overlays.paletteInventoryPublication,
543- SourceKey: importedSourceKey,
544- EntryKey: importedEntryKey,
545- }
546- if open {
547- freezePaletteInventorySelection(ac.overlays, selection)
548- }
549- ac.overlays.paletteMu.Unlock()
550- if !open || protocol.ValidateNavigationInventorySelection(selection) != nil {
551- ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
552- d.invalidateRender(entry, ac, true, "palette.go")
553- return
554- }
555- // Selection crosses before the close demand on the guarded serving
556- // connection; closing after selection must not cancel the client's
557- // pending navigation operation.
558- if err := effect.sendControl(selection); err != nil {
559- ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
560- d.invalidateRender(entry, ac, true, "palette.go")
561- return
562- }
563- d.closeExecutedPalette(ac, effect, generation, rawQuery)
564 return614 return
565 }615 }
616+ if current := ac.currentAttachmentSession(); current != nil {
617+ d.invalidateRender(current, ac, true, "palette.go")
618+ }
619+}
566 620
567- if hasRouteTarget {621+func (d *Daemon) executePaletteImportedSession(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery, importedSourceKey, importedEntryKey string) {
568- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}622+ if effect == nil {
569- if err := exec.NavigateRecentRoute(routeTarget); err != nil {623+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
570- if errors.Is(err, errAttachmentTransition) {624+ d.invalidateRender(entry, ac, true, "palette.go")
571- return625+ return
572- }626+ }
573- ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")627+ if importedSourceKey != protocol.NavigationInventoryLocalSourceKey {
574- d.invalidateRender(entry, ac, true, "palette.go")628+ // Remote vision is read-only: imported remote routes surface
575- return629+ // availability but never attach.
576- }630+ ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached")
577- if d.closeExecutedPalette(ac, effect, generation, rawQuery) {631+ d.invalidateRender(entry, ac, true, "palette.go")
578- d.invalidateRender(entry, ac, true, "palette.go")632+ return
579- }633+ }
634+ ac.overlays.paletteMu.Lock()
635+ open := ac.overlays.paletteInventoryOpen
636+ selection := protocol.NavigationInventorySelection{
637+ CauseActionID: effect.uiActionID,
638+ InteractionGeneration: ac.overlays.paletteInventoryInteraction,
639+ PublicationGeneration: ac.overlays.paletteInventoryPublication,
640+ SourceKey: importedSourceKey,
641+ EntryKey: importedEntryKey,
642+ }
643+ if open {
644+ freezePaletteInventorySelection(ac.overlays, selection)
645+ }
646+ ac.overlays.paletteMu.Unlock()
647+ if !open || protocol.ValidateNavigationInventorySelection(selection) != nil {
648+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
649+ d.invalidateRender(entry, ac, true, "palette.go")
580 return650 return
581 }651 }
652+ // Selection crosses before the close demand on the guarded serving
653+ // connection; closing after selection must not cancel the client's
654+ // pending navigation operation.
655+ if err := effect.sendControl(selection); err != nil {
656+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
657+ d.invalidateRender(entry, ac, true, "palette.go")
658+ return
659+ }
660+ d.closeExecutedPalette(ac, effect, generation, rawQuery)
661+}
582 662
583- if hasSessionTarget {663+func (d *Daemon) executePaletteRouteNavigation(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, routeTarget protocol.RouteNavigationAction) {
584- createdAt, _ := sessionTarget.SessionCreatedAt()664+ exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, effect: effect}
585- expectedCreatedAt := createdAt.UnixNano()665+ if err := exec.NavigateRecentRoute(routeTarget); err != nil {
586- name, _ := sessionTarget.SessionName()
587- exactTarget, _ := sessionTarget.SessionTarget()
588- target := picker.Target{
589- Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
590- ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
591- }
592- var err error
593- if effect != nil {
594- err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
595- } else {
596- err = d.switchToTarget(sess, ac, target)
597- }
598 if errors.Is(err, errAttachmentTransition) {666 if errors.Is(err, errAttachmentTransition) {
599 return667 return
600 }668 }
601- if err != nil {669+ ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
602- ac.paletteFailure(generation, rawQuery, "requested session is unavailable")670+ d.invalidateRender(entry, ac, true, "palette.go")
603- d.invalidateRender(entry, ac, true, "palette.go")
604- return
605- }
606- if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
607- if current := ac.currentAttachmentSession(); current != nil {
608- d.invalidateRender(current, ac, true, "palette.go")
609- }
610- }
611 return671 return
612 }672 }
673+ if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
674+ d.invalidateRender(entry, ac, true, "palette.go")
675+ }
676+}
677+
678+func (d *Daemon) executePaletteSessionTarget(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, sessionTarget palette.Result) {
679+ createdAt, _ := sessionTarget.SessionCreatedAt()
680+ expectedCreatedAt := createdAt.UnixNano()
681+ name, _ := sessionTarget.SessionName()
682+ exactTarget, _ := sessionTarget.SessionTarget()
683+ target := picker.Target{
684+ Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
685+ ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
686+ }
687+ var err error
688+ if effect != nil {
689+ err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
690+ } else {
691+ err = d.switchToTarget(entry, ac, target)
692+ }
693+ if errors.Is(err, errAttachmentTransition) {
694+ return
695+ }
696+ if err != nil {
697+ ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
698+ d.invalidateRender(entry, ac, true, "palette.go")
699+ return
700+ }
701+ if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
702+ if current := ac.currentAttachmentSession(); current != nil {
703+ d.invalidateRender(current, ac, true, "palette.go")
704+ }
705+ }
706+}
613 707
708+func (d *Daemon) executePaletteCommand(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, cmd command.Command, args []string, routeSnapshot protocol.RecentRouteSnapshot) {
614 if cmd.Slug == "jump-recent-session" {709 if cmd.Slug == "jump-recent-session" {
615 rank, err := command.ParsePositiveDecimal(args)710 rank, err := command.ParsePositiveDecimal(args)
616 if err != nil {711 if err != nil {
@@ -618,7 +713,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
618 d.invalidateRender(entry, ac, true, "palette.go")713 d.invalidateRender(entry, ac, true, "palette.go")
619 return714 return
620 }715 }
621- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect}716+ exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect}
622 if err := exec.JumpRecentSession(rank); err != nil {717 if err := exec.JumpRecentSession(rank); err != nil {
623 if errors.Is(err, errAttachmentTransition) {718 if errors.Is(err, errAttachmentTransition) {
624 return719 return
@@ -628,7 +723,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
628 return723 return
629 }724 }
630 if d.closeExecutedPalette(ac, effect, generation, rawQuery) {725 if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
631- d.recordPaletteUse(cmd.Code)726+ d.paletteHistory.record(cmd.Code)
632 if current := ac.currentAttachmentSession(); current != nil {727 if current := ac.currentAttachmentSession(); current != nil {
633 d.invalidateRender(current, ac, true, "palette.go")728 d.invalidateRender(current, ac, true, "palette.go")
634 }729 }
@@ -639,9 +734,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
639 if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {734 if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
640 return735 return
641 }736 }
642- sess.dispatchMu.Lock()737+ entry.dispatchMu.Lock()
643- err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)738+ err := cmd.Run(paletteExec{d: d, sess: entry, ac: ac, effect: effect, redrawClosedPalette: true}, args)
644- sess.dispatchMu.Unlock()739+ entry.dispatchMu.Unlock()
645 if attachmentHandoff {740 if attachmentHandoff {
646 if current := ac.currentSession(); current != nil {741 if current := ac.currentSession(); current != nil {
647 currentToken := current.captureAttachmentCapability(ac, ac.transport())742 currentToken := current.captureAttachmentCapability(ac, ac.transport())
@@ -661,9 +756,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
661 }756 }
662 if err != nil {757 if err != nil {
663 d.log.Error("palette command failed", "err", err, "command", cmd.Code)758 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
664- d.reportError(sess, paletteCommandNoticeError(cmd, err))759+ d.reportError(entry, paletteCommandNoticeError(cmd, err))
665 } else {760 } else {
666- d.recordPaletteUse(cmd.Code)761+ d.paletteHistory.record(cmd.Code)
667 }762 }
668 }763 }
669 764
@@ -682,6 +777,7 @@ func paletteArgs(query string, cmd command.Command) []string {
682 return action.Args777 return action.Args
683 }778 }
684 779
780+// clearPaletteLocked requires ac.overlays.paletteMu.
685 func (ac *attachedClient) clearPaletteLocked() {781 func (ac *attachedClient) clearPaletteLocked() {
686 ac.overlays.paletteGeneration++782 ac.overlays.paletteGeneration++
687 ac.overlays.palette = nil783 ac.overlays.palette = nil
M internal/usecase/daemon/palette_test.go +8 -8
@@ -387,7 +387,7 @@ func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *t
387387 require.Same(t, target, ac.currentSession())
388388 require.Contains(t, target.snapshotAttachments(), ac, "canonical handoff reuses the attached client")
389389 require.False(t, ac.overlays.paletteActive())
390- require.Empty(t, d.paletteRecent, "session selections are not command recency")
390+ require.Empty(t, d.paletteHistory.recent, "session selections are not command recency")
391391 awaitFrame(t, sends, "Output")
392392 awaitFrame(t, sends, "Output")
393393 }
@@ -923,7 +923,7 @@ func TestCommandByEffectiveCodeExcludesAPIOnlyCommands(t *testing.T) {
923923 "new-tab": "TAB",
924924 "toast": "API",
925925 }
926- d.codeOverrides.Store(&overrides)
926+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.codeOverrides = overrides })
927927
928928 cmd, ok := d.commandByEffectiveCode("tab")
929929 require.True(t, ok)
@@ -936,11 +936,11 @@ func TestCommandByEffectiveCodeExcludesAPIOnlyCommands(t *testing.T) {
936936
937937 func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
938938 d := &Daemon{}
939- d.recordPaletteUse("SSP")
940- d.recordPaletteUse("NXT")
939+ d.paletteHistory.record("SSP")
940+ d.paletteHistory.record("NXT")
941941 // STALE is not a registered command code; it must be dropped from output.
942- d.recordPaletteUse("STALE")
943- d.recordPaletteUse("SSP")
942+ d.paletteHistory.record("STALE")
943+ d.paletteHistory.record("SSP")
944944
945945 commands := d.paletteCommands()
946946 codes := make([]string, len(commands))
@@ -960,7 +960,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
960960 wg.Add(1)
961961 go func(code string) {
962962 defer wg.Done()
963- d.recordPaletteUse(code)
963+ d.paletteHistory.record(code)
964964 _ = d.paletteCommands()
965965 }(code)
966966 }
@@ -1228,7 +1228,7 @@ func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T)
12281228 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
12291229 invs := installPaletteInvalidationObserver(sess)
12301230 if tt.recent != "" {
1231- d.recordPaletteUse(tt.recent)
1231+ d.paletteHistory.record(tt.recent)
12321232 }
12331233 if tt.config != nil {
12341234 d.ApplyConfig(domain.Config{Codes: tt.config})
@@ -387,7 +387,7 @@ func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *t
387 require.Same(t, target, ac.currentSession())387 require.Same(t, target, ac.currentSession())
388 require.Contains(t, target.snapshotAttachments(), ac, "canonical handoff reuses the attached client")388 require.Contains(t, target.snapshotAttachments(), ac, "canonical handoff reuses the attached client")
389 require.False(t, ac.overlays.paletteActive())389 require.False(t, ac.overlays.paletteActive())
390- require.Empty(t, d.paletteRecent, "session selections are not command recency")390+ require.Empty(t, d.paletteHistory.recent, "session selections are not command recency")
391 awaitFrame(t, sends, "Output")391 awaitFrame(t, sends, "Output")
392 awaitFrame(t, sends, "Output")392 awaitFrame(t, sends, "Output")
393 }393 }
@@ -923,7 +923,7 @@ func TestCommandByEffectiveCodeExcludesAPIOnlyCommands(t *testing.T) {
923 "new-tab": "TAB",923 "new-tab": "TAB",
924 "toast": "API",924 "toast": "API",
925 }925 }
926- d.codeOverrides.Store(&overrides)926+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.codeOverrides = overrides })
927 927
928 cmd, ok := d.commandByEffectiveCode("tab")928 cmd, ok := d.commandByEffectiveCode("tab")
929 require.True(t, ok)929 require.True(t, ok)
@@ -936,11 +936,11 @@ func TestCommandByEffectiveCodeExcludesAPIOnlyCommands(t *testing.T) {
936 936
937 func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {937 func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
938 d := &Daemon{}938 d := &Daemon{}
939- d.recordPaletteUse("SSP")939+ d.paletteHistory.record("SSP")
940- d.recordPaletteUse("NXT")940+ d.paletteHistory.record("NXT")
941 // STALE is not a registered command code; it must be dropped from output.941 // STALE is not a registered command code; it must be dropped from output.
942- d.recordPaletteUse("STALE")942+ d.paletteHistory.record("STALE")
943- d.recordPaletteUse("SSP")943+ d.paletteHistory.record("SSP")
944 944
945 commands := d.paletteCommands()945 commands := d.paletteCommands()
946 codes := make([]string, len(commands))946 codes := make([]string, len(commands))
@@ -960,7 +960,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
960 wg.Add(1)960 wg.Add(1)
961 go func(code string) {961 go func(code string) {
962 defer wg.Done()962 defer wg.Done()
963- d.recordPaletteUse(code)963+ d.paletteHistory.record(code)
964 _ = d.paletteCommands()964 _ = d.paletteCommands()
965 }(code)965 }(code)
966 }966 }
@@ -1228,7 +1228,7 @@ func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T)
1228 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1228 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1229 invs := installPaletteInvalidationObserver(sess)1229 invs := installPaletteInvalidationObserver(sess)
1230 if tt.recent != "" {1230 if tt.recent != "" {
1231- d.recordPaletteUse(tt.recent)1231+ d.paletteHistory.record(tt.recent)
1232 }1232 }
1233 if tt.config != nil {1233 if tt.config != nil {
1234 d.ApplyConfig(domain.Config{Codes: tt.config})1234 d.ApplyConfig(domain.Config{Codes: tt.config})
M internal/usecase/daemon/pane_titles.go +2 -2
@@ -187,9 +187,9 @@ func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string
187187 // distinct from the display fallback: an OSC title must then stand alone,
188188 // and future floating panes can supply their command fallback at format time.
189189 processName := ""
190- if d.procComm != nil && p.pty != nil {
190+ if d.hasProcessDetails() && p.pty != nil {
191191 if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {
192- if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {
192+ if comm, err := d.proc.Comm(pgid); err == nil && strings.TrimSpace(comm) != "" {
193193 processName = strings.TrimSpace(comm)
194194 }
195195 }
@@ -187,9 +187,9 @@ func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string
187 // distinct from the display fallback: an OSC title must then stand alone,187 // distinct from the display fallback: an OSC title must then stand alone,
188 // and future floating panes can supply their command fallback at format time.188 // and future floating panes can supply their command fallback at format time.
189 processName := ""189 processName := ""
190- if d.procComm != nil && p.pty != nil {190+ if d.hasProcessDetails() && p.pty != nil {
191 if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {191 if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {
192- if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {192+ if comm, err := d.proc.Comm(pgid); err == nil && strings.TrimSpace(comm) != "" {
193 processName = strings.TrimSpace(comm)193 processName = strings.TrimSpace(comm)
194 }194 }
195 }195 }
M internal/usecase/daemon/pane_titles_test.go +46 -8
@@ -71,10 +71,10 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
7171 clk.EXPECT().Now().Return(time.Time{}).Maybe()
7272 d := newTestDaemon(t, nil, clk)
7373 d.shell = "/bin/zsh"
74- d.procComm = func(pid int) (string, error) {
74+ d.proc = &processInspectorFake{comm: func(pid int) (string, error) {
7575 require.Equal(t, 1234, pid)
7676 return "vim\n", nil
77- }
77+ }}
7878
7979 title := d.refreshPaneTitle(sess, "pane-1")
8080 require.Equal(t, "vim", title)
@@ -99,10 +99,10 @@ func TestRefreshPaneTitleUsesProvidedOwningTab(t *testing.T) {
9999 sess.tabs = append(sess.tabs, second)
100100 sess.mu.Unlock()
101101 d := newTestDaemon(t, nil, stubClock{})
102- d.procComm = func(pid int) (string, error) {
102+ d.proc = &processInspectorFake{comm: func(pid int) (string, error) {
103103 require.Equal(t, 222, pid)
104104 return "vim", nil
105- }
105+ }}
106106
107107 require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1", second))
108108 secondPane := second.panes["pane-1"]
@@ -120,11 +120,11 @@ func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
120120 clk.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe()
121121 d := newTestDaemon(t, nil, clk)
122122 var calls atomic.Int32
123- d.procComm = func(pid int) (string, error) {
123+ d.proc = &processInspectorFake{comm: func(pid int) (string, error) {
124124 require.Equal(t, 1234, pid)
125125 calls.Add(1)
126126 return "vim\n", nil
127- }
127+ }}
128128
129129 require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1"))
130130 now = now.Add(paneTitleCacheTTL / 2)
@@ -141,7 +141,7 @@ func TestRefreshFloatingPaneTitlePreservesConfiguredCommandFallback(t *testing.T
141141 clk.EXPECT().Now().Return(time.Time{}).Maybe()
142142 d := newTestDaemon(t, nil, clk)
143143 d.shell = "/usr/bin/fish"
144- d.procComm = func(int) (string, error) { return "", errors.New("unused") }
144+ d.proc = &processInspectorFake{comm: func(int) (string, error) { return "", errors.New("unused") }}
145145 p := newPane("floating", pty, domain.Size{Cols: 20, Rows: 5})
146146 p.title.displayFallback = floatingCommandFallback("btop --utf", d.shell)
147147
@@ -186,7 +186,7 @@ func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) {
186186 clk.EXPECT().Now().Return(time.Time{}).Maybe()
187187 d := newTestDaemon(t, nil, clk)
188188 d.shell = "/usr/bin/fish"
189- d.procComm = func(int) (string, error) { return "", errors.New("unused") }
189+ d.proc = &processInspectorFake{comm: func(int) (string, error) { return "", errors.New("unused") }}
190190 p := testAttachmentTab(sess).focusedPane()
191191
192192 p.mu.Lock()
@@ -204,3 +204,41 @@ func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) {
204204 p.mu.Unlock()
205205 require.Equal(t, "sh", d.refreshPaneDisplayTitle(sess, p, true), "pointer refresh must retain its pane-owned fallback")
206206 }
207+
208+// processInspectorFake is a hand-written ports.ProcessInspector whose
209+// behavior is set per test through func fields; an unset func reports an
210+// error, the same as an inspector that cannot read the process.
211+type processInspectorFake struct {
212+ cwd func(int) (string, error)
213+ comm func(int) (string, error)
214+ argv func(int) ([]string, error)
215+ groupArgv func(int, int) ([]string, error)
216+}
217+
218+func (f *processInspectorFake) Cwd(pid int) (string, error) {
219+ if f.cwd == nil {
220+ return "", errProcessInspectionUnavailable
221+ }
222+ return f.cwd(pid)
223+}
224+
225+func (f *processInspectorFake) Comm(pid int) (string, error) {
226+ if f.comm == nil {
227+ return "", errProcessInspectionUnavailable
228+ }
229+ return f.comm(pid)
230+}
231+
232+func (f *processInspectorFake) Argv(pid int) ([]string, error) {
233+ if f.argv == nil {
234+ return nil, errProcessInspectionUnavailable
235+ }
236+ return f.argv(pid)
237+}
238+
239+func (f *processInspectorFake) GroupArgv(pgid, shellPid int) ([]string, error) {
240+ if f.groupArgv == nil {
241+ return nil, errProcessInspectionUnavailable
242+ }
243+ return f.groupArgv(pgid, shellPid)
244+}
@@ -71,10 +71,10 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
71 clk.EXPECT().Now().Return(time.Time{}).Maybe()71 clk.EXPECT().Now().Return(time.Time{}).Maybe()
72 d := newTestDaemon(t, nil, clk)72 d := newTestDaemon(t, nil, clk)
73 d.shell = "/bin/zsh"73 d.shell = "/bin/zsh"
74- d.procComm = func(pid int) (string, error) {74+ d.proc = &processInspectorFake{comm: func(pid int) (string, error) {
75 require.Equal(t, 1234, pid)75 require.Equal(t, 1234, pid)
76 return "vim\n", nil76 return "vim\n", nil
77- }77+ }}
78 78
79 title := d.refreshPaneTitle(sess, "pane-1")79 title := d.refreshPaneTitle(sess, "pane-1")
80 require.Equal(t, "vim", title)80 require.Equal(t, "vim", title)
@@ -99,10 +99,10 @@ func TestRefreshPaneTitleUsesProvidedOwningTab(t *testing.T) {
99 sess.tabs = append(sess.tabs, second)99 sess.tabs = append(sess.tabs, second)
100 sess.mu.Unlock()100 sess.mu.Unlock()
101 d := newTestDaemon(t, nil, stubClock{})101 d := newTestDaemon(t, nil, stubClock{})
102- d.procComm = func(pid int) (string, error) {102+ d.proc = &processInspectorFake{comm: func(pid int) (string, error) {
103 require.Equal(t, 222, pid)103 require.Equal(t, 222, pid)
104 return "vim", nil104 return "vim", nil
105- }105+ }}
106 106
107 require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1", second))107 require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1", second))
108 secondPane := second.panes["pane-1"]108 secondPane := second.panes["pane-1"]
@@ -120,11 +120,11 @@ func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
120 clk.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe()120 clk.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe()
121 d := newTestDaemon(t, nil, clk)121 d := newTestDaemon(t, nil, clk)
122 var calls atomic.Int32122 var calls atomic.Int32
123- d.procComm = func(pid int) (string, error) {123+ d.proc = &processInspectorFake{comm: func(pid int) (string, error) {
124 require.Equal(t, 1234, pid)124 require.Equal(t, 1234, pid)
125 calls.Add(1)125 calls.Add(1)
126 return "vim\n", nil126 return "vim\n", nil
127- }127+ }}
128 128
129 require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1"))129 require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1"))
130 now = now.Add(paneTitleCacheTTL / 2)130 now = now.Add(paneTitleCacheTTL / 2)
@@ -141,7 +141,7 @@ func TestRefreshFloatingPaneTitlePreservesConfiguredCommandFallback(t *testing.T
141 clk.EXPECT().Now().Return(time.Time{}).Maybe()141 clk.EXPECT().Now().Return(time.Time{}).Maybe()
142 d := newTestDaemon(t, nil, clk)142 d := newTestDaemon(t, nil, clk)
143 d.shell = "/usr/bin/fish"143 d.shell = "/usr/bin/fish"
144- d.procComm = func(int) (string, error) { return "", errors.New("unused") }144+ d.proc = &processInspectorFake{comm: func(int) (string, error) { return "", errors.New("unused") }}
145 p := newPane("floating", pty, domain.Size{Cols: 20, Rows: 5})145 p := newPane("floating", pty, domain.Size{Cols: 20, Rows: 5})
146 p.title.displayFallback = floatingCommandFallback("btop --utf", d.shell)146 p.title.displayFallback = floatingCommandFallback("btop --utf", d.shell)
147 147
@@ -186,7 +186,7 @@ func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) {
186 clk.EXPECT().Now().Return(time.Time{}).Maybe()186 clk.EXPECT().Now().Return(time.Time{}).Maybe()
187 d := newTestDaemon(t, nil, clk)187 d := newTestDaemon(t, nil, clk)
188 d.shell = "/usr/bin/fish"188 d.shell = "/usr/bin/fish"
189- d.procComm = func(int) (string, error) { return "", errors.New("unused") }189+ d.proc = &processInspectorFake{comm: func(int) (string, error) { return "", errors.New("unused") }}
190 p := testAttachmentTab(sess).focusedPane()190 p := testAttachmentTab(sess).focusedPane()
191 191
192 p.mu.Lock()192 p.mu.Lock()
@@ -204,3 +204,41 @@ func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) {
204 p.mu.Unlock()204 p.mu.Unlock()
205 require.Equal(t, "sh", d.refreshPaneDisplayTitle(sess, p, true), "pointer refresh must retain its pane-owned fallback")205 require.Equal(t, "sh", d.refreshPaneDisplayTitle(sess, p, true), "pointer refresh must retain its pane-owned fallback")
206 }206 }
207+
208+// processInspectorFake is a hand-written ports.ProcessInspector whose
209+// behavior is set per test through func fields; an unset func reports an
210+// error, the same as an inspector that cannot read the process.
211+type processInspectorFake struct {
212+ cwd func(int) (string, error)
213+ comm func(int) (string, error)
214+ argv func(int) ([]string, error)
215+ groupArgv func(int, int) ([]string, error)
216+}
217+
218+func (f *processInspectorFake) Cwd(pid int) (string, error) {
219+ if f.cwd == nil {
220+ return "", errProcessInspectionUnavailable
221+ }
222+ return f.cwd(pid)
223+}
224+
225+func (f *processInspectorFake) Comm(pid int) (string, error) {
226+ if f.comm == nil {
227+ return "", errProcessInspectionUnavailable
228+ }
229+ return f.comm(pid)
230+}
231+
232+func (f *processInspectorFake) Argv(pid int) ([]string, error) {
233+ if f.argv == nil {
234+ return nil, errProcessInspectionUnavailable
235+ }
236+ return f.argv(pid)
237+}
238+
239+func (f *processInspectorFake) GroupArgv(pgid, shellPid int) ([]string, error) {
240+ if f.groupArgv == nil {
241+ return nil, errProcessInspectionUnavailable
242+ }
243+ return f.groupArgv(pgid, shellPid)
244+}
M internal/usecase/daemon/performance_benchmark_test.go +3 -3
@@ -180,9 +180,9 @@ func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) {
180180 }
181181 d.mu.Unlock()
182182 require.NotContains(t, sessionIDs, sess.id)
183- d.snapshotWorkerMu.Lock()
184- workerCancel := d.snapshotWorkerCancel
185- d.snapshotWorkerMu.Unlock()
183+ d.snapshots.mu.Lock()
184+ workerCancel := d.snapshots.cancel
185+ d.snapshots.mu.Unlock()
186186 require.Nil(t, workerCancel)
187187 }
188188
@@ -180,9 +180,9 @@ func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) {
180 }180 }
181 d.mu.Unlock()181 d.mu.Unlock()
182 require.NotContains(t, sessionIDs, sess.id)182 require.NotContains(t, sessionIDs, sess.id)
183- d.snapshotWorkerMu.Lock()183+ d.snapshots.mu.Lock()
184- workerCancel := d.snapshotWorkerCancel184+ workerCancel := d.snapshots.cancel
185- d.snapshotWorkerMu.Unlock()185+ d.snapshots.mu.Unlock()
186 require.Nil(t, workerCancel)186 require.Nil(t, workerCancel)
187 }187 }
188 188
A internal/usecase/daemon/process_inspector.go +58 -0
@@ -0,0 +1,58 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+var errProcessInspectionUnavailable = errors.New("process inspection unavailable")
10+
11+// cwdOverrideInspector replaces only Cwd on top of an optional base inspector.
12+// With a nil base, Comm, Argv, and GroupArgv report the inspection as
13+// unavailable, matching a daemon that was given only a cwd reader.
14+type cwdOverrideInspector struct {
15+ base ports.ProcessInspector
16+ cwd func(int) (string, error)
17+}
18+
19+func (i cwdOverrideInspector) Cwd(pid int) (string, error) { return i.cwd(pid) }
20+
21+func (i cwdOverrideInspector) Comm(pid int) (string, error) {
22+ if i.base == nil {
23+ return "", errProcessInspectionUnavailable
24+ }
25+ return i.base.Comm(pid)
26+}
27+
28+func (i cwdOverrideInspector) Argv(pid int) ([]string, error) {
29+ if i.base == nil {
30+ return nil, errProcessInspectionUnavailable
31+ }
32+ return i.base.Argv(pid)
33+}
34+
35+func (i cwdOverrideInspector) GroupArgv(pgid, shellPid int) ([]string, error) {
36+ if i.base == nil {
37+ return nil, errProcessInspectionUnavailable
38+ }
39+ return i.base.GroupArgv(pgid, shellPid)
40+}
41+
42+// unwrapCwdOverride returns the inspector beneath a previous cwd override so
43+// repeated overrides do not stack.
44+func unwrapCwdOverride(ins ports.ProcessInspector) ports.ProcessInspector {
45+ if o, ok := ins.(cwdOverrideInspector); ok {
46+ return o.base
47+ }
48+ return ins
49+}
50+
51+// hasProcessDetails reports whether Comm, Argv, and GroupArgv are backed by a
52+// real inspector rather than a cwd-only override.
53+func (d *Daemon) hasProcessDetails() bool {
54+ if o, ok := d.proc.(cwdOverrideInspector); ok {
55+ return o.base != nil
56+ }
57+ return d.proc != nil
58+}
@@ -0,0 +1,58 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+var errProcessInspectionUnavailable = errors.New("process inspection unavailable")
10+
11+// cwdOverrideInspector replaces only Cwd on top of an optional base inspector.
12+// With a nil base, Comm, Argv, and GroupArgv report the inspection as
13+// unavailable, matching a daemon that was given only a cwd reader.
14+type cwdOverrideInspector struct {
15+ base ports.ProcessInspector
16+ cwd func(int) (string, error)
17+}
18+
19+func (i cwdOverrideInspector) Cwd(pid int) (string, error) { return i.cwd(pid) }
20+
21+func (i cwdOverrideInspector) Comm(pid int) (string, error) {
22+ if i.base == nil {
23+ return "", errProcessInspectionUnavailable
24+ }
25+ return i.base.Comm(pid)
26+}
27+
28+func (i cwdOverrideInspector) Argv(pid int) ([]string, error) {
29+ if i.base == nil {
30+ return nil, errProcessInspectionUnavailable
31+ }
32+ return i.base.Argv(pid)
33+}
34+
35+func (i cwdOverrideInspector) GroupArgv(pgid, shellPid int) ([]string, error) {
36+ if i.base == nil {
37+ return nil, errProcessInspectionUnavailable
38+ }
39+ return i.base.GroupArgv(pgid, shellPid)
40+}
41+
42+// unwrapCwdOverride returns the inspector beneath a previous cwd override so
43+// repeated overrides do not stack.
44+func unwrapCwdOverride(ins ports.ProcessInspector) ports.ProcessInspector {
45+ if o, ok := ins.(cwdOverrideInspector); ok {
46+ return o.base
47+ }
48+ return ins
49+}
50+
51+// hasProcessDetails reports whether Comm, Argv, and GroupArgv are backed by a
52+// real inspector rather than a cwd-only override.
53+func (d *Daemon) hasProcessDetails() bool {
54+ if o, ok := d.proc.(cwdOverrideInspector); ok {
55+ return o.base != nil
56+ }
57+ return d.proc != nil
58+}
A internal/usecase/daemon/process_inspector_test.go +83 -0
@@ -0,0 +1,83 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestProcessInspectorOptionsComposition(t *testing.T) {
10+ cwd := func(int) (string, error) { return "/override", nil }
11+ base := &processInspectorFake{
12+ cwd: func(int) (string, error) { return "/base", nil },
13+ comm: func(int) (string, error) { return "vim", nil },
14+ }
15+
16+ tests := []struct {
17+ name string
18+ opts []Option
19+ wantDetails bool
20+ wantCwd string
21+ wantComm string
22+ wantCommErr error
23+ }{
24+ {name: "none", wantDetails: false},
25+ {
26+ name: "cwd reader only",
27+ opts: []Option{WithCwdReader(cwd)},
28+ wantDetails: false,
29+ wantCwd: "/override",
30+ wantCommErr: errProcessInspectionUnavailable,
31+ },
32+ {
33+ name: "inspector only",
34+ opts: []Option{WithProcessInspector(base)},
35+ wantDetails: true,
36+ wantCwd: "/base",
37+ wantComm: "vim",
38+ },
39+ {
40+ name: "inspector then cwd reader keeps base details",
41+ opts: []Option{WithProcessInspector(base), WithCwdReader(cwd)},
42+ wantDetails: true,
43+ wantCwd: "/override",
44+ wantComm: "vim",
45+ },
46+ {
47+ name: "cwd reader then inspector replaces override",
48+ opts: []Option{WithCwdReader(cwd), WithProcessInspector(base)},
49+ wantDetails: true,
50+ wantCwd: "/base",
51+ wantComm: "vim",
52+ },
53+ {
54+ name: "repeated cwd readers do not stack",
55+ opts: []Option{WithProcessInspector(base), WithCwdReader(cwd), WithCwdReader(cwd)},
56+ wantDetails: true,
57+ wantCwd: "/override",
58+ wantComm: "vim",
59+ },
60+ }
61+ for _, tt := range tests {
62+ t.Run(tt.name, func(t *testing.T) {
63+ d := &Daemon{}
64+ for _, opt := range tt.opts {
65+ opt(d)
66+ }
67+ require.Equal(t, tt.wantDetails, d.hasProcessDetails())
68+ if d.proc == nil {
69+ return
70+ }
71+ got, err := d.proc.Cwd(1)
72+ require.NoError(t, err)
73+ require.Equal(t, tt.wantCwd, got)
74+ comm, err := d.proc.Comm(1)
75+ require.ErrorIs(t, err, tt.wantCommErr)
76+ require.Equal(t, tt.wantComm, comm)
77+ if o, ok := d.proc.(cwdOverrideInspector); ok {
78+ _, nested := o.base.(cwdOverrideInspector)
79+ require.False(t, nested)
80+ }
81+ })
82+ }
83+}
@@ -0,0 +1,83 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestProcessInspectorOptionsComposition(t *testing.T) {
10+ cwd := func(int) (string, error) { return "/override", nil }
11+ base := &processInspectorFake{
12+ cwd: func(int) (string, error) { return "/base", nil },
13+ comm: func(int) (string, error) { return "vim", nil },
14+ }
15+
16+ tests := []struct {
17+ name string
18+ opts []Option
19+ wantDetails bool
20+ wantCwd string
21+ wantComm string
22+ wantCommErr error
23+ }{
24+ {name: "none", wantDetails: false},
25+ {
26+ name: "cwd reader only",
27+ opts: []Option{WithCwdReader(cwd)},
28+ wantDetails: false,
29+ wantCwd: "/override",
30+ wantCommErr: errProcessInspectionUnavailable,
31+ },
32+ {
33+ name: "inspector only",
34+ opts: []Option{WithProcessInspector(base)},
35+ wantDetails: true,
36+ wantCwd: "/base",
37+ wantComm: "vim",
38+ },
39+ {
40+ name: "inspector then cwd reader keeps base details",
41+ opts: []Option{WithProcessInspector(base), WithCwdReader(cwd)},
42+ wantDetails: true,
43+ wantCwd: "/override",
44+ wantComm: "vim",
45+ },
46+ {
47+ name: "cwd reader then inspector replaces override",
48+ opts: []Option{WithCwdReader(cwd), WithProcessInspector(base)},
49+ wantDetails: true,
50+ wantCwd: "/base",
51+ wantComm: "vim",
52+ },
53+ {
54+ name: "repeated cwd readers do not stack",
55+ opts: []Option{WithProcessInspector(base), WithCwdReader(cwd), WithCwdReader(cwd)},
56+ wantDetails: true,
57+ wantCwd: "/override",
58+ wantComm: "vim",
59+ },
60+ }
61+ for _, tt := range tests {
62+ t.Run(tt.name, func(t *testing.T) {
63+ d := &Daemon{}
64+ for _, opt := range tt.opts {
65+ opt(d)
66+ }
67+ require.Equal(t, tt.wantDetails, d.hasProcessDetails())
68+ if d.proc == nil {
69+ return
70+ }
71+ got, err := d.proc.Cwd(1)
72+ require.NoError(t, err)
73+ require.Equal(t, tt.wantCwd, got)
74+ comm, err := d.proc.Comm(1)
75+ require.ErrorIs(t, err, tt.wantCommErr)
76+ require.Equal(t, tt.wantComm, comm)
77+ if o, ok := d.proc.(cwdOverrideInspector); ok {
78+ _, nested := o.base.(cwdOverrideInspector)
79+ require.False(t, nested)
80+ }
81+ })
82+ }
83+}
M internal/usecase/daemon/render.go +5 -3
@@ -56,9 +56,12 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
5656 return false
5757 }
5858
59-// ptyReader is retained as a compatibility entry point for focused tests. The
60-// production goroutine calls readPanePTY, whose closure owns only the pane.
59+// ptyReader is retained as a compatibility entry point for focused tests, which
60+// register the goroutine with sessWg.Add before calling it. The production
61+// goroutine is launched with sessWg.Go and calls readPanePTY, whose closure owns
62+// only the pane.
6163 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
64+ defer d.sessWg.Done()
6265 if p != nil && p.ownerSnapshot() == nil && sess != nil && tb != nil {
6366 publishPaneOwner(p, sess, tb, 0)
6467 }
@@ -66,7 +69,6 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
6669 }
6770
6871 func (d *Daemon) readPanePTY(p *pane) {
69- defer d.sessWg.Done()
7072 if p == nil {
7173 return
7274 }
@@ -56,9 +56,12 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
56 return false56 return false
57 }57 }
58 58
59-// ptyReader is retained as a compatibility entry point for focused tests. The59+// ptyReader is retained as a compatibility entry point for focused tests, which
60-// production goroutine calls readPanePTY, whose closure owns only the pane.60+// register the goroutine with sessWg.Add before calling it. The production
61+// goroutine is launched with sessWg.Go and calls readPanePTY, whose closure owns
62+// only the pane.
61 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {63 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
64+ defer d.sessWg.Done()
62 if p != nil && p.ownerSnapshot() == nil && sess != nil && tb != nil {65 if p != nil && p.ownerSnapshot() == nil && sess != nil && tb != nil {
63 publishPaneOwner(p, sess, tb, 0)66 publishPaneOwner(p, sess, tb, 0)
64 }67 }
@@ -66,7 +69,6 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
66 }69 }
67 70
68 func (d *Daemon) readPanePTY(p *pane) {71 func (d *Daemon) readPanePTY(p *pane) {
69- defer d.sessWg.Done()
70 if p == nil {72 if p == nil {
71 return73 return
72 }74 }
M internal/usecase/daemon/resize_mode.go +1 -0
@@ -146,6 +146,7 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) {
146146 }
147147 }
148148
149+// retainResizeESCLocked requires ac.overlays.resizeMu, not d.mu.
149150 func (d *Daemon) retainResizeESCLocked(ac *attachedClient) {
150151 rt := ac.overlays
151152 rt.resizePending = append(rt.resizePending[:0], keys.ESC)
@@ -146,6 +146,7 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) {
146 }146 }
147 }147 }
148 148
149+// retainResizeESCLocked requires ac.overlays.resizeMu, not d.mu.
149 func (d *Daemon) retainResizeESCLocked(ac *attachedClient) {150 func (d *Daemon) retainResizeESCLocked(ac *attachedClient) {
150 rt := ac.overlays151 rt := ac.overlays
151 rt.resizePending = append(rt.resizePending[:0], keys.ESC)152 rt.resizePending = append(rt.resizePending[:0], keys.ESC)
M internal/usecase/daemon/resume_test.go +1 -1
@@ -1397,7 +1397,7 @@ func TestEphemeralParkExpiryKeepsSessionWhenCloseOnExitOff(t *testing.T) {
13971397 pty, release := newBlockingPTY(t)
13981398 defer release()
13991399 d := newTestDaemon(t, newFactory(t, pty), clk)
1400- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: false})
1400+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: false} })
14011401
14021402 tr := &closeTrackingTransport{}
14031403 sess, ac, err := d.route(helloResumeCapable(protocol.IntentEphemeral, "", 0), tr)
@@ -1397,7 +1397,7 @@ func TestEphemeralParkExpiryKeepsSessionWhenCloseOnExitOff(t *testing.T) {
1397 pty, release := newBlockingPTY(t)1397 pty, release := newBlockingPTY(t)
1398 defer release()1398 defer release()
1399 d := newTestDaemon(t, newFactory(t, pty), clk)1399 d := newTestDaemon(t, newFactory(t, pty), clk)
1400- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: false})1400+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: false} })
1401 1401
1402 tr := &closeTrackingTransport{}1402 tr := &closeTrackingTransport{}
1403 sess, ac, err := d.route(helloResumeCapable(protocol.IntentEphemeral, "", 0), tr)1403 sess, ac, err := d.route(helloResumeCapable(protocol.IntentEphemeral, "", 0), tr)
M internal/usecase/daemon/session.go +5 -6
@@ -380,7 +380,7 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme
380380 tabs: tabs,
381381 cwd: cwd,
382382 env: env,
383- snapshotWake: d.snapshotWake,
383+ snapshotWake: d.snapshots.wake,
384384 }
385385 if !ephemeral && name != "" {
386386 sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
@@ -901,8 +901,7 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
901901 return
902902 }
903903 // Scheduler ownership was removed; this launch creates exactly one reader.
904- d.sessWg.Add(1)
905- go d.readPanePTY(p)
904+ d.sessWg.Go(func() { d.readPanePTY(p) })
906905 }
907906
908907 // detachIfCurrent publishes terminal attachment invalidation through the attachment
@@ -2111,7 +2110,7 @@ func (d *Daemon) cwdSampler(ctx context.Context) {
21112110 return
21122111 case <-t.C():
21132112 }
2114- if d.procCwd != nil {
2113+ if d.proc != nil {
21152114 d.refreshNamedSessionCwds()
21162115 }
21172116 if err := d.flushCatalogue(); err != nil {
@@ -2136,7 +2135,7 @@ func (d *Daemon) refreshNamedSessionCwds() {
21362135 }
21372136
21382137 func (d *Daemon) refreshSessionCwd(sess *session) {
2139- if d.procCwd == nil {
2138+ if d.proc == nil {
21402139 return
21412140 }
21422141 tb := sess.firstTab()
@@ -2149,7 +2148,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
21492148 if p == nil {
21502149 return
21512150 }
2152- cwd, err := d.procCwd(p.pty.Pid())
2151+ cwd, err := d.proc.Cwd(p.pty.Pid())
21532152 if err != nil || cwd == "" {
21542153 return
21552154 }
@@ -380,7 +380,7 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme
380 tabs: tabs,380 tabs: tabs,
381 cwd: cwd,381 cwd: cwd,
382 env: env,382 env: env,
383- snapshotWake: d.snapshotWake,383+ snapshotWake: d.snapshots.wake,
384 }384 }
385 if !ephemeral && name != "" {385 if !ephemeral && name != "" {
386 sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)386 sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
@@ -901,8 +901,7 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
901 return901 return
902 }902 }
903 // Scheduler ownership was removed; this launch creates exactly one reader.903 // Scheduler ownership was removed; this launch creates exactly one reader.
904- d.sessWg.Add(1)904+ d.sessWg.Go(func() { d.readPanePTY(p) })
905- go d.readPanePTY(p)
906 }905 }
907 906
908 // detachIfCurrent publishes terminal attachment invalidation through the attachment907 // detachIfCurrent publishes terminal attachment invalidation through the attachment
@@ -2111,7 +2110,7 @@ func (d *Daemon) cwdSampler(ctx context.Context) {
2111 return2110 return
2112 case <-t.C():2111 case <-t.C():
2113 }2112 }
2114- if d.procCwd != nil {2113+ if d.proc != nil {
2115 d.refreshNamedSessionCwds()2114 d.refreshNamedSessionCwds()
2116 }2115 }
2117 if err := d.flushCatalogue(); err != nil {2116 if err := d.flushCatalogue(); err != nil {
@@ -2136,7 +2135,7 @@ func (d *Daemon) refreshNamedSessionCwds() {
2136 }2135 }
2137 2136
2138 func (d *Daemon) refreshSessionCwd(sess *session) {2137 func (d *Daemon) refreshSessionCwd(sess *session) {
2139- if d.procCwd == nil {2138+ if d.proc == nil {
2140 return2139 return
2141 }2140 }
2142 tb := sess.firstTab()2141 tb := sess.firstTab()
@@ -2149,7 +2148,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
2149 if p == nil {2148 if p == nil {
2150 return2149 return
2151 }2150 }
2152- cwd, err := d.procCwd(p.pty.Pid())2151+ cwd, err := d.proc.Cwd(p.pty.Pid())
2153 if err != nil || cwd == "" {2152 if err != nil || cwd == "" {
2154 return2153 return
2155 }2154 }
M internal/usecase/daemon/snapshot.go +1 -1
@@ -47,7 +47,7 @@ type snapshotCapture struct {
4747 publicationContext context.Context
4848 sealedRefs map[*vt.HistoryChunk]snapcodec.ObjectRef // set by the single encoder worker
4949 coordinatorDiscarded bool // guarded by session.snapshotMu
50- // normalWorkerAdmitted is set before snapshotJobs admission and immutable
50+ // normalWorkerAdmitted is set before snapshots.jobs admission and immutable
5151 // until completion; final-queue captures leave it false.
5252 normalWorkerAdmitted bool
5353 finishOnce sync.Once
@@ -47,7 +47,7 @@ type snapshotCapture struct {
47 publicationContext context.Context47 publicationContext context.Context
48 sealedRefs map[*vt.HistoryChunk]snapcodec.ObjectRef // set by the single encoder worker48 sealedRefs map[*vt.HistoryChunk]snapcodec.ObjectRef // set by the single encoder worker
49 coordinatorDiscarded bool // guarded by session.snapshotMu49 coordinatorDiscarded bool // guarded by session.snapshotMu
50- // normalWorkerAdmitted is set before snapshotJobs admission and immutable50+ // normalWorkerAdmitted is set before snapshots.jobs admission and immutable
51 // until completion; final-queue captures leave it false.51 // until completion; final-queue captures leave it false.
52 normalWorkerAdmitted bool52 normalWorkerAdmitted bool
53 finishOnce sync.Once53 finishOnce sync.Once
M internal/usecase/daemon/snapshot_capture.go +4 -4
@@ -61,8 +61,8 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
6161 paneCapture.tail = paneCapture.sealed.Tail()
6262 p.mu.Unlock()
6363 paneCapture.cwd = fallbackCwd
64- if d.procCwd != nil && pid > 0 {
65- if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
64+ if d.proc != nil && pid > 0 {
65+ if cwd, err := d.proc.Cwd(pid); err == nil && cwd != "" {
6666 paneCapture.cwd = cwd
6767 }
6868 }
@@ -82,14 +82,14 @@ func (d *Daemon) captureSession(sess *session) bool {
8282 }
8383
8484 func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
85- if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
85+ if d == nil || pty == nil || shellPid <= 0 || !d.hasProcessDetails() {
8686 return nil
8787 }
8888 pgid, err := pty.ForegroundPgid()
8989 if err != nil || pgid <= 0 || pgid == shellPid {
9090 return nil
9191 }
92- argv, err := d.procGroupArgv(pgid, shellPid)
92+ argv, err := d.proc.GroupArgv(pgid, shellPid)
9393 if err != nil || len(argv) == 0 || argv[0] == "" {
9494 return nil
9595 }
@@ -61,8 +61,8 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
61 paneCapture.tail = paneCapture.sealed.Tail()61 paneCapture.tail = paneCapture.sealed.Tail()
62 p.mu.Unlock()62 p.mu.Unlock()
63 paneCapture.cwd = fallbackCwd63 paneCapture.cwd = fallbackCwd
64- if d.procCwd != nil && pid > 0 {64+ if d.proc != nil && pid > 0 {
65- if cwd, err := d.procCwd(pid); err == nil && cwd != "" {65+ if cwd, err := d.proc.Cwd(pid); err == nil && cwd != "" {
66 paneCapture.cwd = cwd66 paneCapture.cwd = cwd
67 }67 }
68 }68 }
@@ -82,14 +82,14 @@ func (d *Daemon) captureSession(sess *session) bool {
82 }82 }
83 83
84 func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {84 func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
85- if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {85+ if d == nil || pty == nil || shellPid <= 0 || !d.hasProcessDetails() {
86 return nil86 return nil
87 }87 }
88 pgid, err := pty.ForegroundPgid()88 pgid, err := pty.ForegroundPgid()
89 if err != nil || pgid <= 0 || pgid == shellPid {89 if err != nil || pgid <= 0 || pgid == shellPid {
90 return nil90 return nil
91 }91 }
92- argv, err := d.procGroupArgv(pgid, shellPid)92+ argv, err := d.proc.GroupArgv(pgid, shellPid)
93 if err != nil || len(argv) == 0 || argv[0] == "" {93 if err != nil || len(argv) == 0 || argv[0] == "" {
94 return nil94 return nil
95 }95 }
M internal/usecase/daemon/snapshot_coordinator.go +3 -3
@@ -247,9 +247,9 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
247247 wake := capture.session.snapshotWake
248248 capture.session.snapshotMu.Unlock()
249249 if capture.normalWorkerAdmitted {
250- d.snapshotWorkerMu.Lock()
251- delete(d.snapshotAdmitted, capture)
252- d.snapshotWorkerMu.Unlock()
250+ d.snapshots.mu.Lock()
251+ delete(d.snapshots.admitted, capture)
252+ d.snapshots.mu.Unlock()
253253 }
254254 if wake != nil {
255255 select {
@@ -247,9 +247,9 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
247 wake := capture.session.snapshotWake247 wake := capture.session.snapshotWake
248 capture.session.snapshotMu.Unlock()248 capture.session.snapshotMu.Unlock()
249 if capture.normalWorkerAdmitted {249 if capture.normalWorkerAdmitted {
250- d.snapshotWorkerMu.Lock()250+ d.snapshots.mu.Lock()
251- delete(d.snapshotAdmitted, capture)251+ delete(d.snapshots.admitted, capture)
252- d.snapshotWorkerMu.Unlock()252+ d.snapshots.mu.Unlock()
253 }253 }
254 if wake != nil {254 if wake != nil {
255 select {255 select {
M internal/usecase/daemon/snapshot_queue.go +19 -19
@@ -102,18 +102,18 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
102102 // queue send. The queue is deliberately never closed: producers can race
103103 // shutdown without risking a send-on-closed panic.
104104 func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
105- d.snapshotWorkerMu.Lock()
106- defer d.snapshotWorkerMu.Unlock()
107- if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
105+ d.snapshots.mu.Lock()
106+ defer d.snapshots.mu.Unlock()
107+ if d.snapshots.closing || d.snapshots.cancel == nil || d.snapshots.ctx == nil || d.snapshots.ctx.Err() != nil {
108108 return false
109109 }
110110 capture.normalWorkerAdmitted = true
111111 select {
112- case d.snapshotJobs <- capture:
113- if d.snapshotAdmitted == nil {
114- d.snapshotAdmitted = make(map[*snapshotCapture]struct{})
112+ case d.snapshots.jobs <- capture:
113+ if d.snapshots.admitted == nil {
114+ d.snapshots.admitted = make(map[*snapshotCapture]struct{})
115115 }
116- d.snapshotAdmitted[capture] = struct{}{}
116+ d.snapshots.admitted[capture] = struct{}{}
117117 return true
118118 default:
119119 capture.normalWorkerAdmitted = false
@@ -129,17 +129,17 @@ func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
129129 if capture == nil || capture.session == nil {
130130 return false
131131 }
132- d.snapshotWorkerMu.Lock()
133- if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
134- d.snapshotWorkerMu.Unlock()
132+ d.snapshots.mu.Lock()
133+ if d.snapshots.closing || d.snapshots.cancel == nil || d.snapshots.ctx == nil || d.snapshots.ctx.Err() != nil {
134+ d.snapshots.mu.Unlock()
135135 return false
136136 }
137- if d.snapshotFinalJobs == nil {
138- d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
137+ if d.snapshots.finalJobs == nil {
138+ d.snapshots.finalJobs = make(map[*session]*snapshotCapture)
139139 }
140- replaced, exists := d.snapshotFinalJobs[capture.session]
141- if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity {
142- d.snapshotWorkerMu.Unlock()
140+ replaced, exists := d.snapshots.finalJobs[capture.session]
141+ if !exists && len(d.snapshots.finalJobs) >= snapshotFinalQueueCapacity {
142+ d.snapshots.mu.Unlock()
143143 d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity)
144144 return false
145145 }
@@ -148,13 +148,13 @@ func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
148148 // mark the session dirty again.
149149 d.finishSnapshotCapture(replaced, false)
150150 if !exists {
151- d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session)
151+ d.snapshots.finalOrder = append(d.snapshots.finalOrder, capture.session)
152152 }
153- d.snapshotFinalJobs[capture.session] = capture
153+ d.snapshots.finalJobs[capture.session] = capture
154154 select {
155- case d.snapshotWorkerFinalWake <- struct{}{}:
155+ case d.snapshots.finalWake <- struct{}{}:
156156 default:
157157 }
158- d.snapshotWorkerMu.Unlock()
158+ d.snapshots.mu.Unlock()
159159 return true
160160 }
@@ -102,18 +102,18 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
102 // queue send. The queue is deliberately never closed: producers can race102 // queue send. The queue is deliberately never closed: producers can race
103 // shutdown without risking a send-on-closed panic.103 // shutdown without risking a send-on-closed panic.
104 func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {104 func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
105- d.snapshotWorkerMu.Lock()105+ d.snapshots.mu.Lock()
106- defer d.snapshotWorkerMu.Unlock()106+ defer d.snapshots.mu.Unlock()
107- if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {107+ if d.snapshots.closing || d.snapshots.cancel == nil || d.snapshots.ctx == nil || d.snapshots.ctx.Err() != nil {
108 return false108 return false
109 }109 }
110 capture.normalWorkerAdmitted = true110 capture.normalWorkerAdmitted = true
111 select {111 select {
112- case d.snapshotJobs <- capture:112+ case d.snapshots.jobs <- capture:
113- if d.snapshotAdmitted == nil {113+ if d.snapshots.admitted == nil {
114- d.snapshotAdmitted = make(map[*snapshotCapture]struct{})114+ d.snapshots.admitted = make(map[*snapshotCapture]struct{})
115 }115 }
116- d.snapshotAdmitted[capture] = struct{}{}116+ d.snapshots.admitted[capture] = struct{}{}
117 return true117 return true
118 default:118 default:
119 capture.normalWorkerAdmitted = false119 capture.normalWorkerAdmitted = false
@@ -129,17 +129,17 @@ func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
129 if capture == nil || capture.session == nil {129 if capture == nil || capture.session == nil {
130 return false130 return false
131 }131 }
132- d.snapshotWorkerMu.Lock()132+ d.snapshots.mu.Lock()
133- if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {133+ if d.snapshots.closing || d.snapshots.cancel == nil || d.snapshots.ctx == nil || d.snapshots.ctx.Err() != nil {
134- d.snapshotWorkerMu.Unlock()134+ d.snapshots.mu.Unlock()
135 return false135 return false
136 }136 }
137- if d.snapshotFinalJobs == nil {137+ if d.snapshots.finalJobs == nil {
138- d.snapshotFinalJobs = make(map[*session]*snapshotCapture)138+ d.snapshots.finalJobs = make(map[*session]*snapshotCapture)
139 }139 }
140- replaced, exists := d.snapshotFinalJobs[capture.session]140+ replaced, exists := d.snapshots.finalJobs[capture.session]
141- if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity {141+ if !exists && len(d.snapshots.finalJobs) >= snapshotFinalQueueCapacity {
142- d.snapshotWorkerMu.Unlock()142+ d.snapshots.mu.Unlock()
143 d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity)143 d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity)
144 return false144 return false
145 }145 }
@@ -148,13 +148,13 @@ func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
148 // mark the session dirty again.148 // mark the session dirty again.
149 d.finishSnapshotCapture(replaced, false)149 d.finishSnapshotCapture(replaced, false)
150 if !exists {150 if !exists {
151- d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session)151+ d.snapshots.finalOrder = append(d.snapshots.finalOrder, capture.session)
152 }152 }
153- d.snapshotFinalJobs[capture.session] = capture153+ d.snapshots.finalJobs[capture.session] = capture
154 select {154 select {
155- case d.snapshotWorkerFinalWake <- struct{}{}:155+ case d.snapshots.finalWake <- struct{}{}:
156 default:156 default:
157 }157 }
158- d.snapshotWorkerMu.Unlock()158+ d.snapshots.mu.Unlock()
159 return true159 return true
160 }160 }
M internal/usecase/daemon/snapshot_restore.go +6 -6
@@ -45,14 +45,14 @@ func (d *Daemon) startSnapshotRestoration() {
4545 if d == nil {
4646 return
4747 }
48- d.snapshotWorkerMu.Lock()
49- if d.restoreWorkerDone != nil {
50- d.snapshotWorkerMu.Unlock()
48+ d.snapshots.mu.Lock()
49+ if d.snapshots.restoreWorkerDone != nil {
50+ d.snapshots.mu.Unlock()
5151 return
5252 }
5353 done := make(chan struct{})
54- d.restoreWorkerDone = done
55- d.snapshotWorkerMu.Unlock()
54+ d.snapshots.restoreWorkerDone = done
55+ d.snapshots.mu.Unlock()
5656 d.sessWg.Go(func() {
5757 defer close(done)
5858 d.restoreSnapshots(d.serveCtx)
@@ -292,7 +292,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
292292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
293293 // Restored sessions keep ordered tabs; attachment repair deterministically
294294 // selects the first tab instead of restoring an interactive client view.
295- sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, env: copyEnvironment(d.baseEnv), envProvisional: true, snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
295+ sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, env: copyEnvironment(d.baseEnv), envProvisional: true, snapshotWake: d.snapshots.wake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
296296 // Restored tabs remain private until persistAndRegisterRestoredSession.
297297 // Initialize owners now so registration and reader startup cannot expose an
298298 // ownerless pane.
@@ -45,14 +45,14 @@ func (d *Daemon) startSnapshotRestoration() {
45 if d == nil {45 if d == nil {
46 return46 return
47 }47 }
48- d.snapshotWorkerMu.Lock()48+ d.snapshots.mu.Lock()
49- if d.restoreWorkerDone != nil {49+ if d.snapshots.restoreWorkerDone != nil {
50- d.snapshotWorkerMu.Unlock()50+ d.snapshots.mu.Unlock()
51 return51 return
52 }52 }
53 done := make(chan struct{})53 done := make(chan struct{})
54- d.restoreWorkerDone = done54+ d.snapshots.restoreWorkerDone = done
55- d.snapshotWorkerMu.Unlock()55+ d.snapshots.mu.Unlock()
56 d.sessWg.Go(func() {56 d.sessWg.Go(func() {
57 defer close(done)57 defer close(done)
58 d.restoreSnapshots(d.serveCtx)58 d.restoreSnapshots(d.serveCtx)
@@ -292,7 +292,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
293 // Restored sessions keep ordered tabs; attachment repair deterministically293 // Restored sessions keep ordered tabs; attachment repair deterministically
294 // selects the first tab instead of restoring an interactive client view.294 // selects the first tab instead of restoring an interactive client view.
295- sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, env: copyEnvironment(d.baseEnv), envProvisional: true, snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}295+ sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, env: copyEnvironment(d.baseEnv), envProvisional: true, snapshotWake: d.snapshots.wake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
296 // Restored tabs remain private until persistAndRegisterRestoredSession.296 // Restored tabs remain private until persistAndRegisterRestoredSession.
297 // Initialize owners now so registration and reader startup cannot expose an297 // Initialize owners now so registration and reader startup cannot expose an
298 // ownerless pane.298 // ownerless pane.
M internal/usecase/daemon/snapshot_restore_test.go +3 -3
@@ -529,9 +529,9 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
529529 markSnapshotDirty(restored)
530530 require.True(t, d.scheduleSnapshot(restored))
531531 awaitSnapshotIdle(t, restored)
532- d.snapshotNoticeMu.Lock()
533- failure := d.snapshotActiveFailureSignature
534- d.snapshotNoticeMu.Unlock()
532+ d.snapshots.noticeMu.Lock()
533+ failure := d.snapshots.activeFailureSignature
534+ d.snapshots.noticeMu.Unlock()
535535 require.Empty(t, failure)
536536 require.Len(t, repository.publishes, 1)
537537 require.Equal(t, record.Committed, repository.publishes[0].ParentCheckpoint)
@@ -529,9 +529,9 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
529 markSnapshotDirty(restored)529 markSnapshotDirty(restored)
530 require.True(t, d.scheduleSnapshot(restored))530 require.True(t, d.scheduleSnapshot(restored))
531 awaitSnapshotIdle(t, restored)531 awaitSnapshotIdle(t, restored)
532- d.snapshotNoticeMu.Lock()532+ d.snapshots.noticeMu.Lock()
533- failure := d.snapshotActiveFailureSignature533+ failure := d.snapshots.activeFailureSignature
534- d.snapshotNoticeMu.Unlock()534+ d.snapshots.noticeMu.Unlock()
535 require.Empty(t, failure)535 require.Empty(t, failure)
536 require.Len(t, repository.publishes, 1)536 require.Len(t, repository.publishes, 1)
537 require.Equal(t, record.Committed, repository.publishes[0].ParentCheckpoint)537 require.Equal(t, record.Committed, repository.publishes[0].ParentCheckpoint)
M internal/usecase/daemon/snapshot_resume_publication_test.go +3 -3
@@ -204,9 +204,9 @@ func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing
204204 require.Len(t, attempts, 3, "the resumed checkpoint must reach the repository after the initial and terminal checkpoints")
205205 require.NoError(t, publicationErrors[2])
206206 require.False(t, resumed.snapDirty.Load(), "the published resumed checkpoint must clear dirty state")
207- d.snapshotNoticeMu.Lock()
208- failure := d.snapshotActiveFailureSignature
209- d.snapshotNoticeMu.Unlock()
207+ d.snapshots.noticeMu.Lock()
208+ failure := d.snapshots.activeFailureSignature
209+ d.snapshots.noticeMu.Unlock()
210210 require.Empty(t, failure, "successful resumed publication must not retain a failure signature")
211211 require.Equal(t, closedRecord.Committed.Generation+1, resumedRecord.Committed.Generation,
212212 "same-daemon resume must publish exactly committed generation+1")
@@ -204,9 +204,9 @@ func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing
204 require.Len(t, attempts, 3, "the resumed checkpoint must reach the repository after the initial and terminal checkpoints")204 require.Len(t, attempts, 3, "the resumed checkpoint must reach the repository after the initial and terminal checkpoints")
205 require.NoError(t, publicationErrors[2])205 require.NoError(t, publicationErrors[2])
206 require.False(t, resumed.snapDirty.Load(), "the published resumed checkpoint must clear dirty state")206 require.False(t, resumed.snapDirty.Load(), "the published resumed checkpoint must clear dirty state")
207- d.snapshotNoticeMu.Lock()207+ d.snapshots.noticeMu.Lock()
208- failure := d.snapshotActiveFailureSignature208+ failure := d.snapshots.activeFailureSignature
209- d.snapshotNoticeMu.Unlock()209+ d.snapshots.noticeMu.Unlock()
210 require.Empty(t, failure, "successful resumed publication must not retain a failure signature")210 require.Empty(t, failure, "successful resumed publication must not retain a failure signature")
211 require.Equal(t, closedRecord.Committed.Generation+1, resumedRecord.Committed.Generation,211 require.Equal(t, closedRecord.Committed.Generation+1, resumedRecord.Committed.Generation,
212 "same-daemon resume must publish exactly committed generation+1")212 "same-daemon resume must publish exactly committed generation+1")
M internal/usecase/daemon/snapshot_scheduler.go +1 -1
@@ -17,7 +17,7 @@ func (d *Daemon) snapshotRepositorySaver(ctx context.Context) {
1717 select {
1818 case <-ctx.Done():
1919 return
20- case <-d.snapshotWake:
20+ case <-d.snapshots.wake:
2121 case <-timer.C():
2222 }
2323 }
@@ -17,7 +17,7 @@ func (d *Daemon) snapshotRepositorySaver(ctx context.Context) {
17 select {17 select {
18 case <-ctx.Done():18 case <-ctx.Done():
19 return19 return
20- case <-d.snapshotWake:20+ case <-d.snapshots.wake:
21 case <-timer.C():21 case <-timer.C():
22 }22 }
23 }23 }
M internal/usecase/daemon/snapshot_scheduler_test.go +18 -18
@@ -135,7 +135,7 @@ func TestQuarantineDiscardDoesNotDoubleDecrementPendingCaptures(t *testing.T) {
135135 // Model the worker having already dequeued the stale capture: the global
136136 // bounded queue still holds it, so free the slot before the replacement.
137137 select {
138- case dequeued := <-d.snapshotJobs:
138+ case dequeued := <-d.snapshots.jobs:
139139 require.Same(t, first, dequeued)
140140 default:
141141 t.Fatal("the discarded capture must still be queued in the global worker queue")
@@ -356,9 +356,9 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
356356 require.NotNil(t, sess.snapshotQueuedCapture, "the one routine capture remains retryable")
357357 require.Nil(t, sess.snapshotInFlightCapture)
358358 }()
359- d.snapshotWorkerMu.Lock()
360- require.LessOrEqual(t, len(d.snapshotJobs), snapshotQueueCapacity)
361- d.snapshotWorkerMu.Unlock()
359+ d.snapshots.mu.Lock()
360+ require.LessOrEqual(t, len(d.snapshots.jobs), snapshotQueueCapacity)
361+ d.snapshots.mu.Unlock()
362362
363363 // The retained capture is not published after quarantine, but releasing the
364364 // unrelated blocked call lets the worker discard it and lets cleanup join.
@@ -572,7 +572,7 @@ func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
572572
573573 awaitFrame(t, sends, "Welcome")
574574 markSnapshotDirty(firstSession(d))
575- d.snapshotWake <- struct{}{}
575+ d.snapshots.wake <- struct{}{}
576576 first := <-published
577577 require.Equal(t, uint64(1), first.Generation)
578578
@@ -629,7 +629,7 @@ func TestServeShutdownDeadlineStillJoinsUncooperativeSnapshotRepository(t *testi
629629
630630 awaitFrame(t, sends, "Welcome")
631631 markSnapshotDirty(firstSession(d))
632- d.snapshotWake <- struct{}{}
632+ d.snapshots.wake <- struct{}{}
633633 select {
634634 case <-firstPublicationCompleted:
635635 case <-time.After(testWaitTimeout):
@@ -642,9 +642,9 @@ func TestServeShutdownDeadlineStillJoinsUncooperativeSnapshotRepository(t *testi
642642 case <-time.After(testWaitTimeout):
643643 t.Fatal("shutdown terminal checkpoint did not reach the worker")
644644 }
645- d.snapshotWorkerMu.Lock()
646- workerDone := d.snapshotWorkerDone
647- d.snapshotWorkerMu.Unlock()
645+ d.snapshots.mu.Lock()
646+ workerDone := d.snapshots.done
647+ d.snapshots.mu.Unlock()
648648 require.NotNil(t, workerDone)
649649 clock.nextFinalTimer(t).fire()
650650 select {
@@ -926,7 +926,7 @@ func TestSnapshotSchedulerImmediateEligibilityAndStaleCapturesRemainDirty(t *tes
926926 }).Once()
927927
928928 sess := newSnapshotTestSession(t, "immediate", false, "/work")
929- sess.snapshotWake = d.snapshotWake
929+ sess.snapshotWake = d.snapshots.wake
930930 sess.snapDirty.Store(true)
931931 d.sessions[sess.id] = sess
932932
@@ -1000,10 +1000,10 @@ func TestSnapshotWorkerQueueIsBoundedAndForcedCapturesCoalesce(t *testing.T) {
10001000 markSnapshotDirty(queued)
10011001 require.True(t, d.scheduleSnapshot(queued))
10021002
1003- d.snapshotWorkerMu.Lock()
1004- require.NotNil(t, d.snapshotWorkerInFlight)
1005- require.Len(t, d.snapshotJobs, snapshotQueueCapacity)
1006- d.snapshotWorkerMu.Unlock()
1003+ d.snapshots.mu.Lock()
1004+ require.NotNil(t, d.snapshots.inFlight)
1005+ require.Len(t, d.snapshots.jobs, snapshotQueueCapacity)
1006+ d.snapshots.mu.Unlock()
10071007
10081008 saturated := newSnapshotTestSession(t, "saturated", false, "/work")
10091009 markSnapshotDirty(saturated)
@@ -1041,7 +1041,7 @@ func TestSnapshotRepositorySaverUsesEarliestDeadlineAndRecomputes(t *testing.T)
10411041 }).Once()
10421042
10431043 later := newSnapshotTestSession(t, "later", false, "/work")
1044- later.snapshotWake = d.snapshotWake
1044+ later.snapshotWake = d.snapshots.wake
10451045 later.snapDirty.Store(true)
10461046 later.snapshotNextEligibleAt = now.Add(10 * time.Minute)
10471047 d.sessions[later.id] = later
@@ -1056,11 +1056,11 @@ func TestSnapshotRepositorySaverUsesEarliestDeadlineAndRecomputes(t *testing.T)
10561056 require.Equal(t, 10*time.Minute, <-timer.resets)
10571057
10581058 earlier := newSnapshotTestSession(t, "earlier", false, "/work")
1059- earlier.snapshotWake = d.snapshotWake
1059+ earlier.snapshotWake = d.snapshots.wake
10601060 earlier.snapDirty.Store(true)
10611061 earlier.snapshotNextEligibleAt = now.Add(5 * time.Minute)
10621062 d.sessions[earlier.id] = earlier
1063- d.snapshotWake <- struct{}{}
1063+ d.snapshots.wake <- struct{}{}
10641064 require.Equal(t, 5*time.Minute, <-timer.resets)
10651065
10661066 clock.advance(now.Add(5 * time.Minute))
@@ -1083,7 +1083,7 @@ func TestSnapshotRepositorySaverUsesEarliestDeadlineAndRecomputes(t *testing.T)
10831083 require.Equal(t, 5*time.Minute, <-timer.resets)
10841084
10851085 delete(d.sessions, later.id)
1086- d.snapshotWake <- struct{}{}
1086+ d.snapshots.wake <- struct{}{}
10871087 require.Equal(t, 24*time.Hour, <-timer.resets)
10881088
10891089 cancel()
@@ -135,7 +135,7 @@ func TestQuarantineDiscardDoesNotDoubleDecrementPendingCaptures(t *testing.T) {
135 // Model the worker having already dequeued the stale capture: the global135 // Model the worker having already dequeued the stale capture: the global
136 // bounded queue still holds it, so free the slot before the replacement.136 // bounded queue still holds it, so free the slot before the replacement.
137 select {137 select {
138- case dequeued := <-d.snapshotJobs:138+ case dequeued := <-d.snapshots.jobs:
139 require.Same(t, first, dequeued)139 require.Same(t, first, dequeued)
140 default:140 default:
141 t.Fatal("the discarded capture must still be queued in the global worker queue")141 t.Fatal("the discarded capture must still be queued in the global worker queue")
@@ -356,9 +356,9 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
356 require.NotNil(t, sess.snapshotQueuedCapture, "the one routine capture remains retryable")356 require.NotNil(t, sess.snapshotQueuedCapture, "the one routine capture remains retryable")
357 require.Nil(t, sess.snapshotInFlightCapture)357 require.Nil(t, sess.snapshotInFlightCapture)
358 }()358 }()
359- d.snapshotWorkerMu.Lock()359+ d.snapshots.mu.Lock()
360- require.LessOrEqual(t, len(d.snapshotJobs), snapshotQueueCapacity)360+ require.LessOrEqual(t, len(d.snapshots.jobs), snapshotQueueCapacity)
361- d.snapshotWorkerMu.Unlock()361+ d.snapshots.mu.Unlock()
362 362
363 // The retained capture is not published after quarantine, but releasing the363 // The retained capture is not published after quarantine, but releasing the
364 // unrelated blocked call lets the worker discard it and lets cleanup join.364 // unrelated blocked call lets the worker discard it and lets cleanup join.
@@ -572,7 +572,7 @@ func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
572 572
573 awaitFrame(t, sends, "Welcome")573 awaitFrame(t, sends, "Welcome")
574 markSnapshotDirty(firstSession(d))574 markSnapshotDirty(firstSession(d))
575- d.snapshotWake <- struct{}{}575+ d.snapshots.wake <- struct{}{}
576 first := <-published576 first := <-published
577 require.Equal(t, uint64(1), first.Generation)577 require.Equal(t, uint64(1), first.Generation)
578 578
@@ -629,7 +629,7 @@ func TestServeShutdownDeadlineStillJoinsUncooperativeSnapshotRepository(t *testi
629 629
630 awaitFrame(t, sends, "Welcome")630 awaitFrame(t, sends, "Welcome")
631 markSnapshotDirty(firstSession(d))631 markSnapshotDirty(firstSession(d))
632- d.snapshotWake <- struct{}{}632+ d.snapshots.wake <- struct{}{}
633 select {633 select {
634 case <-firstPublicationCompleted:634 case <-firstPublicationCompleted:
635 case <-time.After(testWaitTimeout):635 case <-time.After(testWaitTimeout):
@@ -642,9 +642,9 @@ func TestServeShutdownDeadlineStillJoinsUncooperativeSnapshotRepository(t *testi
642 case <-time.After(testWaitTimeout):642 case <-time.After(testWaitTimeout):
643 t.Fatal("shutdown terminal checkpoint did not reach the worker")643 t.Fatal("shutdown terminal checkpoint did not reach the worker")
644 }644 }
645- d.snapshotWorkerMu.Lock()645+ d.snapshots.mu.Lock()
646- workerDone := d.snapshotWorkerDone646+ workerDone := d.snapshots.done
647- d.snapshotWorkerMu.Unlock()647+ d.snapshots.mu.Unlock()
648 require.NotNil(t, workerDone)648 require.NotNil(t, workerDone)
649 clock.nextFinalTimer(t).fire()649 clock.nextFinalTimer(t).fire()
650 select {650 select {
@@ -926,7 +926,7 @@ func TestSnapshotSchedulerImmediateEligibilityAndStaleCapturesRemainDirty(t *tes
926 }).Once()926 }).Once()
927 927
928 sess := newSnapshotTestSession(t, "immediate", false, "/work")928 sess := newSnapshotTestSession(t, "immediate", false, "/work")
929- sess.snapshotWake = d.snapshotWake929+ sess.snapshotWake = d.snapshots.wake
930 sess.snapDirty.Store(true)930 sess.snapDirty.Store(true)
931 d.sessions[sess.id] = sess931 d.sessions[sess.id] = sess
932 932
@@ -1000,10 +1000,10 @@ func TestSnapshotWorkerQueueIsBoundedAndForcedCapturesCoalesce(t *testing.T) {
1000 markSnapshotDirty(queued)1000 markSnapshotDirty(queued)
1001 require.True(t, d.scheduleSnapshot(queued))1001 require.True(t, d.scheduleSnapshot(queued))
1002 1002
1003- d.snapshotWorkerMu.Lock()1003+ d.snapshots.mu.Lock()
1004- require.NotNil(t, d.snapshotWorkerInFlight)1004+ require.NotNil(t, d.snapshots.inFlight)
1005- require.Len(t, d.snapshotJobs, snapshotQueueCapacity)1005+ require.Len(t, d.snapshots.jobs, snapshotQueueCapacity)
1006- d.snapshotWorkerMu.Unlock()1006+ d.snapshots.mu.Unlock()
1007 1007
1008 saturated := newSnapshotTestSession(t, "saturated", false, "/work")1008 saturated := newSnapshotTestSession(t, "saturated", false, "/work")
1009 markSnapshotDirty(saturated)1009 markSnapshotDirty(saturated)
@@ -1041,7 +1041,7 @@ func TestSnapshotRepositorySaverUsesEarliestDeadlineAndRecomputes(t *testing.T)
1041 }).Once()1041 }).Once()
1042 1042
1043 later := newSnapshotTestSession(t, "later", false, "/work")1043 later := newSnapshotTestSession(t, "later", false, "/work")
1044- later.snapshotWake = d.snapshotWake1044+ later.snapshotWake = d.snapshots.wake
1045 later.snapDirty.Store(true)1045 later.snapDirty.Store(true)
1046 later.snapshotNextEligibleAt = now.Add(10 * time.Minute)1046 later.snapshotNextEligibleAt = now.Add(10 * time.Minute)
1047 d.sessions[later.id] = later1047 d.sessions[later.id] = later
@@ -1056,11 +1056,11 @@ func TestSnapshotRepositorySaverUsesEarliestDeadlineAndRecomputes(t *testing.T)
1056 require.Equal(t, 10*time.Minute, <-timer.resets)1056 require.Equal(t, 10*time.Minute, <-timer.resets)
1057 1057
1058 earlier := newSnapshotTestSession(t, "earlier", false, "/work")1058 earlier := newSnapshotTestSession(t, "earlier", false, "/work")
1059- earlier.snapshotWake = d.snapshotWake1059+ earlier.snapshotWake = d.snapshots.wake
1060 earlier.snapDirty.Store(true)1060 earlier.snapDirty.Store(true)
1061 earlier.snapshotNextEligibleAt = now.Add(5 * time.Minute)1061 earlier.snapshotNextEligibleAt = now.Add(5 * time.Minute)
1062 d.sessions[earlier.id] = earlier1062 d.sessions[earlier.id] = earlier
1063- d.snapshotWake <- struct{}{}1063+ d.snapshots.wake <- struct{}{}
1064 require.Equal(t, 5*time.Minute, <-timer.resets)1064 require.Equal(t, 5*time.Minute, <-timer.resets)
1065 1065
1066 clock.advance(now.Add(5 * time.Minute))1066 clock.advance(now.Add(5 * time.Minute))
@@ -1083,7 +1083,7 @@ func TestSnapshotRepositorySaverUsesEarliestDeadlineAndRecomputes(t *testing.T)
1083 require.Equal(t, 5*time.Minute, <-timer.resets)1083 require.Equal(t, 5*time.Minute, <-timer.resets)
1084 1084
1085 delete(d.sessions, later.id)1085 delete(d.sessions, later.id)
1086- d.snapshotWake <- struct{}{}1086+ d.snapshots.wake <- struct{}{}
1087 require.Equal(t, 24*time.Hour, <-timer.resets)1087 require.Equal(t, 24*time.Hour, <-timer.resets)
1088 1088
1089 cancel()1089 cancel()
M internal/usecase/daemon/snapshot_test.go +4 -4
@@ -30,10 +30,10 @@ func TestWithSnapshotRepositoryRejectsTypedNil(t *testing.T) {
3030 func TestDurableWriterFailureNamesIncludesBufferedCapture(t *testing.T) {
3131 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
3232 capture := &snapshotCapture{name: "work", session: newSnapshotTestSession(t, "work", false, "/work")}
33- d.snapshotWorkerMu.Lock()
34- d.snapshotAdmitted[capture] = struct{}{}
35- d.snapshotJobs <- capture
36- d.snapshotWorkerMu.Unlock()
33+ d.snapshots.mu.Lock()
34+ d.snapshots.admitted[capture] = struct{}{}
35+ d.snapshots.jobs <- capture
36+ d.snapshots.mu.Unlock()
3737
3838 require.Equal(t, []string{"work"}, d.durableWriterFailureNames())
3939 }
@@ -30,10 +30,10 @@ func TestWithSnapshotRepositoryRejectsTypedNil(t *testing.T) {
30 func TestDurableWriterFailureNamesIncludesBufferedCapture(t *testing.T) {30 func TestDurableWriterFailureNamesIncludesBufferedCapture(t *testing.T) {
31 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})31 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
32 capture := &snapshotCapture{name: "work", session: newSnapshotTestSession(t, "work", false, "/work")}32 capture := &snapshotCapture{name: "work", session: newSnapshotTestSession(t, "work", false, "/work")}
33- d.snapshotWorkerMu.Lock()33+ d.snapshots.mu.Lock()
34- d.snapshotAdmitted[capture] = struct{}{}34+ d.snapshots.admitted[capture] = struct{}{}
35- d.snapshotJobs <- capture35+ d.snapshots.jobs <- capture
36- d.snapshotWorkerMu.Unlock()36+ d.snapshots.mu.Unlock()
37 37
38 require.Equal(t, []string{"work"}, d.durableWriterFailureNames())38 require.Equal(t, []string{"work"}, d.durableWriterFailureNames())
39 }39 }
M internal/usecase/daemon/snapshot_worker.go +76 -76
@@ -13,10 +13,10 @@ func (d *Daemon) reportSnapshotFailure(capture *snapshotCapture, phase string, c
1313 return
1414 }
1515 signature := snapshotFailureSignature(phase, cause)
16- d.snapshotNoticeMu.Lock()
17- changed := d.snapshotActiveFailureSignature != signature
18- d.snapshotActiveFailureSignature = signature
19- d.snapshotNoticeMu.Unlock()
16+ d.snapshots.noticeMu.Lock()
17+ changed := d.snapshots.activeFailureSignature != signature
18+ d.snapshots.activeFailureSignature = signature
19+ d.snapshots.noticeMu.Unlock()
2020
2121 n := domain.Notification{
2222 Code: domain.NoticeSnapshotWrite,
@@ -38,38 +38,38 @@ func (d *Daemon) clearSnapshotFailure() {
3838 if d == nil {
3939 return
4040 }
41- d.snapshotNoticeMu.Lock()
42- d.snapshotActiveFailureSignature = ""
43- d.snapshotNoticeMu.Unlock()
41+ d.snapshots.noticeMu.Lock()
42+ d.snapshots.activeFailureSignature = ""
43+ d.snapshots.noticeMu.Unlock()
4444 }
4545
4646 func (d *Daemon) startSnapshotEncodeWorker() {
4747 if d == nil {
4848 return
4949 }
50- d.snapshotWorkerMu.Lock()
51- if d.snapshotWorkerCancel != nil {
52- d.snapshotWorkerMu.Unlock()
50+ d.snapshots.mu.Lock()
51+ if d.snapshots.cancel != nil {
52+ d.snapshots.mu.Unlock()
5353 return
5454 }
5555 // Shutdown owns the worker lifetime so it can flush captures after Serve
5656 // cancels its parent context. The worker is always stopped explicitly.
5757 workerCtx, cancel := context.WithCancel(context.Background())
58- d.snapshotWorkerID++
59- workerID := d.snapshotWorkerID
60- d.snapshotWorkerCtx = workerCtx
61- d.snapshotWorkerCancel = cancel
62- d.snapshotWorkerDone = make(chan struct{})
63- d.snapshotWorkerFlush = make(chan struct{})
64- d.snapshotWorkerFinalWake = make(chan struct{}, 1)
65- d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
66- d.snapshotFinalOrder = nil
67- d.snapshotWorkerClosing = false
68- d.snapshotWorkerInFlight = nil
69- done := d.snapshotWorkerDone
70- flush := d.snapshotWorkerFlush
71- finalWake := d.snapshotWorkerFinalWake
72- d.snapshotWorkerMu.Unlock()
58+ d.snapshots.id++
59+ workerID := d.snapshots.id
60+ d.snapshots.ctx = workerCtx
61+ d.snapshots.cancel = cancel
62+ d.snapshots.done = make(chan struct{})
63+ d.snapshots.flush = make(chan struct{})
64+ d.snapshots.finalWake = make(chan struct{}, 1)
65+ d.snapshots.finalJobs = make(map[*session]*snapshotCapture)
66+ d.snapshots.finalOrder = nil
67+ d.snapshots.closing = false
68+ d.snapshots.inFlight = nil
69+ done := d.snapshots.done
70+ flush := d.snapshots.flush
71+ finalWake := d.snapshots.finalWake
72+ d.snapshots.mu.Unlock()
7373 go d.runSnapshotEncodeWorker(workerCtx, workerID, done, flush, finalWake)
7474 }
7575
@@ -89,7 +89,7 @@ func (d *Daemon) runSnapshotEncodeWorker(workerCtx context.Context, workerID uin
8989 if !d.drainFinalSnapshotCaptures(workerCtx, workerID) {
9090 return
9191 }
92- case capture := <-d.snapshotJobs:
92+ case capture := <-d.snapshots.jobs:
9393 if !d.publishSnapshotCapture(workerCtx, workerID, capture) {
9494 return
9595 }
@@ -100,7 +100,7 @@ func (d *Daemon) runSnapshotEncodeWorker(workerCtx context.Context, workerID uin
100100 func (d *Daemon) flushSnapshotCaptures(workerCtx context.Context, workerID uint64) {
101101 for {
102102 select {
103- case capture := <-d.snapshotJobs:
103+ case capture := <-d.snapshots.jobs:
104104 if !d.publishSnapshotCapture(workerCtx, workerID, capture) {
105105 return
106106 }
@@ -162,14 +162,14 @@ func (d *Daemon) publishSnapshotCapture(workerCtx context.Context, workerID uint
162162 }
163163
164164 func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {
165- d.snapshotWorkerMu.Lock()
166- defer d.snapshotWorkerMu.Unlock()
167- for len(d.snapshotFinalOrder) > 0 {
168- sess := d.snapshotFinalOrder[0]
169- d.snapshotFinalOrder[0] = nil
170- d.snapshotFinalOrder = d.snapshotFinalOrder[1:]
171- capture := d.snapshotFinalJobs[sess]
172- delete(d.snapshotFinalJobs, sess)
165+ d.snapshots.mu.Lock()
166+ defer d.snapshots.mu.Unlock()
167+ for len(d.snapshots.finalOrder) > 0 {
168+ sess := d.snapshots.finalOrder[0]
169+ d.snapshots.finalOrder[0] = nil
170+ d.snapshots.finalOrder = d.snapshots.finalOrder[1:]
171+ capture := d.snapshots.finalJobs[sess]
172+ delete(d.snapshots.finalJobs, sess)
173173 if capture != nil {
174174 return capture
175175 }
@@ -178,20 +178,20 @@ func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {
178178 }
179179
180180 func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {
181- d.snapshotWorkerMu.Lock()
182- defer d.snapshotWorkerMu.Unlock()
183- if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
181+ d.snapshots.mu.Lock()
182+ defer d.snapshots.mu.Unlock()
183+ if d.snapshots.id != workerID || d.snapshots.cancel == nil || d.snapshots.ctx == nil || d.snapshots.ctx.Err() != nil {
184184 return false
185185 }
186- d.snapshotWorkerInFlight = capture
186+ d.snapshots.inFlight = capture
187187 return true
188188 }
189189
190190 func (d *Daemon) clearSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) {
191- d.snapshotWorkerMu.Lock()
192- defer d.snapshotWorkerMu.Unlock()
193- if d.snapshotWorkerID == workerID && d.snapshotWorkerInFlight == capture {
194- d.snapshotWorkerInFlight = nil
191+ d.snapshots.mu.Lock()
192+ defer d.snapshots.mu.Unlock()
193+ if d.snapshots.id == workerID && d.snapshots.inFlight == capture {
194+ d.snapshots.inFlight = nil
195195 }
196196 }
197197
@@ -240,11 +240,11 @@ func (d *Daemon) WaitDurableWriters() {
240240 if d == nil {
241241 return
242242 }
243- d.snapshotWorkerMu.Lock()
244- snapshotDone := d.snapshotWorkerDone
245- maintenanceDone := d.maintenanceWorkerDone
246- restoreDone := d.restoreWorkerDone
247- d.snapshotWorkerMu.Unlock()
243+ d.snapshots.mu.Lock()
244+ snapshotDone := d.snapshots.done
245+ maintenanceDone := d.snapshots.maintenanceWorkerDone
246+ restoreDone := d.snapshots.restoreWorkerDone
247+ d.snapshots.mu.Unlock()
248248 if snapshotDone != nil {
249249 <-snapshotDone
250250 d.finishStoppedSnapshotWorker(false)
@@ -260,37 +260,37 @@ func (d *Daemon) WaitDurableWriters() {
260260 }
261261
262262 func (d *Daemon) requestDurableWriterStop() (context.CancelFunc, <-chan struct{}) {
263- d.snapshotWorkerMu.Lock()
264- defer d.snapshotWorkerMu.Unlock()
263+ d.snapshots.mu.Lock()
264+ defer d.snapshots.mu.Unlock()
265265 d.cancelDurableMaintenanceLocked()
266- cancel := d.snapshotWorkerCancel
266+ cancel := d.snapshots.cancel
267267 if cancel == nil {
268268 return nil, nil
269269 }
270- if !d.snapshotWorkerClosing {
271- d.snapshotWorkerClosing = true
272- close(d.snapshotWorkerFlush)
270+ if !d.snapshots.closing {
271+ d.snapshots.closing = true
272+ close(d.snapshots.flush)
273273 }
274- return cancel, d.snapshotWorkerDone
274+ return cancel, d.snapshots.done
275275 }
276276
277277 // durableWriterFailureNames includes admitted buffered captures as well as the
278-// active and final queues. snapshotAdmitted tracks normal captures from queue
278+// active and final queues. snapshots.admitted tracks normal captures from queue
279279 // admission through completion, so worker dequeue cannot make one disappear.
280280 func (d *Daemon) durableWriterFailureNames() []string {
281- d.snapshotWorkerMu.Lock()
282- defer d.snapshotWorkerMu.Unlock()
281+ d.snapshots.mu.Lock()
282+ defer d.snapshots.mu.Unlock()
283283 seen := make(map[string]struct{})
284284 add := func(capture *snapshotCapture) {
285285 if capture != nil && capture.name != "" {
286286 seen[capture.name] = struct{}{}
287287 }
288288 }
289- add(d.snapshotWorkerInFlight)
290- for _, capture := range d.snapshotFinalJobs {
289+ add(d.snapshots.inFlight)
290+ for _, capture := range d.snapshots.finalJobs {
291291 add(capture)
292292 }
293- for capture := range d.snapshotAdmitted {
293+ for capture := range d.snapshots.admitted {
294294 add(capture)
295295 }
296296 names := make([]string, 0, len(seen))
@@ -302,27 +302,27 @@ func (d *Daemon) durableWriterFailureNames() []string {
302302 }
303303
304304 func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) {
305- d.snapshotWorkerMu.Lock()
306- inFlight := d.snapshotWorkerInFlight
307- d.snapshotWorkerCtx = nil
308- d.snapshotWorkerCancel = nil
309- d.snapshotWorkerDone = nil
310- d.snapshotWorkerFlush = nil
311- d.snapshotWorkerFinalWake = nil
312- d.snapshotWorkerClosing = false
313- d.snapshotWorkerInFlight = nil
314- queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs))
315- for _, capture := range d.snapshotFinalJobs {
305+ d.snapshots.mu.Lock()
306+ inFlight := d.snapshots.inFlight
307+ d.snapshots.ctx = nil
308+ d.snapshots.cancel = nil
309+ d.snapshots.done = nil
310+ d.snapshots.flush = nil
311+ d.snapshots.finalWake = nil
312+ d.snapshots.closing = false
313+ d.snapshots.inFlight = nil
314+ queued := make([]*snapshotCapture, 0, len(d.snapshots.jobs)+len(d.snapshots.finalJobs))
315+ for _, capture := range d.snapshots.finalJobs {
316316 queued = append(queued, capture)
317317 }
318- d.snapshotFinalJobs = nil
319- d.snapshotFinalOrder = nil
318+ d.snapshots.finalJobs = nil
319+ d.snapshots.finalOrder = nil
320320 for {
321321 select {
322- case capture := <-d.snapshotJobs:
322+ case capture := <-d.snapshots.jobs:
323323 queued = append(queued, capture)
324324 default:
325- d.snapshotWorkerMu.Unlock()
325+ d.snapshots.mu.Unlock()
326326 if abandoned {
327327 d.finishSnapshotCapture(inFlight, false)
328328 }
@@ -13,10 +13,10 @@ func (d *Daemon) reportSnapshotFailure(capture *snapshotCapture, phase string, c
13 return13 return
14 }14 }
15 signature := snapshotFailureSignature(phase, cause)15 signature := snapshotFailureSignature(phase, cause)
16- d.snapshotNoticeMu.Lock()16+ d.snapshots.noticeMu.Lock()
17- changed := d.snapshotActiveFailureSignature != signature17+ changed := d.snapshots.activeFailureSignature != signature
18- d.snapshotActiveFailureSignature = signature18+ d.snapshots.activeFailureSignature = signature
19- d.snapshotNoticeMu.Unlock()19+ d.snapshots.noticeMu.Unlock()
20 20
21 n := domain.Notification{21 n := domain.Notification{
22 Code: domain.NoticeSnapshotWrite,22 Code: domain.NoticeSnapshotWrite,
@@ -38,38 +38,38 @@ func (d *Daemon) clearSnapshotFailure() {
38 if d == nil {38 if d == nil {
39 return39 return
40 }40 }
41- d.snapshotNoticeMu.Lock()41+ d.snapshots.noticeMu.Lock()
42- d.snapshotActiveFailureSignature = ""42+ d.snapshots.activeFailureSignature = ""
43- d.snapshotNoticeMu.Unlock()43+ d.snapshots.noticeMu.Unlock()
44 }44 }
45 45
46 func (d *Daemon) startSnapshotEncodeWorker() {46 func (d *Daemon) startSnapshotEncodeWorker() {
47 if d == nil {47 if d == nil {
48 return48 return
49 }49 }
50- d.snapshotWorkerMu.Lock()50+ d.snapshots.mu.Lock()
51- if d.snapshotWorkerCancel != nil {51+ if d.snapshots.cancel != nil {
52- d.snapshotWorkerMu.Unlock()52+ d.snapshots.mu.Unlock()
53 return53 return
54 }54 }
55 // Shutdown owns the worker lifetime so it can flush captures after Serve55 // Shutdown owns the worker lifetime so it can flush captures after Serve
56 // cancels its parent context. The worker is always stopped explicitly.56 // cancels its parent context. The worker is always stopped explicitly.
57 workerCtx, cancel := context.WithCancel(context.Background())57 workerCtx, cancel := context.WithCancel(context.Background())
58- d.snapshotWorkerID++58+ d.snapshots.id++
59- workerID := d.snapshotWorkerID59+ workerID := d.snapshots.id
60- d.snapshotWorkerCtx = workerCtx60+ d.snapshots.ctx = workerCtx
61- d.snapshotWorkerCancel = cancel61+ d.snapshots.cancel = cancel
62- d.snapshotWorkerDone = make(chan struct{})62+ d.snapshots.done = make(chan struct{})
63- d.snapshotWorkerFlush = make(chan struct{})63+ d.snapshots.flush = make(chan struct{})
64- d.snapshotWorkerFinalWake = make(chan struct{}, 1)64+ d.snapshots.finalWake = make(chan struct{}, 1)
65- d.snapshotFinalJobs = make(map[*session]*snapshotCapture)65+ d.snapshots.finalJobs = make(map[*session]*snapshotCapture)
66- d.snapshotFinalOrder = nil66+ d.snapshots.finalOrder = nil
67- d.snapshotWorkerClosing = false67+ d.snapshots.closing = false
68- d.snapshotWorkerInFlight = nil68+ d.snapshots.inFlight = nil
69- done := d.snapshotWorkerDone69+ done := d.snapshots.done
70- flush := d.snapshotWorkerFlush70+ flush := d.snapshots.flush
71- finalWake := d.snapshotWorkerFinalWake71+ finalWake := d.snapshots.finalWake
72- d.snapshotWorkerMu.Unlock()72+ d.snapshots.mu.Unlock()
73 go d.runSnapshotEncodeWorker(workerCtx, workerID, done, flush, finalWake)73 go d.runSnapshotEncodeWorker(workerCtx, workerID, done, flush, finalWake)
74 }74 }
75 75
@@ -89,7 +89,7 @@ func (d *Daemon) runSnapshotEncodeWorker(workerCtx context.Context, workerID uin
89 if !d.drainFinalSnapshotCaptures(workerCtx, workerID) {89 if !d.drainFinalSnapshotCaptures(workerCtx, workerID) {
90 return90 return
91 }91 }
92- case capture := <-d.snapshotJobs:92+ case capture := <-d.snapshots.jobs:
93 if !d.publishSnapshotCapture(workerCtx, workerID, capture) {93 if !d.publishSnapshotCapture(workerCtx, workerID, capture) {
94 return94 return
95 }95 }
@@ -100,7 +100,7 @@ func (d *Daemon) runSnapshotEncodeWorker(workerCtx context.Context, workerID uin
100 func (d *Daemon) flushSnapshotCaptures(workerCtx context.Context, workerID uint64) {100 func (d *Daemon) flushSnapshotCaptures(workerCtx context.Context, workerID uint64) {
101 for {101 for {
102 select {102 select {
103- case capture := <-d.snapshotJobs:103+ case capture := <-d.snapshots.jobs:
104 if !d.publishSnapshotCapture(workerCtx, workerID, capture) {104 if !d.publishSnapshotCapture(workerCtx, workerID, capture) {
105 return105 return
106 }106 }
@@ -162,14 +162,14 @@ func (d *Daemon) publishSnapshotCapture(workerCtx context.Context, workerID uint
162 }162 }
163 163
164 func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {164 func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {
165- d.snapshotWorkerMu.Lock()165+ d.snapshots.mu.Lock()
166- defer d.snapshotWorkerMu.Unlock()166+ defer d.snapshots.mu.Unlock()
167- for len(d.snapshotFinalOrder) > 0 {167+ for len(d.snapshots.finalOrder) > 0 {
168- sess := d.snapshotFinalOrder[0]168+ sess := d.snapshots.finalOrder[0]
169- d.snapshotFinalOrder[0] = nil169+ d.snapshots.finalOrder[0] = nil
170- d.snapshotFinalOrder = d.snapshotFinalOrder[1:]170+ d.snapshots.finalOrder = d.snapshots.finalOrder[1:]
171- capture := d.snapshotFinalJobs[sess]171+ capture := d.snapshots.finalJobs[sess]
172- delete(d.snapshotFinalJobs, sess)172+ delete(d.snapshots.finalJobs, sess)
173 if capture != nil {173 if capture != nil {
174 return capture174 return capture
175 }175 }
@@ -178,20 +178,20 @@ func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {
178 }178 }
179 179
180 func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {180 func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {
181- d.snapshotWorkerMu.Lock()181+ d.snapshots.mu.Lock()
182- defer d.snapshotWorkerMu.Unlock()182+ defer d.snapshots.mu.Unlock()
183- if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {183+ if d.snapshots.id != workerID || d.snapshots.cancel == nil || d.snapshots.ctx == nil || d.snapshots.ctx.Err() != nil {
184 return false184 return false
185 }185 }
186- d.snapshotWorkerInFlight = capture186+ d.snapshots.inFlight = capture
187 return true187 return true
188 }188 }
189 189
190 func (d *Daemon) clearSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) {190 func (d *Daemon) clearSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) {
191- d.snapshotWorkerMu.Lock()191+ d.snapshots.mu.Lock()
192- defer d.snapshotWorkerMu.Unlock()192+ defer d.snapshots.mu.Unlock()
193- if d.snapshotWorkerID == workerID && d.snapshotWorkerInFlight == capture {193+ if d.snapshots.id == workerID && d.snapshots.inFlight == capture {
194- d.snapshotWorkerInFlight = nil194+ d.snapshots.inFlight = nil
195 }195 }
196 }196 }
197 197
@@ -240,11 +240,11 @@ func (d *Daemon) WaitDurableWriters() {
240 if d == nil {240 if d == nil {
241 return241 return
242 }242 }
243- d.snapshotWorkerMu.Lock()243+ d.snapshots.mu.Lock()
244- snapshotDone := d.snapshotWorkerDone244+ snapshotDone := d.snapshots.done
245- maintenanceDone := d.maintenanceWorkerDone245+ maintenanceDone := d.snapshots.maintenanceWorkerDone
246- restoreDone := d.restoreWorkerDone246+ restoreDone := d.snapshots.restoreWorkerDone
247- d.snapshotWorkerMu.Unlock()247+ d.snapshots.mu.Unlock()
248 if snapshotDone != nil {248 if snapshotDone != nil {
249 <-snapshotDone249 <-snapshotDone
250 d.finishStoppedSnapshotWorker(false)250 d.finishStoppedSnapshotWorker(false)
@@ -260,37 +260,37 @@ func (d *Daemon) WaitDurableWriters() {
260 }260 }
261 261
262 func (d *Daemon) requestDurableWriterStop() (context.CancelFunc, <-chan struct{}) {262 func (d *Daemon) requestDurableWriterStop() (context.CancelFunc, <-chan struct{}) {
263- d.snapshotWorkerMu.Lock()263+ d.snapshots.mu.Lock()
264- defer d.snapshotWorkerMu.Unlock()264+ defer d.snapshots.mu.Unlock()
265 d.cancelDurableMaintenanceLocked()265 d.cancelDurableMaintenanceLocked()
266- cancel := d.snapshotWorkerCancel266+ cancel := d.snapshots.cancel
267 if cancel == nil {267 if cancel == nil {
268 return nil, nil268 return nil, nil
269 }269 }
270- if !d.snapshotWorkerClosing {270+ if !d.snapshots.closing {
271- d.snapshotWorkerClosing = true271+ d.snapshots.closing = true
272- close(d.snapshotWorkerFlush)272+ close(d.snapshots.flush)
273 }273 }
274- return cancel, d.snapshotWorkerDone274+ return cancel, d.snapshots.done
275 }275 }
276 276
277 // durableWriterFailureNames includes admitted buffered captures as well as the277 // durableWriterFailureNames includes admitted buffered captures as well as the
278-// active and final queues. snapshotAdmitted tracks normal captures from queue278+// active and final queues. snapshots.admitted tracks normal captures from queue
279 // admission through completion, so worker dequeue cannot make one disappear.279 // admission through completion, so worker dequeue cannot make one disappear.
280 func (d *Daemon) durableWriterFailureNames() []string {280 func (d *Daemon) durableWriterFailureNames() []string {
281- d.snapshotWorkerMu.Lock()281+ d.snapshots.mu.Lock()
282- defer d.snapshotWorkerMu.Unlock()282+ defer d.snapshots.mu.Unlock()
283 seen := make(map[string]struct{})283 seen := make(map[string]struct{})
284 add := func(capture *snapshotCapture) {284 add := func(capture *snapshotCapture) {
285 if capture != nil && capture.name != "" {285 if capture != nil && capture.name != "" {
286 seen[capture.name] = struct{}{}286 seen[capture.name] = struct{}{}
287 }287 }
288 }288 }
289- add(d.snapshotWorkerInFlight)289+ add(d.snapshots.inFlight)
290- for _, capture := range d.snapshotFinalJobs {290+ for _, capture := range d.snapshots.finalJobs {
291 add(capture)291 add(capture)
292 }292 }
293- for capture := range d.snapshotAdmitted {293+ for capture := range d.snapshots.admitted {
294 add(capture)294 add(capture)
295 }295 }
296 names := make([]string, 0, len(seen))296 names := make([]string, 0, len(seen))
@@ -302,27 +302,27 @@ func (d *Daemon) durableWriterFailureNames() []string {
302 }302 }
303 303
304 func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) {304 func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) {
305- d.snapshotWorkerMu.Lock()305+ d.snapshots.mu.Lock()
306- inFlight := d.snapshotWorkerInFlight306+ inFlight := d.snapshots.inFlight
307- d.snapshotWorkerCtx = nil307+ d.snapshots.ctx = nil
308- d.snapshotWorkerCancel = nil308+ d.snapshots.cancel = nil
309- d.snapshotWorkerDone = nil309+ d.snapshots.done = nil
310- d.snapshotWorkerFlush = nil310+ d.snapshots.flush = nil
311- d.snapshotWorkerFinalWake = nil311+ d.snapshots.finalWake = nil
312- d.snapshotWorkerClosing = false312+ d.snapshots.closing = false
313- d.snapshotWorkerInFlight = nil313+ d.snapshots.inFlight = nil
314- queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs))314+ queued := make([]*snapshotCapture, 0, len(d.snapshots.jobs)+len(d.snapshots.finalJobs))
315- for _, capture := range d.snapshotFinalJobs {315+ for _, capture := range d.snapshots.finalJobs {
316 queued = append(queued, capture)316 queued = append(queued, capture)
317 }317 }
318- d.snapshotFinalJobs = nil318+ d.snapshots.finalJobs = nil
319- d.snapshotFinalOrder = nil319+ d.snapshots.finalOrder = nil
320 for {320 for {
321 select {321 select {
322- case capture := <-d.snapshotJobs:322+ case capture := <-d.snapshots.jobs:
323 queued = append(queued, capture)323 queued = append(queued, capture)
324 default:324 default:
325- d.snapshotWorkerMu.Unlock()325+ d.snapshots.mu.Unlock()
326 if abandoned {326 if abandoned {
327 d.finishSnapshotCapture(inFlight, false)327 d.finishSnapshotCapture(inFlight, false)
328 }328 }
A internal/usecase/daemon/snapshot_worker_state.go +61 -0
@@ -0,0 +1,61 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+)
7+
8+// snapshotWorker owns the state of the repository snapshot worker and of the
9+// other durable writers joined with it (maintenance and restoration), so
10+// WaitDurableWriters can read all three completion signals under one lock.
11+//
12+// Lock position: mu is a leaf with respect to Daemon.mu. No code path acquires
13+// Daemon.mu, a session lock, or a pane lock while holding it, and capture,
14+// queue, and worker code never takes Daemon.mu. noticeMu is likewise a leaf and
15+// is never held together with mu.
16+//
17+// The zero value is usable for tests that build &Daemon{}: nil maps are
18+// allocated lazily by the enqueue paths, and a nil jobs channel never becomes
19+// ready in a select.
20+type snapshotWorker struct {
21+ // jobs is the bounded regular capture queue. It is never closed: producers
22+ // can race shutdown without risking a send-on-closed panic.
23+ jobs chan *snapshotCapture
24+ // wake wakes the repository scheduler when a session becomes dirty
25+ // or an attempt completes. It is never closed and producers only send
26+ // non-blockingly.
27+ wake chan struct{}
28+
29+ // mu guards every field below it.
30+ mu sync.Mutex
31+ // admitted contains every capture accepted by either worker queue,
32+ // including captures buffered in jobs.
33+ admitted map[*snapshotCapture]struct{}
34+ id uint64
35+ ctx context.Context
36+ cancel context.CancelFunc
37+ done chan struct{}
38+ flush chan struct{}
39+ finalWake chan struct{}
40+ // finalJobs coalesces terminal captures by session when the bounded
41+ // regular queue is full. It retains at most snapshotFinalQueueCapacity named
42+ // sessions, each with only its newest terminal state while the worker blocks.
43+ finalJobs map[*session]*snapshotCapture
44+ finalOrder []*session
45+ closing bool
46+ inFlight *snapshotCapture
47+
48+ // maintenanceWorkerCancel/Done and restoreWorkerDone are the other durable
49+ // writers' ownership signals. They share mu with the snapshot worker and are
50+ // joined together in WaitDurableWriters; never split them. Startup
51+ // restoration reconciles durable checkpoints, so it is a durable writer.
52+ maintenanceWorkerCancel context.CancelFunc
53+ maintenanceWorkerDone chan struct{}
54+ restoreWorkerDone chan struct{}
55+
56+ // noticeMu guards the active global persistence failure signature. It is
57+ // separate from mu so notice routing cannot block a producer or a
58+ // repository worker.
59+ noticeMu sync.Mutex
60+ activeFailureSignature string
61+}
@@ -0,0 +1,61 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+)
7+
8+// snapshotWorker owns the state of the repository snapshot worker and of the
9+// other durable writers joined with it (maintenance and restoration), so
10+// WaitDurableWriters can read all three completion signals under one lock.
11+//
12+// Lock position: mu is a leaf with respect to Daemon.mu. No code path acquires
13+// Daemon.mu, a session lock, or a pane lock while holding it, and capture,
14+// queue, and worker code never takes Daemon.mu. noticeMu is likewise a leaf and
15+// is never held together with mu.
16+//
17+// The zero value is usable for tests that build &Daemon{}: nil maps are
18+// allocated lazily by the enqueue paths, and a nil jobs channel never becomes
19+// ready in a select.
20+type snapshotWorker struct {
21+ // jobs is the bounded regular capture queue. It is never closed: producers
22+ // can race shutdown without risking a send-on-closed panic.
23+ jobs chan *snapshotCapture
24+ // wake wakes the repository scheduler when a session becomes dirty
25+ // or an attempt completes. It is never closed and producers only send
26+ // non-blockingly.
27+ wake chan struct{}
28+
29+ // mu guards every field below it.
30+ mu sync.Mutex
31+ // admitted contains every capture accepted by either worker queue,
32+ // including captures buffered in jobs.
33+ admitted map[*snapshotCapture]struct{}
34+ id uint64
35+ ctx context.Context
36+ cancel context.CancelFunc
37+ done chan struct{}
38+ flush chan struct{}
39+ finalWake chan struct{}
40+ // finalJobs coalesces terminal captures by session when the bounded
41+ // regular queue is full. It retains at most snapshotFinalQueueCapacity named
42+ // sessions, each with only its newest terminal state while the worker blocks.
43+ finalJobs map[*session]*snapshotCapture
44+ finalOrder []*session
45+ closing bool
46+ inFlight *snapshotCapture
47+
48+ // maintenanceWorkerCancel/Done and restoreWorkerDone are the other durable
49+ // writers' ownership signals. They share mu with the snapshot worker and are
50+ // joined together in WaitDurableWriters; never split them. Startup
51+ // restoration reconciles durable checkpoints, so it is a durable writer.
52+ maintenanceWorkerCancel context.CancelFunc
53+ maintenanceWorkerDone chan struct{}
54+ restoreWorkerDone chan struct{}
55+
56+ // noticeMu guards the active global persistence failure signature. It is
57+ // separate from mu so notice routing cannot block a producer or a
58+ // repository worker.
59+ noticeMu sync.Mutex
60+ activeFailureSignature string
61+}
M internal/usecase/daemon/theme_resolve.go +2 -0
@@ -15,6 +15,8 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
1515 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
1616 }
1717
18+// applyHostThemeLocked requires sess.themeMu (and ac.sendMu when ac is non-nil),
19+// not d.mu.
1820 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
1921 // Resolve while applying, never while composing. The theme mutex only
2022 // publishes the completed value and is not held across session/tab/pane
@@ -15,6 +15,8 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
15 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)15 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
16 }16 }
17 17
18+// applyHostThemeLocked requires sess.themeMu (and ac.sendMu when ac is non-nil),
19+// not d.mu.
18 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {20 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
19 // Resolve while applying, never while composing. The theme mutex only21 // Resolve while applying, never while composing. The theme mutex only
20 // publishes the completed value and is not held across session/tab/pane22 // publishes the completed value and is not held across session/tab/pane

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close