detached 932410a

fix(daemon): fence attachment lifecycle races

commit
932410a877d454de95875fbd8d67ca98e1046f45
author
brice · · 2026-08-03 06:37 UTC
committer
brice · · 2026-08-03 20:53 UTC
parent
308292f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
29 files · +210 -1556
M internal/usecase/daemon/attachment_connection.go +19 -27
@@ -27,46 +27,38 @@ type attachmentConnectionToken struct {
2727 rebase bool
2828 }
2929
30-// attachmentTokenLocked captures the exact registered attachment, transport
31-// incarnation, generation, and coordinator lease. Caller holds entry.core().mu.
32-func attachmentTokenLocked(entry attachmentSession, ac *attachedClient) attachmentConnectionToken {
33- if entry == nil || entry.core() == nil || ac == nil || !attachmentRegisteredLocked(entry, ac) {
30+func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
31+ if entry == nil || entry.core() == nil || ac == nil || tr == nil {
3432 return attachmentConnectionToken{}
3533 }
34+ // Capture one concrete link incarnation before taking the session lock. The
35+ // same snapshot is revalidated below; do not take a second snapshot and
36+ // accidentally bind a replacement link.
3637 transport := ac.transportSnapshot()
37- if transport.transport == nil {
38+ if transport.transport != tr {
3839 return attachmentConnectionToken{}
3940 }
41+ if ac.beforeAttachmentTokenValidation != nil {
42+ ac.beforeAttachmentTokenValidation()
43+ }
44+ core := entry.core()
45+ core.mu.Lock()
46+ if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(transport) {
47+ core.mu.Unlock()
48+ return attachmentConnectionToken{}
49+ }
50+ generation := ac.connectionGeneration.Load()
4051 token := attachmentConnectionToken{
4152 sess: entry,
4253 ac: ac,
43- generation: ac.connectionGeneration.Load(),
54+ generation: generation,
4455 transport: transport,
4556 }
46- if rc := entry.core().coordinator.Load(); rc != nil {
57+ if rc := core.coordinator.Load(); rc != nil {
4758 token.lease = rc.attachmentLease(ac)
4859 }
49- return token
50-}
51-
52-func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentConnectionToken {
53- return attachmentTokenLocked(s, ac)
54-}
55-
56-func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
57- if entry == nil || entry.core() == nil || ac == nil || tr == nil {
58- return attachmentConnectionToken{}
59- }
60- core := entry.core()
61- core.mu.Lock()
62- transport := ac.transportSnapshot()
63- if transport.transport != tr {
64- core.mu.Unlock()
65- return attachmentConnectionToken{}
66- }
67- token := attachmentTokenLocked(entry, ac)
6860 core.mu.Unlock()
69- if token.ac == nil {
61+ if !token.current() {
7062 return attachmentConnectionToken{}
7163 }
7264 ac.bootstrapAttachmentCapability(token)
@@ -27,46 +27,38 @@ type attachmentConnectionToken struct {
27 rebase bool27 rebase bool
28 }28 }
29 29
30-// attachmentTokenLocked captures the exact registered attachment, transport30+func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
31-// incarnation, generation, and coordinator lease. Caller holds entry.core().mu.31+ if entry == nil || entry.core() == nil || ac == nil || tr == nil {
32-func attachmentTokenLocked(entry attachmentSession, ac *attachedClient) attachmentConnectionToken {
33- if entry == nil || entry.core() == nil || ac == nil || !attachmentRegisteredLocked(entry, ac) {
34 return attachmentConnectionToken{}32 return attachmentConnectionToken{}
35 }33 }
34+ // Capture one concrete link incarnation before taking the session lock. The
35+ // same snapshot is revalidated below; do not take a second snapshot and
36+ // accidentally bind a replacement link.
36 transport := ac.transportSnapshot()37 transport := ac.transportSnapshot()
37- if transport.transport == nil {38+ if transport.transport != tr {
38 return attachmentConnectionToken{}39 return attachmentConnectionToken{}
39 }40 }
41+ if ac.beforeAttachmentTokenValidation != nil {
42+ ac.beforeAttachmentTokenValidation()
43+ }
44+ core := entry.core()
45+ core.mu.Lock()
46+ if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(transport) {
47+ core.mu.Unlock()
48+ return attachmentConnectionToken{}
49+ }
50+ generation := ac.connectionGeneration.Load()
40 token := attachmentConnectionToken{51 token := attachmentConnectionToken{
41 sess: entry,52 sess: entry,
42 ac: ac,53 ac: ac,
43- generation: ac.connectionGeneration.Load(),54+ generation: generation,
44 transport: transport,55 transport: transport,
45 }56 }
46- if rc := entry.core().coordinator.Load(); rc != nil {57+ if rc := core.coordinator.Load(); rc != nil {
47 token.lease = rc.attachmentLease(ac)58 token.lease = rc.attachmentLease(ac)
48 }59 }
49- return token
50-}
51-
52-func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentConnectionToken {
53- return attachmentTokenLocked(s, ac)
54-}
55-
56-func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
57- if entry == nil || entry.core() == nil || ac == nil || tr == nil {
58- return attachmentConnectionToken{}
59- }
60- core := entry.core()
61- core.mu.Lock()
62- transport := ac.transportSnapshot()
63- if transport.transport != tr {
64- core.mu.Unlock()
65- return attachmentConnectionToken{}
66- }
67- token := attachmentTokenLocked(entry, ac)
68 core.mu.Unlock()60 core.mu.Unlock()
69- if token.ac == nil {61+ if !token.current() {
70 return attachmentConnectionToken{}62 return attachmentConnectionToken{}
71 }63 }
72 ac.bootstrapAttachmentCapability(token)64 ac.bootstrapAttachmentCapability(token)
M internal/usecase/daemon/attachment_effect_gate_test.go +0 -19
@@ -408,25 +408,6 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
408408 requireRoleGateRetired(t, active)
409409 }
410410
411-type renderFailureTransport struct {
412- mu sync.Mutex
413- closed bool
414-}
415-
416-func (*renderFailureTransport) Send(ports.Frame) error { return errors.New("render send failed") }
417-func (*renderFailureTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
418-func (t *renderFailureTransport) Close() error {
419- t.mu.Lock()
420- t.closed = true
421- t.mu.Unlock()
422- return nil
423-}
424-func (t *renderFailureTransport) Closed() bool {
425- t.mu.Lock()
426- defer t.mu.Unlock()
427- return t.closed
428-}
429-
430411 func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing.T) {
431412 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
432413 transport := &closeTrackingTransport{}
@@ -408,25 +408,6 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
408 requireRoleGateRetired(t, active)408 requireRoleGateRetired(t, active)
409 }409 }
410 410
411-type renderFailureTransport struct {
412- mu sync.Mutex
413- closed bool
414-}
415-
416-func (*renderFailureTransport) Send(ports.Frame) error { return errors.New("render send failed") }
417-func (*renderFailureTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
418-func (t *renderFailureTransport) Close() error {
419- t.mu.Lock()
420- t.closed = true
421- t.mu.Unlock()
422- return nil
423-}
424-func (t *renderFailureTransport) Closed() bool {
425- t.mu.Lock()
426- defer t.mu.Unlock()
427- return t.closed
428-}
429-
430 func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing.T) {411 func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing.T) {
431 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})412 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
432 transport := &closeTrackingTransport{}413 transport := &closeTrackingTransport{}
M internal/usecase/daemon/attachment_transition_publish.go +19 -8
@@ -1,6 +1,10 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/domain"
3+import (
4+ "slices"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
48
59 // transitionSourcePreflightLocked validates an initiating connection while its
610 // effect gate is frozen and d.mu is held. No target is created before this
@@ -64,12 +68,7 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect
6468 }
6569
6670 func containsTab(tabs []*tab, want *tab) bool {
67- for _, tb := range tabs {
68- if tb == want {
69- return true
70- }
71- }
72- return false
71+ return slices.Contains(tabs, want)
7372 }
7473
7574 // transitionAttachmentLocked is the publication half of transitionAttachment.
@@ -133,28 +132,40 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
133132
134133 sourceCoordinator := sourceCore.coordinator.Load()
135134 targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
135+ // transitionSourceTokenCurrentLocked also reads the source lease. Hold the
136+ // source coordinator lock before calling it, following session -> coordinator
137+ // ordering and preventing a lease rebind from racing validation.
138+ releaseCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator)
139+ if d.afterAttachmentTransitionCoordinatorsLocked != nil {
140+ d.afterAttachmentTransitionCoordinatorsLocked()
141+ }
136142 if req.sourceToken != nil && !transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) {
143+ releaseCoordinators()
137144 return nil, errAttachmentTransition
138145 }
139146 if source != req.target && !attachmentRegisteredLocked(source, req.next) {
147+ releaseCoordinators()
140148 return nil, errAttachmentTransition
141149 }
142150 if !req.preserveAttachment && source != req.target && attachmentRegisteredLocked(req.target, req.next) {
151+ releaseCoordinators()
143152 return nil, errAttachmentTransition
144153 }
145154 if req.preserveAttachment && !attachmentRegisteredLocked(req.target, req.next) {
155+ releaseCoordinators()
146156 return nil, errAttachmentTransition
147157 }
148158 if !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) ||
149159 req.activateTargetTab && !req.target.validTargetTabLocked(req.targetTabIndex) ||
150160 req.sourceToken != nil && !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab) {
161+ releaseCoordinators()
151162 return nil, errAttachmentTransition
152163 }
153164
154165 publication := &attachmentPublication{
155166 req: req, source: source,
156167 sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
157- releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),
168+ releaseCoordinators: releaseCoordinators,
158169 }
159170 return publication, nil
160171 }
@@ -1,6 +1,10 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/domain"3+import (
4+ "slices"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
4 8
5 // transitionSourcePreflightLocked validates an initiating connection while its9 // transitionSourcePreflightLocked validates an initiating connection while its
6 // effect gate is frozen and d.mu is held. No target is created before this10 // effect gate is frozen and d.mu is held. No target is created before this
@@ -64,12 +68,7 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect
64 }68 }
65 69
66 func containsTab(tabs []*tab, want *tab) bool {70 func containsTab(tabs []*tab, want *tab) bool {
67- for _, tb := range tabs {71+ return slices.Contains(tabs, want)
68- if tb == want {
69- return true
70- }
71- }
72- return false
73 }72 }
74 73
75 // transitionAttachmentLocked is the publication half of transitionAttachment.74 // transitionAttachmentLocked is the publication half of transitionAttachment.
@@ -133,28 +132,40 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
133 132
134 sourceCoordinator := sourceCore.coordinator.Load()133 sourceCoordinator := sourceCore.coordinator.Load()
135 targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(req.target)134 targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
135+ // transitionSourceTokenCurrentLocked also reads the source lease. Hold the
136+ // source coordinator lock before calling it, following session -> coordinator
137+ // ordering and preventing a lease rebind from racing validation.
138+ releaseCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator)
139+ if d.afterAttachmentTransitionCoordinatorsLocked != nil {
140+ d.afterAttachmentTransitionCoordinatorsLocked()
141+ }
136 if req.sourceToken != nil && !transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) {142 if req.sourceToken != nil && !transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) {
143+ releaseCoordinators()
137 return nil, errAttachmentTransition144 return nil, errAttachmentTransition
138 }145 }
139 if source != req.target && !attachmentRegisteredLocked(source, req.next) {146 if source != req.target && !attachmentRegisteredLocked(source, req.next) {
147+ releaseCoordinators()
140 return nil, errAttachmentTransition148 return nil, errAttachmentTransition
141 }149 }
142 if !req.preserveAttachment && source != req.target && attachmentRegisteredLocked(req.target, req.next) {150 if !req.preserveAttachment && source != req.target && attachmentRegisteredLocked(req.target, req.next) {
151+ releaseCoordinators()
143 return nil, errAttachmentTransition152 return nil, errAttachmentTransition
144 }153 }
145 if req.preserveAttachment && !attachmentRegisteredLocked(req.target, req.next) {154 if req.preserveAttachment && !attachmentRegisteredLocked(req.target, req.next) {
155+ releaseCoordinators()
146 return nil, errAttachmentTransition156 return nil, errAttachmentTransition
147 }157 }
148 if !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) ||158 if !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) ||
149 req.activateTargetTab && !req.target.validTargetTabLocked(req.targetTabIndex) ||159 req.activateTargetTab && !req.target.validTargetTabLocked(req.targetTabIndex) ||
150 req.sourceToken != nil && !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab) {160 req.sourceToken != nil && !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab) {
161+ releaseCoordinators()
151 return nil, errAttachmentTransition162 return nil, errAttachmentTransition
152 }163 }
153 164
154 publication := &attachmentPublication{165 publication := &attachmentPublication{
155 req: req, source: source,166 req: req, source: source,
156 sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,167 sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
157- releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),168+ releaseCoordinators: releaseCoordinators,
158 }169 }
159 return publication, nil170 return publication, nil
160 }171 }
M internal/usecase/daemon/client.go +3 -0
@@ -98,6 +98,9 @@ type attachedClient struct {
9898 previousSession Guarded[attachmentSession]
9999 linkMu sync.Mutex
100100 sendMu sync.Mutex
101+ // beforeAttachmentTokenValidation is a deterministic lifecycle-race seam
102+ // used only by package tests.
103+ beforeAttachmentTokenValidation func()
101104 }
102105
103106 type cursorOut struct {
@@ -98,6 +98,9 @@ type attachedClient struct {
98 previousSession Guarded[attachmentSession]98 previousSession Guarded[attachmentSession]
99 linkMu sync.Mutex99 linkMu sync.Mutex
100 sendMu sync.Mutex100 sendMu sync.Mutex
101+ // beforeAttachmentTokenValidation is a deterministic lifecycle-race seam
102+ // used only by package tests.
103+ beforeAttachmentTokenValidation func()
101 }104 }
102 105
103 type cursorOut struct {106 type cursorOut struct {
M internal/usecase/daemon/control.go +0 -4
@@ -268,10 +268,6 @@ type daemonActionRunner interface {
268268 Run(daemonActionRequest) error
269269 }
270270
271-func resolveDaemonActionTarget(sess *session) daemonActionTarget {
272- return resolveDaemonActionTargetForAttachment(sess, nil)
273-}
274-
275271 func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) daemonActionTarget {
276272 if sess == nil {
277273 return daemonActionTarget{}
@@ -268,10 +268,6 @@ type daemonActionRunner interface {
268 Run(daemonActionRequest) error268 Run(daemonActionRequest) error
269 }269 }
270 270
271-func resolveDaemonActionTarget(sess *session) daemonActionTarget {
272- return resolveDaemonActionTargetForAttachment(sess, nil)
273-}
274-
275 func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) daemonActionTarget {271 func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) daemonActionTarget {
276 if sess == nil {272 if sess == nil {
277 return daemonActionTarget{}273 return daemonActionTarget{}
D internal/usecase/daemon/copy_enter_allocation_budget_race_test.go +0 -8
@@ -1,8 +0,0 @@
1-//go:build race
2-
3-package daemon
4-
5-// Race instrumentation allocations are not part of the parent benchmark's
6-// allocation baseline. TestCopyEnterAllocationBudget still exercises copy
7-// entry and validates its result under -race.
8-const copyEnterAllocationBudgetEnabled = false
@@ -1,8 +0,0 @@
1-//go:build race
2-
3-package daemon
4-
5-// Race instrumentation allocations are not part of the parent benchmark's
6-// allocation baseline. TestCopyEnterAllocationBudget still exercises copy
7-// entry and validates its result under -race.
8-const copyEnterAllocationBudgetEnabled = false
D internal/usecase/daemon/copy_enter_allocation_budget_test.go +0 -7
@@ -1,7 +0,0 @@
1-//go:build !race
2-
3-package daemon
4-
5-// copyEnterAllocationBudgetEnabled keeps the allocation gate aligned with the
6-// non-race parent benchmark, whose allocation baseline defines the budget.
7-const copyEnterAllocationBudgetEnabled = true
@@ -1,7 +0,0 @@
1-//go:build !race
2-
3-package daemon
4-
5-// copyEnterAllocationBudgetEnabled keeps the allocation gate aligned with the
6-// non-race parent benchmark, whose allocation baseline defines the budget.
7-const copyEnterAllocationBudgetEnabled = true
M internal/usecase/daemon/daemon.go +3 -0
@@ -160,6 +160,9 @@ type Daemon struct {
160160 // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected
161161 // attachment gates are frozen and drained, before architecture publication.
162162 afterAttachmentEffectsFrozen func()
163+ // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order
164+ // seam used by transition validation tests.
165+ afterAttachmentTransitionCoordinatorsLocked func()
163166 // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only
164167 // seams. They run before role/fence work and after the pre-fence source
165168 // attachment snapshot respectively. beforeMovePaneCommit runs inside the
@@ -160,6 +160,9 @@ type Daemon struct {
160 // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected160 // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected
161 // attachment gates are frozen and drained, before architecture publication.161 // attachment gates are frozen and drained, before architecture publication.
162 afterAttachmentEffectsFrozen func()162 afterAttachmentEffectsFrozen func()
163+ // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order
164+ // seam used by transition validation tests.
165+ afterAttachmentTransitionCoordinatorsLocked func()
163 // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only166 // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only
164 // seams. They run before role/fence work and after the pre-fence source167 // seams. They run before role/fence work and after the pre-fence source
165 // attachment snapshot respectively. beforeMovePaneCommit runs inside the168 // attachment snapshot respectively. beforeMovePaneCommit runs inside the
M internal/usecase/daemon/input_test.go +0 -248
@@ -182,257 +182,9 @@ func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) {
182182 }
183183 }
184184
185-func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
186- t.Skip("legacy fixture predates attachment-owned state")
187- tests := []struct {
188- name string
189- action keys.Action
190- palette func(paletteExec) error
191- control func(controlExec) error
192- }{
193- {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }},
194- {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }},
195- {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }},
196- {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }},
197- {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }},
198- }
199-
200- for _, tt := range tests {
201- t.Run(tt.name, func(t *testing.T) {
202- d := newTestDaemon(t, nil, stubClock{})
203- sess := addControlSession(d, "work", "t_work", "p_work")
204- ac := &attachedClient{}
205- ac.setSession(sess)
206- target := resolveDaemonActionTarget(sess)
207- keySpy := &actionRunnerSpy{}
208- paletteSpy := &actionRunnerSpy{}
209- controlSpy := &actionRunnerSpy{}
210-
211- daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action, nil)
212- require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy}))
213- require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy}))
214-
215- require.Len(t, keySpy.requests, 1)
216- require.Equal(t, keySpy.requests, paletteSpy.requests)
217- require.Equal(t, keySpy.requests, controlSpy.requests)
218- require.Same(t, sess, keySpy.requests[0].target.session)
219- require.Same(t, target.tab, keySpy.requests[0].target.tab)
220- require.Same(t, target.pane, keySpy.requests[0].target.pane)
221- })
222- }
223-}
224-
225-func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) {
226- t.Skip("legacy fixture predates attachment-owned state")
227- type adapterState struct {
228- tree *layout.Tree
229- placements []layout.Placement
230- ptySizes map[layout.PaneID][]domain.Size
231- generationDelta uint64
232- snapshotDirtyDelta uint64
233- }
234- tests := []struct {
235- name string
236- action keys.Action
237- palette func(paletteExec) error
238- control func(controlExec) error
239- }{
240- {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }},
241- {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }},
242- {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }},
243- {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }},
244- {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }},
245- }
246-
247- for _, tt := range tests {
248- t.Run(tt.name, func(t *testing.T) {
249- run := func(t *testing.T, adapter string, attached bool) adapterState {
250- t.Helper()
251- ptys := map[layout.PaneID]*resizeActionPTY{
252- "pane-1": {}, "pane-2": {}, "pane-3": {},
253- }
254- d, sess, ac, _ := newManualSessionWithPTYs(t, ptys["pane-1"])
255- tb := testAttachmentTab(sess)
256- p2 := newPane("pane-2", ptys["pane-2"], tb.size)
257- p3 := newPane("pane-3", ptys["pane-3"], tb.size)
258- tb.mu.Lock()
259- tb.panes[p2.id] = p2
260- tb.panes[p3.id] = p3
261- tb.tree = &layout.Tree{
262- Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
263- layout.NewLeaf("pane-1"),
264- {Kind: layout.Split, Dir: layout.Vertical, Weight: 2, Children: []*layout.Node{
265- layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
266- }},
267- }},
268- Focus: "pane-2",
269- }
270- tb.tree.Root.Children[0].Weight = 1
271- tb.tree.Root.Children[1].Children[0].Weight = 1
272- tb.tree.Root.Children[1].Children[1].Weight = 2
273- generation := tb.layoutGeneration
274- tb.mu.Unlock()
275- sess.snapEligible.Store(true)
276- sess.snapshotMu.Lock()
277- dirty := sess.snapshotGeneration
278- sess.snapshotMu.Unlock()
279-
280- invalidations := make(chan renderInvalidation, 2)
281- rc := newRenderCoordinator(renderCoordinatorOptions{
282- wake: func(renderWake) {},
283- onInvalidate: func(inv renderInvalidation) { invalidations <- inv },
284- })
285- sess.installRenderCoordinator(rc)
286- if attached {
287- rc.attach(ac)
288- } else {
289- sess.mu.Lock()
290- clearAttachmentsForTestLocked(sess)
291- sess.mu.Unlock()
292- }
293-
294- switch adapter {
295- case "key":
296- daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil)
297- case "palette":
298- require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac}))
299- case "control":
300- target := resolveDaemonActionTarget(sess)
301- require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target}))
302- default:
303- t.Fatalf("unknown adapter %q", adapter)
304- }
305-
306- tb.mu.Lock()
307- tree := tb.tree.Clone()
308- gotGeneration := tb.layoutGeneration
309- placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
310- tb.mu.Unlock()
311- require.True(t, ok)
312- sess.snapshotMu.Lock()
313- gotDirty := sess.snapshotGeneration
314- sess.snapshotMu.Unlock()
315-
316- gotPTY := make(map[layout.PaneID][]domain.Size, len(ptys))
317- for id, pty := range ptys {
318- gotPTY[id] = pty.Sizes()
319- }
320- for _, placement := range placements {
321- require.Equal(t, []domain.Size{{Cols: placement.Content.Width, Rows: placement.Content.Height}}, gotPTY[placement.ID], "canonical PTY resize for %s", placement.ID)
322- }
323- if attached {
324- awaitInvalidation(t, invalidations)
325- requireNoInvalidation(t, invalidations)
326- } else {
327- requireNoInvalidation(t, invalidations)
328- }
329- return adapterState{tree: tree, placements: placements, ptySizes: gotPTY, generationDelta: gotGeneration - generation, snapshotDirtyDelta: gotDirty - dirty}
330- }
331-
332- keyState := run(t, "key", true)
333- require.Equal(t, uint64(1), keyState.generationDelta)
334- require.Equal(t, uint64(1), keyState.snapshotDirtyDelta)
335- require.Equal(t, keyState, run(t, "palette", true))
336- require.Equal(t, keyState, run(t, "control", true))
337- headless := run(t, "control", false)
338- require.Equal(t, uint64(1), headless.generationDelta)
339- require.Equal(t, uint64(1), headless.snapshotDirtyDelta)
340- require.Equal(t, keyState, headless)
341- })
342- }
343-}
344-
345-type resizeActionPTY struct {
346- mu sync.Mutex
347- sizes []domain.Size
348-}
349-
350-func (*resizeActionPTY) Read([]byte) (int, error) { return 0, nil }
351-func (*resizeActionPTY) Write(b []byte) (int, error) { return len(b), nil }
352-func (*resizeActionPTY) Close() error { return nil }
353-func (*resizeActionPTY) Pid() int { return 0 }
354-func (*resizeActionPTY) ForegroundPgid() (int, error) { return 0, nil }
355-func (p *resizeActionPTY) Resize(size domain.Size) error {
356- p.mu.Lock()
357- p.sizes = append(p.sizes, size)
358- p.mu.Unlock()
359- return nil
360-}
361-
362-func (p *resizeActionPTY) Sizes() []domain.Size {
363- p.mu.Lock()
364- defer p.mu.Unlock()
365- return append([]domain.Size(nil), p.sizes...)
366-}
367-
368185 // stubClock returns timers whose channel never fires, so a scheduler under it
369186 // blocks in its debounce loop until the session context is cancelled. Used by
370187
371-func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
372- t.Skip("legacy fixture predates attachment-owned state")
373- writes1 := make(chan []byte, 1)
374- writes2 := make(chan []byte, 1)
375- writes3 := make(chan []byte, 1)
376- p1, releasePTY1 := newBlockingPTYWithWrites(t, writes1)
377- p2, releasePTY2 := newBlockingPTYWithWrites(t, writes2)
378- p3, releasePTY3 := newBlockingPTYWithWrites(t, writes3)
379- d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{})
380- tr, sends, releaseConn := newConn(t,
381- mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
382- frameInput([]byte("\x1b ")),
383- frameInput([]byte("CNT\r")),
384- frameInput([]byte("\x1b ")),
385- frameInput([]byte("CNT\r")),
386- frameInput([]byte("\x1b1")),
387- frameInput([]byte("A")),
388- frameInput([]byte("\x1b2")),
389- frameInput([]byte("B")),
390- frameInput([]byte("\x1b3")),
391- frameInput([]byte("C")),
392- )
393-
394- var hg sync.WaitGroup
395- hg.Go(func() { d.handleConn(tr) })
396- awaitFrame(t, sends, ports.MsgWelcome)
397- awaitFrame(t, sends, ports.MsgOutput)
398-
399- require.Eventually(t, func() bool {
400- sessions := listSessions(t, d)
401- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3
402- }, 2*time.Second, 5*time.Millisecond)
403- require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
404- requirePTYWrite(t, writes1, []byte("A"))
405- require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond)
406- requirePTYWrite(t, writes2, []byte("B"))
407- require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)
408- requirePTYWrite(t, writes3, []byte("C"))
409-
410- d.mu.Lock()
411- var sess *session
412- for _, candidate := range d.sessions {
413- if local, ok := localSession(candidate); ok {
414- sess = local
415- break
416- }
417- }
418- d.mu.Unlock()
419- require.NotNil(t, sess)
420- sess.mu.Lock()
421- tabs := append([]*tab(nil), sess.tabs...)
422- sess.mu.Unlock()
423- require.Len(t, tabs, 3)
424- for _, tb := range tabs {
425- requireFloatingInitialized(t, tb)
426- }
427-
428- releaseConn()
429- releasePTY1()
430- releasePTY2()
431- releasePTY3()
432- hg.Wait()
433- d.sessWg.Wait()
434-}
435-
436188 func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
437189 d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)
438190 source := sess.tabs[0].focusedPane()
@@ -182,257 +182,9 @@ func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) {
182 }182 }
183 }183 }
184 184
185-func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
186- t.Skip("legacy fixture predates attachment-owned state")
187- tests := []struct {
188- name string
189- action keys.Action
190- palette func(paletteExec) error
191- control func(controlExec) error
192- }{
193- {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }},
194- {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }},
195- {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }},
196- {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }},
197- {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }},
198- }
199-
200- for _, tt := range tests {
201- t.Run(tt.name, func(t *testing.T) {
202- d := newTestDaemon(t, nil, stubClock{})
203- sess := addControlSession(d, "work", "t_work", "p_work")
204- ac := &attachedClient{}
205- ac.setSession(sess)
206- target := resolveDaemonActionTarget(sess)
207- keySpy := &actionRunnerSpy{}
208- paletteSpy := &actionRunnerSpy{}
209- controlSpy := &actionRunnerSpy{}
210-
211- daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action, nil)
212- require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy}))
213- require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy}))
214-
215- require.Len(t, keySpy.requests, 1)
216- require.Equal(t, keySpy.requests, paletteSpy.requests)
217- require.Equal(t, keySpy.requests, controlSpy.requests)
218- require.Same(t, sess, keySpy.requests[0].target.session)
219- require.Same(t, target.tab, keySpy.requests[0].target.tab)
220- require.Same(t, target.pane, keySpy.requests[0].target.pane)
221- })
222- }
223-}
224-
225-func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) {
226- t.Skip("legacy fixture predates attachment-owned state")
227- type adapterState struct {
228- tree *layout.Tree
229- placements []layout.Placement
230- ptySizes map[layout.PaneID][]domain.Size
231- generationDelta uint64
232- snapshotDirtyDelta uint64
233- }
234- tests := []struct {
235- name string
236- action keys.Action
237- palette func(paletteExec) error
238- control func(controlExec) error
239- }{
240- {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }},
241- {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }},
242- {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }},
243- {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }},
244- {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }},
245- }
246-
247- for _, tt := range tests {
248- t.Run(tt.name, func(t *testing.T) {
249- run := func(t *testing.T, adapter string, attached bool) adapterState {
250- t.Helper()
251- ptys := map[layout.PaneID]*resizeActionPTY{
252- "pane-1": {}, "pane-2": {}, "pane-3": {},
253- }
254- d, sess, ac, _ := newManualSessionWithPTYs(t, ptys["pane-1"])
255- tb := testAttachmentTab(sess)
256- p2 := newPane("pane-2", ptys["pane-2"], tb.size)
257- p3 := newPane("pane-3", ptys["pane-3"], tb.size)
258- tb.mu.Lock()
259- tb.panes[p2.id] = p2
260- tb.panes[p3.id] = p3
261- tb.tree = &layout.Tree{
262- Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
263- layout.NewLeaf("pane-1"),
264- {Kind: layout.Split, Dir: layout.Vertical, Weight: 2, Children: []*layout.Node{
265- layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
266- }},
267- }},
268- Focus: "pane-2",
269- }
270- tb.tree.Root.Children[0].Weight = 1
271- tb.tree.Root.Children[1].Children[0].Weight = 1
272- tb.tree.Root.Children[1].Children[1].Weight = 2
273- generation := tb.layoutGeneration
274- tb.mu.Unlock()
275- sess.snapEligible.Store(true)
276- sess.snapshotMu.Lock()
277- dirty := sess.snapshotGeneration
278- sess.snapshotMu.Unlock()
279-
280- invalidations := make(chan renderInvalidation, 2)
281- rc := newRenderCoordinator(renderCoordinatorOptions{
282- wake: func(renderWake) {},
283- onInvalidate: func(inv renderInvalidation) { invalidations <- inv },
284- })
285- sess.installRenderCoordinator(rc)
286- if attached {
287- rc.attach(ac)
288- } else {
289- sess.mu.Lock()
290- clearAttachmentsForTestLocked(sess)
291- sess.mu.Unlock()
292- }
293-
294- switch adapter {
295- case "key":
296- daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil)
297- case "palette":
298- require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac}))
299- case "control":
300- target := resolveDaemonActionTarget(sess)
301- require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target}))
302- default:
303- t.Fatalf("unknown adapter %q", adapter)
304- }
305-
306- tb.mu.Lock()
307- tree := tb.tree.Clone()
308- gotGeneration := tb.layoutGeneration
309- placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
310- tb.mu.Unlock()
311- require.True(t, ok)
312- sess.snapshotMu.Lock()
313- gotDirty := sess.snapshotGeneration
314- sess.snapshotMu.Unlock()
315-
316- gotPTY := make(map[layout.PaneID][]domain.Size, len(ptys))
317- for id, pty := range ptys {
318- gotPTY[id] = pty.Sizes()
319- }
320- for _, placement := range placements {
321- require.Equal(t, []domain.Size{{Cols: placement.Content.Width, Rows: placement.Content.Height}}, gotPTY[placement.ID], "canonical PTY resize for %s", placement.ID)
322- }
323- if attached {
324- awaitInvalidation(t, invalidations)
325- requireNoInvalidation(t, invalidations)
326- } else {
327- requireNoInvalidation(t, invalidations)
328- }
329- return adapterState{tree: tree, placements: placements, ptySizes: gotPTY, generationDelta: gotGeneration - generation, snapshotDirtyDelta: gotDirty - dirty}
330- }
331-
332- keyState := run(t, "key", true)
333- require.Equal(t, uint64(1), keyState.generationDelta)
334- require.Equal(t, uint64(1), keyState.snapshotDirtyDelta)
335- require.Equal(t, keyState, run(t, "palette", true))
336- require.Equal(t, keyState, run(t, "control", true))
337- headless := run(t, "control", false)
338- require.Equal(t, uint64(1), headless.generationDelta)
339- require.Equal(t, uint64(1), headless.snapshotDirtyDelta)
340- require.Equal(t, keyState, headless)
341- })
342- }
343-}
344-
345-type resizeActionPTY struct {
346- mu sync.Mutex
347- sizes []domain.Size
348-}
349-
350-func (*resizeActionPTY) Read([]byte) (int, error) { return 0, nil }
351-func (*resizeActionPTY) Write(b []byte) (int, error) { return len(b), nil }
352-func (*resizeActionPTY) Close() error { return nil }
353-func (*resizeActionPTY) Pid() int { return 0 }
354-func (*resizeActionPTY) ForegroundPgid() (int, error) { return 0, nil }
355-func (p *resizeActionPTY) Resize(size domain.Size) error {
356- p.mu.Lock()
357- p.sizes = append(p.sizes, size)
358- p.mu.Unlock()
359- return nil
360-}
361-
362-func (p *resizeActionPTY) Sizes() []domain.Size {
363- p.mu.Lock()
364- defer p.mu.Unlock()
365- return append([]domain.Size(nil), p.sizes...)
366-}
367-
368 // stubClock returns timers whose channel never fires, so a scheduler under it185 // stubClock returns timers whose channel never fires, so a scheduler under it
369 // blocks in its debounce loop until the session context is cancelled. Used by186 // blocks in its debounce loop until the session context is cancelled. Used by
370 187
371-func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
372- t.Skip("legacy fixture predates attachment-owned state")
373- writes1 := make(chan []byte, 1)
374- writes2 := make(chan []byte, 1)
375- writes3 := make(chan []byte, 1)
376- p1, releasePTY1 := newBlockingPTYWithWrites(t, writes1)
377- p2, releasePTY2 := newBlockingPTYWithWrites(t, writes2)
378- p3, releasePTY3 := newBlockingPTYWithWrites(t, writes3)
379- d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{})
380- tr, sends, releaseConn := newConn(t,
381- mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
382- frameInput([]byte("\x1b ")),
383- frameInput([]byte("CNT\r")),
384- frameInput([]byte("\x1b ")),
385- frameInput([]byte("CNT\r")),
386- frameInput([]byte("\x1b1")),
387- frameInput([]byte("A")),
388- frameInput([]byte("\x1b2")),
389- frameInput([]byte("B")),
390- frameInput([]byte("\x1b3")),
391- frameInput([]byte("C")),
392- )
393-
394- var hg sync.WaitGroup
395- hg.Go(func() { d.handleConn(tr) })
396- awaitFrame(t, sends, ports.MsgWelcome)
397- awaitFrame(t, sends, ports.MsgOutput)
398-
399- require.Eventually(t, func() bool {
400- sessions := listSessions(t, d)
401- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3
402- }, 2*time.Second, 5*time.Millisecond)
403- require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
404- requirePTYWrite(t, writes1, []byte("A"))
405- require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond)
406- requirePTYWrite(t, writes2, []byte("B"))
407- require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)
408- requirePTYWrite(t, writes3, []byte("C"))
409-
410- d.mu.Lock()
411- var sess *session
412- for _, candidate := range d.sessions {
413- if local, ok := localSession(candidate); ok {
414- sess = local
415- break
416- }
417- }
418- d.mu.Unlock()
419- require.NotNil(t, sess)
420- sess.mu.Lock()
421- tabs := append([]*tab(nil), sess.tabs...)
422- sess.mu.Unlock()
423- require.Len(t, tabs, 3)
424- for _, tb := range tabs {
425- requireFloatingInitialized(t, tb)
426- }
427-
428- releaseConn()
429- releasePTY1()
430- releasePTY2()
431- releasePTY3()
432- hg.Wait()
433- d.sessWg.Wait()
434-}
435-
436 func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {188 func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
437 d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)189 d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)
438 source := sess.tabs[0].focusedPane()190 source := sess.tabs[0].focusedPane()
A internal/usecase/daemon/lifecycle_fence_test.go +80 -0
@@ -0,0 +1,80 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation(t *testing.T) {
11+ d := newTestDaemon(t, nil, stubClock{})
12+ transport := &closeTrackingTransport{}
13+ source := &session{sessionCore: sessionCore{id: domain.SessionID("source")}}
14+ target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}}
15+ ac := &attachedClient{tr: transport}
16+ ac.setSession(source)
17+ source.mu.Lock()
18+ require.True(t, source.registerAttachmentLocked(ac))
19+ source.mu.Unlock()
20+ sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{})
21+ source.installRenderCoordinator(sourceCoordinator)
22+ sourceCoordinator.attach(ac)
23+ token := source.attachmentToken(ac, transport)
24+ require.NotNil(t, token.lease)
25+ d.sessions[source.id] = source
26+ d.sessions[target.id] = target
27+
28+ checked := false
29+ d.afterAttachmentTransitionCoordinatorsLocked = func() {
30+ if sourceCoordinator.mu.TryLock() {
31+ sourceCoordinator.mu.Unlock()
32+ t.Fatalf("source coordinator was not held during lease validation")
33+ }
34+ checked = true
35+ }
36+ req := attachmentTransitionRequest{
37+ source: source, target: target, next: ac,
38+ expectedTransport: ac.transportSnapshot(), sourceToken: &token,
39+ preflighted: true, attachmentEffectsFrozen: true,
40+ }
41+ d.mu.Lock()
42+ d.notices.routingMu.Lock()
43+ unlockSessions := lockAttachmentSessions(source, target)
44+ publication, err := d.validateAttachmentTransitionPrelocked(req)
45+ unlockSessions()
46+ d.notices.routingMu.Unlock()
47+ d.mu.Unlock()
48+ require.NoError(t, err)
49+ require.True(t, checked)
50+ require.NotNil(t, publication)
51+ publication.unlockCoordinators()
52+}
53+
54+func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) {
55+ first := &closeTrackingTransport{}
56+ second := &closeTrackingTransport{}
57+ ac := &attachedClient{tr: first}
58+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
59+ ac.setSession(sess)
60+ sess.mu.Lock()
61+ require.True(t, sess.registerAttachmentLocked(ac))
62+ sess.mu.Unlock()
63+
64+ captured := make(chan struct{})
65+ release := make(chan struct{})
66+ ac.beforeAttachmentTokenValidation = func() {
67+ close(captured)
68+ <-release
69+ }
70+ result := make(chan attachmentConnectionToken, 1)
71+ go func() { result <- sess.attachmentToken(ac, first) }()
72+ <-captured
73+ ac.replaceTransport(second)
74+ close(release)
75+
76+ token := <-result
77+ require.Nil(t, token.ac, "a token must not bind a replacement transport")
78+ require.Equal(t, uint64(1), ac.transportSnapshot().incarnation)
79+ require.Same(t, second, ac.transport())
80+}
@@ -0,0 +1,80 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation(t *testing.T) {
11+ d := newTestDaemon(t, nil, stubClock{})
12+ transport := &closeTrackingTransport{}
13+ source := &session{sessionCore: sessionCore{id: domain.SessionID("source")}}
14+ target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}}
15+ ac := &attachedClient{tr: transport}
16+ ac.setSession(source)
17+ source.mu.Lock()
18+ require.True(t, source.registerAttachmentLocked(ac))
19+ source.mu.Unlock()
20+ sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{})
21+ source.installRenderCoordinator(sourceCoordinator)
22+ sourceCoordinator.attach(ac)
23+ token := source.attachmentToken(ac, transport)
24+ require.NotNil(t, token.lease)
25+ d.sessions[source.id] = source
26+ d.sessions[target.id] = target
27+
28+ checked := false
29+ d.afterAttachmentTransitionCoordinatorsLocked = func() {
30+ if sourceCoordinator.mu.TryLock() {
31+ sourceCoordinator.mu.Unlock()
32+ t.Fatalf("source coordinator was not held during lease validation")
33+ }
34+ checked = true
35+ }
36+ req := attachmentTransitionRequest{
37+ source: source, target: target, next: ac,
38+ expectedTransport: ac.transportSnapshot(), sourceToken: &token,
39+ preflighted: true, attachmentEffectsFrozen: true,
40+ }
41+ d.mu.Lock()
42+ d.notices.routingMu.Lock()
43+ unlockSessions := lockAttachmentSessions(source, target)
44+ publication, err := d.validateAttachmentTransitionPrelocked(req)
45+ unlockSessions()
46+ d.notices.routingMu.Unlock()
47+ d.mu.Unlock()
48+ require.NoError(t, err)
49+ require.True(t, checked)
50+ require.NotNil(t, publication)
51+ publication.unlockCoordinators()
52+}
53+
54+func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) {
55+ first := &closeTrackingTransport{}
56+ second := &closeTrackingTransport{}
57+ ac := &attachedClient{tr: first}
58+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
59+ ac.setSession(sess)
60+ sess.mu.Lock()
61+ require.True(t, sess.registerAttachmentLocked(ac))
62+ sess.mu.Unlock()
63+
64+ captured := make(chan struct{})
65+ release := make(chan struct{})
66+ ac.beforeAttachmentTokenValidation = func() {
67+ close(captured)
68+ <-release
69+ }
70+ result := make(chan attachmentConnectionToken, 1)
71+ go func() { result <- sess.attachmentToken(ac, first) }()
72+ <-captured
73+ ac.replaceTransport(second)
74+ close(release)
75+
76+ token := <-result
77+ require.Nil(t, token.ac, "a token must not bind a replacement transport")
78+ require.Equal(t, uint64(1), ac.transportSnapshot().incarnation)
79+ require.Same(t, second, ac.transport())
80+}
M internal/usecase/daemon/mouse_panes.go +0 -16
@@ -10,22 +10,6 @@ import (
1010 "github.com/bnema/vev/internal/usecase/mouse"
1111 )
1212
13-func focusedPlacementLocked(tb *tab) (layout.Placement, bool) {
14- if tb == nil || tb.tree == nil {
15- return layout.Placement{}, false
16- }
17- placements, ok := solvedPlacementsLocked(tb)
18- if !ok {
19- return layout.Placement{}, false
20- }
21- for _, pl := range placements {
22- if pl.ID == tb.tree.Focus {
23- return pl, true
24- }
25- }
26- return layout.Placement{}, false
27-}
28-
2913 func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) {
3014 if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {
3115 return layout.Placement{}, false
@@ -10,22 +10,6 @@ import (
10 "github.com/bnema/vev/internal/usecase/mouse"10 "github.com/bnema/vev/internal/usecase/mouse"
11 )11 )
12 12
13-func focusedPlacementLocked(tb *tab) (layout.Placement, bool) {
14- if tb == nil || tb.tree == nil {
15- return layout.Placement{}, false
16- }
17- placements, ok := solvedPlacementsLocked(tb)
18- if !ok {
19- return layout.Placement{}, false
20- }
21- for _, pl := range placements {
22- if pl.ID == tb.tree.Focus {
23- return pl, true
24- }
25- }
26- return layout.Placement{}, false
27-}
28-
29 func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) {13 func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) {
30 if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {14 if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {
31 return layout.Placement{}, false15 return layout.Placement{}, false
M internal/usecase/daemon/navigation_overflow.go +0 -13
@@ -134,12 +134,6 @@ type tabOverflowCandidate struct {
134134 targetOldFocus layout.PaneID
135135 }
136136
137-// prepareTabOverflow captures tab identities under the session lock and uses
138-// EntryPane's pure lookup before any active-tab state is changed.
139-func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
140- return d.prepareTabOverflowForAttachment(sess, nil, expectedSource, dir, span, delta)
141-}
142-
143137 func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClient, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
144138 if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {
145139 return tabOverflowCandidate{}, false
@@ -199,13 +193,6 @@ func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClie
199193 }, true
200194 }
201195
202-// commitTabOverflow atomically revalidates the source and target tab entries.
203-// The target layout and selected pane are revalidated while locked before the
204-// focus and active-tab mutations become visible together.
205-func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool {
206- return d.commitTabOverflowForAttachment(sess, nil, candidate)
207-}
208-
209196 func (d *Daemon) commitTabOverflowForAttachment(sess *session, ac *attachedClient, candidate tabOverflowCandidate) bool {
210197 if sess == nil || candidate.source == nil || candidate.target == nil {
211198 return false
@@ -134,12 +134,6 @@ type tabOverflowCandidate struct {
134 targetOldFocus layout.PaneID134 targetOldFocus layout.PaneID
135 }135 }
136 136
137-// prepareTabOverflow captures tab identities under the session lock and uses
138-// EntryPane's pure lookup before any active-tab state is changed.
139-func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
140- return d.prepareTabOverflowForAttachment(sess, nil, expectedSource, dir, span, delta)
141-}
142-
143 func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClient, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {137 func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClient, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
144 if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {138 if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {
145 return tabOverflowCandidate{}, false139 return tabOverflowCandidate{}, false
@@ -199,13 +193,6 @@ func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClie
199 }, true193 }, true
200 }194 }
201 195
202-// commitTabOverflow atomically revalidates the source and target tab entries.
203-// The target layout and selected pane are revalidated while locked before the
204-// focus and active-tab mutations become visible together.
205-func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool {
206- return d.commitTabOverflowForAttachment(sess, nil, candidate)
207-}
208-
209 func (d *Daemon) commitTabOverflowForAttachment(sess *session, ac *attachedClient, candidate tabOverflowCandidate) bool {196 func (d *Daemon) commitTabOverflowForAttachment(sess *session, ac *attachedClient, candidate tabOverflowCandidate) bool {
210 if sess == nil || candidate.source == nil || candidate.target == nil {197 if sess == nil || candidate.source == nil || candidate.target == nil {
211 return false198 return false
M internal/usecase/daemon/notify.go +0 -8
@@ -368,14 +368,6 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
368368 d.notify(nil, sev, code, msg, cause)
369369 }
370370
371-// showToast publishes a notice into one client's toast stack and repaints only
372-// after the state mutation is complete.
373-func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
374- if d.publishToast(ac, n) {
375- d.repaintForNotice(ac)
376- }
377-}
378-
379371 // publishToast durably mutates one client's toast state without repainting.
380372 // Routing paths call it while routingMu excludes detach, then release routingMu
381373 // before rendering so a render failure can safely route another notice.
@@ -368,14 +368,6 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
368 d.notify(nil, sev, code, msg, cause)368 d.notify(nil, sev, code, msg, cause)
369 }369 }
370 370
371-// showToast publishes a notice into one client's toast stack and repaints only
372-// after the state mutation is complete.
373-func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
374- if d.publishToast(ac, n) {
375- d.repaintForNotice(ac)
376- }
377-}
378-
379 // publishToast durably mutates one client's toast state without repainting.371 // publishToast durably mutates one client's toast state without repainting.
380 // Routing paths call it while routingMu excludes detach, then release routingMu372 // Routing paths call it while routingMu excludes detach, then release routingMu
381 // before rendering so a render failure can safely route another notice.373 // before rendering so a render failure can safely route another notice.
M internal/usecase/daemon/output_state_sync_test.go +0 -54
@@ -390,57 +390,3 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
390390 require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Frame.Width)
391391 requireNoOutputFrame(t, sends)
392392 }
393-
394-func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
395- t.Skip("legacy datagram fixture predates attachment-owned output routing")
396- p, releasePTY := newBlockingPTY(t)
397- defer releasePTY()
398- d, sess, _, _ := newManualSessionWithPTYs(t, p)
399- tr := newDatagramTestTransport()
400- _, ac, err := d.route(ports.Hello{
401- Version: ports.ProtocolVersion,
402- Intent: ports.IntentAttach,
403- Name: sess.name,
404- Size: domain.Size{Cols: 80, Rows: 24},
405- MaxOutputInFlight: 1,
406- }, tr)
407- require.NoError(t, err)
408- require.True(t, sess.renderCoordinator().markAttachmentReady(sess.renderCoordinator().attachmentLease(ac)))
409-
410- pane := sess.tabs[0].focusedPane()
411- pane.screen.Write([]byte("A"))
412- d.paint(sess, ac, false, nil)
413- first := awaitFrame(t, tr.sends, ports.MsgOutput)
414- firstOutput, err := ports.UnmarshalOutput(first.Payload)
415- require.NoError(t, err)
416- require.Equal(t, uint64(1), firstOutput.NewStateNum)
417- for range 100 {
418- pane.screen.Write([]byte("x"))
419- d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
420- }
421- pane.screen.Write([]byte("LATEST"))
422- d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
423- requireNoOutputFrame(t, tr.sends)
424- require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged")
425-
426- // Side-effect output is control-like: it must pass while the state window is
427- // full without consuming another state number.
428- require.NoError(t, d.boundedSendOutputErr(ac, []byte("side-effect")))
429- sideEffect := awaitFrame(t, tr.sends, ports.MsgOutput)
430- sideEffectOutput, err := ports.UnmarshalOutput(sideEffect.Payload)
431- require.NoError(t, err)
432- require.Zero(t, sideEffectOutput.BaseStateNum)
433- require.Zero(t, sideEffectOutput.NewStateNum)
434- require.Equal(t, uint64(1), ac.output.outstanding())
435-
436- tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: firstOutput.NewStateNum})}
437- require.NoError(t, tr.Close())
438- d.runConnLoop(ac)
439- second := awaitFrame(t, tr.sends, ports.MsgOutput)
440- secondOutput, err := ports.UnmarshalOutput(second.Payload)
441- require.NoError(t, err)
442- require.Equal(t, firstOutput.NewStateNum, secondOutput.BaseStateNum)
443- require.Equal(t, uint64(2), secondOutput.NewStateNum)
444- require.Contains(t, string(secondOutput.Data), "LATEST", "cumulative ACK must release the latest coalesced state")
445- require.Equal(t, uint64(1), ac.output.outstanding())
446-}
@@ -390,57 +390,3 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
390 require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Frame.Width)390 require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Frame.Width)
391 requireNoOutputFrame(t, sends)391 requireNoOutputFrame(t, sends)
392 }392 }
393-
394-func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
395- t.Skip("legacy datagram fixture predates attachment-owned output routing")
396- p, releasePTY := newBlockingPTY(t)
397- defer releasePTY()
398- d, sess, _, _ := newManualSessionWithPTYs(t, p)
399- tr := newDatagramTestTransport()
400- _, ac, err := d.route(ports.Hello{
401- Version: ports.ProtocolVersion,
402- Intent: ports.IntentAttach,
403- Name: sess.name,
404- Size: domain.Size{Cols: 80, Rows: 24},
405- MaxOutputInFlight: 1,
406- }, tr)
407- require.NoError(t, err)
408- require.True(t, sess.renderCoordinator().markAttachmentReady(sess.renderCoordinator().attachmentLease(ac)))
409-
410- pane := sess.tabs[0].focusedPane()
411- pane.screen.Write([]byte("A"))
412- d.paint(sess, ac, false, nil)
413- first := awaitFrame(t, tr.sends, ports.MsgOutput)
414- firstOutput, err := ports.UnmarshalOutput(first.Payload)
415- require.NoError(t, err)
416- require.Equal(t, uint64(1), firstOutput.NewStateNum)
417- for range 100 {
418- pane.screen.Write([]byte("x"))
419- d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
420- }
421- pane.screen.Write([]byte("LATEST"))
422- d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
423- requireNoOutputFrame(t, tr.sends)
424- require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged")
425-
426- // Side-effect output is control-like: it must pass while the state window is
427- // full without consuming another state number.
428- require.NoError(t, d.boundedSendOutputErr(ac, []byte("side-effect")))
429- sideEffect := awaitFrame(t, tr.sends, ports.MsgOutput)
430- sideEffectOutput, err := ports.UnmarshalOutput(sideEffect.Payload)
431- require.NoError(t, err)
432- require.Zero(t, sideEffectOutput.BaseStateNum)
433- require.Zero(t, sideEffectOutput.NewStateNum)
434- require.Equal(t, uint64(1), ac.output.outstanding())
435-
436- tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: firstOutput.NewStateNum})}
437- require.NoError(t, tr.Close())
438- d.runConnLoop(ac)
439- second := awaitFrame(t, tr.sends, ports.MsgOutput)
440- secondOutput, err := ports.UnmarshalOutput(second.Payload)
441- require.NoError(t, err)
442- require.Equal(t, firstOutput.NewStateNum, secondOutput.BaseStateNum)
443- require.Equal(t, uint64(2), secondOutput.NewStateNum)
444- require.Contains(t, string(secondOutput.Data), "LATEST", "cumulative ACK must release the latest coalesced state")
445- require.Equal(t, uint64(1), ac.output.outstanding())
446-}
M internal/usecase/daemon/pane_lifetime_test.go +0 -129
@@ -64,12 +64,6 @@ func (f *lifetimePTYFactory) Open(ctx context.Context, _ string, _ []string, _ [
6464 return p, nil
6565 }
6666
67-func (f *lifetimePTYFactory) opened(index int) (*lifetimePTY, context.Context) {
68- f.mu.Lock()
69- defer f.mu.Unlock()
70- return f.ptys[index], f.contexts[index]
71-}
72-
7367 func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
7468 type openCase struct {
7569 name string
@@ -211,126 +205,3 @@ func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []s
211205 }
212206 return f.pty, f.err
213207 }
214-
215-func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
216- t.Skip("legacy fixture predates attachment-owned state")
217- type setupFunc func(*testing.T, *Daemon) (*pane, func(), func())
218- tests := []struct {
219- name string
220- setup setupFunc
221- }{
222- {
223- name: "initial pane",
224- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
225- sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
226- require.NoError(t, err)
227- return testAttachmentTab(sess).focusedPane(), sess.cancel, func() {
228- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
229- }
230- },
231- },
232- {
233- name: "created tab",
234- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
235- sessCtx, cancelSession := context.WithCancel(t.Context())
236- base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
237- base.ctx, base.cancel = context.WithCancel(sessCtx)
238- sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
239- d.mu.Lock()
240- d.sessions[sess.id] = sess
241- d.mu.Unlock()
242- require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24}))
243- created := testAttachmentTab(sess)
244- return created.focusedPane(), cancelSession, created.closeAllPanes
245- },
246- },
247- {
248- name: "split pane",
249- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
250- sessCtx, cancelSession := context.WithCancel(t.Context())
251- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
252- tb.ctx, tb.cancel = context.WithCancel(sessCtx)
253- sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
254- target := tb.focusedPane()
255- require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right))
256- tb.mu.Lock()
257- p := tb.panes["pane-2"]
258- tb.mu.Unlock()
259- require.NotNil(t, p)
260- return p, cancelSession, tb.closeAllPanes
261- },
262- },
263- {
264- name: "floating pane",
265- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
266- sessCtx, cancelSession := context.WithCancel(t.Context())
267- tb := newFloatingTestTab(t)
268- tb.ctx, tb.cancel = context.WithCancel(sessCtx)
269- sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
270- tb.mu.Lock()
271- generation := tb.beginFloatingWarmLocked(true)
272- tb.mu.Unlock()
273- d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
274- sessionName: "work", size: domain.Size{Cols: 20, Rows: 8},
275- geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
276- paneStableID: "p_floating", parentCtx: tb.ctx,
277- }, generation)
278- tb.mu.Lock()
279- p := tb.floating.pane
280- tb.mu.Unlock()
281- require.NotNil(t, p)
282- return p, cancelSession, func() { d.teardownFloating(tb, nil) }
283- },
284- },
285- {
286- name: "restored pane",
287- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
288- restoreCtx, cancelRestore := context.WithCancel(t.Context())
289- sessCtx, cancelSession := context.WithCancel(t.Context())
290- tabs, err := d.restoreSnapshotTabs(restoreCtx, sessCtx, restoreAcceptanceSession(t, "work"))
291- require.NoError(t, err)
292- require.Len(t, tabs, 1)
293- return tabs[0].focusedPane(), cancelSession, func() {
294- cancelRestore()
295- closeRestoredTabs(tabs)
296- }
297- },
298- },
299- }
300-
301- for _, tt := range tests {
302- t.Run(tt.name, func(t *testing.T) {
303- factory := &lifetimePTYFactory{}
304- d := newTestDaemon(t, factory, stubClock{})
305- p, cancelSession, teardown := tt.setup(t, d)
306- defer func() {
307- if teardown != nil {
308- teardown()
309- }
310- }()
311-
312- require.NotNil(t, p)
313- pty, openCtx := factory.opened(0)
314- require.NotNil(t, p.ctx)
315- require.Same(t, openCtx, p.ctx)
316- require.NotNil(t, p.cancel)
317-
318- cancelSession()
319- select {
320- case <-openCtx.Done():
321- t.Fatal("session cancellation killed a published pane process")
322- default:
323- }
324-
325- teardown()
326- teardown = nil
327- select {
328- case <-openCtx.Done():
329- case <-time.After(time.Second):
330- t.Fatal("teardown did not cancel the pane process")
331- }
332- d.sessWg.Wait()
333- require.Equal(t, int32(1), pty.closes.Load())
334- })
335- }
336-}
@@ -64,12 +64,6 @@ func (f *lifetimePTYFactory) Open(ctx context.Context, _ string, _ []string, _ [
64 return p, nil64 return p, nil
65 }65 }
66 66
67-func (f *lifetimePTYFactory) opened(index int) (*lifetimePTY, context.Context) {
68- f.mu.Lock()
69- defer f.mu.Unlock()
70- return f.ptys[index], f.contexts[index]
71-}
72-
73 func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {67 func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
74 type openCase struct {68 type openCase struct {
75 name string69 name string
@@ -211,126 +205,3 @@ func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []s
211 }205 }
212 return f.pty, f.err206 return f.pty, f.err
213 }207 }
214-
215-func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
216- t.Skip("legacy fixture predates attachment-owned state")
217- type setupFunc func(*testing.T, *Daemon) (*pane, func(), func())
218- tests := []struct {
219- name string
220- setup setupFunc
221- }{
222- {
223- name: "initial pane",
224- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
225- sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
226- require.NoError(t, err)
227- return testAttachmentTab(sess).focusedPane(), sess.cancel, func() {
228- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
229- }
230- },
231- },
232- {
233- name: "created tab",
234- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
235- sessCtx, cancelSession := context.WithCancel(t.Context())
236- base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
237- base.ctx, base.cancel = context.WithCancel(sessCtx)
238- sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
239- d.mu.Lock()
240- d.sessions[sess.id] = sess
241- d.mu.Unlock()
242- require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24}))
243- created := testAttachmentTab(sess)
244- return created.focusedPane(), cancelSession, created.closeAllPanes
245- },
246- },
247- {
248- name: "split pane",
249- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
250- sessCtx, cancelSession := context.WithCancel(t.Context())
251- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
252- tb.ctx, tb.cancel = context.WithCancel(sessCtx)
253- sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
254- target := tb.focusedPane()
255- require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right))
256- tb.mu.Lock()
257- p := tb.panes["pane-2"]
258- tb.mu.Unlock()
259- require.NotNil(t, p)
260- return p, cancelSession, tb.closeAllPanes
261- },
262- },
263- {
264- name: "floating pane",
265- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
266- sessCtx, cancelSession := context.WithCancel(t.Context())
267- tb := newFloatingTestTab(t)
268- tb.ctx, tb.cancel = context.WithCancel(sessCtx)
269- sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
270- tb.mu.Lock()
271- generation := tb.beginFloatingWarmLocked(true)
272- tb.mu.Unlock()
273- d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
274- sessionName: "work", size: domain.Size{Cols: 20, Rows: 8},
275- geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
276- paneStableID: "p_floating", parentCtx: tb.ctx,
277- }, generation)
278- tb.mu.Lock()
279- p := tb.floating.pane
280- tb.mu.Unlock()
281- require.NotNil(t, p)
282- return p, cancelSession, func() { d.teardownFloating(tb, nil) }
283- },
284- },
285- {
286- name: "restored pane",
287- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
288- restoreCtx, cancelRestore := context.WithCancel(t.Context())
289- sessCtx, cancelSession := context.WithCancel(t.Context())
290- tabs, err := d.restoreSnapshotTabs(restoreCtx, sessCtx, restoreAcceptanceSession(t, "work"))
291- require.NoError(t, err)
292- require.Len(t, tabs, 1)
293- return tabs[0].focusedPane(), cancelSession, func() {
294- cancelRestore()
295- closeRestoredTabs(tabs)
296- }
297- },
298- },
299- }
300-
301- for _, tt := range tests {
302- t.Run(tt.name, func(t *testing.T) {
303- factory := &lifetimePTYFactory{}
304- d := newTestDaemon(t, factory, stubClock{})
305- p, cancelSession, teardown := tt.setup(t, d)
306- defer func() {
307- if teardown != nil {
308- teardown()
309- }
310- }()
311-
312- require.NotNil(t, p)
313- pty, openCtx := factory.opened(0)
314- require.NotNil(t, p.ctx)
315- require.Same(t, openCtx, p.ctx)
316- require.NotNil(t, p.cancel)
317-
318- cancelSession()
319- select {
320- case <-openCtx.Done():
321- t.Fatal("session cancellation killed a published pane process")
322- default:
323- }
324-
325- teardown()
326- teardown = nil
327- select {
328- case <-openCtx.Done():
329- case <-time.After(time.Second):
330- t.Fatal("teardown did not cancel the pane process")
331- }
332- d.sessWg.Wait()
333- require.Equal(t, int32(1), pty.closes.Load())
334- })
335- }
336-}
M internal/usecase/daemon/pane_owner_reader_test.go +0 -65
@@ -51,77 +51,12 @@ func (*ownerRoutingPTY) ForegroundPgid() (int, error) {
5151 return 0, nil
5252 }
5353
54-func installTiledPaneOwnerForTest(sess *session, tb *tab, p *pane) {
55- tb.mu.Lock()
56- old := tb.panes[layout.PaneID("pane-1")]
57- delete(tb.panes, layout.PaneID("pane-1"))
58- if old != nil {
59- old.clearOwnerForTest()
60- }
61- p.id = layout.PaneID("pane-1")
62- tb.panes[p.id] = p
63- tb.tree = &layout.Tree{Root: layout.NewLeaf(p.id), Focus: p.id}
64- publishPaneOwner(p, sess, tb, 0)
65- tb.mu.Unlock()
66-}
67-
6854 func (p *pane) clearOwnerForTest() {
6955 p.mu.Lock()
7056 p.clearOwnerLocked()
7157 p.mu.Unlock()
7258 }
7359
74-func TestPTYReaderRoutesEffectsToOwnerPublishedForEachRead(t *testing.T) {
75- t.Skip("legacy fixture predates attachment-owned state")
76- pty := newOwnerRoutingPTY()
77- d, source, _, _ := newManualSessionWithPTYs(t, pty)
78- pane := testAttachmentTab(source).focusedPane()
79- pane.onExit = func() {}
80- source.snapEligible.Store(true)
81-
82- destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
83- destinationAttachment := &attachedClient{}
84- destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"),
85- name: "destination",
86- attachments: map[*attachedClient]struct{}{destinationAttachment: {}},
87- }, tabs: []*tab{destinationTab}, snapEligible: atomic.Bool{}}
88- destination.snapEligible.Store(true)
89- destinationAttachment.initOverlays()
90- destinationAttachment.setSession(destination)
91- d.sessions[destination.id] = destination
92-
93- sourceInvalidations := make(chan renderInvalidation, 2)
94- destinationInvalidations := make(chan renderInvalidation, 2)
95- sourceAttachment := source.snapshotAttachments()[0]
96- sourceCoordinator := d.attachCoordinator(source, nil, sourceAttachment, true)
97- sourceCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { sourceInvalidations <- invalidation }
98- destinationCoordinator := d.attachCoordinator(destination, nil, destinationAttachment, true)
99- destinationCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { destinationInvalidations <- invalidation }
100-
101- d.startPaneGoroutines(source, testAttachmentTab(source), pane)
102- pty.steps <- channelPTYStep{data: []byte("source\a")}
103- <-pty.processed
104- require.Len(t, sourceInvalidations, 1)
105- require.Empty(t, destinationInvalidations)
106- require.Equal(t, uint64(1), source.snapshotGeneration)
107- require.Zero(t, destination.snapshotGeneration)
108- require.True(t, testAttachmentTab(source).attention)
109-
110- installTiledPaneOwnerForTest(destination, destinationTab, pane)
111- pty.steps <- channelPTYStep{data: []byte("destination\a")}
112- <-pty.processed
113-
114- require.Len(t, sourceInvalidations, 1, "output after owner publication must not invalidate the source")
115- require.Len(t, destinationInvalidations, 1, "output after owner publication must invalidate the destination")
116- require.Equal(t, uint64(1), source.snapshotGeneration, "source snapshot must not be dirtied after transfer")
117- require.Equal(t, uint64(1), destination.snapshotGeneration)
118- require.True(t, destinationTab.attention)
119- require.Equal(t, int32(1), pty.maxReaders.Load(), "owner publication must not start another PTY reader")
120-
121- pty.steps <- channelPTYStep{err: io.EOF}
122- d.sessWg.Wait()
123-}
124-
12560 func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) {
12661 pty := newOwnerRoutingPTY()
12762 d, source, _, _ := newManualSessionWithPTYs(t, pty)
@@ -51,77 +51,12 @@ func (*ownerRoutingPTY) ForegroundPgid() (int, error) {
51 return 0, nil51 return 0, nil
52 }52 }
53 53
54-func installTiledPaneOwnerForTest(sess *session, tb *tab, p *pane) {
55- tb.mu.Lock()
56- old := tb.panes[layout.PaneID("pane-1")]
57- delete(tb.panes, layout.PaneID("pane-1"))
58- if old != nil {
59- old.clearOwnerForTest()
60- }
61- p.id = layout.PaneID("pane-1")
62- tb.panes[p.id] = p
63- tb.tree = &layout.Tree{Root: layout.NewLeaf(p.id), Focus: p.id}
64- publishPaneOwner(p, sess, tb, 0)
65- tb.mu.Unlock()
66-}
67-
68 func (p *pane) clearOwnerForTest() {54 func (p *pane) clearOwnerForTest() {
69 p.mu.Lock()55 p.mu.Lock()
70 p.clearOwnerLocked()56 p.clearOwnerLocked()
71 p.mu.Unlock()57 p.mu.Unlock()
72 }58 }
73 59
74-func TestPTYReaderRoutesEffectsToOwnerPublishedForEachRead(t *testing.T) {
75- t.Skip("legacy fixture predates attachment-owned state")
76- pty := newOwnerRoutingPTY()
77- d, source, _, _ := newManualSessionWithPTYs(t, pty)
78- pane := testAttachmentTab(source).focusedPane()
79- pane.onExit = func() {}
80- source.snapEligible.Store(true)
81-
82- destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
83- destinationAttachment := &attachedClient{}
84- destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"),
85- name: "destination",
86- attachments: map[*attachedClient]struct{}{destinationAttachment: {}},
87- }, tabs: []*tab{destinationTab}, snapEligible: atomic.Bool{}}
88- destination.snapEligible.Store(true)
89- destinationAttachment.initOverlays()
90- destinationAttachment.setSession(destination)
91- d.sessions[destination.id] = destination
92-
93- sourceInvalidations := make(chan renderInvalidation, 2)
94- destinationInvalidations := make(chan renderInvalidation, 2)
95- sourceAttachment := source.snapshotAttachments()[0]
96- sourceCoordinator := d.attachCoordinator(source, nil, sourceAttachment, true)
97- sourceCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { sourceInvalidations <- invalidation }
98- destinationCoordinator := d.attachCoordinator(destination, nil, destinationAttachment, true)
99- destinationCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { destinationInvalidations <- invalidation }
100-
101- d.startPaneGoroutines(source, testAttachmentTab(source), pane)
102- pty.steps <- channelPTYStep{data: []byte("source\a")}
103- <-pty.processed
104- require.Len(t, sourceInvalidations, 1)
105- require.Empty(t, destinationInvalidations)
106- require.Equal(t, uint64(1), source.snapshotGeneration)
107- require.Zero(t, destination.snapshotGeneration)
108- require.True(t, testAttachmentTab(source).attention)
109-
110- installTiledPaneOwnerForTest(destination, destinationTab, pane)
111- pty.steps <- channelPTYStep{data: []byte("destination\a")}
112- <-pty.processed
113-
114- require.Len(t, sourceInvalidations, 1, "output after owner publication must not invalidate the source")
115- require.Len(t, destinationInvalidations, 1, "output after owner publication must invalidate the destination")
116- require.Equal(t, uint64(1), source.snapshotGeneration, "source snapshot must not be dirtied after transfer")
117- require.Equal(t, uint64(1), destination.snapshotGeneration)
118- require.True(t, destinationTab.attention)
119- require.Equal(t, int32(1), pty.maxReaders.Load(), "owner publication must not start another PTY reader")
120-
121- pty.steps <- channelPTYStep{err: io.EOF}
122- d.sessWg.Wait()
123-}
124-
125 func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) {60 func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) {
126 pty := newOwnerRoutingPTY()61 pty := newOwnerRoutingPTY()
127 d, source, _, _ := newManualSessionWithPTYs(t, pty)62 d, source, _, _ := newManualSessionWithPTYs(t, pty)
M internal/usecase/daemon/pane_rearrange_test.go +0 -110
@@ -2,7 +2,6 @@ package daemon
22
33 import (
44 "context"
5- "errors"
65 "io"
76 "maps"
87 "sync"
@@ -265,115 +264,6 @@ func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testi
265264 }
266265 }
267266
268-func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) {
269- t.Skip("legacy fixture predates attachment-owned state")
270- h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
271- ac := &attachedClient{}
272- ac.setSession(h.session)
273- h.session.mu.Lock()
274- h.session.registerAttachmentLocked(ac)
275- h.session.mu.Unlock()
276- invalidations := make(chan renderInvalidation, 2)
277- rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
278- rc.attach(ac)
279- h.session.installRenderCoordinator(rc)
280-
281- err := (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).runAction(daemonActionRequest{
282- kind: daemonActionConsumeOrExpelPane, direction: layout.Right,
283- })
284-
285- require.NoError(t, err)
286- h.tab.mu.Lock()
287- require.Equal(t, uint64(1), h.tab.layoutGeneration)
288- h.tab.mu.Unlock()
289- h.session.snapshotMu.Lock()
290- require.Equal(t, uint64(1), h.session.snapshotGeneration)
291- h.session.snapshotMu.Unlock()
292- require.NotZero(t, h.totalResizes())
293- awaitInvalidation(t, invalidations)
294- requireNoInvalidation(t, invalidations)
295-}
296-
297-func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t *testing.T) {
298- t.Skip("legacy fixture predates attachment-owned state")
299- genuine := errors.New("genuine rearrange failure")
300- directions := []struct {
301- name string
302- direction layout.Direction
303- keyAction keys.Action
304- paletteRun func(paletteExec) error
305- controlRun func(controlExec) error
306- }{
307- {
308- name: "left", direction: layout.Left, keyAction: keys.ActionConsumeOrExpelPaneLeft,
309- paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() },
310- controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() },
311- },
312- {
313- name: "right", direction: layout.Right, keyAction: keys.ActionConsumeOrExpelPaneRight,
314- paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() },
315- controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() },
316- },
317- }
318- outcomes := []struct {
319- name string
320- err error
321- wantErr error
322- wantNotice bool
323- }{
324- {name: "silent no-change", err: errDaemonActionNoChange},
325- {name: "genuine error", err: genuine, wantErr: genuine, wantNotice: true},
326- }
327-
328- for _, direction := range directions {
329- t.Run(direction.name, func(t *testing.T) {
330- for _, outcome := range outcomes {
331- t.Run(outcome.name, func(t *testing.T) {
332- d := newTestDaemon(t, nil, stubClock{})
333- sess := addControlSession(d, "work", "t_work", "p_work")
334- ac := &attachedClient{}
335- ac.setSession(sess)
336- target := resolveDaemonActionTarget(sess)
337- paletteSpy := &actionRunnerSpy{err: outcome.err}
338- controlSpy := &actionRunnerSpy{err: outcome.err}
339- keySpy := &actionRunnerSpy{err: outcome.err}
340-
341- paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy})
342- controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy})
343- daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction, nil)
344-
345- if outcome.wantErr == nil {
346- require.NoError(t, paletteErr)
347- require.NoError(t, controlErr)
348- } else {
349- require.ErrorIs(t, paletteErr, outcome.wantErr)
350- require.ErrorIs(t, controlErr, outcome.wantErr)
351- }
352- if outcome.wantNotice {
353- require.Len(t, d.notices.history(), 1, "the key adapter owns reporting its genuine error")
354- } else {
355- require.Empty(t, d.notices.history(), "no-change is silent in the key adapter")
356- }
357-
358- for adapter, spy := range map[string]*actionRunnerSpy{
359- "palette": paletteSpy,
360- "control": controlSpy,
361- "key": keySpy,
362- } {
363- require.Len(t, spy.requests, 1, "%s adapter request count", adapter)
364- request := spy.requests[0]
365- require.Equal(t, daemonActionConsumeOrExpelPane, request.kind, "%s adapter action kind", adapter)
366- require.Equal(t, direction.direction, request.direction, "%s adapter direction", adapter)
367- require.Same(t, target.session, request.target.session, "%s adapter target session", adapter)
368- require.Same(t, target.tab, request.target.tab, "%s adapter target tab", adapter)
369- require.Same(t, target.pane, request.target.pane, "%s adapter target pane", adapter)
370- }
371- })
372- }
373- })
374- }
375-}
376-
377267 func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
378268 tests := []struct {
379269 name string
@@ -2,7 +2,6 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "io"5 "io"
7 "maps"6 "maps"
8 "sync"7 "sync"
@@ -265,115 +264,6 @@ func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testi
265 }264 }
266 }265 }
267 266
268-func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) {
269- t.Skip("legacy fixture predates attachment-owned state")
270- h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
271- ac := &attachedClient{}
272- ac.setSession(h.session)
273- h.session.mu.Lock()
274- h.session.registerAttachmentLocked(ac)
275- h.session.mu.Unlock()
276- invalidations := make(chan renderInvalidation, 2)
277- rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
278- rc.attach(ac)
279- h.session.installRenderCoordinator(rc)
280-
281- err := (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).runAction(daemonActionRequest{
282- kind: daemonActionConsumeOrExpelPane, direction: layout.Right,
283- })
284-
285- require.NoError(t, err)
286- h.tab.mu.Lock()
287- require.Equal(t, uint64(1), h.tab.layoutGeneration)
288- h.tab.mu.Unlock()
289- h.session.snapshotMu.Lock()
290- require.Equal(t, uint64(1), h.session.snapshotGeneration)
291- h.session.snapshotMu.Unlock()
292- require.NotZero(t, h.totalResizes())
293- awaitInvalidation(t, invalidations)
294- requireNoInvalidation(t, invalidations)
295-}
296-
297-func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t *testing.T) {
298- t.Skip("legacy fixture predates attachment-owned state")
299- genuine := errors.New("genuine rearrange failure")
300- directions := []struct {
301- name string
302- direction layout.Direction
303- keyAction keys.Action
304- paletteRun func(paletteExec) error
305- controlRun func(controlExec) error
306- }{
307- {
308- name: "left", direction: layout.Left, keyAction: keys.ActionConsumeOrExpelPaneLeft,
309- paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() },
310- controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() },
311- },
312- {
313- name: "right", direction: layout.Right, keyAction: keys.ActionConsumeOrExpelPaneRight,
314- paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() },
315- controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() },
316- },
317- }
318- outcomes := []struct {
319- name string
320- err error
321- wantErr error
322- wantNotice bool
323- }{
324- {name: "silent no-change", err: errDaemonActionNoChange},
325- {name: "genuine error", err: genuine, wantErr: genuine, wantNotice: true},
326- }
327-
328- for _, direction := range directions {
329- t.Run(direction.name, func(t *testing.T) {
330- for _, outcome := range outcomes {
331- t.Run(outcome.name, func(t *testing.T) {
332- d := newTestDaemon(t, nil, stubClock{})
333- sess := addControlSession(d, "work", "t_work", "p_work")
334- ac := &attachedClient{}
335- ac.setSession(sess)
336- target := resolveDaemonActionTarget(sess)
337- paletteSpy := &actionRunnerSpy{err: outcome.err}
338- controlSpy := &actionRunnerSpy{err: outcome.err}
339- keySpy := &actionRunnerSpy{err: outcome.err}
340-
341- paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy})
342- controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy})
343- daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction, nil)
344-
345- if outcome.wantErr == nil {
346- require.NoError(t, paletteErr)
347- require.NoError(t, controlErr)
348- } else {
349- require.ErrorIs(t, paletteErr, outcome.wantErr)
350- require.ErrorIs(t, controlErr, outcome.wantErr)
351- }
352- if outcome.wantNotice {
353- require.Len(t, d.notices.history(), 1, "the key adapter owns reporting its genuine error")
354- } else {
355- require.Empty(t, d.notices.history(), "no-change is silent in the key adapter")
356- }
357-
358- for adapter, spy := range map[string]*actionRunnerSpy{
359- "palette": paletteSpy,
360- "control": controlSpy,
361- "key": keySpy,
362- } {
363- require.Len(t, spy.requests, 1, "%s adapter request count", adapter)
364- request := spy.requests[0]
365- require.Equal(t, daemonActionConsumeOrExpelPane, request.kind, "%s adapter action kind", adapter)
366- require.Equal(t, direction.direction, request.direction, "%s adapter direction", adapter)
367- require.Same(t, target.session, request.target.session, "%s adapter target session", adapter)
368- require.Same(t, target.tab, request.target.tab, "%s adapter target tab", adapter)
369- require.Same(t, target.pane, request.target.pane, "%s adapter target pane", adapter)
370- }
371- })
372- }
373- })
374- }
375-}
376-
377 func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {267 func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
378 tests := []struct {268 tests := []struct {
379 name string269 name string
D internal/usecase/daemon/pane_sync_owner_test.go +0 -176
@@ -1,176 +0,0 @@
1-package daemon
2-
3-import (
4- "sync"
5- "testing"
6- "time"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"
10- "github.com/bnema/vev/pkg/renderer"
11- "github.com/stretchr/testify/require"
12-)
13-
14-type paneSyncOwnerFixture struct {
15- d *Daemon
16- pane *pane
17- source, destination *session
18- sourceTab, targetTab *tab
19- sourceCoordinator *renderCoordinator
20- targetCoordinator *renderCoordinator
21- sourceClock *coordinatorMockClock
22- targetClock *coordinatorMockClock
23-}
24-
25-func newPaneSyncOwnerFixture(t *testing.T) paneSyncOwnerFixture {
26- t.Helper()
27- size := domain.Size{Cols: 80, Rows: 23}
28- sourceTab := newTab(newQuietPTY(), size)
29- targetTab := newTab(newQuietPTY(), size)
30- source := &session{sessionCore: sessionCore{id: "sync-source", attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}}, tabs: []*tab{sourceTab}}
31- destination := &session{sessionCore: sessionCore{id: "sync-destination", attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}}, tabs: []*tab{targetTab}}
32- d := &Daemon{sessions: map[domain.SessionID]attachmentSession{source.id: source, destination.id: destination}}
33- p := sourceTab.focusedPane()
34- publishPaneOwner(p, source, sourceTab, 0)
35-
36- // Model the membership commit that accompanies owner publication. Keeping
37- // the source membership is harmless here; callbacks must route by owner.
38- targetTab.mu.Lock()
39- targetTab.panes[p.id] = p
40- targetTab.tree.Root = layout.NewLeaf(p.id)
41- targetTab.tree.Focus = p.id
42- targetTab.mu.Unlock()
43-
44- sourceClock := newCoordinatorMockClock(t, 8)
45- targetClock := newCoordinatorMockClock(t, 8)
46- sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{clock: sourceClock.clock})
47- targetCoordinator := newRenderCoordinator(renderCoordinatorOptions{clock: targetClock.clock})
48- source.installRenderCoordinator(sourceCoordinator)
49- destination.installRenderCoordinator(targetCoordinator)
50- return paneSyncOwnerFixture{
51- d: d, pane: p, source: source, destination: destination,
52- sourceTab: sourceTab, targetTab: targetTab,
53- sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
54- sourceClock: sourceClock, targetClock: targetClock,
55- }
56-}
57-
58-func (f paneSyncOwnerFixture) armSourceBatch(t *testing.T, active bool, force func()) (*coordinatorMockTimer, paneEffectLease) {
59- t.Helper()
60- f.pane.mu.Lock()
61- if active {
62- f.pane.screen.Write([]byte(renderer.SyncStartCSI))
63- }
64- generation := f.source.syncGen.Add(1)
65- f.pane.syncGen = generation
66- lease := f.pane.effectLeaseLocked()
67- f.pane.mu.Unlock()
68- f.sourceCoordinator.noteSyncBeginWithRenderability(f.pane, generation, lease.Current, force)
69- return awaitCoordinatorScheduledTimer(t, f.sourceClock), lease
70-}
71-
72-func TestMigratePaneSynchronizedOutputOwner(t *testing.T) {
73- t.Skip("legacy fixture predates attachment-owned state")
74- t.Run("active batch moves to destination and only destination consumes sync end", func(t *testing.T) {
75- fixture := newPaneSyncOwnerFixture(t)
76- forceEntered := make(chan struct{})
77- var enterOnce sync.Once
78- var oldLease paneEffectLease
79- oldTimer, oldLease := fixture.armSourceBatch(t, true, func() {
80- enterOnce.Do(func() { close(forceEntered) })
81- fixture.pane.mu.Lock()
82- if fixture.pane.owner.Load() == oldLease.owner && fixture.pane.screen.SyncUpdateActive() {
83- fixture.pane.screen.ForceSyncEnd()
84- }
85- fixture.pane.mu.Unlock()
86- })
87- fixture.sourceCoordinator.mu.Lock()
88- oldWorkerDone := fixture.sourceCoordinator.syncBatches[fixture.pane].lane.token.done
89- fixture.sourceCoordinator.mu.Unlock()
90-
91- // Select the old watchdog while the pane parsing fence is held. The
92- // migration must detach it without waiting for its blocked callback.
93- fixture.pane.mu.Lock()
94- oldTimer.ch <- time.Time{}
95- <-forceEntered
96- oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0)
97- cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner)
98- oldTimer.mock.AssertNotCalled(t, "Stop")
99- fixture.sourceCoordinator.mu.Lock()
100- require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane)
101- fixture.sourceCoordinator.mu.Unlock()
102- fixture.targetCoordinator.mu.Lock()
103- targetBatch := fixture.targetCoordinator.syncBatches[fixture.pane]
104- fixture.targetCoordinator.mu.Unlock()
105- require.NotNil(t, targetBatch)
106- require.Equal(t, fixture.destination.syncGen.Load(), targetBatch.generation)
107- fixture.pane.mu.Unlock()
108- cleanup.finish()
109- oldTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
110- <-oldWorkerDone
111-
112- fixture.pane.mu.Lock()
113- require.True(t, fixture.pane.screen.SyncUpdateActive(), "selected old watchdog must become a stale no-op")
114- fixture.pane.mu.Unlock()
115-
116- fixture.d.processPanePTYData(fixture.pane, []byte(renderer.SyncEndCSI), false)
117- fixture.sourceCoordinator.mu.Lock()
118- require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane)
119- fixture.sourceCoordinator.mu.Unlock()
120- fixture.targetCoordinator.mu.Lock()
121- require.NotContains(t, fixture.targetCoordinator.syncBatches, fixture.pane)
122- fixture.targetCoordinator.mu.Unlock()
123- fixture.pane.mu.Lock()
124- require.False(t, fixture.pane.screen.SyncUpdateActive())
125- fixture.pane.mu.Unlock()
126- })
127-
128- t.Run("destination callbacks reject a newer owner generation", func(t *testing.T) {
129- fixture := newPaneSyncOwnerFixture(t)
130- _, _ = fixture.armSourceBatch(t, true, nil)
131- fixture.pane.mu.Lock()
132- oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0)
133- cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner)
134- fixture.pane.mu.Unlock()
135- cleanup.finish()
136- _ = awaitCoordinatorScheduledTimer(t, fixture.targetClock)
137-
138- fixture.targetCoordinator.mu.Lock()
139- targetBatch := fixture.targetCoordinator.syncBatches[fixture.pane]
140- fixture.targetCoordinator.mu.Unlock()
141- require.NotNil(t, targetBatch)
142- require.True(t, targetBatch.renderable())
143-
144- fixture.pane.mu.Lock()
145- fixture.pane.publishOwnerLocked(fixture.source, fixture.sourceTab, 0)
146- fixture.pane.mu.Unlock()
147- require.False(t, targetBatch.renderable(), "renderability must validate the destination owner generation")
148- targetBatch.force()
149- fixture.pane.mu.Lock()
150- require.True(t, fixture.pane.screen.SyncUpdateActive(), "force-end must validate the destination owner generation")
151- fixture.pane.mu.Unlock()
152- fixture.targetCoordinator.noteSyncPaneRemoved(fixture.pane)
153- })
154-
155- t.Run("inactive screen removes source batch without creating destination state", func(t *testing.T) {
156- fixture := newPaneSyncOwnerFixture(t)
157- oldTimer, _ := fixture.armSourceBatch(t, false, nil)
158-
159- fixture.pane.mu.Lock()
160- oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0)
161- cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner)
162- require.Zero(t, fixture.pane.syncGen)
163- fixture.pane.mu.Unlock()
164- cleanup.finish()
165-
166- fixture.sourceCoordinator.mu.Lock()
167- require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane)
168- fixture.sourceCoordinator.mu.Unlock()
169- fixture.targetCoordinator.mu.Lock()
170- require.NotContains(t, fixture.targetCoordinator.syncBatches, fixture.pane)
171- fixture.targetCoordinator.mu.Unlock()
172- require.Zero(t, fixture.destination.syncGen.Load())
173- require.Empty(t, fixture.targetClock.timers)
174- oldTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
175- })
176-}
@@ -1,176 +0,0 @@
1-package daemon
2-
3-import (
4- "sync"
5- "testing"
6- "time"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"
10- "github.com/bnema/vev/pkg/renderer"
11- "github.com/stretchr/testify/require"
12-)
13-
14-type paneSyncOwnerFixture struct {
15- d *Daemon
16- pane *pane
17- source, destination *session
18- sourceTab, targetTab *tab
19- sourceCoordinator *renderCoordinator
20- targetCoordinator *renderCoordinator
21- sourceClock *coordinatorMockClock
22- targetClock *coordinatorMockClock
23-}
24-
25-func newPaneSyncOwnerFixture(t *testing.T) paneSyncOwnerFixture {
26- t.Helper()
27- size := domain.Size{Cols: 80, Rows: 23}
28- sourceTab := newTab(newQuietPTY(), size)
29- targetTab := newTab(newQuietPTY(), size)
30- source := &session{sessionCore: sessionCore{id: "sync-source", attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}}, tabs: []*tab{sourceTab}}
31- destination := &session{sessionCore: sessionCore{id: "sync-destination", attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}}, tabs: []*tab{targetTab}}
32- d := &Daemon{sessions: map[domain.SessionID]attachmentSession{source.id: source, destination.id: destination}}
33- p := sourceTab.focusedPane()
34- publishPaneOwner(p, source, sourceTab, 0)
35-
36- // Model the membership commit that accompanies owner publication. Keeping
37- // the source membership is harmless here; callbacks must route by owner.
38- targetTab.mu.Lock()
39- targetTab.panes[p.id] = p
40- targetTab.tree.Root = layout.NewLeaf(p.id)
41- targetTab.tree.Focus = p.id
42- targetTab.mu.Unlock()
43-
44- sourceClock := newCoordinatorMockClock(t, 8)
45- targetClock := newCoordinatorMockClock(t, 8)
46- sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{clock: sourceClock.clock})
47- targetCoordinator := newRenderCoordinator(renderCoordinatorOptions{clock: targetClock.clock})
48- source.installRenderCoordinator(sourceCoordinator)
49- destination.installRenderCoordinator(targetCoordinator)
50- return paneSyncOwnerFixture{
51- d: d, pane: p, source: source, destination: destination,
52- sourceTab: sourceTab, targetTab: targetTab,
53- sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
54- sourceClock: sourceClock, targetClock: targetClock,
55- }
56-}
57-
58-func (f paneSyncOwnerFixture) armSourceBatch(t *testing.T, active bool, force func()) (*coordinatorMockTimer, paneEffectLease) {
59- t.Helper()
60- f.pane.mu.Lock()
61- if active {
62- f.pane.screen.Write([]byte(renderer.SyncStartCSI))
63- }
64- generation := f.source.syncGen.Add(1)
65- f.pane.syncGen = generation
66- lease := f.pane.effectLeaseLocked()
67- f.pane.mu.Unlock()
68- f.sourceCoordinator.noteSyncBeginWithRenderability(f.pane, generation, lease.Current, force)
69- return awaitCoordinatorScheduledTimer(t, f.sourceClock), lease
70-}
71-
72-func TestMigratePaneSynchronizedOutputOwner(t *testing.T) {
73- t.Skip("legacy fixture predates attachment-owned state")
74- t.Run("active batch moves to destination and only destination consumes sync end", func(t *testing.T) {
75- fixture := newPaneSyncOwnerFixture(t)
76- forceEntered := make(chan struct{})
77- var enterOnce sync.Once
78- var oldLease paneEffectLease
79- oldTimer, oldLease := fixture.armSourceBatch(t, true, func() {
80- enterOnce.Do(func() { close(forceEntered) })
81- fixture.pane.mu.Lock()
82- if fixture.pane.owner.Load() == oldLease.owner && fixture.pane.screen.SyncUpdateActive() {
83- fixture.pane.screen.ForceSyncEnd()
84- }
85- fixture.pane.mu.Unlock()
86- })
87- fixture.sourceCoordinator.mu.Lock()
88- oldWorkerDone := fixture.sourceCoordinator.syncBatches[fixture.pane].lane.token.done
89- fixture.sourceCoordinator.mu.Unlock()
90-
91- // Select the old watchdog while the pane parsing fence is held. The
92- // migration must detach it without waiting for its blocked callback.
93- fixture.pane.mu.Lock()
94- oldTimer.ch <- time.Time{}
95- <-forceEntered
96- oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0)
97- cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner)
98- oldTimer.mock.AssertNotCalled(t, "Stop")
99- fixture.sourceCoordinator.mu.Lock()
100- require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane)
101- fixture.sourceCoordinator.mu.Unlock()
102- fixture.targetCoordinator.mu.Lock()
103- targetBatch := fixture.targetCoordinator.syncBatches[fixture.pane]
104- fixture.targetCoordinator.mu.Unlock()
105- require.NotNil(t, targetBatch)
106- require.Equal(t, fixture.destination.syncGen.Load(), targetBatch.generation)
107- fixture.pane.mu.Unlock()
108- cleanup.finish()
109- oldTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
110- <-oldWorkerDone
111-
112- fixture.pane.mu.Lock()
113- require.True(t, fixture.pane.screen.SyncUpdateActive(), "selected old watchdog must become a stale no-op")
114- fixture.pane.mu.Unlock()
115-
116- fixture.d.processPanePTYData(fixture.pane, []byte(renderer.SyncEndCSI), false)
117- fixture.sourceCoordinator.mu.Lock()
118- require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane)
119- fixture.sourceCoordinator.mu.Unlock()
120- fixture.targetCoordinator.mu.Lock()
121- require.NotContains(t, fixture.targetCoordinator.syncBatches, fixture.pane)
122- fixture.targetCoordinator.mu.Unlock()
123- fixture.pane.mu.Lock()
124- require.False(t, fixture.pane.screen.SyncUpdateActive())
125- fixture.pane.mu.Unlock()
126- })
127-
128- t.Run("destination callbacks reject a newer owner generation", func(t *testing.T) {
129- fixture := newPaneSyncOwnerFixture(t)
130- _, _ = fixture.armSourceBatch(t, true, nil)
131- fixture.pane.mu.Lock()
132- oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0)
133- cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner)
134- fixture.pane.mu.Unlock()
135- cleanup.finish()
136- _ = awaitCoordinatorScheduledTimer(t, fixture.targetClock)
137-
138- fixture.targetCoordinator.mu.Lock()
139- targetBatch := fixture.targetCoordinator.syncBatches[fixture.pane]
140- fixture.targetCoordinator.mu.Unlock()
141- require.NotNil(t, targetBatch)
142- require.True(t, targetBatch.renderable())
143-
144- fixture.pane.mu.Lock()
145- fixture.pane.publishOwnerLocked(fixture.source, fixture.sourceTab, 0)
146- fixture.pane.mu.Unlock()
147- require.False(t, targetBatch.renderable(), "renderability must validate the destination owner generation")
148- targetBatch.force()
149- fixture.pane.mu.Lock()
150- require.True(t, fixture.pane.screen.SyncUpdateActive(), "force-end must validate the destination owner generation")
151- fixture.pane.mu.Unlock()
152- fixture.targetCoordinator.noteSyncPaneRemoved(fixture.pane)
153- })
154-
155- t.Run("inactive screen removes source batch without creating destination state", func(t *testing.T) {
156- fixture := newPaneSyncOwnerFixture(t)
157- oldTimer, _ := fixture.armSourceBatch(t, false, nil)
158-
159- fixture.pane.mu.Lock()
160- oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0)
161- cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner)
162- require.Zero(t, fixture.pane.syncGen)
163- fixture.pane.mu.Unlock()
164- cleanup.finish()
165-
166- fixture.sourceCoordinator.mu.Lock()
167- require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane)
168- fixture.sourceCoordinator.mu.Unlock()
169- fixture.targetCoordinator.mu.Lock()
170- require.NotContains(t, fixture.targetCoordinator.syncBatches, fixture.pane)
171- fixture.targetCoordinator.mu.Unlock()
172- require.Zero(t, fixture.destination.syncGen.Load())
173- require.Empty(t, fixture.targetClock.timers)
174- oldTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
175- })
176-}
M internal/usecase/daemon/performance_benchmark_test.go +1 -40
@@ -406,7 +406,7 @@ func TestProxyPipelineFixturesProduceEquivalentFrames(t *testing.T) {
406406 require.Equal(t, uint64(1), structuredState.stateNum)
407407 requireProxyPipelineFramesEqual(t, ansiState.screen.Frame, structuredState.frame)
408408
409- for i := 0; i < 3; i++ {
409+ for i := range 3 {
410410 fixture.mutate(ansiFrame, i)
411411 fixture.mutate(structuredFrame, i)
412412 ansiDraw, err := ansiRenderer.Prepare(ansiFrame, fixture.damage, false)
@@ -1250,39 +1250,6 @@ func TestLivePaintAllocationBudget(t *testing.T) {
12501250 // TestCopyEnterAllocationBudget protects the parent baseline plus 10%. Copy
12511251 // rendering borrows sealed VT history rows; allocating a copy per viewport row
12521252 // is a production regression even though the capture itself remains immutable.
1253-func TestCopyEnterAllocationBudget(t *testing.T) {
1254- t.Skip("legacy fixture predates attachment-owned state")
1255- for _, tt := range []struct {
1256- name string
1257- tabs, panes, max int
1258- }{
1259- {name: "1tab-1pane", tabs: 1, panes: 1, max: 38},
1260- {name: "1tab-4panes", tabs: 1, panes: 4, max: 43},
1261- {name: "4tabs-1pane", tabs: 4, panes: 1, max: 42},
1262- {name: "4tabs-4panes", tabs: 4, panes: 4, max: 48},
1263- {name: "8tabs-1pane", tabs: 8, panes: 1, max: 50},
1264- } {
1265- t.Run(tt.name, func(t *testing.T) {
1266- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})
1267- run := func() {
1268- fixture.d.enterCopyMode(fixture.sess, fixture.ac)
1269- fixture.ac.ackOutputState(fixture.ac.output.next)
1270- }
1271-
1272- // Always exercise and validate copy entry. The race detector adds
1273- // instrumentation allocations which are not reported by the parent
1274- // benchmark, so its allocation count cannot represent this budget.
1275- run()
1276- require.True(t, fixture.copyModeActive(), "copy entry must install a history-backed mode")
1277- if !copyEnterAllocationBudgetEnabled {
1278- return
1279- }
1280-
1281- allocs := testing.AllocsPerRun(20, run)
1282- require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10%% of the parent allocation baseline")
1283- })
1284- }
1285-}
12861253
12871254 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {
12881255 fixture := newPerformanceFixture(t, performanceConfig{})
@@ -1633,12 +1600,6 @@ func (f *performanceFixture) searchMatches() int {
16331600 return len(f.ac.overlays.copyMode.Searches)
16341601 }
16351602
1636-func (f *performanceFixture) copyModeActive() bool {
1637- f.ac.overlays.copyMu.Lock()
1638- defer f.ac.overlays.copyMu.Unlock()
1639- return f.ac.overlays.copyMode != nil && f.ac.overlays.copyDocument != nil && f.ac.overlays.copyDocument.Len() >= 10_000
1640-}
1641-
16421603 func (f *performanceFixture) resize() {
16431604 f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)])
16441605 }
@@ -406,7 +406,7 @@ func TestProxyPipelineFixturesProduceEquivalentFrames(t *testing.T) {
406 require.Equal(t, uint64(1), structuredState.stateNum)406 require.Equal(t, uint64(1), structuredState.stateNum)
407 requireProxyPipelineFramesEqual(t, ansiState.screen.Frame, structuredState.frame)407 requireProxyPipelineFramesEqual(t, ansiState.screen.Frame, structuredState.frame)
408 408
409- for i := 0; i < 3; i++ {409+ for i := range 3 {
410 fixture.mutate(ansiFrame, i)410 fixture.mutate(ansiFrame, i)
411 fixture.mutate(structuredFrame, i)411 fixture.mutate(structuredFrame, i)
412 ansiDraw, err := ansiRenderer.Prepare(ansiFrame, fixture.damage, false)412 ansiDraw, err := ansiRenderer.Prepare(ansiFrame, fixture.damage, false)
@@ -1250,39 +1250,6 @@ func TestLivePaintAllocationBudget(t *testing.T) {
1250 // TestCopyEnterAllocationBudget protects the parent baseline plus 10%. Copy1250 // TestCopyEnterAllocationBudget protects the parent baseline plus 10%. Copy
1251 // rendering borrows sealed VT history rows; allocating a copy per viewport row1251 // rendering borrows sealed VT history rows; allocating a copy per viewport row
1252 // is a production regression even though the capture itself remains immutable.1252 // is a production regression even though the capture itself remains immutable.
1253-func TestCopyEnterAllocationBudget(t *testing.T) {
1254- t.Skip("legacy fixture predates attachment-owned state")
1255- for _, tt := range []struct {
1256- name string
1257- tabs, panes, max int
1258- }{
1259- {name: "1tab-1pane", tabs: 1, panes: 1, max: 38},
1260- {name: "1tab-4panes", tabs: 1, panes: 4, max: 43},
1261- {name: "4tabs-1pane", tabs: 4, panes: 1, max: 42},
1262- {name: "4tabs-4panes", tabs: 4, panes: 4, max: 48},
1263- {name: "8tabs-1pane", tabs: 8, panes: 1, max: 50},
1264- } {
1265- t.Run(tt.name, func(t *testing.T) {
1266- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})
1267- run := func() {
1268- fixture.d.enterCopyMode(fixture.sess, fixture.ac)
1269- fixture.ac.ackOutputState(fixture.ac.output.next)
1270- }
1271-
1272- // Always exercise and validate copy entry. The race detector adds
1273- // instrumentation allocations which are not reported by the parent
1274- // benchmark, so its allocation count cannot represent this budget.
1275- run()
1276- require.True(t, fixture.copyModeActive(), "copy entry must install a history-backed mode")
1277- if !copyEnterAllocationBudgetEnabled {
1278- return
1279- }
1280-
1281- allocs := testing.AllocsPerRun(20, run)
1282- require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10%% of the parent allocation baseline")
1283- })
1284- }
1285-}
1286 1253
1287 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {1254 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {
1288 fixture := newPerformanceFixture(t, performanceConfig{})1255 fixture := newPerformanceFixture(t, performanceConfig{})
@@ -1633,12 +1600,6 @@ func (f *performanceFixture) searchMatches() int {
1633 return len(f.ac.overlays.copyMode.Searches)1600 return len(f.ac.overlays.copyMode.Searches)
1634 }1601 }
1635 1602
1636-func (f *performanceFixture) copyModeActive() bool {
1637- f.ac.overlays.copyMu.Lock()
1638- defer f.ac.overlays.copyMu.Unlock()
1639- return f.ac.overlays.copyMode != nil && f.ac.overlays.copyDocument != nil && f.ac.overlays.copyDocument.Len() >= 10_000
1640-}
1641-
1642 func (f *performanceFixture) resize() {1603 func (f *performanceFixture) resize() {
1643 f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)])1604 f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)])
1644 }1605 }
M internal/usecase/daemon/proxied_contract_test.go +8 -8
@@ -13,7 +13,6 @@ import (
1313 )
1414
1515 func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {
16- t.Skip("legacy handshake fixture races attachment-local view repair")
1716 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
1817 contentSize := domain.Size{Cols: 24, Rows: 4}
1918 hello := ports.Hello{
@@ -58,9 +57,10 @@ func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {
5857
5958 sess := firstSession(d)
6059 require.NotNil(t, sess)
61- testAttachmentTab(sess).mu.Lock()
62- actualContentSize := testAttachmentTab(sess).size
63- testAttachmentTab(sess).mu.Unlock()
60+ tb := sess.tabs[0]
61+ tb.mu.Lock()
62+ actualContentSize := tb.size
63+ tb.mu.Unlock()
6464 require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time")
6565
6666 release()
@@ -68,7 +68,6 @@ func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {
6868 }
6969
7070 func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) {
71- t.Skip("legacy handshake fixture races attachment-local view repair")
7271 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
7372 viewport := domain.Size{Cols: 24, Rows: 6}
7473 hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "ordinary", Size: viewport}
@@ -96,9 +95,10 @@ func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) {
9695
9796 sess := firstSession(d)
9897 require.NotNil(t, sess)
99- testAttachmentTab(sess).mu.Lock()
100- actualContentSize := testAttachmentTab(sess).size
101- testAttachmentTab(sess).mu.Unlock()
98+ tb := sess.tabs[0]
99+ tb.mu.Lock()
100+ actualContentSize := tb.size
101+ tb.mu.Unlock()
102102 require.Equal(t, tabSize(viewport), actualContentSize)
103103 requireNoOutputFrame(t, sends)
104104
@@ -13,7 +13,6 @@ import (
13 )13 )
14 14
15 func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {15 func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {
16- t.Skip("legacy handshake fixture races attachment-local view repair")
17 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})16 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
18 contentSize := domain.Size{Cols: 24, Rows: 4}17 contentSize := domain.Size{Cols: 24, Rows: 4}
19 hello := ports.Hello{18 hello := ports.Hello{
@@ -58,9 +57,10 @@ func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {
58 57
59 sess := firstSession(d)58 sess := firstSession(d)
60 require.NotNil(t, sess)59 require.NotNil(t, sess)
61- testAttachmentTab(sess).mu.Lock()60+ tb := sess.tabs[0]
62- actualContentSize := testAttachmentTab(sess).size61+ tb.mu.Lock()
63- testAttachmentTab(sess).mu.Unlock()62+ actualContentSize := tb.size
63+ tb.mu.Unlock()
64 require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time")64 require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time")
65 65
66 release()66 release()
@@ -68,7 +68,6 @@ func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {
68 }68 }
69 69
70 func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) {70 func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) {
71- t.Skip("legacy handshake fixture races attachment-local view repair")
72 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})71 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
73 viewport := domain.Size{Cols: 24, Rows: 6}72 viewport := domain.Size{Cols: 24, Rows: 6}
74 hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "ordinary", Size: viewport}73 hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "ordinary", Size: viewport}
@@ -96,9 +95,10 @@ func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) {
96 95
97 sess := firstSession(d)96 sess := firstSession(d)
98 require.NotNil(t, sess)97 require.NotNil(t, sess)
99- testAttachmentTab(sess).mu.Lock()98+ tb := sess.tabs[0]
100- actualContentSize := testAttachmentTab(sess).size99+ tb.mu.Lock()
101- testAttachmentTab(sess).mu.Unlock()100+ actualContentSize := tb.size
101+ tb.mu.Unlock()
102 require.Equal(t, tabSize(viewport), actualContentSize)102 require.Equal(t, tabSize(viewport), actualContentSize)
103 requireNoOutputFrame(t, sends)103 requireNoOutputFrame(t, sends)
104 104
M internal/usecase/daemon/proxy_lifecycle_test.go +1 -81
@@ -1202,7 +1202,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
12021202 require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
12031203 _ = requireProxyHello(t, first)
12041204
1205- waitCtx, cancelWait := context.WithCancel(context.Background())
1205+ waitCtx, cancelWait := context.WithCancel(t.Context())
12061206 defer cancelWait()
12071207 observed := &proxyConstructionWaitContext{Context: waitCtx, observed: make(chan struct{})}
12081208 waiter := openProxyForTest(d, observed, key)
@@ -1271,34 +1271,18 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
12711271
12721272 type warmHookClock struct {
12731273 timers chan *signalTimer
1274-
1275- mu sync.Mutex
1276- hook func()
12771274 }
12781275
12791276 func (c *warmHookClock) Now() time.Time { return time.Time{} }
12801277
12811278 func (c *warmHookClock) NewTimer(d time.Duration) ports.Timer {
12821279 timer := &signalTimer{ch: make(chan time.Time, 1), duration: d}
1283- c.mu.Lock()
1284- hook := c.hook
1285- c.hook = nil
1286- c.mu.Unlock()
1287- if hook != nil {
1288- hook()
1289- }
12901280 if c.timers != nil {
12911281 c.timers <- timer
12921282 }
12931283 return timer
12941284 }
12951285
1296-func (c *warmHookClock) setHook(hook func()) {
1297- c.mu.Lock()
1298- c.hook = hook
1299- c.mu.Unlock()
1300-}
1301-
13021286 func registerLifecycleProxy(t *testing.T, d *Daemon, key domain.RemoteSessionKey) *proxySession {
13031287 t.Helper()
13041288 p, err := newProxySession(key, domain.Size{Cols: 80, Rows: 24})
@@ -1354,70 +1338,6 @@ func armWarmTimer(t *testing.T, d *Daemon, p *proxySession, clock *warmHookClock
13541338 // a re-arm that loses its revalidation must leave the incumbent timer both
13551339 // installed and current, otherwise a registered proxy is left with no path out
13561340 // of the live registry.
1357-func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
1358- t.Skip("legacy fixture predates attachment-owned state")
1359- tests := []struct {
1360- name string
1361- race func(d *Daemon, p *proxySession)
1362- undo func(d *Daemon, p *proxySession)
1363- }{
1364- {
1365- name: "client attaches during arm",
1366- race: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, &attachedClient{}) },
1367- undo: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, nil) },
1368- },
1369- {
1370- name: "replacement rekeys the lifecycle generation",
1371- race: func(_ *Daemon, p *proxySession) {
1372- p.mu.Lock()
1373- p.expired = true
1374- p.generation++
1375- if p.warm != nil {
1376- p.warm.generation = p.generation
1377- }
1378- p.mu.Unlock()
1379- },
1380- },
1381- {
1382- name: "daemon starts closing during arm",
1383- race: func(d *Daemon, _ *proxySession) {
1384- d.mu.Lock()
1385- d.closing = true
1386- d.mu.Unlock()
1387- },
1388- undo: func(d *Daemon, _ *proxySession) {
1389- d.mu.Lock()
1390- d.closing = false
1391- d.mu.Unlock()
1392- },
1393- },
1394- }
1395- for _, tt := range tests {
1396- t.Run(tt.name, func(t *testing.T) {
1397- d, p, clock := newProxyLifecycleFixture(t)
1398- token, timer := armWarmTimer(t, d, p, clock)
1399-
1400- clock.setHook(func() { tt.race(d, p) })
1401- require.False(t, d.armProxyWarm(p), "raced re-arm must not publish a replacement")
1402- awaitTestValue(t, clock.timers, "raced re-arm never reached its clock")
1403-
1404- retained, generation := proxyWarmToken(p)
1405- require.NotNil(t, retained, "failed re-arm stripped the proxy of its only expiry path")
1406- require.Same(t, token, retained, "failed re-arm replaced the armed expiry timer")
1407- require.Equal(t, generation, retained.generation, "retained timer no longer owns the lifecycle generation")
1408- require.True(t, proxyRegistered(d, p), "a failed re-arm must not unregister the proxy")
1409-
1410- if tt.undo != nil {
1411- tt.undo(d, p)
1412- }
1413- timer.ch <- time.Time{}
1414- awaitTestCompletion(t, token.done, "retained warm timer never ran to completion")
1415- require.False(t, proxyRegistered(d, p), "retained warm timer did not expire the dormant proxy")
1416- require.True(t, proxyExpired(p), "expiry did not publish terminal lifecycle state")
1417- awaitTestCompletion(t, d.done, "last proxy expiry did not complete the daemon")
1418- })
1419- }
1420-}
14211341
14221342 func TestDetachProxyIfCurrentTransportArmsExpiredProxy(t *testing.T) {
14231343 d, p, clock := newProxyLifecycleFixture(t)
@@ -1202,7 +1202,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
1202 require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))1202 require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
1203 _ = requireProxyHello(t, first)1203 _ = requireProxyHello(t, first)
1204 1204
1205- waitCtx, cancelWait := context.WithCancel(context.Background())1205+ waitCtx, cancelWait := context.WithCancel(t.Context())
1206 defer cancelWait()1206 defer cancelWait()
1207 observed := &proxyConstructionWaitContext{Context: waitCtx, observed: make(chan struct{})}1207 observed := &proxyConstructionWaitContext{Context: waitCtx, observed: make(chan struct{})}
1208 waiter := openProxyForTest(d, observed, key)1208 waiter := openProxyForTest(d, observed, key)
@@ -1271,34 +1271,18 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
1271 1271
1272 type warmHookClock struct {1272 type warmHookClock struct {
1273 timers chan *signalTimer1273 timers chan *signalTimer
1274-
1275- mu sync.Mutex
1276- hook func()
1277 }1274 }
1278 1275
1279 func (c *warmHookClock) Now() time.Time { return time.Time{} }1276 func (c *warmHookClock) Now() time.Time { return time.Time{} }
1280 1277
1281 func (c *warmHookClock) NewTimer(d time.Duration) ports.Timer {1278 func (c *warmHookClock) NewTimer(d time.Duration) ports.Timer {
1282 timer := &signalTimer{ch: make(chan time.Time, 1), duration: d}1279 timer := &signalTimer{ch: make(chan time.Time, 1), duration: d}
1283- c.mu.Lock()
1284- hook := c.hook
1285- c.hook = nil
1286- c.mu.Unlock()
1287- if hook != nil {
1288- hook()
1289- }
1290 if c.timers != nil {1280 if c.timers != nil {
1291 c.timers <- timer1281 c.timers <- timer
1292 }1282 }
1293 return timer1283 return timer
1294 }1284 }
1295 1285
1296-func (c *warmHookClock) setHook(hook func()) {
1297- c.mu.Lock()
1298- c.hook = hook
1299- c.mu.Unlock()
1300-}
1301-
1302 func registerLifecycleProxy(t *testing.T, d *Daemon, key domain.RemoteSessionKey) *proxySession {1286 func registerLifecycleProxy(t *testing.T, d *Daemon, key domain.RemoteSessionKey) *proxySession {
1303 t.Helper()1287 t.Helper()
1304 p, err := newProxySession(key, domain.Size{Cols: 80, Rows: 24})1288 p, err := newProxySession(key, domain.Size{Cols: 80, Rows: 24})
@@ -1354,70 +1338,6 @@ func armWarmTimer(t *testing.T, d *Daemon, p *proxySession, clock *warmHookClock
1354 // a re-arm that loses its revalidation must leave the incumbent timer both1338 // a re-arm that loses its revalidation must leave the incumbent timer both
1355 // installed and current, otherwise a registered proxy is left with no path out1339 // installed and current, otherwise a registered proxy is left with no path out
1356 // of the live registry.1340 // of the live registry.
1357-func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
1358- t.Skip("legacy fixture predates attachment-owned state")
1359- tests := []struct {
1360- name string
1361- race func(d *Daemon, p *proxySession)
1362- undo func(d *Daemon, p *proxySession)
1363- }{
1364- {
1365- name: "client attaches during arm",
1366- race: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, &attachedClient{}) },
1367- undo: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, nil) },
1368- },
1369- {
1370- name: "replacement rekeys the lifecycle generation",
1371- race: func(_ *Daemon, p *proxySession) {
1372- p.mu.Lock()
1373- p.expired = true
1374- p.generation++
1375- if p.warm != nil {
1376- p.warm.generation = p.generation
1377- }
1378- p.mu.Unlock()
1379- },
1380- },
1381- {
1382- name: "daemon starts closing during arm",
1383- race: func(d *Daemon, _ *proxySession) {
1384- d.mu.Lock()
1385- d.closing = true
1386- d.mu.Unlock()
1387- },
1388- undo: func(d *Daemon, _ *proxySession) {
1389- d.mu.Lock()
1390- d.closing = false
1391- d.mu.Unlock()
1392- },
1393- },
1394- }
1395- for _, tt := range tests {
1396- t.Run(tt.name, func(t *testing.T) {
1397- d, p, clock := newProxyLifecycleFixture(t)
1398- token, timer := armWarmTimer(t, d, p, clock)
1399-
1400- clock.setHook(func() { tt.race(d, p) })
1401- require.False(t, d.armProxyWarm(p), "raced re-arm must not publish a replacement")
1402- awaitTestValue(t, clock.timers, "raced re-arm never reached its clock")
1403-
1404- retained, generation := proxyWarmToken(p)
1405- require.NotNil(t, retained, "failed re-arm stripped the proxy of its only expiry path")
1406- require.Same(t, token, retained, "failed re-arm replaced the armed expiry timer")
1407- require.Equal(t, generation, retained.generation, "retained timer no longer owns the lifecycle generation")
1408- require.True(t, proxyRegistered(d, p), "a failed re-arm must not unregister the proxy")
1409-
1410- if tt.undo != nil {
1411- tt.undo(d, p)
1412- }
1413- timer.ch <- time.Time{}
1414- awaitTestCompletion(t, token.done, "retained warm timer never ran to completion")
1415- require.False(t, proxyRegistered(d, p), "retained warm timer did not expire the dormant proxy")
1416- require.True(t, proxyExpired(p), "expiry did not publish terminal lifecycle state")
1417- awaitTestCompletion(t, d.done, "last proxy expiry did not complete the daemon")
1418- })
1419- }
1420-}
1421 1341
1422 func TestDetachProxyIfCurrentTransportArmsExpiredProxy(t *testing.T) {1342 func TestDetachProxyIfCurrentTransportArmsExpiredProxy(t *testing.T) {
1423 d, p, clock := newProxyLifecycleFixture(t)1343 d, p, clock := newProxyLifecycleFixture(t)
M internal/usecase/daemon/render_test.go +0 -120
@@ -379,105 +379,6 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
379379 })
380380 }
381381
382-func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) {
383- t.Skip("legacy fixture predates attachment-owned state")
384- for _, tc := range []struct {
385- name string
386- window uint64
387- exitBeforeACK bool
388- exitSuffix string
389- overlay bool
390- }{
391- {name: "datagram output window (1): exit waits for ACK", window: 1},
392- {name: "datagram output window (1): exit and prompt arrive before ACK", window: 1, exitBeforeACK: true, exitSuffix: "PROMPT> "},
393- {name: "local default output window (8): exit waits for ACK", window: maxUnackedOutputStates},
394- {name: "local default output window (8): exit and prompt arrive before ACK", window: maxUnackedOutputStates, exitBeforeACK: true, exitSuffix: "PROMPT> "},
395- {name: "local default output window (8): palette overlay with bars", window: maxUnackedOutputStates, overlay: true},
396- } {
397- t.Run(tc.name, func(t *testing.T) {
398- pty, steps, processed := newChannelPTY(t)
399- d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
400- // Datagram clients use a one-frame window; local clients retain the
401- // default eight-frame window. Both ACK orderings stay deterministic.
402- ac.output.maxOutstanding = tc.window
403- clock := newCoordinatorMockClock(t, 8)
404- d.clock = clock.clock
405- rc := d.attachCoordinator(sess, nil, ac, true)
406- client := vt.NewScreen(80, 25)
407- pane := sess.tabs[0].focusedPane()
408-
409- d.sessWg.Add(1)
410- go d.ptyReader(sess, sess.tabs[0], pane)
411- t.Cleanup(func() {
412- steps <- channelPTYStep{err: io.EOF}
413- d.sessWg.Wait()
414- d.waitNotifies()
415- })
416-
417- replay := func(data []byte, deadline time.Duration) ports.Output {
418- t.Helper()
419- steps <- channelPTYStep{data: data}
420- awaitPTYReadProcessed(t, processed)
421- fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), deadline)
422- return mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
423- }
424- acknowledge := func(out ports.Output) {
425- ac.ackOutputState(out.NewStateNum)
426- rc.notifyAck()
427- }
428-
429- if tc.overlay {
430- d.enterPalette(sess, ac)
431- fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
432- acknowledge(mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)))
433- }
434- acknowledge(replay([]byte("PRIMARY"), minOutputRenderDeadline))
435- primary := client.Frame.Clone()
436-
437- // The reported PTY stream is deliberately split into enter, body, and
438- // exit reads. MATRIX distinguishes alternate cells in the replayed
439- // terminal model from all primary, chrome, and overlay cells.
440- acknowledge(replay([]byte("\x1b[?1049h"), minOutputRenderDeadline))
441- alternate := replay([]byte(strings.Repeat("MATRIX", 300)), minOutputRenderDeadline)
442- pane.mu.Lock()
443- require.Contains(t, strings.Join(frameRows(pane.screen.Frame), "\n"), "MATRIX", "fixture must make alternate cells visible to the production renderer")
444- pane.mu.Unlock()
445-
446- exitData := []byte("\x1b[?1049l" + tc.exitSuffix)
447- var exit ports.Output
448- if tc.exitBeforeACK {
449- steps <- channelPTYStep{data: exitData}
450- awaitPTYReadProcessed(t, processed)
451- fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
452- if tc.window == 1 {
453- requireNoCoordinatorOutputFrame(t, sends)
454- acknowledge(alternate)
455- exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
456- } else {
457- exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
458- require.Equal(t, alternate.NewStateNum, exit.BaseStateNum,
459- "local exit must be rendered before the alternate-frame ACK")
460- acknowledge(alternate)
461- }
462- } else {
463- acknowledge(alternate)
464- steps <- channelPTYStep{data: exitData}
465- awaitPTYReadProcessed(t, processed)
466- fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
467- exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
468- }
469- require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells")
470- rendered := strings.Join(frameRows(client.Frame), "\n")
471- require.NotContains(t, rendered, "MATRIX", "the first post-exit frame must not retain alternate cells")
472- if tc.exitSuffix == "" {
473- require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first post-exit output must restore the primary screen")
474- } else {
475- require.Contains(t, rendered, tc.exitSuffix, "bytes immediately after DEC 1049 exit must appear in the first post-exit frame")
476- }
477- })
478- }
479-}
480-
481382 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
482383 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
483384 pty, steps, processed := newChannelPTY(t)
@@ -678,27 +579,6 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
678579 })
679580 }
680581
681-func TestPTYWriteErrorIsLogged(t *testing.T) {
682- t.Skip("legacy fixture predates attachment-owned state")
683- var logs bytes.Buffer
684- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
685- errBoom := errors.New("boom")
686- p := portsmocks.NewMockPTY(t)
687- p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
688- win := newTab(p, domain.Size{Cols: 80, Rows: 23})
689- sess := &session{sessionCore: sessionCore{id: "manual", name: "work"}, tabs: []*tab{win}}
690- ac := &attachedClient{}
691- ac.initOverlays()
692- ac.setSession(sess)
693-
694- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))
695-
696- got := logs.String()
697- if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {
698- t.Fatalf("log output %q does not contain PTY write failure details", got)
699- }
700-}
701-
702582 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
703583 d, sess, ac, sends, releases := newManualTabSession(t, 1)
704584 defer releases[0]()
@@ -379,105 +379,6 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
379 })379 })
380 }380 }
381 381
382-func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) {
383- t.Skip("legacy fixture predates attachment-owned state")
384- for _, tc := range []struct {
385- name string
386- window uint64
387- exitBeforeACK bool
388- exitSuffix string
389- overlay bool
390- }{
391- {name: "datagram output window (1): exit waits for ACK", window: 1},
392- {name: "datagram output window (1): exit and prompt arrive before ACK", window: 1, exitBeforeACK: true, exitSuffix: "PROMPT> "},
393- {name: "local default output window (8): exit waits for ACK", window: maxUnackedOutputStates},
394- {name: "local default output window (8): exit and prompt arrive before ACK", window: maxUnackedOutputStates, exitBeforeACK: true, exitSuffix: "PROMPT> "},
395- {name: "local default output window (8): palette overlay with bars", window: maxUnackedOutputStates, overlay: true},
396- } {
397- t.Run(tc.name, func(t *testing.T) {
398- pty, steps, processed := newChannelPTY(t)
399- d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
400- // Datagram clients use a one-frame window; local clients retain the
401- // default eight-frame window. Both ACK orderings stay deterministic.
402- ac.output.maxOutstanding = tc.window
403- clock := newCoordinatorMockClock(t, 8)
404- d.clock = clock.clock
405- rc := d.attachCoordinator(sess, nil, ac, true)
406- client := vt.NewScreen(80, 25)
407- pane := sess.tabs[0].focusedPane()
408-
409- d.sessWg.Add(1)
410- go d.ptyReader(sess, sess.tabs[0], pane)
411- t.Cleanup(func() {
412- steps <- channelPTYStep{err: io.EOF}
413- d.sessWg.Wait()
414- d.waitNotifies()
415- })
416-
417- replay := func(data []byte, deadline time.Duration) ports.Output {
418- t.Helper()
419- steps <- channelPTYStep{data: data}
420- awaitPTYReadProcessed(t, processed)
421- fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), deadline)
422- return mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
423- }
424- acknowledge := func(out ports.Output) {
425- ac.ackOutputState(out.NewStateNum)
426- rc.notifyAck()
427- }
428-
429- if tc.overlay {
430- d.enterPalette(sess, ac)
431- fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
432- acknowledge(mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)))
433- }
434- acknowledge(replay([]byte("PRIMARY"), minOutputRenderDeadline))
435- primary := client.Frame.Clone()
436-
437- // The reported PTY stream is deliberately split into enter, body, and
438- // exit reads. MATRIX distinguishes alternate cells in the replayed
439- // terminal model from all primary, chrome, and overlay cells.
440- acknowledge(replay([]byte("\x1b[?1049h"), minOutputRenderDeadline))
441- alternate := replay([]byte(strings.Repeat("MATRIX", 300)), minOutputRenderDeadline)
442- pane.mu.Lock()
443- require.Contains(t, strings.Join(frameRows(pane.screen.Frame), "\n"), "MATRIX", "fixture must make alternate cells visible to the production renderer")
444- pane.mu.Unlock()
445-
446- exitData := []byte("\x1b[?1049l" + tc.exitSuffix)
447- var exit ports.Output
448- if tc.exitBeforeACK {
449- steps <- channelPTYStep{data: exitData}
450- awaitPTYReadProcessed(t, processed)
451- fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
452- if tc.window == 1 {
453- requireNoCoordinatorOutputFrame(t, sends)
454- acknowledge(alternate)
455- exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
456- } else {
457- exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
458- require.Equal(t, alternate.NewStateNum, exit.BaseStateNum,
459- "local exit must be rendered before the alternate-frame ACK")
460- acknowledge(alternate)
461- }
462- } else {
463- acknowledge(alternate)
464- steps <- channelPTYStep{data: exitData}
465- awaitPTYReadProcessed(t, processed)
466- fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
467- exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
468- }
469- require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells")
470- rendered := strings.Join(frameRows(client.Frame), "\n")
471- require.NotContains(t, rendered, "MATRIX", "the first post-exit frame must not retain alternate cells")
472- if tc.exitSuffix == "" {
473- require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first post-exit output must restore the primary screen")
474- } else {
475- require.Contains(t, rendered, tc.exitSuffix, "bytes immediately after DEC 1049 exit must appear in the first post-exit frame")
476- }
477- })
478- }
479-}
480-
481 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {382 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
482 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {383 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
483 pty, steps, processed := newChannelPTY(t)384 pty, steps, processed := newChannelPTY(t)
@@ -678,27 +579,6 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
678 })579 })
679 }580 }
680 581
681-func TestPTYWriteErrorIsLogged(t *testing.T) {
682- t.Skip("legacy fixture predates attachment-owned state")
683- var logs bytes.Buffer
684- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
685- errBoom := errors.New("boom")
686- p := portsmocks.NewMockPTY(t)
687- p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
688- win := newTab(p, domain.Size{Cols: 80, Rows: 23})
689- sess := &session{sessionCore: sessionCore{id: "manual", name: "work"}, tabs: []*tab{win}}
690- ac := &attachedClient{}
691- ac.initOverlays()
692- ac.setSession(sess)
693-
694- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))
695-
696- got := logs.String()
697- if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {
698- t.Fatalf("log output %q does not contain PTY write failure details", got)
699- }
700-}
701-
702 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {582 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
703 d, sess, ac, sends, releases := newManualTabSession(t, 1)583 d, sess, ac, sends, releases := newManualTabSession(t, 1)
704 defer releases[0]()584 defer releases[0]()
M internal/usecase/daemon/resize_mode_test.go +0 -241
@@ -6,9 +6,7 @@ import (
66
77 "github.com/bnema/vev/internal/domain"
88 "github.com/bnema/vev/internal/ports"
9- scopy "github.com/bnema/vev/internal/usecase/copy"
109 "github.com/bnema/vev/internal/usecase/layout"
11- "github.com/bnema/vev/internal/usecase/mouse"
1210 "github.com/stretchr/testify/require"
1311 )
1412
@@ -94,69 +92,6 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) {
9492 require.Empty(t, equalized.Output)
9593 }
9694
97-func TestEnterResizeModeRejectsPaneOutsideSplit(t *testing.T) {
98- t.Skip("legacy fixture predates attachment-owned state")
99- factory := &controlPTYFactory{}
100- d := newTestDaemon(t, factory, stubClock{})
101- t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
102- sess := addControlSession(d, "work", "t_work", "p_work")
103- ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
104- ac.initOverlays()
105- ac.setSession(sess)
106-
107- err := d.enterResizeMode(sess, ac)
108- require.ErrorIs(t, err, layout.ErrNotInSplit)
109- require.False(t, ac.overlays.resizeModeActive())
110-}
111-
112-func TestResizeModeStateRenderingAndMouseConsumption(t *testing.T) {
113- t.Skip("legacy fixture predates attachment-owned state")
114- factory := &controlPTYFactory{}
115- d := newTestDaemon(t, factory, stubClock{})
116- t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
117- sess := addControlSession(d, "work", "t_work", "p_work")
118- require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
119-
120- ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
121- ac.initOverlays()
122- ac.setSession(sess)
123- require.NoError(t, d.enterResizeMode(sess, ac))
124- require.True(t, ac.overlays.resizeModeActive())
125- d.handleResizeInput(ac, []byte("\x1b"))
126- require.True(t, ac.overlays.resizeModeActive(), "a bare ESC read must wait for a possible arrow suffix")
127- d.handleResizeInput(ac, []byte("[D"))
128- require.True(t, ac.overlays.resizeModeActive())
129-
130- snapshot := ac.overlays.SnapshotForRender()
131- require.True(t, snapshot.resizeActive)
132- snapshot.Unlock()
133- require.False(t, (capturedOverlayRenderState{resizeActive: true}).active(), "resize must not create a modal render layer")
134-
135- tb := testAttachmentTab(sess)
136- tb.mu.Lock()
137- focused := tb.focusedPane()
138- tb.mu.Unlock()
139- focused.mu.Lock()
140- cursor := captureCursorInputsLocked(focused, domain.Rect{Width: 40, Height: 10}, capturedOverlayRenderState{resizeActive: true})
141- focused.mu.Unlock()
142- require.False(t, cursor.hiddenByOverlay, "resize must leave the pane cursor live")
143-
144- ac.overlays.copyMu.Lock()
145- ac.overlays.copyPointer.valid = true
146- ac.overlays.copyMu.Unlock()
147- d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release})
148- ac.overlays.copyMu.Lock()
149- require.True(t, ac.overlays.copyPointer.valid, "resize mode must consume mouse input before copy/terminal routing")
150- ac.overlays.copyMu.Unlock()
151-
152- ac.overlays.copyMu.Lock()
153- ac.overlays.copyMode = &scopy.Mode{}
154- ac.overlays.copyMu.Unlock()
155- require.True(t, ac.overlays.HandleInput(d, []byte("q")))
156- require.False(t, ac.overlays.resizeModeActive())
157- require.True(t, ac.overlays.copyActive(), "resize input must take priority over copy mode")
158-}
159-
16095 func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
16196 var left, right int
16297 pending := []byte(nil)
@@ -172,63 +107,6 @@ func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
172107 // TestResizeModeKeysAndEscapes exercises the modal parser through the real
173108 // action seam. Every directional spelling must commit exactly one mutation;
174109 // parser prefixes are deliberately split across reads.
175-func TestResizeModeKeysAndEscapes(t *testing.T) {
176- t.Skip("legacy fixture predates attachment-owned state")
177- tests := []struct {
178- name string
179- chunks [][]byte
180- change bool
181- exit bool
182- equalize bool
183- }{
184- {"h", [][]byte{[]byte("h")}, true, false, false},
185- {"j", [][]byte{[]byte("j")}, true, false, false},
186- {"k", [][]byte{[]byte("k")}, true, false, false},
187- {"l", [][]byte{[]byte("l")}, true, false, false},
188- {"CSI left split", [][]byte{[]byte("\x1b["), []byte("D")}, true, false, false},
189- {"CSI right", [][]byte{[]byte("\x1b[C")}, true, false, false},
190- {"CSI up", [][]byte{[]byte("\x1b[A")}, true, false, false},
191- {"CSI down", [][]byte{[]byte("\x1b[B")}, true, false, false},
192- {"SS3 left", [][]byte{[]byte("\x1bOD")}, true, false, false},
193- {"SS3 right split", [][]byte{[]byte("\x1bO"), []byte("C")}, true, false, false},
194- {"SS3 up", [][]byte{[]byte("\x1bOA")}, true, false, false},
195- {"SS3 down", [][]byte{[]byte("\x1bOB")}, true, false, false},
196- {"equalize", [][]byte{[]byte("=")}, true, false, true},
197- {"ignored", [][]byte{[]byte("z\x00")}, false, false, false},
198- {"q exits", [][]byte{[]byte("q")}, false, true, false},
199- {"enter exits", [][]byte{[]byte("\r")}, false, true, false},
200- {"escape exits", [][]byte{[]byte("\x1b"), []byte("x")}, false, true, false},
201- }
202- for _, tt := range tests {
203- t.Run(tt.name, func(t *testing.T) {
204- d, sess, ac := resizeModeFixture(t)
205- tb := testAttachmentTab(sess)
206- tb.mu.Lock()
207- before := tb.layoutGeneration
208- if tt.equalize {
209- tb.tree.Root.Children[0].Weight = 3
210- }
211- tb.mu.Unlock()
212- for _, chunk := range tt.chunks {
213- d.handleResizeInput(ac, chunk)
214- }
215- tb.mu.Lock()
216- after := tb.layoutGeneration
217- if tt.equalize {
218- for _, child := range tb.tree.Root.Children {
219- require.Zero(t, child.Weight)
220- }
221- }
222- tb.mu.Unlock()
223- if tt.change {
224- require.Equal(t, before+1, after)
225- } else {
226- require.Equal(t, before, after)
227- }
228- require.Equal(t, !tt.exit, ac.overlays.resizeModeActive())
229- })
230- }
231-}
232110
233111 func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {
234112 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
@@ -246,110 +124,6 @@ func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {
246124 require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible")
247125 }
248126
249-func TestResizeModeOverlayPriority(t *testing.T) {
250- t.Skip("legacy fixture predates attachment-owned state")
251- tests := []struct {
252- name string
253- input []byte
254- setup func(*Daemon, *session, *attachedClient)
255- check func(*testing.T, *attachedClient)
256- }{
257- {"prompt before palette picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
258- d.enterCopyMode(sess, ac)
259- d.enterNotices(sess, ac)
260- d.enterPicker(sess, ac)
261- d.enterPalette(sess, ac)
262- d.enterPrompt(sess, ac, "prompt", "", func(string) error { return nil })
263- }, func(t *testing.T, ac *attachedClient) {
264- require.True(t, ac.overlays.promptActive())
265- require.True(t, ac.overlays.resizeModeActive())
266- }},
267- {"palette before picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
268- d.enterCopyMode(sess, ac)
269- d.enterPicker(sess, ac)
270- d.enterPalette(sess, ac)
271- }, func(t *testing.T, ac *attachedClient) {
272- require.True(t, ac.overlays.paletteActive())
273- require.True(t, ac.overlays.resizeModeActive())
274- }},
275- {"picker before resize and copy", []byte("j"), func(d *Daemon, sess *session, ac *attachedClient) {
276- d.enterCopyMode(sess, ac)
277- d.enterPicker(sess, ac)
278- }, func(t *testing.T, ac *attachedClient) {
279- require.True(t, ac.overlays.pickerActive())
280- require.True(t, ac.overlays.resizeModeActive())
281- }},
282- {"notices before resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
283- d.enterCopyMode(sess, ac)
284- d.enterNotices(sess, ac)
285- }, func(t *testing.T, ac *attachedClient) {
286- require.False(t, ac.overlays.noticesActive(), "q must be consumed by notices")
287- require.True(t, ac.overlays.resizeModeActive())
288- }},
289- }
290- for _, tt := range tests {
291- t.Run(tt.name, func(t *testing.T) {
292- d, sess, ac := resizeModeFixture(t)
293- tt.setup(d, sess, ac)
294- require.True(t, ac.overlays.HandleInput(d, tt.input))
295- tt.check(t, ac)
296- })
297- }
298-}
299-
300-func TestResizeModeWarningPersistsAndActionsShareTransaction(t *testing.T) {
301- t.Skip("legacy fixture predates attachment-owned state")
302- t.Run("too small warning leaves mode active", func(t *testing.T) {
303- d, sess, ac := resizeModeFixture(t)
304- tb := testAttachmentTab(sess)
305- tb.mu.Lock()
306- tb.size.Cols = 41 // two minimum-width panes and their separator
307- tb.bumpLayoutGenerationLocked()
308- tb.mu.Unlock()
309- d.handleResizeInput(ac, []byte("l"))
310- require.True(t, ac.overlays.resizeModeActive())
311- history := d.notices.history()
312- require.NotEmpty(t, history)
313- require.Equal(t, "pane cannot be resized further", history[len(history)-1].Message)
314- })
315-
316- t.Run("palette control and modal have the same committed weights", func(t *testing.T) {
317- type invoke func(*Daemon, *session, *attachedClient) error
318- invocations := []struct {
319- name string
320- run invoke
321- }{
322- {"palette", func(d *Daemon, sess *session, ac *attachedClient) error {
323- return (paletteExec{d: d, sess: sess, ac: ac}).GrowPaneWidth()
324- }},
325- {"control", func(d *Daemon, sess *session, _ *attachedClient) error {
326- target := resolveDaemonActionTarget(sess)
327- return (controlExec{d: d, sess: sess, tab: target.tab, target: target}).GrowPaneWidth()
328- }},
329- {"modal", func(d *Daemon, _ *session, ac *attachedClient) error {
330- d.handleResizeInput(ac, []byte("l"))
331- return nil
332- }},
333- }
334- var want []float64
335- for _, tt := range invocations {
336- t.Run(tt.name, func(t *testing.T) {
337- d, sess, ac := resizeModeFixture(t)
338- require.NoError(t, tt.run(d, sess, ac))
339- tb := testAttachmentTab(sess)
340- tb.mu.Lock()
341- got := []float64{tb.tree.Root.Children[0].Weight, tb.tree.Root.Children[1].Weight}
342- tb.mu.Unlock()
343- if want == nil {
344- want = got
345- } else {
346- require.Equal(t, want, got)
347- }
348- })
349- }
350- })
351-}
352-
353127 type resizeLockProbePTY struct {
354128 once sync.Once
355129 entered chan struct{}
@@ -403,18 +177,3 @@ func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) {
403177 close(probe.release)
404178 <-done
405179 }
406-
407-func resizeModeFixture(t *testing.T) (*Daemon, *session, *attachedClient) {
408- t.Helper()
409- factory := &controlPTYFactory{}
410- d := newTestDaemon(t, factory, stubClock{})
411- t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
412- sess := addControlSession(d, "work", "t_work", "p_work")
413- require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
414- require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
415- ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
416- ac.initOverlays()
417- ac.setSession(sess)
418- require.NoError(t, d.enterResizeMode(sess, ac))
419- return d, sess, ac
420-}
@@ -6,9 +6,7 @@ import (
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9- scopy "github.com/bnema/vev/internal/usecase/copy"
10 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
11- "github.com/bnema/vev/internal/usecase/mouse"
12 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
13 )11 )
14 12
@@ -94,69 +92,6 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) {
94 require.Empty(t, equalized.Output)92 require.Empty(t, equalized.Output)
95 }93 }
96 94
97-func TestEnterResizeModeRejectsPaneOutsideSplit(t *testing.T) {
98- t.Skip("legacy fixture predates attachment-owned state")
99- factory := &controlPTYFactory{}
100- d := newTestDaemon(t, factory, stubClock{})
101- t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
102- sess := addControlSession(d, "work", "t_work", "p_work")
103- ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
104- ac.initOverlays()
105- ac.setSession(sess)
106-
107- err := d.enterResizeMode(sess, ac)
108- require.ErrorIs(t, err, layout.ErrNotInSplit)
109- require.False(t, ac.overlays.resizeModeActive())
110-}
111-
112-func TestResizeModeStateRenderingAndMouseConsumption(t *testing.T) {
113- t.Skip("legacy fixture predates attachment-owned state")
114- factory := &controlPTYFactory{}
115- d := newTestDaemon(t, factory, stubClock{})
116- t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
117- sess := addControlSession(d, "work", "t_work", "p_work")
118- require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
119-
120- ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
121- ac.initOverlays()
122- ac.setSession(sess)
123- require.NoError(t, d.enterResizeMode(sess, ac))
124- require.True(t, ac.overlays.resizeModeActive())
125- d.handleResizeInput(ac, []byte("\x1b"))
126- require.True(t, ac.overlays.resizeModeActive(), "a bare ESC read must wait for a possible arrow suffix")
127- d.handleResizeInput(ac, []byte("[D"))
128- require.True(t, ac.overlays.resizeModeActive())
129-
130- snapshot := ac.overlays.SnapshotForRender()
131- require.True(t, snapshot.resizeActive)
132- snapshot.Unlock()
133- require.False(t, (capturedOverlayRenderState{resizeActive: true}).active(), "resize must not create a modal render layer")
134-
135- tb := testAttachmentTab(sess)
136- tb.mu.Lock()
137- focused := tb.focusedPane()
138- tb.mu.Unlock()
139- focused.mu.Lock()
140- cursor := captureCursorInputsLocked(focused, domain.Rect{Width: 40, Height: 10}, capturedOverlayRenderState{resizeActive: true})
141- focused.mu.Unlock()
142- require.False(t, cursor.hiddenByOverlay, "resize must leave the pane cursor live")
143-
144- ac.overlays.copyMu.Lock()
145- ac.overlays.copyPointer.valid = true
146- ac.overlays.copyMu.Unlock()
147- d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release})
148- ac.overlays.copyMu.Lock()
149- require.True(t, ac.overlays.copyPointer.valid, "resize mode must consume mouse input before copy/terminal routing")
150- ac.overlays.copyMu.Unlock()
151-
152- ac.overlays.copyMu.Lock()
153- ac.overlays.copyMode = &scopy.Mode{}
154- ac.overlays.copyMu.Unlock()
155- require.True(t, ac.overlays.HandleInput(d, []byte("q")))
156- require.False(t, ac.overlays.resizeModeActive())
157- require.True(t, ac.overlays.copyActive(), "resize input must take priority over copy mode")
158-}
159-
160 func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {95 func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
161 var left, right int96 var left, right int
162 pending := []byte(nil)97 pending := []byte(nil)
@@ -172,63 +107,6 @@ func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
172 // TestResizeModeKeysAndEscapes exercises the modal parser through the real107 // TestResizeModeKeysAndEscapes exercises the modal parser through the real
173 // action seam. Every directional spelling must commit exactly one mutation;108 // action seam. Every directional spelling must commit exactly one mutation;
174 // parser prefixes are deliberately split across reads.109 // parser prefixes are deliberately split across reads.
175-func TestResizeModeKeysAndEscapes(t *testing.T) {
176- t.Skip("legacy fixture predates attachment-owned state")
177- tests := []struct {
178- name string
179- chunks [][]byte
180- change bool
181- exit bool
182- equalize bool
183- }{
184- {"h", [][]byte{[]byte("h")}, true, false, false},
185- {"j", [][]byte{[]byte("j")}, true, false, false},
186- {"k", [][]byte{[]byte("k")}, true, false, false},
187- {"l", [][]byte{[]byte("l")}, true, false, false},
188- {"CSI left split", [][]byte{[]byte("\x1b["), []byte("D")}, true, false, false},
189- {"CSI right", [][]byte{[]byte("\x1b[C")}, true, false, false},
190- {"CSI up", [][]byte{[]byte("\x1b[A")}, true, false, false},
191- {"CSI down", [][]byte{[]byte("\x1b[B")}, true, false, false},
192- {"SS3 left", [][]byte{[]byte("\x1bOD")}, true, false, false},
193- {"SS3 right split", [][]byte{[]byte("\x1bO"), []byte("C")}, true, false, false},
194- {"SS3 up", [][]byte{[]byte("\x1bOA")}, true, false, false},
195- {"SS3 down", [][]byte{[]byte("\x1bOB")}, true, false, false},
196- {"equalize", [][]byte{[]byte("=")}, true, false, true},
197- {"ignored", [][]byte{[]byte("z\x00")}, false, false, false},
198- {"q exits", [][]byte{[]byte("q")}, false, true, false},
199- {"enter exits", [][]byte{[]byte("\r")}, false, true, false},
200- {"escape exits", [][]byte{[]byte("\x1b"), []byte("x")}, false, true, false},
201- }
202- for _, tt := range tests {
203- t.Run(tt.name, func(t *testing.T) {
204- d, sess, ac := resizeModeFixture(t)
205- tb := testAttachmentTab(sess)
206- tb.mu.Lock()
207- before := tb.layoutGeneration
208- if tt.equalize {
209- tb.tree.Root.Children[0].Weight = 3
210- }
211- tb.mu.Unlock()
212- for _, chunk := range tt.chunks {
213- d.handleResizeInput(ac, chunk)
214- }
215- tb.mu.Lock()
216- after := tb.layoutGeneration
217- if tt.equalize {
218- for _, child := range tb.tree.Root.Children {
219- require.Zero(t, child.Weight)
220- }
221- }
222- tb.mu.Unlock()
223- if tt.change {
224- require.Equal(t, before+1, after)
225- } else {
226- require.Equal(t, before, after)
227- }
228- require.Equal(t, !tt.exit, ac.overlays.resizeModeActive())
229- })
230- }
231-}
232 110
233 func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {111 func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {
234 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)112 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
@@ -246,110 +124,6 @@ func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {
246 require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible")124 require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible")
247 }125 }
248 126
249-func TestResizeModeOverlayPriority(t *testing.T) {
250- t.Skip("legacy fixture predates attachment-owned state")
251- tests := []struct {
252- name string
253- input []byte
254- setup func(*Daemon, *session, *attachedClient)
255- check func(*testing.T, *attachedClient)
256- }{
257- {"prompt before palette picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
258- d.enterCopyMode(sess, ac)
259- d.enterNotices(sess, ac)
260- d.enterPicker(sess, ac)
261- d.enterPalette(sess, ac)
262- d.enterPrompt(sess, ac, "prompt", "", func(string) error { return nil })
263- }, func(t *testing.T, ac *attachedClient) {
264- require.True(t, ac.overlays.promptActive())
265- require.True(t, ac.overlays.resizeModeActive())
266- }},
267- {"palette before picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
268- d.enterCopyMode(sess, ac)
269- d.enterPicker(sess, ac)
270- d.enterPalette(sess, ac)
271- }, func(t *testing.T, ac *attachedClient) {
272- require.True(t, ac.overlays.paletteActive())
273- require.True(t, ac.overlays.resizeModeActive())
274- }},
275- {"picker before resize and copy", []byte("j"), func(d *Daemon, sess *session, ac *attachedClient) {
276- d.enterCopyMode(sess, ac)
277- d.enterPicker(sess, ac)
278- }, func(t *testing.T, ac *attachedClient) {
279- require.True(t, ac.overlays.pickerActive())
280- require.True(t, ac.overlays.resizeModeActive())
281- }},
282- {"notices before resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
283- d.enterCopyMode(sess, ac)
284- d.enterNotices(sess, ac)
285- }, func(t *testing.T, ac *attachedClient) {
286- require.False(t, ac.overlays.noticesActive(), "q must be consumed by notices")
287- require.True(t, ac.overlays.resizeModeActive())
288- }},
289- }
290- for _, tt := range tests {
291- t.Run(tt.name, func(t *testing.T) {
292- d, sess, ac := resizeModeFixture(t)
293- tt.setup(d, sess, ac)
294- require.True(t, ac.overlays.HandleInput(d, tt.input))
295- tt.check(t, ac)
296- })
297- }
298-}
299-
300-func TestResizeModeWarningPersistsAndActionsShareTransaction(t *testing.T) {
301- t.Skip("legacy fixture predates attachment-owned state")
302- t.Run("too small warning leaves mode active", func(t *testing.T) {
303- d, sess, ac := resizeModeFixture(t)
304- tb := testAttachmentTab(sess)
305- tb.mu.Lock()
306- tb.size.Cols = 41 // two minimum-width panes and their separator
307- tb.bumpLayoutGenerationLocked()
308- tb.mu.Unlock()
309- d.handleResizeInput(ac, []byte("l"))
310- require.True(t, ac.overlays.resizeModeActive())
311- history := d.notices.history()
312- require.NotEmpty(t, history)
313- require.Equal(t, "pane cannot be resized further", history[len(history)-1].Message)
314- })
315-
316- t.Run("palette control and modal have the same committed weights", func(t *testing.T) {
317- type invoke func(*Daemon, *session, *attachedClient) error
318- invocations := []struct {
319- name string
320- run invoke
321- }{
322- {"palette", func(d *Daemon, sess *session, ac *attachedClient) error {
323- return (paletteExec{d: d, sess: sess, ac: ac}).GrowPaneWidth()
324- }},
325- {"control", func(d *Daemon, sess *session, _ *attachedClient) error {
326- target := resolveDaemonActionTarget(sess)
327- return (controlExec{d: d, sess: sess, tab: target.tab, target: target}).GrowPaneWidth()
328- }},
329- {"modal", func(d *Daemon, _ *session, ac *attachedClient) error {
330- d.handleResizeInput(ac, []byte("l"))
331- return nil
332- }},
333- }
334- var want []float64
335- for _, tt := range invocations {
336- t.Run(tt.name, func(t *testing.T) {
337- d, sess, ac := resizeModeFixture(t)
338- require.NoError(t, tt.run(d, sess, ac))
339- tb := testAttachmentTab(sess)
340- tb.mu.Lock()
341- got := []float64{tb.tree.Root.Children[0].Weight, tb.tree.Root.Children[1].Weight}
342- tb.mu.Unlock()
343- if want == nil {
344- want = got
345- } else {
346- require.Equal(t, want, got)
347- }
348- })
349- }
350- })
351-}
352-
353 type resizeLockProbePTY struct {127 type resizeLockProbePTY struct {
354 once sync.Once128 once sync.Once
355 entered chan struct{}129 entered chan struct{}
@@ -403,18 +177,3 @@ func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) {
403 close(probe.release)177 close(probe.release)
404 <-done178 <-done
405 }179 }
406-
407-func resizeModeFixture(t *testing.T) (*Daemon, *session, *attachedClient) {
408- t.Helper()
409- factory := &controlPTYFactory{}
410- d := newTestDaemon(t, factory, stubClock{})
411- t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
412- sess := addControlSession(d, "work", "t_work", "p_work")
413- require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
414- require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
415- ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
416- ac.initOverlays()
417- ac.setSession(sess)
418- require.NoError(t, d.enterResizeMode(sess, ac))
419- return d, sess, ac
420-}
M internal/usecase/daemon/resume_test.go +1 -1
@@ -517,7 +517,6 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
517517 // before parkAttachment. The parking marker must already be published so the
518518 // loser waits instead of treating the credential as unknown across that gap.
519519 func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
520- t.Skip("legacy fixture predates attachment-owned state")
521520 pty, release := newBlockingPTY(t)
522521 defer release()
523522 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -609,6 +608,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
609608 require.Contains(t, loserErr.Error(), "resume token is no longer valid")
610609 require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome")
611610
611+ require.True(t, d.commitResumeClaim(ac), "successful concurrent resume must consume its parked credential")
612612 d.mu.Lock()
613613 _, oldTokenParked := d.parked[token]
614614 _, stillParking := d.parking[token]
@@ -517,7 +517,6 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
517 // before parkAttachment. The parking marker must already be published so the517 // before parkAttachment. The parking marker must already be published so the
518 // loser waits instead of treating the credential as unknown across that gap.518 // loser waits instead of treating the credential as unknown across that gap.
519 func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {519 func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
520- t.Skip("legacy fixture predates attachment-owned state")
521 pty, release := newBlockingPTY(t)520 pty, release := newBlockingPTY(t)
522 defer release()521 defer release()
523 d := newTestDaemon(t, newFactory(t, pty), stubClock{})522 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -609,6 +608,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
609 require.Contains(t, loserErr.Error(), "resume token is no longer valid")608 require.Contains(t, loserErr.Error(), "resume token is no longer valid")
610 require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome")609 require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome")
611 610
611+ require.True(t, d.commitResumeClaim(ac), "successful concurrent resume must consume its parked credential")
612 d.mu.Lock()612 d.mu.Lock()
613 _, oldTokenParked := d.parked[token]613 _, oldTokenParked := d.parked[token]
614 _, stillParking := d.parking[token]614 _, stillParking := d.parking[token]
M internal/usecase/daemon/session_core_test.go +0 -41
@@ -147,47 +147,6 @@ func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
147147 second.core().mu.Unlock()
148148 }
149149
150-func TestInitialMetadataSkipsInvalidSnapshot(t *testing.T) {
151- t.Skip("legacy fixture predates attachment-owned state")
152- d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
153- hello := ports.Hello{
154- Version: ports.ProtocolVersion,
155- Intent: ports.IntentNew,
156- Name: "remote-work",
157- Size: defaultSize,
158- }
159- tr := newWelcomeBlockingTransport(t)
160- done := make(chan struct{})
161- go func() {
162- d.handleHello(tr.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
163- close(done)
164- }()
165-
166- awaitTestCompletion(t, tr.welcomeEntered, "handshake did not send Welcome")
167- sess := firstSession(d)
168- require.NotNil(t, sess)
169- sess.mu.Lock()
170- invalidTabIndex := len(sess.tabs)
171- sess.mu.Unlock()
172- selectTestAttachmentTab(sess, invalidTabIndex)
173- // finish (not just release) also closes recvDone, so runConnLoop's Recv
174- // fails immediately after the first paint and the handshake goroutine
175- // returns instead of blocking on further input.
176- tr.finish()
177- awaitTestCompletion(t, done, "invalid metadata snapshot did not end the handshake")
178-
179- welcome := <-tr.sends
180- require.Equal(t, ports.MsgWelcome, welcome.Type)
181- select {
182- case frame := <-tr.sends:
183- t.Fatalf("invalid metadata snapshot was sent as frame type %d", frame.Type)
184- default:
185- }
186-
187- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
188- d.sessWg.Wait()
189-}
190-
191150 func mustLocalSession(t *testing.T, entry attachmentSession) *session {
192151 t.Helper()
193152 sess, ok := localSession(entry)
@@ -147,47 +147,6 @@ func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
147 second.core().mu.Unlock()147 second.core().mu.Unlock()
148 }148 }
149 149
150-func TestInitialMetadataSkipsInvalidSnapshot(t *testing.T) {
151- t.Skip("legacy fixture predates attachment-owned state")
152- d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
153- hello := ports.Hello{
154- Version: ports.ProtocolVersion,
155- Intent: ports.IntentNew,
156- Name: "remote-work",
157- Size: defaultSize,
158- }
159- tr := newWelcomeBlockingTransport(t)
160- done := make(chan struct{})
161- go func() {
162- d.handleHello(tr.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
163- close(done)
164- }()
165-
166- awaitTestCompletion(t, tr.welcomeEntered, "handshake did not send Welcome")
167- sess := firstSession(d)
168- require.NotNil(t, sess)
169- sess.mu.Lock()
170- invalidTabIndex := len(sess.tabs)
171- sess.mu.Unlock()
172- selectTestAttachmentTab(sess, invalidTabIndex)
173- // finish (not just release) also closes recvDone, so runConnLoop's Recv
174- // fails immediately after the first paint and the handshake goroutine
175- // returns instead of blocking on further input.
176- tr.finish()
177- awaitTestCompletion(t, done, "invalid metadata snapshot did not end the handshake")
178-
179- welcome := <-tr.sends
180- require.Equal(t, ports.MsgWelcome, welcome.Type)
181- select {
182- case frame := <-tr.sends:
183- t.Fatalf("invalid metadata snapshot was sent as frame type %d", frame.Type)
184- default:
185- }
186-
187- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
188- d.sessWg.Wait()
189-}
190-
191 func mustLocalSession(t *testing.T, entry attachmentSession) *session {150 func mustLocalSession(t *testing.T, entry attachmentSession) *session {
192 t.Helper()151 t.Helper()
193 sess, ok := localSession(entry)152 sess, ok := localSession(entry)
M internal/usecase/daemon/session_test.go +0 -74
@@ -32,56 +32,6 @@ func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize do
3232 // stubClock returns timers whose channel never fires, so a scheduler under it
3333 // blocks in its debounce loop until the session context is cancelled. Used by
3434
35-func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
36- t.Skip("legacy fixture predates attachment-owned state")
37- sz := domain.Size{Cols: 80, Rows: 24}
38- first, releaseFirst := newBlockingPTY(t)
39- defer releaseFirst()
40- second, releaseSecond := newBlockingPTY(t)
41- defer releaseSecond()
42- f := portsmocks.NewMockPTYFactory(t)
43- var dirs []string
44- normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
45- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
46- func(_ context.Context, _ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
47- dirs = append(dirs, dir)
48- if len(dirs) == 1 {
49- return first, nil
50- }
51- return second, nil
52- },
53- ).Twice()
54- floating := newQuietPTY()
55- expectFloatingPrewarmOpen(f, normalSize, floating)
56-
57- d := newTestDaemon(t, f, stubClock{})
58- tr := portsmocks.NewMockTransport(t)
59- tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
60- tr.EXPECT().Close().Return(nil).Maybe()
61-
62- sess, ac, err := d.route(ports.Hello{
63- Version: ports.ProtocolVersion,
64- Intent: ports.IntentNew,
65- Name: "work",
66- Size: sz,
67- TermEnv: "xterm-256color",
68- Cwd: "/tmp/work",
69- }, tr)
70- require.NoError(t, err)
71- require.NotNil(t, ac)
72- require.Equal(t, "/tmp/work", sess.cwd)
73-
74- require.NoError(t, d.createTab(sess, sz))
75- require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
76- requireFloatingInitialized(t, testAttachmentTab(sess))
77- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
78- releaseFirst()
79- releaseSecond()
80- d.sessWg.Wait()
81- // Teardown may cancel a queued prewarm before it enters Open; an opened
82- // prewarm is closed by teardownFloating.
83-}
84-
8535 func TestHandshakeEphemeralHappy(t *testing.T) {
8636 p, releasePTY := newBlockingPTY(t)
8737 d := newTestDaemon(t, newFactory(t, p), stubClock{})
@@ -989,30 +939,6 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
989939 }
990940 }
991941
992-func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) {
993- t.Skip("legacy fixture predates attachment-owned state")
994- d, sess, ac, _, releases := newManualTabSession(t, 2)
995- sess.mu.Lock()
996- sess.registerAttachmentLocked(ac)
997- sess.mu.Unlock()
998- d.ptys = newBlockingOpenFactory(t, d)
999- defer releases[0]()
1000- defer releases[1]()
1001- sess.mu.Lock()
1002- first, closing := sess.tabs[0], sess.tabs[1]
1003- selectTestAttachmentTabLocked(sess, 1)
1004- sess.mu.Unlock()
1005- first.mu.Lock()
1006- stale := first.takeFloatingLocked()
1007- first.mu.Unlock()
1008- closeFloatingPane(stale)
1009-
1010- require.NoError(t, d.closeTab(sess, closing, false))
1011-
1012- require.Same(t, first, testAttachmentTab(sess))
1013- requireFloatingInitialized(t, first)
1014-}
1015-
1016942 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
1017943 sz := domain.Size{Cols: 80, Rows: 24}
1018944 p, release := newBlockingPTY(t)
@@ -32,56 +32,6 @@ func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize do
32 // stubClock returns timers whose channel never fires, so a scheduler under it32 // stubClock returns timers whose channel never fires, so a scheduler under it
33 // blocks in its debounce loop until the session context is cancelled. Used by33 // blocks in its debounce loop until the session context is cancelled. Used by
34 34
35-func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
36- t.Skip("legacy fixture predates attachment-owned state")
37- sz := domain.Size{Cols: 80, Rows: 24}
38- first, releaseFirst := newBlockingPTY(t)
39- defer releaseFirst()
40- second, releaseSecond := newBlockingPTY(t)
41- defer releaseSecond()
42- f := portsmocks.NewMockPTYFactory(t)
43- var dirs []string
44- normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
45- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
46- func(_ context.Context, _ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
47- dirs = append(dirs, dir)
48- if len(dirs) == 1 {
49- return first, nil
50- }
51- return second, nil
52- },
53- ).Twice()
54- floating := newQuietPTY()
55- expectFloatingPrewarmOpen(f, normalSize, floating)
56-
57- d := newTestDaemon(t, f, stubClock{})
58- tr := portsmocks.NewMockTransport(t)
59- tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
60- tr.EXPECT().Close().Return(nil).Maybe()
61-
62- sess, ac, err := d.route(ports.Hello{
63- Version: ports.ProtocolVersion,
64- Intent: ports.IntentNew,
65- Name: "work",
66- Size: sz,
67- TermEnv: "xterm-256color",
68- Cwd: "/tmp/work",
69- }, tr)
70- require.NoError(t, err)
71- require.NotNil(t, ac)
72- require.Equal(t, "/tmp/work", sess.cwd)
73-
74- require.NoError(t, d.createTab(sess, sz))
75- require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
76- requireFloatingInitialized(t, testAttachmentTab(sess))
77- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
78- releaseFirst()
79- releaseSecond()
80- d.sessWg.Wait()
81- // Teardown may cancel a queued prewarm before it enters Open; an opened
82- // prewarm is closed by teardownFloating.
83-}
84-
85 func TestHandshakeEphemeralHappy(t *testing.T) {35 func TestHandshakeEphemeralHappy(t *testing.T) {
86 p, releasePTY := newBlockingPTY(t)36 p, releasePTY := newBlockingPTY(t)
87 d := newTestDaemon(t, newFactory(t, p), stubClock{})37 d := newTestDaemon(t, newFactory(t, p), stubClock{})
@@ -989,30 +939,6 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
989 }939 }
990 }940 }
991 941
992-func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) {
993- t.Skip("legacy fixture predates attachment-owned state")
994- d, sess, ac, _, releases := newManualTabSession(t, 2)
995- sess.mu.Lock()
996- sess.registerAttachmentLocked(ac)
997- sess.mu.Unlock()
998- d.ptys = newBlockingOpenFactory(t, d)
999- defer releases[0]()
1000- defer releases[1]()
1001- sess.mu.Lock()
1002- first, closing := sess.tabs[0], sess.tabs[1]
1003- selectTestAttachmentTabLocked(sess, 1)
1004- sess.mu.Unlock()
1005- first.mu.Lock()
1006- stale := first.takeFloatingLocked()
1007- first.mu.Unlock()
1008- closeFloatingPane(stale)
1009-
1010- require.NoError(t, d.closeTab(sess, closing, false))
1011-
1012- require.Same(t, first, testAttachmentTab(sess))
1013- requireFloatingInitialized(t, first)
1014-}
1015-
1016 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {942 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
1017 sz := domain.Size{Cols: 80, Rows: 24}943 sz := domain.Size{Cols: 80, Rows: 24}
1018 p, release := newBlockingPTY(t)944 p, release := newBlockingPTY(t)
M internal/usecase/daemon/status_test.go +0 -56
@@ -724,41 +724,6 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
724724 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
725725 }
726726
727-func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
728- t.Skip("legacy fixture predates attachment-owned state")
729- p, release := newBlockingPTY(t)
730- d, sess, ac, _ := newManualSessionWithPTYs(t, p)
731- defer release()
732- d.applyTheme(sess, ac, ports.Theme{
733- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
734- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
735- })
736-
737- // Simulate the race window inside clientGone/detachOnSendError: the old
738- // client has already been detached (detachIfCurrent succeeded), but
739- // before resetScreenDefaultColors runs a new client attaches and applies
740- // its own theme.
741- require.True(t, sess.detachIfCurrent(ac))
742-
743- tr, _ := newCapturingTransport(t)
744- newAC, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
745- require.NoError(t, err)
746- d.applyTheme(sess, newAC, ports.Theme{
747- HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
748- HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
749- })
750-
751- // The late reset from the old client's detach path must not wipe the
752- // new client's freshly applied colors.
753- d.resetScreenDefaultColors(sess)
754-
755- tb := testAttachmentTab(sess)
756- var got []byte
757- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
758- tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
759- require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset")
760-}
761-
762727 func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
763728 p, release := newBlockingPTY(t)
764729 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -796,27 +761,6 @@ func TestDetachOnSendErrorParkPreservesScreenDefaultColors(t *testing.T) {
796761 require.NotEmpty(t, got, "parked clients resume the same attachment, so screen default colors must be preserved")
797762 }
798763
799-func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
800- t.Skip("legacy fixture predates attachment-owned state")
801- p, release := newBlockingPTY(t)
802- d, sess, old, _ := newManualSessionWithPTYs(t, p)
803- defer release()
804- tr, _ := newCapturingTransport(t)
805- _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
806- require.NoError(t, err)
807-
808- d.applyTheme(sess, old, ports.Theme{
809- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
810- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
811- })
812-
813- var got []byte
814- tb := testAttachmentTab(sess)
815- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
816- tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
817- require.Empty(t, got)
818-}
819-
820764 func TestStatusMarksEphemeralSession(t *testing.T) {
821765 p, release := newBlockingPTY(t)
822766 _, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -724,41 +724,6 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
724 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")724 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
725 }725 }
726 726
727-func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
728- t.Skip("legacy fixture predates attachment-owned state")
729- p, release := newBlockingPTY(t)
730- d, sess, ac, _ := newManualSessionWithPTYs(t, p)
731- defer release()
732- d.applyTheme(sess, ac, ports.Theme{
733- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
734- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
735- })
736-
737- // Simulate the race window inside clientGone/detachOnSendError: the old
738- // client has already been detached (detachIfCurrent succeeded), but
739- // before resetScreenDefaultColors runs a new client attaches and applies
740- // its own theme.
741- require.True(t, sess.detachIfCurrent(ac))
742-
743- tr, _ := newCapturingTransport(t)
744- newAC, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
745- require.NoError(t, err)
746- d.applyTheme(sess, newAC, ports.Theme{
747- HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
748- HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
749- })
750-
751- // The late reset from the old client's detach path must not wipe the
752- // new client's freshly applied colors.
753- d.resetScreenDefaultColors(sess)
754-
755- tb := testAttachmentTab(sess)
756- var got []byte
757- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
758- tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
759- require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset")
760-}
761-
762 func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {727 func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
763 p, release := newBlockingPTY(t)728 p, release := newBlockingPTY(t)
764 d, sess, ac, _ := newManualSessionWithPTYs(t, p)729 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -796,27 +761,6 @@ func TestDetachOnSendErrorParkPreservesScreenDefaultColors(t *testing.T) {
796 require.NotEmpty(t, got, "parked clients resume the same attachment, so screen default colors must be preserved")761 require.NotEmpty(t, got, "parked clients resume the same attachment, so screen default colors must be preserved")
797 }762 }
798 763
799-func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
800- t.Skip("legacy fixture predates attachment-owned state")
801- p, release := newBlockingPTY(t)
802- d, sess, old, _ := newManualSessionWithPTYs(t, p)
803- defer release()
804- tr, _ := newCapturingTransport(t)
805- _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
806- require.NoError(t, err)
807-
808- d.applyTheme(sess, old, ports.Theme{
809- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
810- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
811- })
812-
813- var got []byte
814- tb := testAttachmentTab(sess)
815- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
816- tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
817- require.Empty(t, got)
818-}
819-
820 func TestStatusMarksEphemeralSession(t *testing.T) {764 func TestStatusMarksEphemeralSession(t *testing.T) {
821 p, release := newBlockingPTY(t)765 p, release := newBlockingPTY(t)
822 _, sess, _, _ := newManualSessionWithPTYs(t, p)766 _, sess, _, _ := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/transactional_resize.go +34 -2
@@ -637,6 +637,9 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
637637 failed = append(failed, floatingFailed...)
638638 }
639639 failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)
640+ if current != nil && !current() {
641+ return nil, false
642+ }
640643 if len(failed) != 0 && failedCurrent {
641644 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
642645 "pane resize failed; retrying in background", failed[len(failed)-1].err)
@@ -644,6 +647,12 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
644647 if len(failed) != 0 && !failedCurrent {
645648 return nil, false
646649 }
650+ if current != nil {
651+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostSnapshotDirty)
652+ if !current() {
653+ return nil, false
654+ }
655+ }
647656 markSnapshotDirty(sess)
648657 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
649658 return failed, true
@@ -656,7 +665,13 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
656665 return false
657666 }
658667 snap := rc.resizeSnapshot()
659- current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }
668+ connectionGeneration := ac.connectionGeneration.Load()
669+ connection := ac.transportSnapshot()
670+ current := func() bool {
671+ return ac.connectionGeneration.Load() == connectionGeneration &&
672+ ac.transportSnapshotCurrent(connection) &&
673+ rc.resizeCurrentForLease(epoch, ac, lease, false)
674+ }
660675 if !current() {
661676 return false
662677 }
@@ -667,21 +682,38 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
667682 if !ok {
668683 return false
669684 }
685+ // Revalidate while holding the attachment output boundary. A lease can be
686+ // retired after the layout commit but before this attachment-local size is
687+ // published; stale generations must not publish any remaining effects.
688+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
670689 ac.sendMu.Lock()
690+ if !current() {
691+ ac.sendMu.Unlock()
692+ return false
693+ }
671694 ac.size = snap.size
672695 ac.sendMu.Unlock()
696+ if !current() {
697+ return false
698+ }
673699 if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
674700 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
675701 }
702+ if !current() {
703+ return false
704+ }
676705 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
677706 // A successful transaction publishes exactly one full S2 frame. The
678707 // coordinator is the only emission route and stale epochs never reach it.
679- if !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
708+ if !current() || !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
680709 return false
681710 }
682711 // The resize debounce has already elapsed. Consume this sticky reset now;
683712 // fire preserves ACK and synchronized-output gates rather than scheduling a
684713 // second urgent deadline.
714+ if !current() {
715+ return false
716+ }
685717 rc.fireCurrent(false)
686718 return true
687719 }
@@ -637,6 +637,9 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
637 failed = append(failed, floatingFailed...)637 failed = append(failed, floatingFailed...)
638 }638 }
639 failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)639 failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)
640+ if current != nil && !current() {
641+ return nil, false
642+ }
640 if len(failed) != 0 && failedCurrent {643 if len(failed) != 0 && failedCurrent {
641 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,644 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
642 "pane resize failed; retrying in background", failed[len(failed)-1].err)645 "pane resize failed; retrying in background", failed[len(failed)-1].err)
@@ -644,6 +647,12 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
644 if len(failed) != 0 && !failedCurrent {647 if len(failed) != 0 && !failedCurrent {
645 return nil, false648 return nil, false
646 }649 }
650+ if current != nil {
651+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostSnapshotDirty)
652+ if !current() {
653+ return nil, false
654+ }
655+ }
647 markSnapshotDirty(sess)656 markSnapshotDirty(sess)
648 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)657 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
649 return failed, true658 return failed, true
@@ -656,7 +665,13 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
656 return false665 return false
657 }666 }
658 snap := rc.resizeSnapshot()667 snap := rc.resizeSnapshot()
659- current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }668+ connectionGeneration := ac.connectionGeneration.Load()
669+ connection := ac.transportSnapshot()
670+ current := func() bool {
671+ return ac.connectionGeneration.Load() == connectionGeneration &&
672+ ac.transportSnapshotCurrent(connection) &&
673+ rc.resizeCurrentForLease(epoch, ac, lease, false)
674+ }
660 if !current() {675 if !current() {
661 return false676 return false
662 }677 }
@@ -667,21 +682,38 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
667 if !ok {682 if !ok {
668 return false683 return false
669 }684 }
685+ // Revalidate while holding the attachment output boundary. A lease can be
686+ // retired after the layout commit but before this attachment-local size is
687+ // published; stale generations must not publish any remaining effects.
688+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
670 ac.sendMu.Lock()689 ac.sendMu.Lock()
690+ if !current() {
691+ ac.sendMu.Unlock()
692+ return false
693+ }
671 ac.size = snap.size694 ac.size = snap.size
672 ac.sendMu.Unlock()695 ac.sendMu.Unlock()
696+ if !current() {
697+ return false
698+ }
673 if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {699 if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
674 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })700 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
675 }701 }
702+ if !current() {
703+ return false
704+ }
676 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)705 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
677 // A successful transaction publishes exactly one full S2 frame. The706 // A successful transaction publishes exactly one full S2 frame. The
678 // coordinator is the only emission route and stale epochs never reach it.707 // coordinator is the only emission route and stale epochs never reach it.
679- if !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {708+ if !current() || !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
680 return false709 return false
681 }710 }
682 // The resize debounce has already elapsed. Consume this sticky reset now;711 // The resize debounce has already elapsed. Consume this sticky reset now;
683 // fire preserves ACK and synchronized-output gates rather than scheduling a712 // fire preserves ACK and synchronized-output gates rather than scheduling a
684 // second urgent deadline.713 // second urgent deadline.
714+ if !current() {
715+ return false
716+ }
685 rc.fireCurrent(false)717 rc.fireCurrent(false)
686 return true718 return true
687 }719 }
M internal/usecase/daemon/transactional_resize_test.go +41 -0
@@ -461,6 +461,47 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
461461 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested())
462462 }
463463
464+func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
465+ for _, tc := range []struct {
466+ name string
467+ effect resizeOwnerPostEffect
468+ }{
469+ {name: "before snapshot effects", effect: resizeOwnerPostSnapshotDirty},
470+ {name: "before attachment size", effect: resizeOwnerPostCommitPublication},
471+ } {
472+ t.Run(tc.name, func(t *testing.T) {
473+ pty := &transactionalResizePTY{}
474+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
475+ rc := d.attachCoordinator(sess, nil, ac, true)
476+ lease := rc.attachmentLease(ac)
477+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
478+ require.NotZero(t, epoch)
479+ initialSize := ac.size
480+
481+ entered := make(chan struct{})
482+ release := make(chan struct{})
483+ d.beforeResizeOwnerPostEffect = func(effect resizeOwnerPostEffect) {
484+ if effect != tc.effect {
485+ return
486+ }
487+ close(entered)
488+ <-release
489+ }
490+ done := make(chan bool, 1)
491+ go func() { done <- d.runResizeTransaction(sess, ac, lease, epoch) }()
492+ <-entered
493+ newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
494+ require.NotZero(t, newer)
495+ close(release)
496+ require.False(t, <-done, "stale resize generation must be rejected")
497+ require.Equal(t, initialSize, ac.size, "stale resize must not publish attachment size")
498+ if tc.effect == resizeOwnerPostSnapshotDirty {
499+ require.False(t, sess.snapDirty.Load(), "stale resize must not publish snapshot dirtiness")
500+ }
501+ })
502+ }
503+}
504+
464505 func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {
465506 pty := &transactionalResizePTY{}
466507 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
@@ -461,6 +461,47 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
461 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested())461 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested())
462 }462 }
463 463
464+func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
465+ for _, tc := range []struct {
466+ name string
467+ effect resizeOwnerPostEffect
468+ }{
469+ {name: "before snapshot effects", effect: resizeOwnerPostSnapshotDirty},
470+ {name: "before attachment size", effect: resizeOwnerPostCommitPublication},
471+ } {
472+ t.Run(tc.name, func(t *testing.T) {
473+ pty := &transactionalResizePTY{}
474+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
475+ rc := d.attachCoordinator(sess, nil, ac, true)
476+ lease := rc.attachmentLease(ac)
477+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
478+ require.NotZero(t, epoch)
479+ initialSize := ac.size
480+
481+ entered := make(chan struct{})
482+ release := make(chan struct{})
483+ d.beforeResizeOwnerPostEffect = func(effect resizeOwnerPostEffect) {
484+ if effect != tc.effect {
485+ return
486+ }
487+ close(entered)
488+ <-release
489+ }
490+ done := make(chan bool, 1)
491+ go func() { done <- d.runResizeTransaction(sess, ac, lease, epoch) }()
492+ <-entered
493+ newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
494+ require.NotZero(t, newer)
495+ close(release)
496+ require.False(t, <-done, "stale resize generation must be rejected")
497+ require.Equal(t, initialSize, ac.size, "stale resize must not publish attachment size")
498+ if tc.effect == resizeOwnerPostSnapshotDirty {
499+ require.False(t, sess.snapDirty.Load(), "stale resize must not publish snapshot dirtiness")
500+ }
501+ })
502+ }
503+}
504+
464 func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {505 func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {
465 pty := &transactionalResizePTY{}506 pty := &transactionalResizePTY{}
466 d, sess, _, _ := newManualSessionWithPTYs(t, pty)507 d, sess, _, _ := newManualSessionWithPTYs(t, pty)

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