detached 70ae2be

perf(copy): release pane lock after snapshot capture

commit
70ae2beab9124315a317ecd7aac51288b78d98ed
author
brice · · 2026-07-11 06:15 UTC
committer
brice · · 2026-07-11 06:16 UTC
parent
a628e59
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +34 -49
M internal/usecase/daemon/copymode.go +10 -48
@@ -50,27 +50,15 @@ func copyTargetPane(rt *overlayRuntime) *pane {
5050
5151 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
5252 rt := ac.overlays
53- p := copyTargetPane(rt)
54- if p == nil {
55- return
56- }
57- p.mu.Lock()
5853 rt.copyMu.Lock()
59- if rt.copyMode == nil || rt.copyPane != p {
54+ if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
6055 rt.copyMu.Unlock()
61- p.mu.Unlock()
62- return
63- }
64- if rt.copySnapshot == nil {
65- rt.copyMu.Unlock()
66- p.mu.Unlock()
6756 return
6857 }
6958 document := rt.copySnapshot
7059 if delta > 0 && rt.copyMode.AtBottom(*document) {
7160 rt.clearCopyModeLocked()
7261 rt.copyMu.Unlock()
73- p.mu.Unlock()
7462 d.paint(sess, ac, true)
7563 return
7664 }
@@ -80,7 +68,6 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
8068 rt.clearCopyModeLocked()
8169 }
8270 rt.copyMu.Unlock()
83- p.mu.Unlock()
8471
8572 d.paint(sess, ac, true)
8673 }
@@ -98,8 +85,11 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
9885 if p == nil {
9986 return
10087 }
88+ // Capture the live pane under pane.mu; all subsequent copy interaction uses
89+ // the immutable document while holding only copyMu.
10190 p.mu.Lock()
10291 document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
92+ p.mu.Unlock()
10393 rt.copyMu.Lock()
10494 rt.copyMode = scopy.NewMode(document)
10595 rt.copySnapshot = &document
@@ -110,7 +100,6 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
110100 rt.copyDragging = false
111101 rt.normalMousePressValid = false
112102 rt.copyMu.Unlock()
113- p.mu.Unlock()
114103 if floatingSource {
115104 tb.mu.Lock()
116105 stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p
@@ -127,33 +116,20 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
127116 if ev.Button != mouse.Left {
128117 return
129118 }
130- p := copyTargetPane(rt)
131- if p == nil {
119+ rt.copyMu.Lock()
120+ if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
121+ rt.copyMu.Unlock()
132122 return
133123 }
134- p.mu.Lock()
135- if ev.Row >= p.screen.Frame.Height {
124+ document := rt.copySnapshot
125+ if ev.Row >= document.Height {
136126 if ev.Type == mouse.Press {
137- rt.copyMu.Lock()
138127 rt.copyPressRowValid = false
139128 rt.copyDragging = false
140- rt.copyMu.Unlock()
141129 }
142- p.mu.Unlock()
143- return
144- }
145- rt.copyMu.Lock()
146- if rt.copyMode == nil || rt.copyPane != p {
147- rt.copyMu.Unlock()
148- p.mu.Unlock()
149- return
150- }
151- if rt.copySnapshot == nil {
152130 rt.copyMu.Unlock()
153- p.mu.Unlock()
154131 return
155132 }
156- document := rt.copySnapshot
157133 absRow := rt.copyMode.ViewportTop + ev.Row
158134 changed := false
159135 switch ev.Type {
@@ -177,7 +153,6 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
177153 // Button release intentionally has no visual effect.
178154 }
179155 rt.copyMu.Unlock()
180- p.mu.Unlock()
181156
182157 if changed {
183158 d.paint(sess, ac, true)
@@ -190,17 +165,11 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
190165 if sess == nil {
191166 return
192167 }
193- p := copyTargetPane(rt)
194- if p == nil {
195- return
196- }
197- p.mu.Lock()
198168 rt.copyMu.Lock()
199- if rt.copyMode == nil || rt.copyPane != p {
169+ if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
200170 rt.copyPending = nil
201171 d.stopCopyPendingTimerLocked(ac)
202172 rt.copyMu.Unlock()
203- p.mu.Unlock()
204173 return
205174 }
206175 if len(rt.copyPending) > 0 {
@@ -211,16 +180,10 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
211180 data = combined
212181 rt.copyPending = nil
213182 }
214- if rt.copySnapshot == nil {
215- rt.copyMu.Unlock()
216- p.mu.Unlock()
217- return
218- }
219183 document := rt.copySnapshot
220184 if rt.copySearch != nil {
221185 changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
222186 rt.copyMu.Unlock()
223- p.mu.Unlock()
224187 if changed || closeSearch || accepted {
225188 d.paint(sess, ac, true)
226189 }
@@ -292,7 +255,6 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
292255 rt.clearCopyModeLocked()
293256 }
294257 rt.copyMu.Unlock()
295- p.mu.Unlock()
296258
297259 if copyOut && text != "" {
298260 chunks := scopy.OSC52(text)
@@ -50,27 +50,15 @@ func copyTargetPane(rt *overlayRuntime) *pane {
50 50
51 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {51 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
52 rt := ac.overlays52 rt := ac.overlays
53- p := copyTargetPane(rt)
54- if p == nil {
55- return
56- }
57- p.mu.Lock()
58 rt.copyMu.Lock()53 rt.copyMu.Lock()
59- if rt.copyMode == nil || rt.copyPane != p {54+ if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
60 rt.copyMu.Unlock()55 rt.copyMu.Unlock()
61- p.mu.Unlock()
62- return
63- }
64- if rt.copySnapshot == nil {
65- rt.copyMu.Unlock()
66- p.mu.Unlock()
67 return56 return
68 }57 }
69 document := rt.copySnapshot58 document := rt.copySnapshot
70 if delta > 0 && rt.copyMode.AtBottom(*document) {59 if delta > 0 && rt.copyMode.AtBottom(*document) {
71 rt.clearCopyModeLocked()60 rt.clearCopyModeLocked()
72 rt.copyMu.Unlock()61 rt.copyMu.Unlock()
73- p.mu.Unlock()
74 d.paint(sess, ac, true)62 d.paint(sess, ac, true)
75 return63 return
76 }64 }
@@ -80,7 +68,6 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
80 rt.clearCopyModeLocked()68 rt.clearCopyModeLocked()
81 }69 }
82 rt.copyMu.Unlock()70 rt.copyMu.Unlock()
83- p.mu.Unlock()
84 71
85 d.paint(sess, ac, true)72 d.paint(sess, ac, true)
86 }73 }
@@ -98,8 +85,11 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
98 if p == nil {85 if p == nil {
99 return86 return
100 }87 }
88+ // Capture the live pane under pane.mu; all subsequent copy interaction uses
89+ // the immutable document while holding only copyMu.
101 p.mu.Lock()90 p.mu.Lock()
102 document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)91 document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
92+ p.mu.Unlock()
103 rt.copyMu.Lock()93 rt.copyMu.Lock()
104 rt.copyMode = scopy.NewMode(document)94 rt.copyMode = scopy.NewMode(document)
105 rt.copySnapshot = &document95 rt.copySnapshot = &document
@@ -110,7 +100,6 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
110 rt.copyDragging = false100 rt.copyDragging = false
111 rt.normalMousePressValid = false101 rt.normalMousePressValid = false
112 rt.copyMu.Unlock()102 rt.copyMu.Unlock()
113- p.mu.Unlock()
114 if floatingSource {103 if floatingSource {
115 tb.mu.Lock()104 tb.mu.Lock()
116 stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p105 stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p
@@ -127,33 +116,20 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
127 if ev.Button != mouse.Left {116 if ev.Button != mouse.Left {
128 return117 return
129 }118 }
130- p := copyTargetPane(rt)119+ rt.copyMu.Lock()
131- if p == nil {120+ if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
121+ rt.copyMu.Unlock()
132 return122 return
133 }123 }
134- p.mu.Lock()124+ document := rt.copySnapshot
135- if ev.Row >= p.screen.Frame.Height {125+ if ev.Row >= document.Height {
136 if ev.Type == mouse.Press {126 if ev.Type == mouse.Press {
137- rt.copyMu.Lock()
138 rt.copyPressRowValid = false127 rt.copyPressRowValid = false
139 rt.copyDragging = false128 rt.copyDragging = false
140- rt.copyMu.Unlock()
141 }129 }
142- p.mu.Unlock()
143- return
144- }
145- rt.copyMu.Lock()
146- if rt.copyMode == nil || rt.copyPane != p {
147- rt.copyMu.Unlock()
148- p.mu.Unlock()
149- return
150- }
151- if rt.copySnapshot == nil {
152 rt.copyMu.Unlock()130 rt.copyMu.Unlock()
153- p.mu.Unlock()
154 return131 return
155 }132 }
156- document := rt.copySnapshot
157 absRow := rt.copyMode.ViewportTop + ev.Row133 absRow := rt.copyMode.ViewportTop + ev.Row
158 changed := false134 changed := false
159 switch ev.Type {135 switch ev.Type {
@@ -177,7 +153,6 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
177 // Button release intentionally has no visual effect.153 // Button release intentionally has no visual effect.
178 }154 }
179 rt.copyMu.Unlock()155 rt.copyMu.Unlock()
180- p.mu.Unlock()
181 156
182 if changed {157 if changed {
183 d.paint(sess, ac, true)158 d.paint(sess, ac, true)
@@ -190,17 +165,11 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
190 if sess == nil {165 if sess == nil {
191 return166 return
192 }167 }
193- p := copyTargetPane(rt)
194- if p == nil {
195- return
196- }
197- p.mu.Lock()
198 rt.copyMu.Lock()168 rt.copyMu.Lock()
199- if rt.copyMode == nil || rt.copyPane != p {169+ if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
200 rt.copyPending = nil170 rt.copyPending = nil
201 d.stopCopyPendingTimerLocked(ac)171 d.stopCopyPendingTimerLocked(ac)
202 rt.copyMu.Unlock()172 rt.copyMu.Unlock()
203- p.mu.Unlock()
204 return173 return
205 }174 }
206 if len(rt.copyPending) > 0 {175 if len(rt.copyPending) > 0 {
@@ -211,16 +180,10 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
211 data = combined180 data = combined
212 rt.copyPending = nil181 rt.copyPending = nil
213 }182 }
214- if rt.copySnapshot == nil {
215- rt.copyMu.Unlock()
216- p.mu.Unlock()
217- return
218- }
219 document := rt.copySnapshot183 document := rt.copySnapshot
220 if rt.copySearch != nil {184 if rt.copySearch != nil {
221 changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)185 changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
222 rt.copyMu.Unlock()186 rt.copyMu.Unlock()
223- p.mu.Unlock()
224 if changed || closeSearch || accepted {187 if changed || closeSearch || accepted {
225 d.paint(sess, ac, true)188 d.paint(sess, ac, true)
226 }189 }
@@ -292,7 +255,6 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
292 rt.clearCopyModeLocked()255 rt.clearCopyModeLocked()
293 }256 }
294 rt.copyMu.Unlock()257 rt.copyMu.Unlock()
295- p.mu.Unlock()
296 258
297 if copyOut && text != "" {259 if copyOut && text != "" {
298 chunks := scopy.OSC52(text)260 chunks := scopy.OSC52(text)
M internal/usecase/daemon/copymode_test.go +22 -0
@@ -599,6 +599,28 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
599599 }
600600 }
601601
602+func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
603+ p, _ := newBlockingPTY(t)
604+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
605+ pane := sess.activeTab().focusedPane()
606+ pane.screen.Write([]byte("live"))
607+ d.enterCopyMode(sess, ac)
608+ awaitFrame(t, sends, ports.MsgOutput)
609+
610+ pane.mu.Lock()
611+ defer pane.mu.Unlock()
612+ done := make(chan struct{})
613+ go func() {
614+ d.handleCopyInput(ac, []byte("x"))
615+ close(done)
616+ }()
617+ select {
618+ case <-done:
619+ case <-time.After(time.Second):
620+ t.Fatal("immutable copy input waited for the live pane lock")
621+ }
622+}
623+
602624 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
603625 p, _ := newBlockingPTY(t)
604626 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -599,6 +599,28 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
599 }599 }
600 }600 }
601 601
602+func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
603+ p, _ := newBlockingPTY(t)
604+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
605+ pane := sess.activeTab().focusedPane()
606+ pane.screen.Write([]byte("live"))
607+ d.enterCopyMode(sess, ac)
608+ awaitFrame(t, sends, ports.MsgOutput)
609+
610+ pane.mu.Lock()
611+ defer pane.mu.Unlock()
612+ done := make(chan struct{})
613+ go func() {
614+ d.handleCopyInput(ac, []byte("x"))
615+ close(done)
616+ }()
617+ select {
618+ case <-done:
619+ case <-time.After(time.Second):
620+ t.Fatal("immutable copy input waited for the live pane lock")
621+ }
622+}
623+
602 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {624 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
603 p, _ := newBlockingPTY(t)625 p, _ := newBlockingPTY(t)
604 d, sess, ac, sends := newManualSessionWithPTYs(t, p)626 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/input.go +2 -1
@@ -270,8 +270,10 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
270270 return
271271 }
272272
273+ // The immutable snapshot is the only live-pane access needed for drag entry.
273274 p.mu.Lock()
274275 document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
276+ p.mu.Unlock()
275277 rt.copyMu.Lock()
276278 mode := scopy.NewMode(document)
277279 mode.StartSelectionAt(document, pressTop+pressRow)
@@ -284,7 +286,6 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
284286 rt.copyDragging = true
285287 rt.normalMousePressValid = false
286288 rt.copyMu.Unlock()
287- p.mu.Unlock()
288289 d.paint(sess, ac, true)
289290 case mouse.Release:
290291 rt.copyMu.Lock()
@@ -270,8 +270,10 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
270 return270 return
271 }271 }
272 272
273+ // The immutable snapshot is the only live-pane access needed for drag entry.
273 p.mu.Lock()274 p.mu.Lock()
274 document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)275 document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
276+ p.mu.Unlock()
275 rt.copyMu.Lock()277 rt.copyMu.Lock()
276 mode := scopy.NewMode(document)278 mode := scopy.NewMode(document)
277 mode.StartSelectionAt(document, pressTop+pressRow)279 mode.StartSelectionAt(document, pressTop+pressRow)
@@ -284,7 +286,6 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
284 rt.copyDragging = true286 rt.copyDragging = true
285 rt.normalMousePressValid = false287 rt.normalMousePressValid = false
286 rt.copyMu.Unlock()288 rt.copyMu.Unlock()
287- p.mu.Unlock()
288 d.paint(sess, ac, true)289 d.paint(sess, ac, true)
289 case mouse.Release:290 case mouse.Release:
290 rt.copyMu.Lock()291 rt.copyMu.Lock()

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