fix(copy): invalidate stale copy documents
- commit
- 440c1a2328557397def16cd708504d3e27b82869
- author
- brice · · 2026-07-11 06:58 UTC
- parent
- 25d9db5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +181 -26
M internal/usecase/daemon/client.go +1 -0
| @@ -578,6 +578,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 578 | 578 | if !sz.Valid() { | |
| 579 | 579 | return | |
| 580 | 580 | } | |
| 581 | + | d.exitCopyMode(ac) | |
| 581 | 582 | tbSize := tabSize(sz) | |
| 582 | 583 | if ac != nil { | |
| 583 | 584 | ac.size = sz | |
| @@ -578,6 +578,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 578 | if !sz.Valid() { | 578 | if !sz.Valid() { | ||
| 579 | return | 579 | return | ||
| 580 | } | 580 | } | ||
| 581 | + | d.exitCopyMode(ac) | |||
| 581 | tbSize := tabSize(sz) | 582 | tbSize := tabSize(sz) | ||
| 582 | if ac != nil { | 583 | if ac != nil { | ||
| 583 | ac.size = sz | 584 | ac.size = sz | ||
M internal/usecase/daemon/copymode.go +34 -9
| @@ -73,14 +73,12 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| 76 | - | rt := ac.overlays | |
| 77 | 76 | tb := sess.activeTab() | |
| 78 | 77 | if tb == nil { | |
| 79 | 78 | return | |
| 80 | 79 | } | |
| 81 | 80 | tb.mu.Lock() | |
| 82 | 81 | p := tb.terminalTargetLocked() | |
| 83 | - | floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p | |
| 84 | 82 | tb.mu.Unlock() | |
| 85 | 83 | if p == nil { | |
| 86 | 84 | return | |
| @@ -90,8 +88,27 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 90 | 88 | p.mu.Lock() | |
| 91 | 89 | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 92 | 90 | p.mu.Unlock() | |
| 91 | + | if !d.publishCopyMode(sess, ac, tb, p, document, nil) { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | d.paint(sess, ac, true) | |
| 95 | + | } | |
| 96 | + | ||
| 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. | |
| 101 | + | 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 { | |
| 103 | + | return false | |
| 104 | + | } | |
| 105 | + | mode := scopy.NewMode(document) | |
| 106 | + | if prepare != nil { | |
| 107 | + | prepare(mode) | |
| 108 | + | } | |
| 109 | + | rt := ac.overlays | |
| 93 | 110 | rt.copyMu.Lock() | |
| 94 | - | rt.copyMode = scopy.NewMode(document) | |
| 111 | + | rt.copyMode = mode | |
| 95 | 112 | rt.copySnapshot = &document | |
| 96 | 113 | rt.copyPane = p | |
| 97 | 114 | rt.copySearch = nil | |
| @@ -100,15 +117,23 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 100 | 117 | rt.copyDragging = false | |
| 101 | 118 | rt.normalMousePressValid = false | |
| 102 | 119 | rt.copyMu.Unlock() | |
| 103 | - | if floatingSource { | |
| 120 | + | ||
| 121 | + | active := sess.activeTab() | |
| 122 | + | valid := active == tb | |
| 123 | + | if valid { | |
| 104 | 124 | tb.mu.Lock() | |
| 105 | - | stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p | |
| 125 | + | valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p) | |
| 106 | 126 | tb.mu.Unlock() | |
| 107 | - | if !stillVisible { | |
| 108 | - | rt.clearCopyModeForPane(p) | |
| 109 | - | } | |
| 110 | 127 | } | |
| 111 | - | d.paint(sess, ac, true) | |
| 128 | + | if valid { | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | rt.copyMu.Lock() | |
| 132 | + | if rt.copyMode == mode && rt.copyPane == p { | |
| 133 | + | rt.clearCopyModeLocked() | |
| 134 | + | } | |
| 135 | + | rt.copyMu.Unlock() | |
| 136 | + | return false | |
| 112 | 137 | } | |
| 113 | 138 | ||
| 114 | 139 | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |
| @@ -73,14 +73,12 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | 75 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | ||
| 76 | - | rt := ac.overlays | |||
| 77 | tb := sess.activeTab() | 76 | tb := sess.activeTab() | ||
| 78 | if tb == nil { | 77 | if tb == nil { | ||
| 79 | return | 78 | return | ||
| 80 | } | 79 | } | ||
| 81 | tb.mu.Lock() | 80 | tb.mu.Lock() | ||
| 82 | p := tb.terminalTargetLocked() | 81 | p := tb.terminalTargetLocked() | ||
| 83 | - | floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p | |||
| 84 | tb.mu.Unlock() | 82 | tb.mu.Unlock() | ||
| 85 | if p == nil { | 83 | if p == nil { | ||
| 86 | return | 84 | return | ||
| @@ -90,8 +88,27 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 90 | p.mu.Lock() | 88 | p.mu.Lock() | ||
| 91 | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | 89 | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | ||
| 92 | p.mu.Unlock() | 90 | p.mu.Unlock() | ||
| 91 | + | if !d.publishCopyMode(sess, ac, tb, p, document, nil) { | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | d.paint(sess, ac, true) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 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. | |||
| 101 | + | 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 { | |||
| 103 | + | return false | |||
| 104 | + | } | |||
| 105 | + | mode := scopy.NewMode(document) | |||
| 106 | + | if prepare != nil { | |||
| 107 | + | prepare(mode) | |||
| 108 | + | } | |||
| 109 | + | rt := ac.overlays | |||
| 93 | rt.copyMu.Lock() | 110 | rt.copyMu.Lock() | ||
| 94 | - | rt.copyMode = scopy.NewMode(document) | 111 | + | rt.copyMode = mode |
| 95 | rt.copySnapshot = &document | 112 | rt.copySnapshot = &document | ||
| 96 | rt.copyPane = p | 113 | rt.copyPane = p | ||
| 97 | rt.copySearch = nil | 114 | rt.copySearch = nil | ||
| @@ -100,15 +117,23 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 100 | rt.copyDragging = false | 117 | rt.copyDragging = false | ||
| 101 | rt.normalMousePressValid = false | 118 | rt.normalMousePressValid = false | ||
| 102 | rt.copyMu.Unlock() | 119 | rt.copyMu.Unlock() | ||
| 103 | - | if floatingSource { | 120 | + | |
| 121 | + | active := sess.activeTab() | |||
| 122 | + | valid := active == tb | |||
| 123 | + | if valid { | |||
| 104 | tb.mu.Lock() | 124 | tb.mu.Lock() | ||
| 105 | - | stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p | 125 | + | valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p) |
| 106 | tb.mu.Unlock() | 126 | tb.mu.Unlock() | ||
| 107 | - | if !stillVisible { | |||
| 108 | - | rt.clearCopyModeForPane(p) | |||
| 109 | - | } | |||
| 110 | } | 127 | } | ||
| 111 | - | d.paint(sess, ac, true) | 128 | + | if valid { |
| 129 | + | return true | |||
| 130 | + | } | |||
| 131 | + | rt.copyMu.Lock() | |||
| 132 | + | if rt.copyMode == mode && rt.copyPane == p { | |||
| 133 | + | rt.clearCopyModeLocked() | |||
| 134 | + | } | |||
| 135 | + | rt.copyMu.Unlock() | |||
| 136 | + | return false | |||
| 112 | } | 137 | } | ||
| 113 | 138 | ||||
| 114 | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | 139 | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | ||
A internal/usecase/daemon/copymode_lifecycle_test.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 10 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) { | |
| 15 | + | pty, release := newBlockingPTY(t) | |
| 16 | + | defer release() | |
| 17 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |
| 18 | + | d.enterCopyMode(sess, ac) | |
| 19 | + | require.True(t, ac.overlays.copyActive()) | |
| 20 | + | ||
| 21 | + | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | |
| 22 | + | ||
| 23 | + | require.False(t, ac.overlays.copyActive()) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) { | |
| 27 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |
| 28 | + | defer func() { | |
| 29 | + | for _, release := range releases { | |
| 30 | + | release() | |
| 31 | + | } | |
| 32 | + | }() | |
| 33 | + | d.enterCopyMode(sess, ac) | |
| 34 | + | require.True(t, ac.overlays.copyActive()) | |
| 35 | + | ||
| 36 | + | require.True(t, sess.switchTab(1)) | |
| 37 | + | d.activateTab(sess, sess.activeTab()) | |
| 38 | + | ||
| 39 | + | require.False(t, ac.overlays.copyActive()) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |
| 43 | + | d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 44 | + | tb := sess.activeTab() | |
| 45 | + | closingPTY := portsmocks.NewMockPTY(t) | |
| 46 | + | closingPTY.EXPECT().Close().Return(nil).Once() | |
| 47 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 48 | + | closing := newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10}) | |
| 49 | + | tb.panes["pane-2"] = closing | |
| 50 | + | ac := &attachedClient{output: newOutputStateStream()} | |
| 51 | + | ac.initOverlays() | |
| 52 | + | sess.client = ac | |
| 53 | + | ac.setSession(sess) | |
| 54 | + | closing.mu.Lock() | |
| 55 | + | document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame) | |
| 56 | + | closing.mu.Unlock() | |
| 57 | + | ac.overlays.copyMu.Lock() | |
| 58 | + | ac.overlays.copyPane = closing | |
| 59 | + | ac.overlays.copySnapshot = &document | |
| 60 | + | ac.overlays.copyMode = scopy.NewMode(document) | |
| 61 | + | ac.overlays.copyMu.Unlock() | |
| 62 | + | ||
| 63 | + | require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false)) | |
| 64 | + | require.False(t, ac.overlays.copyActive()) | |
| 65 | + | require.Nil(t, copyTargetPane(ac.overlays)) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) { | |
| 69 | + | for _, tc := range []struct { | |
| 70 | + | name string | |
| 71 | + | invalidate func(*session, *tab, *pane) | |
| 72 | + | }{ | |
| 73 | + | { | |
| 74 | + | name: "removed pane", | |
| 75 | + | invalidate: func(_ *session, tb *tab, p *pane) { | |
| 76 | + | tb.mu.Lock() | |
| 77 | + | delete(tb.panes, p.id) | |
| 78 | + | tb.mu.Unlock() | |
| 79 | + | }, | |
| 80 | + | }, | |
| 81 | + | { | |
| 82 | + | name: "inactive tab", | |
| 83 | + | invalidate: func(sess *session, _ *tab, _ *pane) { | |
| 84 | + | require.True(t, sess.switchTab(1)) | |
| 85 | + | }, | |
| 86 | + | }, | |
| 87 | + | } { | |
| 88 | + | t.Run(tc.name, func(t *testing.T) { | |
| 89 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |
| 90 | + | defer func() { | |
| 91 | + | for _, release := range releases { | |
| 92 | + | release() | |
| 93 | + | } | |
| 94 | + | }() | |
| 95 | + | tb := sess.activeTab() | |
| 96 | + | p := tb.focusedPane() | |
| 97 | + | p.mu.Lock() | |
| 98 | + | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 99 | + | p.mu.Unlock() | |
| 100 | + | tc.invalidate(sess, tb, p) | |
| 101 | + | ||
| 102 | + | require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil)) | |
| 103 | + | require.False(t, ac.overlays.copyActive()) | |
| 104 | + | require.Nil(t, copyTargetPane(ac.overlays)) | |
| 105 | + | }) | |
| 106 | + | } | |
| 107 | + | } | |
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 10 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) { | |||
| 15 | + | pty, release := newBlockingPTY(t) | |||
| 16 | + | defer release() | |||
| 17 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |||
| 18 | + | d.enterCopyMode(sess, ac) | |||
| 19 | + | require.True(t, ac.overlays.copyActive()) | |||
| 20 | + | ||||
| 21 | + | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | |||
| 22 | + | ||||
| 23 | + | require.False(t, ac.overlays.copyActive()) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) { | |||
| 27 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |||
| 28 | + | defer func() { | |||
| 29 | + | for _, release := range releases { | |||
| 30 | + | release() | |||
| 31 | + | } | |||
| 32 | + | }() | |||
| 33 | + | d.enterCopyMode(sess, ac) | |||
| 34 | + | require.True(t, ac.overlays.copyActive()) | |||
| 35 | + | ||||
| 36 | + | require.True(t, sess.switchTab(1)) | |||
| 37 | + | d.activateTab(sess, sess.activeTab()) | |||
| 38 | + | ||||
| 39 | + | require.False(t, ac.overlays.copyActive()) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||
| 43 | + | d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |||
| 44 | + | tb := sess.activeTab() | |||
| 45 | + | closingPTY := portsmocks.NewMockPTY(t) | |||
| 46 | + | closingPTY.EXPECT().Close().Return(nil).Once() | |||
| 47 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |||
| 48 | + | closing := newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10}) | |||
| 49 | + | tb.panes["pane-2"] = closing | |||
| 50 | + | ac := &attachedClient{output: newOutputStateStream()} | |||
| 51 | + | ac.initOverlays() | |||
| 52 | + | sess.client = ac | |||
| 53 | + | ac.setSession(sess) | |||
| 54 | + | closing.mu.Lock() | |||
| 55 | + | document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame) | |||
| 56 | + | closing.mu.Unlock() | |||
| 57 | + | ac.overlays.copyMu.Lock() | |||
| 58 | + | ac.overlays.copyPane = closing | |||
| 59 | + | ac.overlays.copySnapshot = &document | |||
| 60 | + | ac.overlays.copyMode = scopy.NewMode(document) | |||
| 61 | + | ac.overlays.copyMu.Unlock() | |||
| 62 | + | ||||
| 63 | + | require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false)) | |||
| 64 | + | require.False(t, ac.overlays.copyActive()) | |||
| 65 | + | require.Nil(t, copyTargetPane(ac.overlays)) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) { | |||
| 69 | + | for _, tc := range []struct { | |||
| 70 | + | name string | |||
| 71 | + | invalidate func(*session, *tab, *pane) | |||
| 72 | + | }{ | |||
| 73 | + | { | |||
| 74 | + | name: "removed pane", | |||
| 75 | + | invalidate: func(_ *session, tb *tab, p *pane) { | |||
| 76 | + | tb.mu.Lock() | |||
| 77 | + | delete(tb.panes, p.id) | |||
| 78 | + | tb.mu.Unlock() | |||
| 79 | + | }, | |||
| 80 | + | }, | |||
| 81 | + | { | |||
| 82 | + | name: "inactive tab", | |||
| 83 | + | invalidate: func(sess *session, _ *tab, _ *pane) { | |||
| 84 | + | require.True(t, sess.switchTab(1)) | |||
| 85 | + | }, | |||
| 86 | + | }, | |||
| 87 | + | } { | |||
| 88 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 89 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |||
| 90 | + | defer func() { | |||
| 91 | + | for _, release := range releases { | |||
| 92 | + | release() | |||
| 93 | + | } | |||
| 94 | + | }() | |||
| 95 | + | tb := sess.activeTab() | |||
| 96 | + | p := tb.focusedPane() | |||
| 97 | + | p.mu.Lock() | |||
| 98 | + | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |||
| 99 | + | p.mu.Unlock() | |||
| 100 | + | tc.invalidate(sess, tb, p) | |||
| 101 | + | ||||
| 102 | + | require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil)) | |||
| 103 | + | require.False(t, ac.overlays.copyActive()) | |||
| 104 | + | require.Nil(t, copyTargetPane(ac.overlays)) | |||
| 105 | + | }) | |||
| 106 | + | } | |||
| 107 | + | } | |||
M internal/usecase/daemon/floating.go +3 -2
| @@ -162,9 +162,10 @@ func (d *Daemon) activateTab(sess *session, tb *tab) { | |||
| 162 | 162 | // until firstPaint keeps restored tabs cold and avoids launching children | |
| 163 | 163 | // merely because a tab was manipulated during teardown. | |
| 164 | 164 | sess.mu.Lock() | |
| 165 | - | attached := sess.client != nil | |
| 165 | + | ac := sess.client | |
| 166 | 166 | sess.mu.Unlock() | |
| 167 | - | if !attached { | |
| 167 | + | d.exitCopyMode(ac) | |
| 168 | + | if ac == nil { | |
| 168 | 169 | return | |
| 169 | 170 | } | |
| 170 | 171 | d.ensureFloatingWarm(sess, tb) | |
| @@ -162,9 +162,10 @@ func (d *Daemon) activateTab(sess *session, tb *tab) { | |||||
| 162 | // until firstPaint keeps restored tabs cold and avoids launching children | 162 | // until firstPaint keeps restored tabs cold and avoids launching children | ||
| 163 | // merely because a tab was manipulated during teardown. | 163 | // merely because a tab was manipulated during teardown. | ||
| 164 | sess.mu.Lock() | 164 | sess.mu.Lock() | ||
| 165 | - | attached := sess.client != nil | 165 | + | ac := sess.client |
| 166 | sess.mu.Unlock() | 166 | sess.mu.Unlock() | ||
| 167 | - | if !attached { | 167 | + | d.exitCopyMode(ac) |
| 168 | + | if ac == nil { | |||
| 168 | return | 169 | return | ||
| 169 | } | 170 | } | ||
| 170 | d.ensureFloatingWarm(sess, tb) | 171 | d.ensureFloatingWarm(sess, tb) | ||
M internal/usecase/daemon/input.go +14 -10
| @@ -274,17 +274,21 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 274 | 274 | p.mu.Lock() | |
| 275 | 275 | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 276 | 276 | p.mu.Unlock() | |
| 277 | + | tb := sess.activeTab() | |
| 278 | + | if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) { | |
| 279 | + | mode.StartSelectionAt(document, pressTop+pressRow) | |
| 280 | + | mode.ExtendTo(document, document.Len()-document.Height+ev.Row) | |
| 281 | + | }) { | |
| 282 | + | return | |
| 283 | + | } | |
| 277 | 284 | rt.copyMu.Lock() | |
| 278 | - | mode := scopy.NewMode(document) | |
| 279 | - | mode.StartSelectionAt(document, pressTop+pressRow) | |
| 280 | - | mode.ExtendTo(document, document.Len()-document.Height+ev.Row) | |
| 281 | - | rt.copyMode = mode | |
| 282 | - | rt.copySnapshot = &document | |
| 283 | - | rt.copyPane = p | |
| 284 | - | rt.copyPressRow = pressTop + pressRow | |
| 285 | - | rt.copyPressRowValid = true | |
| 286 | - | rt.copyDragging = true | |
| 287 | - | rt.normalMousePressValid = false | |
| 285 | + | // Publication is revalidated before this state is installed, and no | |
| 286 | + | // lifecycle lock is held while copyMu is taken. | |
| 287 | + | if rt.copyPane == p && rt.copySnapshot != nil && rt.copyMode != nil { | |
| 288 | + | rt.copyPressRow = pressTop + pressRow | |
| 289 | + | rt.copyPressRowValid = true | |
| 290 | + | rt.copyDragging = true | |
| 291 | + | } | |
| 288 | 292 | rt.copyMu.Unlock() | |
| 289 | 293 | d.paint(sess, ac, true) | |
| 290 | 294 | case mouse.Release: | |
| @@ -274,17 +274,21 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 274 | p.mu.Lock() | 274 | p.mu.Lock() | ||
| 275 | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | 275 | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | ||
| 276 | p.mu.Unlock() | 276 | p.mu.Unlock() | ||
| 277 | + | tb := sess.activeTab() | |||
| 278 | + | if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) { | |||
| 279 | + | mode.StartSelectionAt(document, pressTop+pressRow) | |||
| 280 | + | mode.ExtendTo(document, document.Len()-document.Height+ev.Row) | |||
| 281 | + | }) { | |||
| 282 | + | return | |||
| 283 | + | } | |||
| 277 | rt.copyMu.Lock() | 284 | rt.copyMu.Lock() | ||
| 278 | - | mode := scopy.NewMode(document) | 285 | + | // Publication is revalidated before this state is installed, and no |
| 279 | - | mode.StartSelectionAt(document, pressTop+pressRow) | 286 | + | // lifecycle lock is held while copyMu is taken. |
| 280 | - | mode.ExtendTo(document, document.Len()-document.Height+ev.Row) | 287 | + | if rt.copyPane == p && rt.copySnapshot != nil && rt.copyMode != nil { |
| 281 | - | rt.copyMode = mode | 288 | + | rt.copyPressRow = pressTop + pressRow |
| 282 | - | rt.copySnapshot = &document | 289 | + | rt.copyPressRowValid = true |
| 283 | - | rt.copyPane = p | 290 | + | rt.copyDragging = true |
| 284 | - | rt.copyPressRow = pressTop + pressRow | 291 | + | } |
| 285 | - | rt.copyPressRowValid = true | |||
| 286 | - | rt.copyDragging = true | |||
| 287 | - | rt.normalMousePressValid = false | |||
| 288 | rt.copyMu.Unlock() | 292 | rt.copyMu.Unlock() | ||
| 289 | d.paint(sess, ac, true) | 293 | d.paint(sess, ac, true) | ||
| 290 | case mouse.Release: | 294 | case mouse.Release: | ||
M internal/usecase/daemon/mouse_panes.go +3 -0
| @@ -143,6 +143,9 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) { | |||
| 143 | 143 | return | |
| 144 | 144 | } | |
| 145 | 145 | rt := ac.overlays | |
| 146 | + | if rt == nil { | |
| 147 | + | return | |
| 148 | + | } | |
| 146 | 149 | rt.copyMu.Lock() | |
| 147 | 150 | rt.clearCopyModeLocked() | |
| 148 | 151 | rt.copyPressRowValid = false | |
| @@ -143,6 +143,9 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) { | |||||
| 143 | return | 143 | return | ||
| 144 | } | 144 | } | ||
| 145 | rt := ac.overlays | 145 | rt := ac.overlays | ||
| 146 | + | if rt == nil { | |||
| 147 | + | return | |||
| 148 | + | } | |||
| 146 | rt.copyMu.Lock() | 149 | rt.copyMu.Lock() | ||
| 147 | rt.clearCopyModeLocked() | 150 | rt.clearCopyModeLocked() | ||
| 148 | rt.copyPressRowValid = false | 151 | rt.copyPressRowValid = false | ||
M internal/usecase/daemon/pane_actions.go +19 -5
| @@ -234,6 +234,14 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 234 | 234 | } | |
| 235 | 235 | if len(layout.LeafIDs(tb.tree.Root)) <= 1 { | |
| 236 | 236 | tb.mu.Unlock() | |
| 237 | + | if ac == nil { | |
| 238 | + | sess.mu.Lock() | |
| 239 | + | ac = sess.client | |
| 240 | + | sess.mu.Unlock() | |
| 241 | + | } | |
| 242 | + | if ac != nil { | |
| 243 | + | ac.overlays.clearCopyModeForPane(p) | |
| 244 | + | } | |
| 237 | 245 | d.closeTab(sess, tb, repaint) | |
| 238 | 246 | return nil | |
| 239 | 247 | } | |
| @@ -245,6 +253,17 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 245 | 253 | d.applyLayoutLocked(tb) | |
| 246 | 254 | tb.mu.Unlock() | |
| 247 | 255 | ||
| 256 | + | // A reap/normal close can be called without the client argument. Recover | |
| 257 | + | // the attached client after releasing tab.mu so copyMu never overlaps it. | |
| 258 | + | if ac == nil { | |
| 259 | + | sess.mu.Lock() | |
| 260 | + | ac = sess.client | |
| 261 | + | sess.mu.Unlock() | |
| 262 | + | } | |
| 263 | + | if ac != nil { | |
| 264 | + | ac.overlays.clearCopyModeForPane(p) | |
| 265 | + | } | |
| 266 | + | ||
| 248 | 267 | if p.cancel != nil { | |
| 249 | 268 | p.cancel() | |
| 250 | 269 | } | |
| @@ -254,11 +273,6 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 254 | 273 | d.log.Info("pane closed", "session", sess.name, "pane", id) | |
| 255 | 274 | markSnapshotDirty(sess) | |
| 256 | 275 | if repaint { | |
| 257 | - | if ac == nil { | |
| 258 | - | sess.mu.Lock() | |
| 259 | - | ac = sess.client | |
| 260 | - | sess.mu.Unlock() | |
| 261 | - | } | |
| 262 | 276 | if ac != nil { | |
| 263 | 277 | d.paint(sess, ac, true) | |
| 264 | 278 | } | |
| @@ -234,6 +234,14 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||||
| 234 | } | 234 | } | ||
| 235 | if len(layout.LeafIDs(tb.tree.Root)) <= 1 { | 235 | if len(layout.LeafIDs(tb.tree.Root)) <= 1 { | ||
| 236 | tb.mu.Unlock() | 236 | tb.mu.Unlock() | ||
| 237 | + | if ac == nil { | |||
| 238 | + | sess.mu.Lock() | |||
| 239 | + | ac = sess.client | |||
| 240 | + | sess.mu.Unlock() | |||
| 241 | + | } | |||
| 242 | + | if ac != nil { | |||
| 243 | + | ac.overlays.clearCopyModeForPane(p) | |||
| 244 | + | } | |||
| 237 | d.closeTab(sess, tb, repaint) | 245 | d.closeTab(sess, tb, repaint) | ||
| 238 | return nil | 246 | return nil | ||
| 239 | } | 247 | } | ||
| @@ -245,6 +253,17 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||||
| 245 | d.applyLayoutLocked(tb) | 253 | d.applyLayoutLocked(tb) | ||
| 246 | tb.mu.Unlock() | 254 | tb.mu.Unlock() | ||
| 247 | 255 | ||||
| 256 | + | // A reap/normal close can be called without the client argument. Recover | |||
| 257 | + | // the attached client after releasing tab.mu so copyMu never overlaps it. | |||
| 258 | + | if ac == nil { | |||
| 259 | + | sess.mu.Lock() | |||
| 260 | + | ac = sess.client | |||
| 261 | + | sess.mu.Unlock() | |||
| 262 | + | } | |||
| 263 | + | if ac != nil { | |||
| 264 | + | ac.overlays.clearCopyModeForPane(p) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 248 | if p.cancel != nil { | 267 | if p.cancel != nil { | ||
| 249 | p.cancel() | 268 | p.cancel() | ||
| 250 | } | 269 | } | ||
| @@ -254,11 +273,6 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||||
| 254 | d.log.Info("pane closed", "session", sess.name, "pane", id) | 273 | d.log.Info("pane closed", "session", sess.name, "pane", id) | ||
| 255 | markSnapshotDirty(sess) | 274 | markSnapshotDirty(sess) | ||
| 256 | if repaint { | 275 | if repaint { | ||
| 257 | - | if ac == nil { | |||
| 258 | - | sess.mu.Lock() | |||
| 259 | - | ac = sess.client | |||
| 260 | - | sess.mu.Unlock() | |||
| 261 | - | } | |||
| 262 | if ac != nil { | 276 | if ac != nil { | ||
| 263 | d.paint(sess, ac, true) | 277 | d.paint(sess, ac, true) | ||
| 264 | } | 278 | } | ||