detached e03e10d

fix(copy): validate mode publication atomically

commit
e03e10d6d5d70fa4863f705072295891ccc9ec48
author
brice · · 2026-07-11 08:40 UTC
parent
284533a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +81 -14
M internal/usecase/daemon/copymode.go +21 -11
@@ -45,6 +45,9 @@ func copyTargetPane(rt *overlayRuntime) *pane {
4545 }
4646 rt.copyMu.Lock()
4747 defer rt.copyMu.Unlock()
48+ if rt.copyMode == nil {
49+ return nil
50+ }
4851 return rt.copyPane
4952 }
5053
@@ -94,10 +97,10 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
9497 d.paint(sess, ac, true)
9598 }
9699
97-// publishCopyMode publishes an immutable copy document, then validates the
98-// captured target without overlapping copyMu with session or tab locks. A tab
99-// transition or pane close may race capture; retaining copy state in either
100-// case would let an old document render or be yanked.
100+// publishCopyMode installs a non-renderable candidate, validates its captured
101+// target without overlapping copyMu with session or tab locks, then atomically
102+// activates that same candidate. A tab transition, pane close, or newer
103+// publication invalidates the candidate before it can render or be yanked.
101104 func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *pane, document scopy.Snapshot, prepare func(*scopy.Mode)) bool {
102105 if sess == nil || ac == nil || tb == nil || p == nil {
103106 return false
@@ -108,7 +111,10 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
108111 }
109112 rt := ac.overlays
110113 rt.copyMu.Lock()
111- rt.copyMode = mode
114+ d.stopCopyPendingTimerLocked(ac)
115+ rt.copyPending = nil
116+ rt.copyMode = nil
117+ rt.copyCandidate = mode
112118 rt.copySnapshot = &document
113119 rt.copyPane = p
114120 rt.copySearch = nil
@@ -125,15 +131,19 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
125131 valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)
126132 tb.mu.Unlock()
127133 }
128- if valid {
129- return true
130- }
134+
131135 rt.copyMu.Lock()
132- if rt.copyMode == mode && rt.copyPane == p {
136+ defer rt.copyMu.Unlock()
137+ if rt.copyCandidate != mode || rt.copyPane != p || rt.copySnapshot != &document {
138+ return false
139+ }
140+ if !valid {
133141 rt.clearCopyModeLocked()
142+ return false
134143 }
135- rt.copyMu.Unlock()
136- return false
144+ rt.copyCandidate = nil
145+ rt.copyMode = mode
146+ return true
137147 }
138148
139149 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
@@ -45,6 +45,9 @@ func copyTargetPane(rt *overlayRuntime) *pane {
45 }45 }
46 rt.copyMu.Lock()46 rt.copyMu.Lock()
47 defer rt.copyMu.Unlock()47 defer rt.copyMu.Unlock()
48+ if rt.copyMode == nil {
49+ return nil
50+ }
48 return rt.copyPane51 return rt.copyPane
49 }52 }
50 53
@@ -94,10 +97,10 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
94 d.paint(sess, ac, true)97 d.paint(sess, ac, true)
95 }98 }
96 99
97-// publishCopyMode publishes an immutable copy document, then validates the100+// publishCopyMode installs a non-renderable candidate, validates its captured
98-// captured target without overlapping copyMu with session or tab locks. A tab101+// target without overlapping copyMu with session or tab locks, then atomically
99-// transition or pane close may race capture; retaining copy state in either102+// activates that same candidate. A tab transition, pane close, or newer
100-// case would let an old document render or be yanked.103+// publication invalidates the candidate before it can render or be yanked.
101 func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *pane, document scopy.Snapshot, prepare func(*scopy.Mode)) bool {104 func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *pane, document scopy.Snapshot, prepare func(*scopy.Mode)) bool {
102 if sess == nil || ac == nil || tb == nil || p == nil {105 if sess == nil || ac == nil || tb == nil || p == nil {
103 return false106 return false
@@ -108,7 +111,10 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
108 }111 }
109 rt := ac.overlays112 rt := ac.overlays
110 rt.copyMu.Lock()113 rt.copyMu.Lock()
111- rt.copyMode = mode114+ d.stopCopyPendingTimerLocked(ac)
115+ rt.copyPending = nil
116+ rt.copyMode = nil
117+ rt.copyCandidate = mode
112 rt.copySnapshot = &document118 rt.copySnapshot = &document
113 rt.copyPane = p119 rt.copyPane = p
114 rt.copySearch = nil120 rt.copySearch = nil
@@ -125,15 +131,19 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
125 valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)131 valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)
126 tb.mu.Unlock()132 tb.mu.Unlock()
127 }133 }
128- if valid {134+
129- return true
130- }
131 rt.copyMu.Lock()135 rt.copyMu.Lock()
132- if rt.copyMode == mode && rt.copyPane == p {136+ defer rt.copyMu.Unlock()
137+ if rt.copyCandidate != mode || rt.copyPane != p || rt.copySnapshot != &document {
138+ return false
139+ }
140+ if !valid {
133 rt.clearCopyModeLocked()141 rt.clearCopyModeLocked()
142+ return false
134 }143 }
135- rt.copyMu.Unlock()144+ rt.copyCandidate = nil
136- return false145+ rt.copyMode = mode
146+ return true
137 }147 }
138 148
139 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {149 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
M internal/usecase/daemon/copymode_lifecycle_test.go +45 -0
@@ -2,7 +2,9 @@ package daemon
22
33 import (
44 "testing"
5+ "time"
56
7+ "github.com/stretchr/testify/assert"
68 "github.com/stretchr/testify/require"
79
810 "github.com/bnema/vev/internal/domain"
@@ -82,6 +84,49 @@ func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testin
8284 require.Nil(t, copyTargetPane(ac.overlays))
8385 }
8486
87+func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
88+ d, sess, ac, _, releases := newManualTabSession(t, 2)
89+ defer func() {
90+ for _, release := range releases {
91+ release()
92+ }
93+ }()
94+ tb := sess.activeTab()
95+ p := tb.focusedPane()
96+ p.mu.Lock()
97+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
98+ p.mu.Unlock()
99+
100+ // Hold session validation after publication and make the captured target
101+ // stale. A render snapshot taken in this window must not expose the
102+ // candidate document.
103+ sess.mu.Lock()
104+ sess.active = 1
105+ result := make(chan bool, 1)
106+ go func() {
107+ result <- d.publishCopyMode(sess, ac, tb, p, document, nil)
108+ }()
109+ published := assert.Eventually(t, func() bool {
110+ ac.overlays.copyMu.Lock()
111+ defer ac.overlays.copyMu.Unlock()
112+ return ac.overlays.copyPane == p && ac.overlays.copySnapshot != nil
113+ }, time.Second, time.Millisecond)
114+ if !published {
115+ sess.mu.Unlock()
116+ <-result
117+ return
118+ }
119+ renderSnapshot := ac.overlays.SnapshotForRender()
120+ candidateWasRenderable := renderSnapshot.copyActive
121+ renderSnapshot.Unlock()
122+ sess.mu.Unlock()
123+
124+ require.False(t, <-result)
125+ require.False(t, candidateWasRenderable)
126+ require.False(t, ac.overlays.copyActive())
127+ require.Nil(t, copyTargetPane(ac.overlays))
128+}
129+
85130 func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {
86131 for _, tc := range []struct {
87132 name string
@@ -2,7 +2,9 @@ package daemon
2 2
3 import (3 import (
4 "testing"4 "testing"
5+ "time"
5 6
7+ "github.com/stretchr/testify/assert"
6 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
7 9
8 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
@@ -82,6 +84,49 @@ func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testin
82 require.Nil(t, copyTargetPane(ac.overlays))84 require.Nil(t, copyTargetPane(ac.overlays))
83 }85 }
84 86
87+func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
88+ d, sess, ac, _, releases := newManualTabSession(t, 2)
89+ defer func() {
90+ for _, release := range releases {
91+ release()
92+ }
93+ }()
94+ tb := sess.activeTab()
95+ p := tb.focusedPane()
96+ p.mu.Lock()
97+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
98+ p.mu.Unlock()
99+
100+ // Hold session validation after publication and make the captured target
101+ // stale. A render snapshot taken in this window must not expose the
102+ // candidate document.
103+ sess.mu.Lock()
104+ sess.active = 1
105+ result := make(chan bool, 1)
106+ go func() {
107+ result <- d.publishCopyMode(sess, ac, tb, p, document, nil)
108+ }()
109+ published := assert.Eventually(t, func() bool {
110+ ac.overlays.copyMu.Lock()
111+ defer ac.overlays.copyMu.Unlock()
112+ return ac.overlays.copyPane == p && ac.overlays.copySnapshot != nil
113+ }, time.Second, time.Millisecond)
114+ if !published {
115+ sess.mu.Unlock()
116+ <-result
117+ return
118+ }
119+ renderSnapshot := ac.overlays.SnapshotForRender()
120+ candidateWasRenderable := renderSnapshot.copyActive
121+ renderSnapshot.Unlock()
122+ sess.mu.Unlock()
123+
124+ require.False(t, <-result)
125+ require.False(t, candidateWasRenderable)
126+ require.False(t, ac.overlays.copyActive())
127+ require.Nil(t, copyTargetPane(ac.overlays))
128+}
129+
85 func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {130 func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {
86 for _, tc := range []struct {131 for _, tc := range []struct {
87 name string132 name string
M internal/usecase/daemon/copymode_test.go +12 -2
@@ -546,7 +546,8 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
546546 }
547547
548548 func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
549- p, _ := newBlockingPTY(t)
549+ p, release := newBlockingPTY(t)
550+ defer release()
550551 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
551552 clk := &signalClock{timers: make(chan *signalTimer, 1)}
552553 d.clock = clk
@@ -560,6 +561,14 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
560561 d.enterCopyMode(sess, ac)
561562 awaitFrame(t, sends, ports.MsgOutput)
562563
564+ ac.overlays.copyMu.Lock()
565+ require.Nil(t, ac.overlays.copyESC.timer)
566+ require.Nil(t, ac.overlays.copyESC.done)
567+ require.Empty(t, ac.overlays.copyPending)
568+ ac.overlays.copyMu.Unlock()
569+ d.handleInput(sess, ac, []byte("x"))
570+ require.True(t, ac.overlays.copyActive(), "pending input from the replaced mode affected the new mode")
571+
563572 timer.ch <- time.Now()
564573 require.Never(t, func() bool {
565574 ac.overlays.copyMu.Lock()
@@ -609,7 +618,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
609618 }
610619
611620 func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
612- p, _ := newBlockingPTY(t)
621+ p, release := newBlockingPTY(t)
622+ defer release()
613623 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
614624 pane := sess.activeTab().focusedPane()
615625 pane.screen.Write([]byte("live"))
@@ -546,7 +546,8 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
546 }546 }
547 547
548 func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {548 func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
549- p, _ := newBlockingPTY(t)549+ p, release := newBlockingPTY(t)
550+ defer release()
550 d, sess, ac, sends := newManualSessionWithPTYs(t, p)551 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
551 clk := &signalClock{timers: make(chan *signalTimer, 1)}552 clk := &signalClock{timers: make(chan *signalTimer, 1)}
552 d.clock = clk553 d.clock = clk
@@ -560,6 +561,14 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
560 d.enterCopyMode(sess, ac)561 d.enterCopyMode(sess, ac)
561 awaitFrame(t, sends, ports.MsgOutput)562 awaitFrame(t, sends, ports.MsgOutput)
562 563
564+ ac.overlays.copyMu.Lock()
565+ require.Nil(t, ac.overlays.copyESC.timer)
566+ require.Nil(t, ac.overlays.copyESC.done)
567+ require.Empty(t, ac.overlays.copyPending)
568+ ac.overlays.copyMu.Unlock()
569+ d.handleInput(sess, ac, []byte("x"))
570+ require.True(t, ac.overlays.copyActive(), "pending input from the replaced mode affected the new mode")
571+
563 timer.ch <- time.Now()572 timer.ch <- time.Now()
564 require.Never(t, func() bool {573 require.Never(t, func() bool {
565 ac.overlays.copyMu.Lock()574 ac.overlays.copyMu.Lock()
@@ -609,7 +618,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
609 }618 }
610 619
611 func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {620 func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
612- p, _ := newBlockingPTY(t)621+ p, release := newBlockingPTY(t)
622+ defer release()
613 d, sess, ac, sends := newManualSessionWithPTYs(t, p)623 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
614 pane := sess.activeTab().focusedPane()624 pane := sess.activeTab().focusedPane()
615 pane.screen.Write([]byte("live"))625 pane.screen.Write([]byte("live"))
M internal/usecase/daemon/overlay_runtime.go +3 -1
@@ -30,6 +30,7 @@ type overlayRuntime struct {
3030
3131 copyMu sync.Mutex
3232 copyMode *scopy.Mode
33+ copyCandidate *scopy.Mode
3334 copySnapshot *scopy.Snapshot
3435 copyPane *pane
3536 copyPending []byte
@@ -103,6 +104,7 @@ func (rt *overlayRuntime) copySearchActive() bool {
103104
104105 func (rt *overlayRuntime) clearCopyModeLocked() {
105106 rt.copyMode = nil
107+ rt.copyCandidate = nil
106108 rt.copySnapshot = nil
107109 rt.copyPane = nil
108110 rt.copySearch = nil
@@ -115,7 +117,7 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
115117 }
116118 rt.copyMu.Lock()
117119 defer rt.copyMu.Unlock()
118- if rt.copyMode == nil || rt.copyPane != p {
120+ if rt.copyPane != p || (rt.copyMode == nil && rt.copyCandidate == nil) {
119121 return false
120122 }
121123 rt.clearCopyModeLocked()
@@ -30,6 +30,7 @@ type overlayRuntime struct {
30 30
31 copyMu sync.Mutex31 copyMu sync.Mutex
32 copyMode *scopy.Mode32 copyMode *scopy.Mode
33+ copyCandidate *scopy.Mode
33 copySnapshot *scopy.Snapshot34 copySnapshot *scopy.Snapshot
34 copyPane *pane35 copyPane *pane
35 copyPending []byte36 copyPending []byte
@@ -103,6 +104,7 @@ func (rt *overlayRuntime) copySearchActive() bool {
103 104
104 func (rt *overlayRuntime) clearCopyModeLocked() {105 func (rt *overlayRuntime) clearCopyModeLocked() {
105 rt.copyMode = nil106 rt.copyMode = nil
107+ rt.copyCandidate = nil
106 rt.copySnapshot = nil108 rt.copySnapshot = nil
107 rt.copyPane = nil109 rt.copyPane = nil
108 rt.copySearch = nil110 rt.copySearch = nil
@@ -115,7 +117,7 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
115 }117 }
116 rt.copyMu.Lock()118 rt.copyMu.Lock()
117 defer rt.copyMu.Unlock()119 defer rt.copyMu.Unlock()
118- if rt.copyMode == nil || rt.copyPane != p {120+ if rt.copyPane != p || (rt.copyMode == nil && rt.copyCandidate == nil) {
119 return false121 return false
120 }122 }
121 rt.clearCopyModeLocked()123 rt.clearCopyModeLocked()

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 2

Tags 13

↑↓ moveenter openesc close