detached fb67a13

fix: synchronize daemon copy mode

commit
fb67a13ee6c9be0c44b6b058706508f74e00f6b6
author
brice · · 2026-07-02 16:07 UTC
parent
e47efb0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +56 -3
M internal/usecase/daemon/daemon.go +23 -3
@@ -147,10 +147,17 @@ type attachedClient struct {
147147 rend *renderer.Renderer
148148 size domain.Size
149149 keys *keys.Router
150+ copyMu sync.Mutex
150151 copyMode *scopy.Mode
151152 sendMu sync.Mutex
152153 }
153154
155+func (ac *attachedClient) copyModeActive() bool {
156+ ac.copyMu.Lock()
157+ defer ac.copyMu.Unlock()
158+ return ac.copyMode != nil
159+}
160+
154161 // send serialises a frame onto the client's transport.
155162 func (ac *attachedClient) send(f ports.Frame) error {
156163 ac.sendMu.Lock()
@@ -680,7 +687,7 @@ func (s *session) activeWindow() *window {
680687 }
681688
682689 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
683- if ac.copyMode != nil {
690+ if ac.copyModeActive() {
684691 d.handleCopyInput(sess, ac, data)
685692 return
686693 }
@@ -698,17 +705,25 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
698705 win.mu.Lock()
699706 snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
700707 win.mu.Unlock()
708+ ac.copyMu.Lock()
701709 ac.copyMode = scopy.NewMode(snap)
710+ ac.copyMu.Unlock()
702711 d.paint(sess, ac, true)
703712 }
704713
705714 func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) {
706715 win := sess.activeWindow()
707- if win == nil || ac.copyMode == nil {
716+ if win == nil {
708717 return
709718 }
710719
711720 win.mu.Lock()
721+ ac.copyMu.Lock()
722+ if ac.copyMode == nil {
723+ ac.copyMu.Unlock()
724+ win.mu.Unlock()
725+ return
726+ }
712727 snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
713728 changed := false
714729 copyOut := false
@@ -749,6 +764,10 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
749764 if copyOut {
750765 text = ac.copyMode.SelectedText(snap)
751766 }
767+ if exit {
768+ ac.copyMode = nil
769+ }
770+ ac.copyMu.Unlock()
752771 win.mu.Unlock()
753772
754773 if copyOut {
@@ -760,7 +779,6 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
760779 }
761780 }
762781 if exit {
763- ac.copyMode = nil
764782 d.paint(sess, ac, true)
765783 return
766784 }
@@ -1001,6 +1019,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
10011019
10021020 ac.sendMu.Lock()
10031021 win.mu.Lock()
1022+ ac.copyMu.Lock()
10041023 if reset || ac.copyMode != nil {
10051024 ac.rend.Reset()
10061025 }
@@ -1008,6 +1027,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
10081027 if ac.copyMode != nil {
10091028 frame, damage = composeCopyClientFrame(ac.copyMode, win)
10101029 }
1030+ ac.copyMu.Unlock()
10111031 data, err := ac.rend.Draw(frame, damage)
10121032 win.screen.ClearDamage()
10131033 win.mu.Unlock()
@@ -147,10 +147,17 @@ type attachedClient struct {
147 rend *renderer.Renderer147 rend *renderer.Renderer
148 size domain.Size148 size domain.Size
149 keys *keys.Router149 keys *keys.Router
150+ copyMu sync.Mutex
150 copyMode *scopy.Mode151 copyMode *scopy.Mode
151 sendMu sync.Mutex152 sendMu sync.Mutex
152 }153 }
153 154
155+func (ac *attachedClient) copyModeActive() bool {
156+ ac.copyMu.Lock()
157+ defer ac.copyMu.Unlock()
158+ return ac.copyMode != nil
159+}
160+
154 // send serialises a frame onto the client's transport.161 // send serialises a frame onto the client's transport.
155 func (ac *attachedClient) send(f ports.Frame) error {162 func (ac *attachedClient) send(f ports.Frame) error {
156 ac.sendMu.Lock()163 ac.sendMu.Lock()
@@ -680,7 +687,7 @@ func (s *session) activeWindow() *window {
680 }687 }
681 688
682 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {689 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
683- if ac.copyMode != nil {690+ if ac.copyModeActive() {
684 d.handleCopyInput(sess, ac, data)691 d.handleCopyInput(sess, ac, data)
685 return692 return
686 }693 }
@@ -698,17 +705,25 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
698 win.mu.Lock()705 win.mu.Lock()
699 snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)706 snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
700 win.mu.Unlock()707 win.mu.Unlock()
708+ ac.copyMu.Lock()
701 ac.copyMode = scopy.NewMode(snap)709 ac.copyMode = scopy.NewMode(snap)
710+ ac.copyMu.Unlock()
702 d.paint(sess, ac, true)711 d.paint(sess, ac, true)
703 }712 }
704 713
705 func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) {714 func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) {
706 win := sess.activeWindow()715 win := sess.activeWindow()
707- if win == nil || ac.copyMode == nil {716+ if win == nil {
708 return717 return
709 }718 }
710 719
711 win.mu.Lock()720 win.mu.Lock()
721+ ac.copyMu.Lock()
722+ if ac.copyMode == nil {
723+ ac.copyMu.Unlock()
724+ win.mu.Unlock()
725+ return
726+ }
712 snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)727 snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
713 changed := false728 changed := false
714 copyOut := false729 copyOut := false
@@ -749,6 +764,10 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
749 if copyOut {764 if copyOut {
750 text = ac.copyMode.SelectedText(snap)765 text = ac.copyMode.SelectedText(snap)
751 }766 }
767+ if exit {
768+ ac.copyMode = nil
769+ }
770+ ac.copyMu.Unlock()
752 win.mu.Unlock()771 win.mu.Unlock()
753 772
754 if copyOut {773 if copyOut {
@@ -760,7 +779,6 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
760 }779 }
761 }780 }
762 if exit {781 if exit {
763- ac.copyMode = nil
764 d.paint(sess, ac, true)782 d.paint(sess, ac, true)
765 return783 return
766 }784 }
@@ -1001,6 +1019,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1001 1019
1002 ac.sendMu.Lock()1020 ac.sendMu.Lock()
1003 win.mu.Lock()1021 win.mu.Lock()
1022+ ac.copyMu.Lock()
1004 if reset || ac.copyMode != nil {1023 if reset || ac.copyMode != nil {
1005 ac.rend.Reset()1024 ac.rend.Reset()
1006 }1025 }
@@ -1008,6 +1027,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1008 if ac.copyMode != nil {1027 if ac.copyMode != nil {
1009 frame, damage = composeCopyClientFrame(ac.copyMode, win)1028 frame, damage = composeCopyClientFrame(ac.copyMode, win)
1010 }1029 }
1030+ ac.copyMu.Unlock()
1011 data, err := ac.rend.Draw(frame, damage)1031 data, err := ac.rend.Draw(frame, damage)
1012 win.screen.ClearDamage()1032 win.screen.ClearDamage()
1013 win.mu.Unlock()1033 win.mu.Unlock()
M internal/usecase/daemon/daemon_test.go +33 -0
@@ -340,6 +340,39 @@ func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
340340 }
341341 }
342342
343+func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
344+ p, _ := newBlockingPTY(t)
345+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
346+ sess.windows[0].scrollback = scopy.NewScrollback(4)
347+ sess.windows[0].screen.Write([]byte("live"))
348+
349+ done := make(chan struct{})
350+ var drain sync.WaitGroup
351+ drain.Go(func() {
352+ for {
353+ select {
354+ case <-sends:
355+ case <-done:
356+ return
357+ }
358+ }
359+ })
360+
361+ var wg sync.WaitGroup
362+ for range 100 {
363+ wg.Go(func() {
364+ d.enterCopyMode(sess, ac)
365+ d.handleInput(sess, ac, []byte("q"))
366+ })
367+ wg.Go(func() {
368+ d.paint(sess, ac, true)
369+ })
370+ }
371+ wg.Wait()
372+ close(done)
373+ drain.Wait()
374+}
375+
343376 func windowCount(sess *session) int {
344377 sess.mu.Lock()
345378 defer sess.mu.Unlock()
@@ -340,6 +340,39 @@ func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
340 }340 }
341 }341 }
342 342
343+func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
344+ p, _ := newBlockingPTY(t)
345+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
346+ sess.windows[0].scrollback = scopy.NewScrollback(4)
347+ sess.windows[0].screen.Write([]byte("live"))
348+
349+ done := make(chan struct{})
350+ var drain sync.WaitGroup
351+ drain.Go(func() {
352+ for {
353+ select {
354+ case <-sends:
355+ case <-done:
356+ return
357+ }
358+ }
359+ })
360+
361+ var wg sync.WaitGroup
362+ for range 100 {
363+ wg.Go(func() {
364+ d.enterCopyMode(sess, ac)
365+ d.handleInput(sess, ac, []byte("q"))
366+ })
367+ wg.Go(func() {
368+ d.paint(sess, ac, true)
369+ })
370+ }
371+ wg.Wait()
372+ close(done)
373+ drain.Wait()
374+}
375+
343 func windowCount(sess *session) int {376 func windowCount(sess *session) int {
344 sess.mu.Lock()377 sess.mu.Lock()
345 defer sess.mu.Unlock()378 defer sess.mu.Unlock()

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