detached e24215f

test(daemon): specify transactional resize epochs

commit
e24215fdfb4508a665776edcdec785a6e868675d
author
brice · · 2026-07-12 07:53 UTC
parent
0d51098
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +149 -0
A internal/usecase/daemon/transactional_resize_test.go +149 -0
@@ -0,0 +1,149 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "sync"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/usecase/layout"
13+)
14+
15+// transactionalResizePTY is deliberately channel-scripted: a test can stop a
16+// transaction at PTY.Resize without relying on scheduler timing.
17+type transactionalResizePTY struct {
18+ mu sync.Mutex
19+ sizes []domain.Size
20+ errs []error
21+ onResize func()
22+}
23+
24+func (p *transactionalResizePTY) Resize(size domain.Size) error {
25+ p.mu.Lock()
26+ p.sizes = append(p.sizes, size)
27+ n := len(p.sizes)
28+ hook := p.onResize
29+ var err error
30+ if n <= len(p.errs) {
31+ err = p.errs[n-1]
32+ }
33+ p.mu.Unlock()
34+ if hook != nil {
35+ hook()
36+ }
37+ return err
38+}
39+func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }
40+func (*transactionalResizePTY) Write(b []byte) (int, error) { return len(b), nil }
41+func (*transactionalResizePTY) Close() error { return nil }
42+func (*transactionalResizePTY) Pid() int { return 0 }
43+func (*transactionalResizePTY) ForegroundPgid() (int, error) { return 0, nil }
44+func (p *transactionalResizePTY) requested() []domain.Size {
45+ p.mu.Lock()
46+ defer p.mu.Unlock()
47+ return append([]domain.Size(nil), p.sizes...)
48+}
49+
50+// S3 acceptance: prepare may inspect layout under its locks, but apply must
51+// invoke PTY.Resize after all session/tab/pane locks are released. This is the
52+// deadlock boundary with PTY readers and scripts which synchronously re-enter
53+// daemon state.
54+func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
55+ pty := &transactionalResizePTY{}
56+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
57+ tb := sess.activeTab()
58+ p := tb.focusedPane()
59+ var tabUnlocked, paneUnlocked bool
60+ pty.onResize = func() {
61+ tabUnlocked = tb.mu.TryLock()
62+ if tabUnlocked {
63+ tb.mu.Unlock()
64+ }
65+ paneUnlocked = p.mu.TryLock()
66+ if paneUnlocked {
67+ p.mu.Unlock()
68+ }
69+ }
70+
71+ d.resize(sess, nil, domain.Size{Cols: 100, Rows: 30})
72+
73+ require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
74+ require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
75+}
76+
77+// S3 acceptance: a failed member never publishes speculative parser/screen or
78+// rectangle state. Successful members and the client layout commit together;
79+// composition clips/pads the retained failed screen against that new layout.
80+func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testing.T) {
81+ ok := &transactionalResizePTY{}
82+ failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
83+ d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
84+ tb := sess.activeTab()
85+ second := newPane("pane-2", failed, domain.Size{Cols: 80, Rows: 23})
86+ tb.mu.Lock()
87+ tb.panes[second.id] = second
88+ 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-1"}
89+ first := tb.panes["pane-1"]
90+ oldFailedRect := second.rect
91+ oldFailedScreen := domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height}
92+ tb.mu.Unlock()
93+
94+ d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20})
95+
96+ require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size, "client layout commits as one epoch")
97+ require.Equal(t, domain.Rect{Width: 30, Height: 18}, first.rect)
98+ require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, second.rect,
99+ "all pane rectangles publish with the committed layout")
100+ require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested())
101+ require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested())
102+ require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height},
103+ "failed PTY retains its parser/screen size for clipped padded composition")
104+ require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")
105+}
106+
107+// Stale lifecycle callbacks may never advance a transaction owned by a
108+// replacement attachment. The coordinator test clock drives callbacks through
109+// channels elsewhere in this package; this ownership assertion is deliberately
110+// synchronous so it cannot hide a race behind a sleep.
111+func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
112+ rc := newRenderCoordinator(renderCoordinatorOptions{})
113+ old, replacement := &attachedClient{}, &attachedClient{}
114+ rc.attach(old)
115+ require.Equal(t, uint64(1), rc.recordResizeRequest(domain.Size{Cols: 90, Rows: 24}, old))
116+ require.Equal(t, uint64(2), rc.recordResizeRequest(domain.Size{Cols: 100, Rows: 26}, old))
117+ require.Equal(t, uint64(3), rc.recordResizeRequest(domain.Size{Cols: 120, Rows: 30}, old))
118+ rc.noteReplace(old, replacement)
119+ rc.attach(replacement)
120+ require.Zero(t, rc.recordResizeRequest(domain.Size{Cols: 20, Rows: 5}, old),
121+ "obsolete attach/replace/detach/park/resume callbacks cannot publish")
122+ snap := rc.resizeSnapshot()
123+ require.Equal(t, uint64(3), snap.epoch)
124+ require.Equal(t, domain.Size{Cols: 120, Rows: 30}, snap.size)
125+}
126+
127+// Entering copy mode is invalidated at transaction prepare time, while a
128+// blocked apply keeps the old committed pane rectangle visible to mouse and
129+// composition. Channels, rather than a delay, make the publication boundary
130+// deterministic.
131+func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {
132+ entered, release := make(chan struct{}), make(chan struct{})
133+ pty := &transactionalResizePTY{onResize: func() { close(entered); <-release }}
134+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
135+ tb := sess.activeTab()
136+ p := tb.focusedPane()
137+ old := p.rect
138+ d.enterCopyMode(sess, ac)
139+ done := make(chan struct{})
140+ go func() {
141+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
142+ close(done)
143+ }()
144+ <-entered
145+ require.False(t, ac.overlays.copyActive(), "prepare invalidates copy mode and visual search")
146+ require.Equal(t, old, p.rect, "mouse uses the old committed geometry before publication")
147+ close(release)
148+ <-done
149+}
@@ -0,0 +1,149 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "sync"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/usecase/layout"
13+)
14+
15+// transactionalResizePTY is deliberately channel-scripted: a test can stop a
16+// transaction at PTY.Resize without relying on scheduler timing.
17+type transactionalResizePTY struct {
18+ mu sync.Mutex
19+ sizes []domain.Size
20+ errs []error
21+ onResize func()
22+}
23+
24+func (p *transactionalResizePTY) Resize(size domain.Size) error {
25+ p.mu.Lock()
26+ p.sizes = append(p.sizes, size)
27+ n := len(p.sizes)
28+ hook := p.onResize
29+ var err error
30+ if n <= len(p.errs) {
31+ err = p.errs[n-1]
32+ }
33+ p.mu.Unlock()
34+ if hook != nil {
35+ hook()
36+ }
37+ return err
38+}
39+func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }
40+func (*transactionalResizePTY) Write(b []byte) (int, error) { return len(b), nil }
41+func (*transactionalResizePTY) Close() error { return nil }
42+func (*transactionalResizePTY) Pid() int { return 0 }
43+func (*transactionalResizePTY) ForegroundPgid() (int, error) { return 0, nil }
44+func (p *transactionalResizePTY) requested() []domain.Size {
45+ p.mu.Lock()
46+ defer p.mu.Unlock()
47+ return append([]domain.Size(nil), p.sizes...)
48+}
49+
50+// S3 acceptance: prepare may inspect layout under its locks, but apply must
51+// invoke PTY.Resize after all session/tab/pane locks are released. This is the
52+// deadlock boundary with PTY readers and scripts which synchronously re-enter
53+// daemon state.
54+func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
55+ pty := &transactionalResizePTY{}
56+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
57+ tb := sess.activeTab()
58+ p := tb.focusedPane()
59+ var tabUnlocked, paneUnlocked bool
60+ pty.onResize = func() {
61+ tabUnlocked = tb.mu.TryLock()
62+ if tabUnlocked {
63+ tb.mu.Unlock()
64+ }
65+ paneUnlocked = p.mu.TryLock()
66+ if paneUnlocked {
67+ p.mu.Unlock()
68+ }
69+ }
70+
71+ d.resize(sess, nil, domain.Size{Cols: 100, Rows: 30})
72+
73+ require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
74+ require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
75+}
76+
77+// S3 acceptance: a failed member never publishes speculative parser/screen or
78+// rectangle state. Successful members and the client layout commit together;
79+// composition clips/pads the retained failed screen against that new layout.
80+func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testing.T) {
81+ ok := &transactionalResizePTY{}
82+ failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
83+ d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
84+ tb := sess.activeTab()
85+ second := newPane("pane-2", failed, domain.Size{Cols: 80, Rows: 23})
86+ tb.mu.Lock()
87+ tb.panes[second.id] = second
88+ 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-1"}
89+ first := tb.panes["pane-1"]
90+ oldFailedRect := second.rect
91+ oldFailedScreen := domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height}
92+ tb.mu.Unlock()
93+
94+ d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20})
95+
96+ require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size, "client layout commits as one epoch")
97+ require.Equal(t, domain.Rect{Width: 30, Height: 18}, first.rect)
98+ require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, second.rect,
99+ "all pane rectangles publish with the committed layout")
100+ require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested())
101+ require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested())
102+ require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height},
103+ "failed PTY retains its parser/screen size for clipped padded composition")
104+ require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")
105+}
106+
107+// Stale lifecycle callbacks may never advance a transaction owned by a
108+// replacement attachment. The coordinator test clock drives callbacks through
109+// channels elsewhere in this package; this ownership assertion is deliberately
110+// synchronous so it cannot hide a race behind a sleep.
111+func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
112+ rc := newRenderCoordinator(renderCoordinatorOptions{})
113+ old, replacement := &attachedClient{}, &attachedClient{}
114+ rc.attach(old)
115+ require.Equal(t, uint64(1), rc.recordResizeRequest(domain.Size{Cols: 90, Rows: 24}, old))
116+ require.Equal(t, uint64(2), rc.recordResizeRequest(domain.Size{Cols: 100, Rows: 26}, old))
117+ require.Equal(t, uint64(3), rc.recordResizeRequest(domain.Size{Cols: 120, Rows: 30}, old))
118+ rc.noteReplace(old, replacement)
119+ rc.attach(replacement)
120+ require.Zero(t, rc.recordResizeRequest(domain.Size{Cols: 20, Rows: 5}, old),
121+ "obsolete attach/replace/detach/park/resume callbacks cannot publish")
122+ snap := rc.resizeSnapshot()
123+ require.Equal(t, uint64(3), snap.epoch)
124+ require.Equal(t, domain.Size{Cols: 120, Rows: 30}, snap.size)
125+}
126+
127+// Entering copy mode is invalidated at transaction prepare time, while a
128+// blocked apply keeps the old committed pane rectangle visible to mouse and
129+// composition. Channels, rather than a delay, make the publication boundary
130+// deterministic.
131+func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {
132+ entered, release := make(chan struct{}), make(chan struct{})
133+ pty := &transactionalResizePTY{onResize: func() { close(entered); <-release }}
134+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
135+ tb := sess.activeTab()
136+ p := tb.focusedPane()
137+ old := p.rect
138+ d.enterCopyMode(sess, ac)
139+ done := make(chan struct{})
140+ go func() {
141+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
142+ close(done)
143+ }()
144+ <-entered
145+ require.False(t, ac.overlays.copyActive(), "prepare invalidates copy mode and visual search")
146+ require.Equal(t, old, p.rect, "mouse uses the old committed geometry before publication")
147+ close(release)
148+ <-done
149+}

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