detached 4d933cb

refactor(daemon): document lock ownership and Locked suffix

commit
4d933cbd7df333db09041d99fc5865422f35590b
author
brice · · 2026-10-08 06:13 UTC
committer
brice · · 2026-10-08 08:12 UTC
parent
0bb8a8e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +44 -7
M internal/usecase/daemon/bar_refresh.go +1 -0
@@ -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
@@ -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/client.go +19 -0
@@ -32,6 +32,25 @@
3232 //
3333 // paletteHistory.mu guards the daemon-wide recent-command list. It is a leaf:
3434 // it is only held to copy or rewrite that slice, never while calling out.
35+//
36+// purgeAllMu serializes whole KillAll purges (purgeAllSessions). It is the
37+// outermost daemon-level lock on that path: the owner takes d.mu (briefly, to
38+// open and close the purge admission gate) and later per-session teardown locks
39+// while holding it. No path acquires purgeAllMu while holding d.mu,
40+// moveLifecycleMu, a session lock, or a pane lock. It is not an admission gate;
41+// admission is the d.mu-guarded purgeAdmission* state.
42+//
43+// attnMu guards only the attention animation frame counter (animFrame). It is
44+// a leaf: attentionFrame, advanceAttentionFrame, and setAttentionFrame take it
45+// for the read or write and call nothing while holding it.
46+//
47+// Naming rule for the Locked suffix: on a Daemon method (or a free function
48+// taking a Daemon-owned structure) a ...Locked suffix means the caller holds
49+// d.mu. On a method of any other type, it means the caller holds that
50+// receiver's own mutex (session.mu, tab.mu, pane.mu, and so on). Whenever a
51+// ...Locked function instead requires a different lock (for example
52+// overlayRuntime.copyMu, snapshotWorker.mu, moveLifecycleMu, or
53+// barScriptState.mu), its doc comment names that lock explicitly.
3554 package daemon
3655
3756 import (
@@ -32,6 +32,25 @@
32 //32 //
33 // paletteHistory.mu guards the daemon-wide recent-command list. It is a leaf:33 // paletteHistory.mu guards the daemon-wide recent-command list. It is a leaf:
34 // it is only held to copy or rewrite that slice, never while calling out.34 // it is only held to copy or rewrite that slice, never while calling out.
35+//
36+// purgeAllMu serializes whole KillAll purges (purgeAllSessions). It is the
37+// outermost daemon-level lock on that path: the owner takes d.mu (briefly, to
38+// open and close the purge admission gate) and later per-session teardown locks
39+// while holding it. No path acquires purgeAllMu while holding d.mu,
40+// moveLifecycleMu, a session lock, or a pane lock. It is not an admission gate;
41+// admission is the d.mu-guarded purgeAdmission* state.
42+//
43+// attnMu guards only the attention animation frame counter (animFrame). It is
44+// a leaf: attentionFrame, advanceAttentionFrame, and setAttentionFrame take it
45+// for the read or write and call nothing while holding it.
46+//
47+// Naming rule for the Locked suffix: on a Daemon method (or a free function
48+// taking a Daemon-owned structure) a ...Locked suffix means the caller holds
49+// d.mu. On a method of any other type, it means the caller holds that
50+// receiver's own mutex (session.mu, tab.mu, pane.mu, and so on). Whenever a
51+// ...Locked function instead requires a different lock (for example
52+// overlayRuntime.copyMu, snapshotWorker.mu, moveLifecycleMu, or
53+// barScriptState.mu), its doc comment names that lock explicitly.
35 package daemon54 package daemon
36 55
37 import (56 import (
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/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/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/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/palette.go +1 -0
@@ -786,6 +786,7 @@ func paletteArgs(query string, cmd command.Command) []string {
786786 return action.Args
787787 }
788788
789+// clearPaletteLocked requires ac.overlays.paletteMu.
789790 func (ac *attachedClient) clearPaletteLocked() {
790791 ac.overlays.paletteGeneration++
791792 ac.overlays.palette = nil
@@ -786,6 +786,7 @@ func paletteArgs(query string, cmd command.Command) []string {
786 return action.Args786 return action.Args
787 }787 }
788 788
789+// clearPaletteLocked requires ac.overlays.paletteMu.
789 func (ac *attachedClient) clearPaletteLocked() {790 func (ac *attachedClient) clearPaletteLocked() {
790 ac.overlays.paletteGeneration++791 ac.overlays.paletteGeneration++
791 ac.overlays.palette = nil792 ac.overlays.palette = nil
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/session.go +1 -2
@@ -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
@@ -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
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