test(daemon): specify transactional resize epochs
- commit
- e24215fdfb4508a665776edcdec785a6e868675d
- author
- brice · · 2026-07-12 07:53 UTC
- parent
- 0d51098
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||