detached bf32e20

feat(daemon): move panes between live tabs

commit
bf32e20341ae69397e041b69fd4e8df035b23e66
author
brice · · 2026-07-28 09:36 UTC
parent
f0438ea
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +2594 -55
M internal/usecase/daemon/attachment_role.go +27 -0
@@ -60,6 +60,33 @@ type attachmentRoleToken struct {
6060 rebase bool
6161 }
6262
63+// attachmentTokenLocked captures the exact role, transport incarnation, and
64+// active coordinator lease at an architecture commit point. The caller must
65+// hold s.mu; coordinator acquisition follows the canonical session ->
66+// coordinator order.
67+func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken {
68+ if s == nil || ac == nil {
69+ return attachmentRoleToken{}
70+ }
71+ transport := ac.transportSnapshot()
72+ if transport.transport == nil {
73+ return attachmentRoleToken{}
74+ }
75+ token := attachmentRoleToken{
76+ sess: s,
77+ ac: ac,
78+ role: s.attachmentRoleLocked(ac),
79+ generation: ac.roleGeneration.Load(),
80+ transport: transport,
81+ }
82+ if token.role == attachmentActive {
83+ if rc := s.renderCoordinator(); rc != nil {
84+ token.lease = rc.attachmentLease(ac)
85+ }
86+ }
87+ return token
88+}
89+
6390 func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
6491 if s == nil || ac == nil || tr == nil {
6592 return attachmentRoleToken{}
@@ -60,6 +60,33 @@ type attachmentRoleToken struct {
60 rebase bool60 rebase bool
61 }61 }
62 62
63+// attachmentTokenLocked captures the exact role, transport incarnation, and
64+// active coordinator lease at an architecture commit point. The caller must
65+// hold s.mu; coordinator acquisition follows the canonical session ->
66+// coordinator order.
67+func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken {
68+ if s == nil || ac == nil {
69+ return attachmentRoleToken{}
70+ }
71+ transport := ac.transportSnapshot()
72+ if transport.transport == nil {
73+ return attachmentRoleToken{}
74+ }
75+ token := attachmentRoleToken{
76+ sess: s,
77+ ac: ac,
78+ role: s.attachmentRoleLocked(ac),
79+ generation: ac.roleGeneration.Load(),
80+ transport: transport,
81+ }
82+ if token.role == attachmentActive {
83+ if rc := s.renderCoordinator(); rc != nil {
84+ token.lease = rc.attachmentLease(ac)
85+ }
86+ }
87+ return token
88+}
89+
63 func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {90 func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
64 if s == nil || ac == nil || tr == nil {91 if s == nil || ac == nil || tr == nil {
65 return attachmentRoleToken{}92 return attachmentRoleToken{}
A internal/usecase/daemon/attachment_transition_prelocked_test.go +170 -0
@@ -0,0 +1,170 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+func TestAttachmentTransitionPrelockedMatchesWrapper(t *testing.T) {
13+ t.Parallel()
14+
15+ type transitionObservation struct {
16+ result attachmentTransitionResult
17+ activeRole attachmentRole
18+ displacedRole attachmentRole
19+ activeGeneration uint64
20+ displacedGeneration uint64
21+ activeSession *session
22+ displacedSession *session
23+ }
24+
25+ setup := func(t *testing.T) (*Daemon, *session, *attachedClient, *attachedClient) {
26+ t.Helper()
27+ d := newTestDaemon(t, nil, stubClock{})
28+ sess := &session{id: domain.SessionID("work")}
29+ old := &attachedClient{tr: &closeTrackingTransport{}}
30+ old.setSession(sess)
31+ sess.client = old
32+ d.sessions[sess.id] = sess
33+ next := &attachedClient{tr: &closeTrackingTransport{}}
34+ return d, sess, old, next
35+ }
36+ observe := func(sess *session, old, next *attachedClient, result attachmentTransitionResult) transitionObservation {
37+ return transitionObservation{
38+ result: result,
39+ activeRole: sess.attachmentRole(next),
40+ displacedRole: sess.attachmentRole(old),
41+ activeGeneration: next.roleGeneration.Load(),
42+ displacedGeneration: old.roleGeneration.Load(),
43+ activeSession: next.currentSession(),
44+ displacedSession: old.currentSession(),
45+ }
46+ }
47+
48+ wrapperDaemon, wrapperSession, wrapperOld, wrapperNext := setup(t)
49+ wrapperResult, err := wrapperDaemon.transitionAttachment(attachmentTransitionRequest{
50+ target: wrapperSession,
51+ next: wrapperNext,
52+ expectedRole: attachmentDetached,
53+ targetRole: attachmentActive,
54+ expectedTransport: wrapperNext.transportSnapshot(),
55+ ready: true,
56+ })
57+ require.NoError(t, err)
58+ wrapper := observe(wrapperSession, wrapperOld, wrapperNext, wrapperResult)
59+
60+ prelockedDaemon, prelockedSession, prelockedOld, prelockedNext := setup(t)
61+ frozen := freezeRoleEffectGates(prelockedNext, prelockedOld)
62+ require.True(t, frozen.acquired)
63+ require.True(t, frozen.drained)
64+ defer frozen.unfreeze()
65+
66+ req := attachmentTransitionRequest{
67+ target: prelockedSession,
68+ next: prelockedNext,
69+ expectedRole: attachmentDetached,
70+ targetRole: attachmentActive,
71+ expectedTransport: prelockedNext.transportSnapshot(),
72+ expectedTargetCurrent: prelockedOld,
73+ preflighted: true,
74+ roleEffectsFrozen: true,
75+ ready: true,
76+ }
77+ type prelockedOutcome struct {
78+ result attachmentTransitionResult
79+ err error
80+ }
81+ outcome := make(chan prelockedOutcome, 1)
82+ go func() {
83+ prelockedDaemon.mu.Lock()
84+ prelockedDaemon.notices.routingMu.Lock()
85+ unlockSessions := lockAttachmentSessions(prelockedSession, prelockedSession)
86+ publication, validateErr := prelockedDaemon.validateAttachmentTransitionPrelocked(req)
87+ var result attachmentTransitionResult
88+ if validateErr == nil {
89+ result = prelockedDaemon.publishAttachmentTransitionPrelocked(publication)
90+ publication.unlockCoordinators()
91+ }
92+ unlockSessions()
93+ prelockedDaemon.notices.routingMu.Unlock()
94+ prelockedDaemon.mu.Unlock()
95+ outcome <- prelockedOutcome{result: result, err: validateErr}
96+ }()
97+
98+ var prelockedResult attachmentTransitionResult
99+ select {
100+ case got := <-outcome:
101+ require.NoError(t, got.err)
102+ prelockedResult = got.result
103+ case <-time.After(time.Second):
104+ t.Fatal("prelocked attachment transition recursively acquired an already-held lock")
105+ }
106+ prelocked := observe(prelockedSession, prelockedOld, prelockedNext, prelockedResult)
107+
108+ require.Equal(t, wrapper.activeRole, prelocked.activeRole)
109+ require.Equal(t, wrapper.displacedRole, prelocked.displacedRole)
110+ require.Equal(t, wrapper.activeGeneration, prelocked.activeGeneration)
111+ require.Equal(t, wrapper.displacedGeneration, prelocked.displacedGeneration)
112+ require.Same(t, prelockedSession, prelocked.activeSession)
113+ require.Same(t, prelockedSession, prelocked.displacedSession)
114+ require.Equal(t, wrapper.result.published.role, prelocked.result.published.role)
115+ require.Equal(t, wrapper.result.displaced.role, prelocked.result.displaced.role)
116+ require.NotNil(t, prelocked.result.published.lease)
117+}
118+
119+func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure(t *testing.T) {
120+ t.Parallel()
121+
122+ d := newTestDaemon(t, nil, stubClock{})
123+ source := &session{id: domain.SessionID("source")}
124+ target := &session{id: domain.SessionID("target"), active: 0}
125+ next := &attachedClient{tr: &closeTrackingTransport{}}
126+ next.setSession(source)
127+ source.addSnatchedLocked(next)
128+ old := &attachedClient{tr: &closeTrackingTransport{}}
129+ old.setSession(target)
130+ target.client = old
131+ d.sessions[source.id] = source
132+ d.sessions[target.id] = target
133+
134+ frozen := freezeRoleEffectGates(next, old)
135+ require.True(t, frozen.acquired)
136+ require.True(t, frozen.drained)
137+ defer frozen.unfreeze()
138+
139+ req := attachmentTransitionRequest{
140+ source: source,
141+ target: target,
142+ next: next,
143+ expectedRole: attachmentSnatched,
144+ targetRole: attachmentActive,
145+ expectedTransport: next.transportSnapshot(),
146+ expectedTargetCurrent: old,
147+ preflighted: true,
148+ roleEffectsFrozen: true,
149+ activateTargetTab: true,
150+ targetTabIndex: 1,
151+ }
152+
153+ d.mu.Lock()
154+ d.notices.routingMu.Lock()
155+ unlockSessions := lockAttachmentSessions(source, target)
156+ publication, err := d.validateAttachmentTransitionPrelocked(req)
157+ unlockSessions()
158+ d.notices.routingMu.Unlock()
159+ d.mu.Unlock()
160+
161+ require.ErrorIs(t, err, errAttachmentTransition)
162+ require.Nil(t, publication)
163+ require.Equal(t, attachmentSnatched, source.attachmentRole(next))
164+ require.Equal(t, attachmentDetached, target.attachmentRole(next))
165+ require.Equal(t, attachmentActive, target.attachmentRole(old))
166+ require.Same(t, source, next.currentSession())
167+ require.Equal(t, uint64(0), next.roleGeneration.Load())
168+ require.Equal(t, uint64(0), old.roleGeneration.Load())
169+ require.Equal(t, 0, target.active)
170+}
@@ -0,0 +1,170 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+func TestAttachmentTransitionPrelockedMatchesWrapper(t *testing.T) {
13+ t.Parallel()
14+
15+ type transitionObservation struct {
16+ result attachmentTransitionResult
17+ activeRole attachmentRole
18+ displacedRole attachmentRole
19+ activeGeneration uint64
20+ displacedGeneration uint64
21+ activeSession *session
22+ displacedSession *session
23+ }
24+
25+ setup := func(t *testing.T) (*Daemon, *session, *attachedClient, *attachedClient) {
26+ t.Helper()
27+ d := newTestDaemon(t, nil, stubClock{})
28+ sess := &session{id: domain.SessionID("work")}
29+ old := &attachedClient{tr: &closeTrackingTransport{}}
30+ old.setSession(sess)
31+ sess.client = old
32+ d.sessions[sess.id] = sess
33+ next := &attachedClient{tr: &closeTrackingTransport{}}
34+ return d, sess, old, next
35+ }
36+ observe := func(sess *session, old, next *attachedClient, result attachmentTransitionResult) transitionObservation {
37+ return transitionObservation{
38+ result: result,
39+ activeRole: sess.attachmentRole(next),
40+ displacedRole: sess.attachmentRole(old),
41+ activeGeneration: next.roleGeneration.Load(),
42+ displacedGeneration: old.roleGeneration.Load(),
43+ activeSession: next.currentSession(),
44+ displacedSession: old.currentSession(),
45+ }
46+ }
47+
48+ wrapperDaemon, wrapperSession, wrapperOld, wrapperNext := setup(t)
49+ wrapperResult, err := wrapperDaemon.transitionAttachment(attachmentTransitionRequest{
50+ target: wrapperSession,
51+ next: wrapperNext,
52+ expectedRole: attachmentDetached,
53+ targetRole: attachmentActive,
54+ expectedTransport: wrapperNext.transportSnapshot(),
55+ ready: true,
56+ })
57+ require.NoError(t, err)
58+ wrapper := observe(wrapperSession, wrapperOld, wrapperNext, wrapperResult)
59+
60+ prelockedDaemon, prelockedSession, prelockedOld, prelockedNext := setup(t)
61+ frozen := freezeRoleEffectGates(prelockedNext, prelockedOld)
62+ require.True(t, frozen.acquired)
63+ require.True(t, frozen.drained)
64+ defer frozen.unfreeze()
65+
66+ req := attachmentTransitionRequest{
67+ target: prelockedSession,
68+ next: prelockedNext,
69+ expectedRole: attachmentDetached,
70+ targetRole: attachmentActive,
71+ expectedTransport: prelockedNext.transportSnapshot(),
72+ expectedTargetCurrent: prelockedOld,
73+ preflighted: true,
74+ roleEffectsFrozen: true,
75+ ready: true,
76+ }
77+ type prelockedOutcome struct {
78+ result attachmentTransitionResult
79+ err error
80+ }
81+ outcome := make(chan prelockedOutcome, 1)
82+ go func() {
83+ prelockedDaemon.mu.Lock()
84+ prelockedDaemon.notices.routingMu.Lock()
85+ unlockSessions := lockAttachmentSessions(prelockedSession, prelockedSession)
86+ publication, validateErr := prelockedDaemon.validateAttachmentTransitionPrelocked(req)
87+ var result attachmentTransitionResult
88+ if validateErr == nil {
89+ result = prelockedDaemon.publishAttachmentTransitionPrelocked(publication)
90+ publication.unlockCoordinators()
91+ }
92+ unlockSessions()
93+ prelockedDaemon.notices.routingMu.Unlock()
94+ prelockedDaemon.mu.Unlock()
95+ outcome <- prelockedOutcome{result: result, err: validateErr}
96+ }()
97+
98+ var prelockedResult attachmentTransitionResult
99+ select {
100+ case got := <-outcome:
101+ require.NoError(t, got.err)
102+ prelockedResult = got.result
103+ case <-time.After(time.Second):
104+ t.Fatal("prelocked attachment transition recursively acquired an already-held lock")
105+ }
106+ prelocked := observe(prelockedSession, prelockedOld, prelockedNext, prelockedResult)
107+
108+ require.Equal(t, wrapper.activeRole, prelocked.activeRole)
109+ require.Equal(t, wrapper.displacedRole, prelocked.displacedRole)
110+ require.Equal(t, wrapper.activeGeneration, prelocked.activeGeneration)
111+ require.Equal(t, wrapper.displacedGeneration, prelocked.displacedGeneration)
112+ require.Same(t, prelockedSession, prelocked.activeSession)
113+ require.Same(t, prelockedSession, prelocked.displacedSession)
114+ require.Equal(t, wrapper.result.published.role, prelocked.result.published.role)
115+ require.Equal(t, wrapper.result.displaced.role, prelocked.result.displaced.role)
116+ require.NotNil(t, prelocked.result.published.lease)
117+}
118+
119+func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure(t *testing.T) {
120+ t.Parallel()
121+
122+ d := newTestDaemon(t, nil, stubClock{})
123+ source := &session{id: domain.SessionID("source")}
124+ target := &session{id: domain.SessionID("target"), active: 0}
125+ next := &attachedClient{tr: &closeTrackingTransport{}}
126+ next.setSession(source)
127+ source.addSnatchedLocked(next)
128+ old := &attachedClient{tr: &closeTrackingTransport{}}
129+ old.setSession(target)
130+ target.client = old
131+ d.sessions[source.id] = source
132+ d.sessions[target.id] = target
133+
134+ frozen := freezeRoleEffectGates(next, old)
135+ require.True(t, frozen.acquired)
136+ require.True(t, frozen.drained)
137+ defer frozen.unfreeze()
138+
139+ req := attachmentTransitionRequest{
140+ source: source,
141+ target: target,
142+ next: next,
143+ expectedRole: attachmentSnatched,
144+ targetRole: attachmentActive,
145+ expectedTransport: next.transportSnapshot(),
146+ expectedTargetCurrent: old,
147+ preflighted: true,
148+ roleEffectsFrozen: true,
149+ activateTargetTab: true,
150+ targetTabIndex: 1,
151+ }
152+
153+ d.mu.Lock()
154+ d.notices.routingMu.Lock()
155+ unlockSessions := lockAttachmentSessions(source, target)
156+ publication, err := d.validateAttachmentTransitionPrelocked(req)
157+ unlockSessions()
158+ d.notices.routingMu.Unlock()
159+ d.mu.Unlock()
160+
161+ require.ErrorIs(t, err, errAttachmentTransition)
162+ require.Nil(t, publication)
163+ require.Equal(t, attachmentSnatched, source.attachmentRole(next))
164+ require.Equal(t, attachmentDetached, target.attachmentRole(next))
165+ require.Equal(t, attachmentActive, target.attachmentRole(old))
166+ require.Same(t, source, next.currentSession())
167+ require.Equal(t, uint64(0), next.roleGeneration.Load())
168+ require.Equal(t, uint64(0), old.roleGeneration.Load())
169+ require.Equal(t, 0, target.active)
170+}
M internal/usecase/daemon/attachment_transition_publish.go +52 -52
@@ -69,33 +69,31 @@ func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (at
6969 return d.transitionAttachmentRoutedLocked(req)
7070 }
7171
72-// attachmentPublication owns the ordered session and coordinator locks and
73-// accumulates the values needed to construct postcommit capabilities.
72+// attachmentPublication is the validated, coordinator-locked input to the
73+// non-failing ownership and lease publication step. Its caller continues to
74+// own d.mu, notices.routingMu, and the ordered source/target session locks.
7475 type attachmentPublication struct {
7576 req attachmentTransitionRequest
7677 source *session
7778 old *attachedClient
7879 sourceCoordinator *renderCoordinator
7980 targetCoordinator *renderCoordinator
80- unlockSessions func()
81- unlockCoordinators func()
81+ releaseCoordinators func()
8282 displacedTransport transportSnapshot
8383 nextGeneration uint64
8484 displacedGeneration uint64
8585 }
8686
87-// unlock releases coordinator locks before the ordered session locks. The
88-// caller still owns d.mu and notices.routingMu; no external I/O is permitted.
89-func (p *attachmentPublication) unlock() {
90- p.unlockCoordinators()
91- p.unlockSessions()
87+func (p *attachmentPublication) unlockCoordinators() {
88+ p.releaseCoordinators()
9289 }
9390
94-// preflightAttachmentPublicationLocked revalidates source and target authority
95-// and acquires every lock needed by publication. Caller holds d.mu and
96-// notices.routingMu. On success, the returned guard owns ordered session and
97-// coordinator locks until unlock is called.
98-func (d *Daemon) preflightAttachmentPublicationLocked(req attachmentTransitionRequest) (*attachmentPublication, error) {
91+// validateAttachmentTransitionPrelocked performs every fallible transition
92+// check without acquiring d.mu, notices.routingMu, or either session lock.
93+// The caller holds those locks in that order, with session locks ordered by ID.
94+// On success the returned validation retains the ordered coordinator locks
95+// through publishAttachmentTransitionPrelocked.
96+func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionRequest) (*attachmentPublication, error) {
9997 source := req.source
10098 if source == nil {
10199 source = req.target
@@ -104,51 +102,38 @@ func (d *Daemon) preflightAttachmentPublicationLocked(req attachmentTransitionRe
104102 !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
105103 source == nil || req.target == nil || req.next == nil || d.closing ||
106104 d.sessions[source.id] != source || d.sessions[req.target.id] != req.target ||
107- req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) {
105+ req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
106+ req.preserveRole && req.sourceToken == nil {
108107 return nil, errAttachmentTransition
109108 }
110109
111- // Install the target coordinator before taking both session locks. Lease
112- // binding remains part of the atomic publication below.
113110 var targetCoordinator *renderCoordinator
114111 if req.targetRole == attachmentActive {
115- targetCoordinator = d.ensureRenderCoordinator(req.target)
112+ targetCoordinator = d.ensureRenderCoordinatorPrelocked(req.target)
116113 }
117114 sourceCoordinator := source.renderCoordinator()
118- unlockSessions := lockAttachmentSessions(source, req.target)
119115 if source.attachmentRoleLocked(req.next) != req.expectedRole ||
120116 !req.next.transportSnapshotCurrent(req.expectedTransport) {
121- unlockSessions()
122117 return nil, errAttachmentTransition
123118 }
124119
125120 old := req.target.client
126121 if old != req.expectedTargetCurrent || source != req.target && old == req.next {
127- unlockSessions()
128122 return nil, errAttachmentTransition
129123 }
130124 publication := &attachmentPublication{
131125 req: req, source: source, old: old,
132126 sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
133- unlockSessions: unlockSessions,
134- unlockCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),
135- }
136- if req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
137- !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) {
138- publication.unlock()
139- return nil, errAttachmentTransition
140- }
141- if targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) {
142- publication.unlock()
143- return nil, errAttachmentTransition
144- }
145- if req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
146- (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) {
147- publication.unlock()
148- return nil, errAttachmentTransition
149- }
150- if req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) {
151- publication.unlock()
127+ releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),
128+ }
129+ invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
130+ !transitionSourceTabCurrentLocked(source, req.expectedSourceTab))
131+ invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
132+ invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
133+ (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
134+ invalid = invalid || req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs))
135+ if invalid {
136+ publication.unlockCoordinators()
152137 return nil, errAttachmentTransition
153138 }
154139 return publication, nil
@@ -245,22 +230,37 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
245230 return result
246231 }
247232
248-// transitionAttachmentRoutedLocked performs the centralized role publication.
249-// Caller holds d.mu and notices.routingMu. Every fallible lifecycle, role, and
250-// transport check occurs before session.client is changed. Coordinator
251-// finalization after that commit point cannot roll ownership back.
233+// publishAttachmentTransitionPrelocked performs only non-failing metadata,
234+// ownership, generation, and coordinator-lease publication. The caller retains
235+// every lock required by validateAttachmentTransitionPrelocked.
236+func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {
237+ if d.applyTargetStateLocked(publication) {
238+ return attachmentTransitionResult{published: *publication.req.sourceToken}
239+ }
240+ publishAttachmentOwnershipLocked(publication)
241+ return buildAttachmentPostcommitPlanLocked(publication)
242+}
243+
244+// transitionAttachmentRoutedLocked is the ordinary lock-acquiring wrapper for
245+// the composable prelocked validation/publication seam. Caller holds d.mu and
246+// notices.routingMu; this function acquires ordered source/target session locks.
252247 func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
253- publication, err := d.preflightAttachmentPublicationLocked(req)
254- if err != nil {
255- return attachmentTransitionResult{}, err
248+ source := req.source
249+ if source == nil {
250+ source = req.target
256251 }
257- defer publication.unlock()
252+ if source == nil || req.target == nil {
253+ return attachmentTransitionResult{}, errAttachmentTransition
254+ }
255+ unlockSessions := lockAttachmentSessions(source, req.target)
256+ defer unlockSessions()
258257
259- if d.applyTargetStateLocked(publication) {
260- return attachmentTransitionResult{published: *req.sourceToken}, nil
258+ publication, err := d.validateAttachmentTransitionPrelocked(req)
259+ if err != nil {
260+ return attachmentTransitionResult{}, err
261261 }
262- publishAttachmentOwnershipLocked(publication)
263- return buildAttachmentPostcommitPlanLocked(publication), nil
262+ defer publication.unlockCoordinators()
263+ return d.publishAttachmentTransitionPrelocked(publication), nil
264264 }
265265
266266 func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
@@ -69,33 +69,31 @@ func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (at
69 return d.transitionAttachmentRoutedLocked(req)69 return d.transitionAttachmentRoutedLocked(req)
70 }70 }
71 71
72-// attachmentPublication owns the ordered session and coordinator locks and72+// attachmentPublication is the validated, coordinator-locked input to the
73-// accumulates the values needed to construct postcommit capabilities.73+// non-failing ownership and lease publication step. Its caller continues to
74+// own d.mu, notices.routingMu, and the ordered source/target session locks.
74 type attachmentPublication struct {75 type attachmentPublication struct {
75 req attachmentTransitionRequest76 req attachmentTransitionRequest
76 source *session77 source *session
77 old *attachedClient78 old *attachedClient
78 sourceCoordinator *renderCoordinator79 sourceCoordinator *renderCoordinator
79 targetCoordinator *renderCoordinator80 targetCoordinator *renderCoordinator
80- unlockSessions func()81+ releaseCoordinators func()
81- unlockCoordinators func()
82 displacedTransport transportSnapshot82 displacedTransport transportSnapshot
83 nextGeneration uint6483 nextGeneration uint64
84 displacedGeneration uint6484 displacedGeneration uint64
85 }85 }
86 86
87-// unlock releases coordinator locks before the ordered session locks. The87+func (p *attachmentPublication) unlockCoordinators() {
88-// caller still owns d.mu and notices.routingMu; no external I/O is permitted.88+ p.releaseCoordinators()
89-func (p *attachmentPublication) unlock() {
90- p.unlockCoordinators()
91- p.unlockSessions()
92 }89 }
93 90
94-// preflightAttachmentPublicationLocked revalidates source and target authority91+// validateAttachmentTransitionPrelocked performs every fallible transition
95-// and acquires every lock needed by publication. Caller holds d.mu and92+// check without acquiring d.mu, notices.routingMu, or either session lock.
96-// notices.routingMu. On success, the returned guard owns ordered session and93+// The caller holds those locks in that order, with session locks ordered by ID.
97-// coordinator locks until unlock is called.94+// On success the returned validation retains the ordered coordinator locks
98-func (d *Daemon) preflightAttachmentPublicationLocked(req attachmentTransitionRequest) (*attachmentPublication, error) {95+// through publishAttachmentTransitionPrelocked.
96+func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionRequest) (*attachmentPublication, error) {
99 source := req.source97 source := req.source
100 if source == nil {98 if source == nil {
101 source = req.target99 source = req.target
@@ -104,51 +102,38 @@ func (d *Daemon) preflightAttachmentPublicationLocked(req attachmentTransitionRe
104 !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||102 !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
105 source == nil || req.target == nil || req.next == nil || d.closing ||103 source == nil || req.target == nil || req.next == nil || d.closing ||
106 d.sessions[source.id] != source || d.sessions[req.target.id] != req.target ||104 d.sessions[source.id] != source || d.sessions[req.target.id] != req.target ||
107- req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) {105+ req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
106+ req.preserveRole && req.sourceToken == nil {
108 return nil, errAttachmentTransition107 return nil, errAttachmentTransition
109 }108 }
110 109
111- // Install the target coordinator before taking both session locks. Lease
112- // binding remains part of the atomic publication below.
113 var targetCoordinator *renderCoordinator110 var targetCoordinator *renderCoordinator
114 if req.targetRole == attachmentActive {111 if req.targetRole == attachmentActive {
115- targetCoordinator = d.ensureRenderCoordinator(req.target)112+ targetCoordinator = d.ensureRenderCoordinatorPrelocked(req.target)
116 }113 }
117 sourceCoordinator := source.renderCoordinator()114 sourceCoordinator := source.renderCoordinator()
118- unlockSessions := lockAttachmentSessions(source, req.target)
119 if source.attachmentRoleLocked(req.next) != req.expectedRole ||115 if source.attachmentRoleLocked(req.next) != req.expectedRole ||
120 !req.next.transportSnapshotCurrent(req.expectedTransport) {116 !req.next.transportSnapshotCurrent(req.expectedTransport) {
121- unlockSessions()
122 return nil, errAttachmentTransition117 return nil, errAttachmentTransition
123 }118 }
124 119
125 old := req.target.client120 old := req.target.client
126 if old != req.expectedTargetCurrent || source != req.target && old == req.next {121 if old != req.expectedTargetCurrent || source != req.target && old == req.next {
127- unlockSessions()
128 return nil, errAttachmentTransition122 return nil, errAttachmentTransition
129 }123 }
130 publication := &attachmentPublication{124 publication := &attachmentPublication{
131 req: req, source: source, old: old,125 req: req, source: source, old: old,
132 sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,126 sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
133- unlockSessions: unlockSessions,127+ releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),
134- unlockCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),128+ }
135- }129+ invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
136- if req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||130+ !transitionSourceTabCurrentLocked(source, req.expectedSourceTab))
137- !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) {131+ invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
138- publication.unlock()132+ invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
139- return nil, errAttachmentTransition133+ (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
140- }134+ invalid = invalid || req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs))
141- if targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) {135+ if invalid {
142- publication.unlock()136+ publication.unlockCoordinators()
143- return nil, errAttachmentTransition
144- }
145- if req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
146- (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) {
147- publication.unlock()
148- return nil, errAttachmentTransition
149- }
150- if req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) {
151- publication.unlock()
152 return nil, errAttachmentTransition137 return nil, errAttachmentTransition
153 }138 }
154 return publication, nil139 return publication, nil
@@ -245,22 +230,37 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
245 return result230 return result
246 }231 }
247 232
248-// transitionAttachmentRoutedLocked performs the centralized role publication.233+// publishAttachmentTransitionPrelocked performs only non-failing metadata,
249-// Caller holds d.mu and notices.routingMu. Every fallible lifecycle, role, and234+// ownership, generation, and coordinator-lease publication. The caller retains
250-// transport check occurs before session.client is changed. Coordinator235+// every lock required by validateAttachmentTransitionPrelocked.
251-// finalization after that commit point cannot roll ownership back.236+func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {
237+ if d.applyTargetStateLocked(publication) {
238+ return attachmentTransitionResult{published: *publication.req.sourceToken}
239+ }
240+ publishAttachmentOwnershipLocked(publication)
241+ return buildAttachmentPostcommitPlanLocked(publication)
242+}
243+
244+// transitionAttachmentRoutedLocked is the ordinary lock-acquiring wrapper for
245+// the composable prelocked validation/publication seam. Caller holds d.mu and
246+// notices.routingMu; this function acquires ordered source/target session locks.
252 func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {247 func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
253- publication, err := d.preflightAttachmentPublicationLocked(req)248+ source := req.source
254- if err != nil {249+ if source == nil {
255- return attachmentTransitionResult{}, err250+ source = req.target
256 }251 }
257- defer publication.unlock()252+ if source == nil || req.target == nil {
253+ return attachmentTransitionResult{}, errAttachmentTransition
254+ }
255+ unlockSessions := lockAttachmentSessions(source, req.target)
256+ defer unlockSessions()
258 257
259- if d.applyTargetStateLocked(publication) {258+ publication, err := d.validateAttachmentTransitionPrelocked(req)
260- return attachmentTransitionResult{published: *req.sourceToken}, nil259+ if err != nil {
260+ return attachmentTransitionResult{}, err
261 }261 }
262- publishAttachmentOwnershipLocked(publication)262+ defer publication.unlockCoordinators()
263- return buildAttachmentPostcommitPlanLocked(publication), nil263+ return d.publishAttachmentTransitionPrelocked(publication), nil
264 }264 }
265 265
266 func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {266 func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
M internal/usecase/daemon/client.go +6 -0
@@ -527,6 +527,12 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached
527527 func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator {
528528 sess.mu.Lock()
529529 defer sess.mu.Unlock()
530+ return d.ensureRenderCoordinatorPrelocked(sess)
531+}
532+
533+// ensureRenderCoordinatorPrelocked is the non-reentrant coordinator setup seam
534+// for transactions that already hold sess.mu.
535+func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordinator {
530536 if rc := sess.renderCoordinator(); rc != nil {
531537 return rc
532538 }
@@ -527,6 +527,12 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached
527 func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator {527 func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator {
528 sess.mu.Lock()528 sess.mu.Lock()
529 defer sess.mu.Unlock()529 defer sess.mu.Unlock()
530+ return d.ensureRenderCoordinatorPrelocked(sess)
531+}
532+
533+// ensureRenderCoordinatorPrelocked is the non-reentrant coordinator setup seam
534+// for transactions that already hold sess.mu.
535+func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordinator {
530 if rc := sess.renderCoordinator(); rc != nil {536 if rc := sess.renderCoordinator(); rc != nil {
531 return rc537 return rc
532 }538 }
M internal/usecase/daemon/daemon.go +12 -0
@@ -129,6 +129,10 @@ type Daemon struct {
129129 // and immediately before a post-commit effect is published. Tests use it to
130130 // move a pane through the ordered resize fences at the former TOCTOU window.
131131 beforeResizeOwnerPostEffect func(resizeOwnerPostEffect)
132+ // afterResizeCommitSendLocked is a deterministic lock-order seam. It runs
133+ // after publishResizeCommit acquires attachment sendMu and before it acquires
134+ // owner fences. Tests must not perform external work from this callback.
135+ afterResizeCommitSendLocked func()
132136 // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs
133137 // after one overlay-family mutex is released and before the next is taken.
134138 afterSnatchOverlayFamily func(string)
@@ -151,6 +155,14 @@ type Daemon struct {
151155 // afterRoleEffectsFrozen observes the lock-free boundary after all affected
152156 // attachment gates are frozen and drained, before architecture publication.
153157 afterRoleEffectsFrozen func()
158+ // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only
159+ // seams. They run before role/fence work and after the pre-fence source
160+ // attachment snapshot respectively. beforeMovePaneCommit runs inside the
161+ // non-failing publication section. None is set in production, and none may
162+ // perform external work while locks are held.
163+ afterMoveLifecycleReserved func()
164+ afterMovePaneSourceSnapshot func()
165+ beforeMovePaneCommit func()
154166 // afterDetachRoleEffectsFrozen observes terminal detach after it wins the
155167 // attachment gate but before it checks session ownership.
156168 afterDetachRoleEffectsFrozen func()
@@ -129,6 +129,10 @@ type Daemon struct {
129 // and immediately before a post-commit effect is published. Tests use it to129 // and immediately before a post-commit effect is published. Tests use it to
130 // move a pane through the ordered resize fences at the former TOCTOU window.130 // move a pane through the ordered resize fences at the former TOCTOU window.
131 beforeResizeOwnerPostEffect func(resizeOwnerPostEffect)131 beforeResizeOwnerPostEffect func(resizeOwnerPostEffect)
132+ // afterResizeCommitSendLocked is a deterministic lock-order seam. It runs
133+ // after publishResizeCommit acquires attachment sendMu and before it acquires
134+ // owner fences. Tests must not perform external work from this callback.
135+ afterResizeCommitSendLocked func()
132 // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs136 // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs
133 // after one overlay-family mutex is released and before the next is taken.137 // after one overlay-family mutex is released and before the next is taken.
134 afterSnatchOverlayFamily func(string)138 afterSnatchOverlayFamily func(string)
@@ -151,6 +155,14 @@ type Daemon struct {
151 // afterRoleEffectsFrozen observes the lock-free boundary after all affected155 // afterRoleEffectsFrozen observes the lock-free boundary after all affected
152 // attachment gates are frozen and drained, before architecture publication.156 // attachment gates are frozen and drained, before architecture publication.
153 afterRoleEffectsFrozen func()157 afterRoleEffectsFrozen func()
158+ // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only
159+ // seams. They run before role/fence work and after the pre-fence source
160+ // attachment snapshot respectively. beforeMovePaneCommit runs inside the
161+ // non-failing publication section. None is set in production, and none may
162+ // perform external work while locks are held.
163+ afterMoveLifecycleReserved func()
164+ afterMovePaneSourceSnapshot func()
165+ beforeMovePaneCommit func()
154 // afterDetachRoleEffectsFrozen observes terminal detach after it wins the166 // afterDetachRoleEffectsFrozen observes terminal detach after it wins the
155 // attachment gate but before it checks session ownership.167 // attachment gate but before it checks session ownership.
156 afterDetachRoleEffectsFrozen func()168 afterDetachRoleEffectsFrozen func()
A internal/usecase/daemon/move.go +306 -0
@@ -0,0 +1,306 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+)
6+
7+// movePaneRequest identifies both lifecycles and both topology objects by
8+// immutable identity. Session names are advisory; the ID and incarnation are
9+// the commit-time authority.
10+type movePaneRequest struct {
11+ Source moveSessionLocator
12+ SourceTabID domain.TabStableID
13+ SourcePaneID domain.PaneStableID
14+ Destination moveSessionLocator
15+ DestinationTabID domain.TabStableID
16+}
17+
18+// movePane resolves, reserves, validates, and commits one pane relocation. All
19+// fallible work happens before the membership publication section. Once the
20+// section starts, the caller holds the ordered architecture locks and only
21+// non-failing in-memory writes are permitted.
22+func (d *Daemon) movePane(req movePaneRequest) error {
23+ if d == nil || req.Source.ID == "" || req.Destination.ID == "" ||
24+ req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" {
25+ return errMovePaneInvalid
26+ }
27+
28+ d.mu.Lock()
29+ source := moveSessionForLocatorLocked(d, req.Source)
30+ destination := moveSessionForLocatorLocked(d, req.Destination)
31+ d.mu.Unlock()
32+ if source == nil || destination == nil {
33+ return errMovePaneInvalid
34+ }
35+
36+ reservation, err := d.reserveMoveLifecycles(source, destination)
37+ if err != nil {
38+ return err
39+ }
40+ reservationHeld := true
41+ defer func() {
42+ if reservationHeld {
43+ reservation.Release()
44+ }
45+ }()
46+ if d.afterMoveLifecycleReserved != nil {
47+ d.afterMoveLifecycleReserved()
48+ }
49+
50+ unlockDispatch := lockMoveDispatch(source, destination)
51+ dispatchHeld := true
52+ defer func() {
53+ if dispatchHeld {
54+ unlockDispatch()
55+ }
56+ }()
57+
58+ // Snapshot the exact live objects and generations before waiting on resize
59+ // fences. The locked commit repeats every authority check before publication.
60+ admission, err := d.snapshotMovePaneAdmission(req, source, destination)
61+ if err != nil {
62+ return err
63+ }
64+ sourceTab := admission.sourceTab
65+ destinationTab := admission.destinationTab
66+ movedPane := admission.movedPane
67+ sourceClient := admission.sourceClient
68+ destinationClient := admission.destinationClient
69+ sourceSnatchedCount := admission.sourceSnatched
70+ sourceTabWasActive := admission.sourceTabWasActive
71+ sourceTabInitialGeneration := admission.sourceGeneration
72+ destinationTabInitialGeneration := admission.destinationGeneration
73+ finalSourceTab := admission.finalSourceTab
74+ if d.afterMovePaneSourceSnapshot != nil {
75+ d.afterMovePaneSourceSnapshot()
76+ }
77+
78+ // A final source tab with an active client needs the centralized attachment
79+ // transition. Freeze that exact role set before entering the fence callback;
80+ // the callback then validates the handoff before changing any topology.
81+ var handoffReq attachmentTransitionRequest
82+ var handoffParticipants attachmentTransitionParticipants
83+ var frozen frozenRoleEffectGates
84+ var sourceRolesFrozen bool
85+ var handoffFrozen bool
86+
87+ if finalSourceTab && source != destination && sourceClient != nil {
88+ transport := sourceClient.transportSnapshot()
89+ sourceToken := source.attachmentToken(sourceClient, transport.transport)
90+ if sourceToken.role != attachmentActive || sourceToken.transport.transport == nil {
91+ return errMovePaneInvalid
92+ }
93+ destinationTabIndex := moveTabIndex(destination, destinationTab)
94+ if destinationTabIndex < 0 {
95+ return errMovePaneInvalid
96+ }
97+ handoffReq = attachmentTransitionRequest{
98+ source: source, target: destination, next: sourceClient,
99+ expectedRole: attachmentActive, targetRole: attachmentActive,
100+ expectedTransport: sourceToken.transport, sourceToken: &sourceToken,
101+ expectedSourceTab: sourceTab, activateTargetTab: true,
102+ targetTabIndex: destinationTabIndex, ready: true,
103+ }
104+ var err error
105+ handoffReq, handoffParticipants, err = d.snapshotAttachmentTransition(handoffReq)
106+ if err != nil {
107+ return err
108+ }
109+ // Retiring source snatched clients is part of the same role gate set as
110+ // the follower. Their exact membership is checked again in the commit.
111+ d.mu.Lock()
112+ source.mu.Lock()
113+ for ac := range source.snatched {
114+ handoffParticipants.clients = append(handoffParticipants.clients, ac)
115+ tr := ac.transportSnapshot()
116+ if tr.transport != nil {
117+ handoffParticipants.interrupts = append(handoffParticipants.interrupts, roleTransportInterrupt{ac: ac, transport: tr})
118+ }
119+ }
120+ source.mu.Unlock()
121+ d.mu.Unlock()
122+ frozen = tryFreezeRoleEffectGatesInterruptingObserved(handoffParticipants.interrupts, func(ac *attachedClient) {
123+ if d.afterRoleEffectGateFrozen != nil {
124+ d.afterRoleEffectGateFrozen("move-pane", ac)
125+ }
126+ }, handoffParticipants.clients...)
127+ if !frozen.acquired || !frozen.drained {
128+ frozen.unfreeze()
129+ return errMovePaneInvalid
130+ }
131+ handoffFrozen = true
132+ sourceRolesFrozen = true
133+ handoffReq.roleEffectsFrozen = true
134+ } else if finalSourceTab && source != destination && sourceSnatchedCount > 0 {
135+ // A headless source may still have persistent snatched clients. Freeze
136+ // their exact role gates before retiring the source registry.
137+ d.mu.Lock()
138+ source.mu.Lock()
139+ participants := make([]*attachedClient, 0, len(source.snatched))
140+ interrupts := make([]roleTransportInterrupt, 0, len(source.snatched))
141+ for ac := range source.snatched {
142+ participants = append(participants, ac)
143+ if transport := ac.transportSnapshot(); transport.transport != nil {
144+ interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport})
145+ }
146+ }
147+ source.mu.Unlock()
148+ d.mu.Unlock()
149+ frozen = tryFreezeRoleEffectGatesInterruptingObserved(interrupts, nil, participants...)
150+ if !frozen.acquired || !frozen.drained {
151+ frozen.unfreeze()
152+ return errMovePaneInvalid
153+ }
154+ sourceRolesFrozen = true
155+ }
156+ if sourceRolesFrozen {
157+ defer func() {
158+ if sourceRolesFrozen {
159+ frozen.unfreeze()
160+ }
161+ }()
162+ }
163+
164+ commit := movePaneCommit{
165+ req: req,
166+ source: source,
167+ destination: destination,
168+ sourceTab: sourceTab,
169+ destinationTab: destinationTab,
170+ movedPane: movedPane,
171+ sourceClient: sourceClient,
172+ sourceSnatchedCount: sourceSnatchedCount,
173+ sourceGeneration: sourceTabInitialGeneration,
174+ destinationGeneration: destinationTabInitialGeneration,
175+ handoffFrozen: handoffFrozen,
176+ sourceRolesFrozen: sourceRolesFrozen,
177+ handoffReq: handoffReq,
178+ }
179+ fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane)
180+ if !fences.acquire(func() bool { return commit.publishLocked(d) }) {
181+ commit.releasePublication()
182+ if commit.err != nil {
183+ return commit.err
184+ }
185+ return errMovePaneInvalid
186+ }
187+ // Owner/layout fences end at the atomic publication boundary. The immutable
188+ // postcommit plan owns every later cleanup and effect.
189+ fences.Release()
190+ postcommit := movePanePostcommitPlan{
191+ source: source,
192+ destination: destination,
193+ sourceTab: sourceTab,
194+ destinationTab: destinationTab,
195+ movedPane: movedPane,
196+ destinationClient: destinationClient,
197+ sourceCleanupToken: commit.sourceCleanupToken,
198+ handoffResult: commit.handoffResult,
199+ syncCleanup: commit.syncCleanup,
200+ frozenRoles: frozen,
201+ rolesFrozen: sourceRolesFrozen,
202+ unlockDispatch: unlockDispatch,
203+ reservation: reservation,
204+ sourceTabWasActive: sourceTabWasActive,
205+ sourceTabRemoved: commit.sourceTabRemoved,
206+ sourceEmpty: commit.sourceEmpty,
207+ retiredParked: commit.retiredParked,
208+ retiredAttachments: commit.retiredAttachments,
209+ sourceMetadata: commit.sourceMetadata,
210+ sourceMetadataValid: commit.sourceMetadataValid,
211+ destinationMetadata: commit.destinationMetadata,
212+ destinationMetadataValid: commit.destinationMetadataValid,
213+ }
214+ // Transfer cleanup ownership away from the admission defers only after the
215+ // complete postcommit plan exists.
216+ sourceRolesFrozen = false
217+ dispatchHeld = false
218+ reservationHeld = false
219+ postcommit.execute(d)
220+ return nil
221+}
222+
223+// moveSessionForLocatorLocked resolves only live sessions. The name is
224+// intentionally ignored after the stable ID/incarnation pair is checked.
225+func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session {
226+ if d == nil {
227+ return nil
228+ }
229+ sess := d.sessions[locator.ID]
230+ if sess == nil || sess.incarnation != locator.Incarnation {
231+ return nil
232+ }
233+ return sess
234+}
235+
236+func moveSessionLocatorCurrentLocked(sess *session, locator moveSessionLocator) bool {
237+ return sess != nil && sess.id == locator.ID && sess.incarnation == locator.Incarnation
238+}
239+
240+func findMoveTabLocked(sess *session, stableID domain.TabStableID) *tab {
241+ if sess == nil {
242+ return nil
243+ }
244+ for _, tb := range sess.tabs {
245+ if tb != nil && tb.stableID == string(stableID) {
246+ return tb
247+ }
248+ }
249+ return nil
250+}
251+
252+func moveTabIndex(sess *session, target *tab) int {
253+ if sess == nil || target == nil {
254+ return -1
255+ }
256+ sess.mu.Lock()
257+ defer sess.mu.Unlock()
258+ return indexMoveTabLocked(sess, target)
259+}
260+
261+func indexMoveTabLocked(sess *session, target *tab) int {
262+ for i, tb := range sess.tabs {
263+ if tb == target {
264+ return i
265+ }
266+ }
267+ return -1
268+}
269+
270+func moveTabMemberLocked(sess *session, target *tab) bool {
271+ return indexMoveTabLocked(sess, target) >= 0
272+}
273+
274+func lockMoveTabs(a, b *tab) func() {
275+ if a == b {
276+ a.mu.Lock()
277+ return a.mu.Unlock
278+ }
279+ first, second := a, b
280+ if first.stableID > second.stableID {
281+ first, second = second, first
282+ }
283+ first.mu.Lock()
284+ second.mu.Lock()
285+ return func() {
286+ second.mu.Unlock()
287+ first.mu.Unlock()
288+ }
289+}
290+
291+func lockMoveDispatch(a, b *session) func() {
292+ if a == b {
293+ a.dispatchMu.Lock()
294+ return a.dispatchMu.Unlock
295+ }
296+ first, second := a, b
297+ if first.id > second.id {
298+ first, second = second, first
299+ }
300+ first.dispatchMu.Lock()
301+ second.dispatchMu.Lock()
302+ return func() {
303+ second.dispatchMu.Unlock()
304+ first.dispatchMu.Unlock()
305+ }
306+}
@@ -0,0 +1,306 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+)
6+
7+// movePaneRequest identifies both lifecycles and both topology objects by
8+// immutable identity. Session names are advisory; the ID and incarnation are
9+// the commit-time authority.
10+type movePaneRequest struct {
11+ Source moveSessionLocator
12+ SourceTabID domain.TabStableID
13+ SourcePaneID domain.PaneStableID
14+ Destination moveSessionLocator
15+ DestinationTabID domain.TabStableID
16+}
17+
18+// movePane resolves, reserves, validates, and commits one pane relocation. All
19+// fallible work happens before the membership publication section. Once the
20+// section starts, the caller holds the ordered architecture locks and only
21+// non-failing in-memory writes are permitted.
22+func (d *Daemon) movePane(req movePaneRequest) error {
23+ if d == nil || req.Source.ID == "" || req.Destination.ID == "" ||
24+ req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" {
25+ return errMovePaneInvalid
26+ }
27+
28+ d.mu.Lock()
29+ source := moveSessionForLocatorLocked(d, req.Source)
30+ destination := moveSessionForLocatorLocked(d, req.Destination)
31+ d.mu.Unlock()
32+ if source == nil || destination == nil {
33+ return errMovePaneInvalid
34+ }
35+
36+ reservation, err := d.reserveMoveLifecycles(source, destination)
37+ if err != nil {
38+ return err
39+ }
40+ reservationHeld := true
41+ defer func() {
42+ if reservationHeld {
43+ reservation.Release()
44+ }
45+ }()
46+ if d.afterMoveLifecycleReserved != nil {
47+ d.afterMoveLifecycleReserved()
48+ }
49+
50+ unlockDispatch := lockMoveDispatch(source, destination)
51+ dispatchHeld := true
52+ defer func() {
53+ if dispatchHeld {
54+ unlockDispatch()
55+ }
56+ }()
57+
58+ // Snapshot the exact live objects and generations before waiting on resize
59+ // fences. The locked commit repeats every authority check before publication.
60+ admission, err := d.snapshotMovePaneAdmission(req, source, destination)
61+ if err != nil {
62+ return err
63+ }
64+ sourceTab := admission.sourceTab
65+ destinationTab := admission.destinationTab
66+ movedPane := admission.movedPane
67+ sourceClient := admission.sourceClient
68+ destinationClient := admission.destinationClient
69+ sourceSnatchedCount := admission.sourceSnatched
70+ sourceTabWasActive := admission.sourceTabWasActive
71+ sourceTabInitialGeneration := admission.sourceGeneration
72+ destinationTabInitialGeneration := admission.destinationGeneration
73+ finalSourceTab := admission.finalSourceTab
74+ if d.afterMovePaneSourceSnapshot != nil {
75+ d.afterMovePaneSourceSnapshot()
76+ }
77+
78+ // A final source tab with an active client needs the centralized attachment
79+ // transition. Freeze that exact role set before entering the fence callback;
80+ // the callback then validates the handoff before changing any topology.
81+ var handoffReq attachmentTransitionRequest
82+ var handoffParticipants attachmentTransitionParticipants
83+ var frozen frozenRoleEffectGates
84+ var sourceRolesFrozen bool
85+ var handoffFrozen bool
86+
87+ if finalSourceTab && source != destination && sourceClient != nil {
88+ transport := sourceClient.transportSnapshot()
89+ sourceToken := source.attachmentToken(sourceClient, transport.transport)
90+ if sourceToken.role != attachmentActive || sourceToken.transport.transport == nil {
91+ return errMovePaneInvalid
92+ }
93+ destinationTabIndex := moveTabIndex(destination, destinationTab)
94+ if destinationTabIndex < 0 {
95+ return errMovePaneInvalid
96+ }
97+ handoffReq = attachmentTransitionRequest{
98+ source: source, target: destination, next: sourceClient,
99+ expectedRole: attachmentActive, targetRole: attachmentActive,
100+ expectedTransport: sourceToken.transport, sourceToken: &sourceToken,
101+ expectedSourceTab: sourceTab, activateTargetTab: true,
102+ targetTabIndex: destinationTabIndex, ready: true,
103+ }
104+ var err error
105+ handoffReq, handoffParticipants, err = d.snapshotAttachmentTransition(handoffReq)
106+ if err != nil {
107+ return err
108+ }
109+ // Retiring source snatched clients is part of the same role gate set as
110+ // the follower. Their exact membership is checked again in the commit.
111+ d.mu.Lock()
112+ source.mu.Lock()
113+ for ac := range source.snatched {
114+ handoffParticipants.clients = append(handoffParticipants.clients, ac)
115+ tr := ac.transportSnapshot()
116+ if tr.transport != nil {
117+ handoffParticipants.interrupts = append(handoffParticipants.interrupts, roleTransportInterrupt{ac: ac, transport: tr})
118+ }
119+ }
120+ source.mu.Unlock()
121+ d.mu.Unlock()
122+ frozen = tryFreezeRoleEffectGatesInterruptingObserved(handoffParticipants.interrupts, func(ac *attachedClient) {
123+ if d.afterRoleEffectGateFrozen != nil {
124+ d.afterRoleEffectGateFrozen("move-pane", ac)
125+ }
126+ }, handoffParticipants.clients...)
127+ if !frozen.acquired || !frozen.drained {
128+ frozen.unfreeze()
129+ return errMovePaneInvalid
130+ }
131+ handoffFrozen = true
132+ sourceRolesFrozen = true
133+ handoffReq.roleEffectsFrozen = true
134+ } else if finalSourceTab && source != destination && sourceSnatchedCount > 0 {
135+ // A headless source may still have persistent snatched clients. Freeze
136+ // their exact role gates before retiring the source registry.
137+ d.mu.Lock()
138+ source.mu.Lock()
139+ participants := make([]*attachedClient, 0, len(source.snatched))
140+ interrupts := make([]roleTransportInterrupt, 0, len(source.snatched))
141+ for ac := range source.snatched {
142+ participants = append(participants, ac)
143+ if transport := ac.transportSnapshot(); transport.transport != nil {
144+ interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport})
145+ }
146+ }
147+ source.mu.Unlock()
148+ d.mu.Unlock()
149+ frozen = tryFreezeRoleEffectGatesInterruptingObserved(interrupts, nil, participants...)
150+ if !frozen.acquired || !frozen.drained {
151+ frozen.unfreeze()
152+ return errMovePaneInvalid
153+ }
154+ sourceRolesFrozen = true
155+ }
156+ if sourceRolesFrozen {
157+ defer func() {
158+ if sourceRolesFrozen {
159+ frozen.unfreeze()
160+ }
161+ }()
162+ }
163+
164+ commit := movePaneCommit{
165+ req: req,
166+ source: source,
167+ destination: destination,
168+ sourceTab: sourceTab,
169+ destinationTab: destinationTab,
170+ movedPane: movedPane,
171+ sourceClient: sourceClient,
172+ sourceSnatchedCount: sourceSnatchedCount,
173+ sourceGeneration: sourceTabInitialGeneration,
174+ destinationGeneration: destinationTabInitialGeneration,
175+ handoffFrozen: handoffFrozen,
176+ sourceRolesFrozen: sourceRolesFrozen,
177+ handoffReq: handoffReq,
178+ }
179+ fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane)
180+ if !fences.acquire(func() bool { return commit.publishLocked(d) }) {
181+ commit.releasePublication()
182+ if commit.err != nil {
183+ return commit.err
184+ }
185+ return errMovePaneInvalid
186+ }
187+ // Owner/layout fences end at the atomic publication boundary. The immutable
188+ // postcommit plan owns every later cleanup and effect.
189+ fences.Release()
190+ postcommit := movePanePostcommitPlan{
191+ source: source,
192+ destination: destination,
193+ sourceTab: sourceTab,
194+ destinationTab: destinationTab,
195+ movedPane: movedPane,
196+ destinationClient: destinationClient,
197+ sourceCleanupToken: commit.sourceCleanupToken,
198+ handoffResult: commit.handoffResult,
199+ syncCleanup: commit.syncCleanup,
200+ frozenRoles: frozen,
201+ rolesFrozen: sourceRolesFrozen,
202+ unlockDispatch: unlockDispatch,
203+ reservation: reservation,
204+ sourceTabWasActive: sourceTabWasActive,
205+ sourceTabRemoved: commit.sourceTabRemoved,
206+ sourceEmpty: commit.sourceEmpty,
207+ retiredParked: commit.retiredParked,
208+ retiredAttachments: commit.retiredAttachments,
209+ sourceMetadata: commit.sourceMetadata,
210+ sourceMetadataValid: commit.sourceMetadataValid,
211+ destinationMetadata: commit.destinationMetadata,
212+ destinationMetadataValid: commit.destinationMetadataValid,
213+ }
214+ // Transfer cleanup ownership away from the admission defers only after the
215+ // complete postcommit plan exists.
216+ sourceRolesFrozen = false
217+ dispatchHeld = false
218+ reservationHeld = false
219+ postcommit.execute(d)
220+ return nil
221+}
222+
223+// moveSessionForLocatorLocked resolves only live sessions. The name is
224+// intentionally ignored after the stable ID/incarnation pair is checked.
225+func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session {
226+ if d == nil {
227+ return nil
228+ }
229+ sess := d.sessions[locator.ID]
230+ if sess == nil || sess.incarnation != locator.Incarnation {
231+ return nil
232+ }
233+ return sess
234+}
235+
236+func moveSessionLocatorCurrentLocked(sess *session, locator moveSessionLocator) bool {
237+ return sess != nil && sess.id == locator.ID && sess.incarnation == locator.Incarnation
238+}
239+
240+func findMoveTabLocked(sess *session, stableID domain.TabStableID) *tab {
241+ if sess == nil {
242+ return nil
243+ }
244+ for _, tb := range sess.tabs {
245+ if tb != nil && tb.stableID == string(stableID) {
246+ return tb
247+ }
248+ }
249+ return nil
250+}
251+
252+func moveTabIndex(sess *session, target *tab) int {
253+ if sess == nil || target == nil {
254+ return -1
255+ }
256+ sess.mu.Lock()
257+ defer sess.mu.Unlock()
258+ return indexMoveTabLocked(sess, target)
259+}
260+
261+func indexMoveTabLocked(sess *session, target *tab) int {
262+ for i, tb := range sess.tabs {
263+ if tb == target {
264+ return i
265+ }
266+ }
267+ return -1
268+}
269+
270+func moveTabMemberLocked(sess *session, target *tab) bool {
271+ return indexMoveTabLocked(sess, target) >= 0
272+}
273+
274+func lockMoveTabs(a, b *tab) func() {
275+ if a == b {
276+ a.mu.Lock()
277+ return a.mu.Unlock
278+ }
279+ first, second := a, b
280+ if first.stableID > second.stableID {
281+ first, second = second, first
282+ }
283+ first.mu.Lock()
284+ second.mu.Lock()
285+ return func() {
286+ second.mu.Unlock()
287+ first.mu.Unlock()
288+ }
289+}
290+
291+func lockMoveDispatch(a, b *session) func() {
292+ if a == b {
293+ a.dispatchMu.Lock()
294+ return a.dispatchMu.Unlock
295+ }
296+ first, second := a, b
297+ if first.id > second.id {
298+ first, second = second, first
299+ }
300+ first.dispatchMu.Lock()
301+ second.dispatchMu.Lock()
302+ return func() {
303+ second.dispatchMu.Unlock()
304+ first.dispatchMu.Unlock()
305+ }
306+}
A internal/usecase/daemon/move_acceptance_test.go +229 -0
@@ -0,0 +1,229 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+var errMoveCatalogueLocked = errors.New("move catalogue called while architecture locks were held")
15+
16+func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string {
17+ names := make([]string, 0, len(updates))
18+ for _, update := range updates {
19+ names = append(names, update.Name)
20+ }
21+ return names
22+}
23+
24+type moveCatalogueRecorder struct {
25+ daemon *Daemon
26+ source *session
27+ destination *session
28+ mu sync.Mutex
29+ updates []domain.CatalogueMetadataUpdate
30+}
31+
32+func (c *moveCatalogueRecorder) Records() ([]domain.CatalogueRecord, error) { return nil, nil }
33+func (c *moveCatalogueRecorder) Record(string) (domain.CatalogueRecord, bool, error) {
34+ return domain.CatalogueRecord{}, false, nil
35+}
36+func (c *moveCatalogueRecorder) Create(domain.CatalogueRecord) error { return nil }
37+func (c *moveCatalogueRecorder) Replace(string, domain.CatalogueRecord) error { return nil }
38+func (c *moveCatalogueRecorder) Rename(string, domain.CatalogueRecord) error { return nil }
39+func (c *moveCatalogueRecorder) Delete(string) error { return nil }
40+func (c *moveCatalogueRecorder) Sync() error { return nil }
41+func (c *moveCatalogueRecorder) Close() error { return nil }
42+func (c *moveCatalogueRecorder) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
43+ if c.daemon.mu.TryLock() {
44+ c.daemon.mu.Unlock()
45+ } else {
46+ return errMoveCatalogueLocked
47+ }
48+ if c.source.mu.TryLock() {
49+ c.source.mu.Unlock()
50+ } else {
51+ return errMoveCatalogueLocked
52+ }
53+ if c.destination.mu.TryLock() {
54+ c.destination.mu.Unlock()
55+ } else {
56+ return errMoveCatalogueLocked
57+ }
58+ c.mu.Lock()
59+ c.updates = append(c.updates, update)
60+ c.mu.Unlock()
61+ return nil
62+}
63+
64+func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T) {
65+ movedPTY, releaseMoved := newBlockingPTY(t)
66+ remainingPTY, releaseRemaining := newBlockingPTY(t)
67+ destinationPTY, releaseDestination := newBlockingPTY(t)
68+ activePTY, releaseActive := newBlockingPTY(t)
69+ defer releaseMoved()
70+ defer releaseRemaining()
71+ defer releaseDestination()
72+ defer releaseActive()
73+
74+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
75+ sourceTab := source.tabs[0]
76+ sourceTab.stableID = "source-tab"
77+ moved := sourceTab.focusedPane()
78+ remaining := newPaneWithStableID("pane-2", "remaining-pane", remainingPTY, domain.Size{Cols: 80, Rows: 23})
79+ sourceTab.tree = &layout.Tree{
80+ Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
81+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"),
82+ }},
83+ Focus: "pane-1",
84+ }
85+ sourceTab.panes[remaining.id] = remaining
86+ publishPaneOwner(remaining, source, sourceTab, 0)
87+
88+ destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23})
89+ destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})
90+ destination := &session{
91+ id: "destination", name: "destination", ephemeral: true,
92+ tabs: []*tab{destinationActive, destinationTab}, active: 0,
93+ }
94+ publishTiledPaneOwners(destination, destinationActive)
95+ publishTiledPaneOwners(destination, destinationTab)
96+ d.mu.Lock()
97+ d.sessions[destination.id] = destination
98+ d.mu.Unlock()
99+ source.ephemeral = false
100+ destination.ephemeral = false
101+ source.snapEligible.Store(true)
102+ destination.snapEligible.Store(true)
103+ catalogue := &moveCatalogueRecorder{daemon: d, source: source, destination: destination}
104+ d.catalogue = catalogue
105+ d.persistEnabled = true
106+ source.snapshotMu.Lock()
107+ beforeSourceSnapshot := source.snapshotGeneration
108+ source.snapshotMu.Unlock()
109+ destination.snapshotMu.Lock()
110+ beforeDestinationSnapshot := destination.snapshotGeneration
111+ destination.snapshotMu.Unlock()
112+
113+ beforeSourceGeneration := sourceTab.layoutGeneration
114+ beforeDestinationGeneration := destinationTab.layoutGeneration
115+ beforeOwnerGeneration := moved.ownerGeneration
116+
117+ require.NoError(t, d.movePane(movePaneRequest{
118+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
119+ SourceTabID: domain.TabStableID(sourceTab.stableID),
120+ SourcePaneID: domain.PaneStableID(moved.stableID),
121+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
122+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
123+ }))
124+
125+ require.Len(t, source.tabs, 1)
126+ require.Same(t, sourceTab, source.tabs[0])
127+ require.Same(t, remaining, sourceTab.panes[remaining.id])
128+ require.Equal(t, layout.PaneID("pane-2"), sourceTab.tree.Focus, "source focus must use the valid fallback leaf")
129+ require.Same(t, destinationActive, destination.tabs[destination.active], "destination active tab must remain unchanged")
130+ require.Same(t, moved, destinationTab.panes[moved.id])
131+ require.Equal(t, destinationTab.tree.Focus, moved.id)
132+ require.Equal(t, beforeOwnerGeneration+1, moved.ownerGeneration, "move publishes exactly one new owner generation")
133+ require.Equal(t, beforeSourceGeneration+1, sourceTab.layoutGeneration)
134+ require.Equal(t, beforeDestinationGeneration+1, destinationTab.layoutGeneration)
135+ source.snapshotMu.Lock()
136+ require.GreaterOrEqual(t, source.snapshotGeneration, beforeSourceSnapshot+1)
137+ require.True(t, source.snapDirty.Load())
138+ source.snapshotMu.Unlock()
139+ destination.snapshotMu.Lock()
140+ require.GreaterOrEqual(t, destination.snapshotGeneration, beforeDestinationSnapshot+1)
141+ require.True(t, destination.snapDirty.Load())
142+ destination.snapshotMu.Unlock()
143+ catalogue.mu.Lock()
144+ updates := append([]domain.CatalogueMetadataUpdate(nil), catalogue.updates...)
145+ catalogue.mu.Unlock()
146+ require.ElementsMatch(t, []string{source.name, destination.name}, catalogueUpdateNames(updates))
147+}
148+
149+func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) {
150+ movedPTY, releaseMoved := newBlockingPTY(t)
151+ destinationPTY, releaseDestination := newBlockingPTY(t)
152+ defer releaseMoved()
153+ defer releaseDestination()
154+
155+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
156+ sourceTab := source.tabs[0]
157+ sourceTab.stableID = "source-tab"
158+ moved := sourceTab.focusedPane()
159+ destination := &session{
160+ id: "destination", name: "destination", ephemeral: true,
161+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
162+ }
163+ destinationTab := destination.tabs[0]
164+ publishTiledPaneOwners(destination, destinationTab)
165+ d.mu.Lock()
166+ d.sessions[destination.id] = destination
167+ d.mu.Unlock()
168+
169+ beforeSource := sourceTab.tree.Clone()
170+ beforeDestination := destinationTab.tree.Clone()
171+ beforeOwner := moved.ownerSnapshot()
172+ staleIncarnation := source.incarnation
173+ staleIncarnation[0]++
174+ err := d.movePane(movePaneRequest{
175+ Source: moveSessionLocator{ID: source.id, Incarnation: staleIncarnation},
176+ SourceTabID: domain.TabStableID(sourceTab.stableID),
177+ SourcePaneID: domain.PaneStableID(moved.stableID),
178+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
179+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
180+ })
181+ require.ErrorIs(t, err, errMovePaneInvalid)
182+ require.Equal(t, beforeSource, sourceTab.tree)
183+ require.Equal(t, beforeDestination, destinationTab.tree)
184+ require.Same(t, beforeOwner, moved.ownerSnapshot())
185+ require.Same(t, moved, sourceTab.panes[moved.id])
186+ require.NotSame(t, moved, destinationTab.panes[moved.id])
187+}
188+
189+func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) {
190+ movedPTY, releaseMoved := newBlockingPTY(t)
191+ destinationPTY, releaseDestination := newBlockingPTY(t)
192+ defer releaseMoved()
193+ defer releaseDestination()
194+
195+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
196+ sourceTab := source.tabs[0]
197+ sourceTab.stableID = "source-tab"
198+ moved := sourceTab.focusedPane()
199+ destination := &session{
200+ id: "destination", name: "destination", ephemeral: true,
201+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
202+ }
203+ destinationTab := destination.tabs[0]
204+ publishTiledPaneOwners(destination, destinationTab)
205+ d.mu.Lock()
206+ d.sessions[destination.id] = destination
207+ d.mu.Unlock()
208+
209+ beforeDestination := destinationTab.tree.Clone()
210+ beforeOwner := moved.ownerSnapshot()
211+ d.afterMovePaneSourceSnapshot = func() {
212+ sourceTab.mu.Lock()
213+ sourceTab.layoutGeneration++
214+ sourceTab.mu.Unlock()
215+ }
216+ defer func() { d.afterMovePaneSourceSnapshot = nil }()
217+
218+ err := d.movePane(movePaneRequest{
219+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
220+ SourceTabID: domain.TabStableID(sourceTab.stableID),
221+ SourcePaneID: domain.PaneStableID(moved.stableID),
222+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
223+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
224+ })
225+ require.Error(t, err)
226+ require.Equal(t, beforeDestination, destinationTab.tree)
227+ require.Same(t, beforeOwner, moved.ownerSnapshot())
228+ require.Same(t, moved, sourceTab.panes[moved.id])
229+}
@@ -0,0 +1,229 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+var errMoveCatalogueLocked = errors.New("move catalogue called while architecture locks were held")
15+
16+func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string {
17+ names := make([]string, 0, len(updates))
18+ for _, update := range updates {
19+ names = append(names, update.Name)
20+ }
21+ return names
22+}
23+
24+type moveCatalogueRecorder struct {
25+ daemon *Daemon
26+ source *session
27+ destination *session
28+ mu sync.Mutex
29+ updates []domain.CatalogueMetadataUpdate
30+}
31+
32+func (c *moveCatalogueRecorder) Records() ([]domain.CatalogueRecord, error) { return nil, nil }
33+func (c *moveCatalogueRecorder) Record(string) (domain.CatalogueRecord, bool, error) {
34+ return domain.CatalogueRecord{}, false, nil
35+}
36+func (c *moveCatalogueRecorder) Create(domain.CatalogueRecord) error { return nil }
37+func (c *moveCatalogueRecorder) Replace(string, domain.CatalogueRecord) error { return nil }
38+func (c *moveCatalogueRecorder) Rename(string, domain.CatalogueRecord) error { return nil }
39+func (c *moveCatalogueRecorder) Delete(string) error { return nil }
40+func (c *moveCatalogueRecorder) Sync() error { return nil }
41+func (c *moveCatalogueRecorder) Close() error { return nil }
42+func (c *moveCatalogueRecorder) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
43+ if c.daemon.mu.TryLock() {
44+ c.daemon.mu.Unlock()
45+ } else {
46+ return errMoveCatalogueLocked
47+ }
48+ if c.source.mu.TryLock() {
49+ c.source.mu.Unlock()
50+ } else {
51+ return errMoveCatalogueLocked
52+ }
53+ if c.destination.mu.TryLock() {
54+ c.destination.mu.Unlock()
55+ } else {
56+ return errMoveCatalogueLocked
57+ }
58+ c.mu.Lock()
59+ c.updates = append(c.updates, update)
60+ c.mu.Unlock()
61+ return nil
62+}
63+
64+func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T) {
65+ movedPTY, releaseMoved := newBlockingPTY(t)
66+ remainingPTY, releaseRemaining := newBlockingPTY(t)
67+ destinationPTY, releaseDestination := newBlockingPTY(t)
68+ activePTY, releaseActive := newBlockingPTY(t)
69+ defer releaseMoved()
70+ defer releaseRemaining()
71+ defer releaseDestination()
72+ defer releaseActive()
73+
74+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
75+ sourceTab := source.tabs[0]
76+ sourceTab.stableID = "source-tab"
77+ moved := sourceTab.focusedPane()
78+ remaining := newPaneWithStableID("pane-2", "remaining-pane", remainingPTY, domain.Size{Cols: 80, Rows: 23})
79+ sourceTab.tree = &layout.Tree{
80+ Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
81+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"),
82+ }},
83+ Focus: "pane-1",
84+ }
85+ sourceTab.panes[remaining.id] = remaining
86+ publishPaneOwner(remaining, source, sourceTab, 0)
87+
88+ destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23})
89+ destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})
90+ destination := &session{
91+ id: "destination", name: "destination", ephemeral: true,
92+ tabs: []*tab{destinationActive, destinationTab}, active: 0,
93+ }
94+ publishTiledPaneOwners(destination, destinationActive)
95+ publishTiledPaneOwners(destination, destinationTab)
96+ d.mu.Lock()
97+ d.sessions[destination.id] = destination
98+ d.mu.Unlock()
99+ source.ephemeral = false
100+ destination.ephemeral = false
101+ source.snapEligible.Store(true)
102+ destination.snapEligible.Store(true)
103+ catalogue := &moveCatalogueRecorder{daemon: d, source: source, destination: destination}
104+ d.catalogue = catalogue
105+ d.persistEnabled = true
106+ source.snapshotMu.Lock()
107+ beforeSourceSnapshot := source.snapshotGeneration
108+ source.snapshotMu.Unlock()
109+ destination.snapshotMu.Lock()
110+ beforeDestinationSnapshot := destination.snapshotGeneration
111+ destination.snapshotMu.Unlock()
112+
113+ beforeSourceGeneration := sourceTab.layoutGeneration
114+ beforeDestinationGeneration := destinationTab.layoutGeneration
115+ beforeOwnerGeneration := moved.ownerGeneration
116+
117+ require.NoError(t, d.movePane(movePaneRequest{
118+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
119+ SourceTabID: domain.TabStableID(sourceTab.stableID),
120+ SourcePaneID: domain.PaneStableID(moved.stableID),
121+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
122+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
123+ }))
124+
125+ require.Len(t, source.tabs, 1)
126+ require.Same(t, sourceTab, source.tabs[0])
127+ require.Same(t, remaining, sourceTab.panes[remaining.id])
128+ require.Equal(t, layout.PaneID("pane-2"), sourceTab.tree.Focus, "source focus must use the valid fallback leaf")
129+ require.Same(t, destinationActive, destination.tabs[destination.active], "destination active tab must remain unchanged")
130+ require.Same(t, moved, destinationTab.panes[moved.id])
131+ require.Equal(t, destinationTab.tree.Focus, moved.id)
132+ require.Equal(t, beforeOwnerGeneration+1, moved.ownerGeneration, "move publishes exactly one new owner generation")
133+ require.Equal(t, beforeSourceGeneration+1, sourceTab.layoutGeneration)
134+ require.Equal(t, beforeDestinationGeneration+1, destinationTab.layoutGeneration)
135+ source.snapshotMu.Lock()
136+ require.GreaterOrEqual(t, source.snapshotGeneration, beforeSourceSnapshot+1)
137+ require.True(t, source.snapDirty.Load())
138+ source.snapshotMu.Unlock()
139+ destination.snapshotMu.Lock()
140+ require.GreaterOrEqual(t, destination.snapshotGeneration, beforeDestinationSnapshot+1)
141+ require.True(t, destination.snapDirty.Load())
142+ destination.snapshotMu.Unlock()
143+ catalogue.mu.Lock()
144+ updates := append([]domain.CatalogueMetadataUpdate(nil), catalogue.updates...)
145+ catalogue.mu.Unlock()
146+ require.ElementsMatch(t, []string{source.name, destination.name}, catalogueUpdateNames(updates))
147+}
148+
149+func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) {
150+ movedPTY, releaseMoved := newBlockingPTY(t)
151+ destinationPTY, releaseDestination := newBlockingPTY(t)
152+ defer releaseMoved()
153+ defer releaseDestination()
154+
155+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
156+ sourceTab := source.tabs[0]
157+ sourceTab.stableID = "source-tab"
158+ moved := sourceTab.focusedPane()
159+ destination := &session{
160+ id: "destination", name: "destination", ephemeral: true,
161+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
162+ }
163+ destinationTab := destination.tabs[0]
164+ publishTiledPaneOwners(destination, destinationTab)
165+ d.mu.Lock()
166+ d.sessions[destination.id] = destination
167+ d.mu.Unlock()
168+
169+ beforeSource := sourceTab.tree.Clone()
170+ beforeDestination := destinationTab.tree.Clone()
171+ beforeOwner := moved.ownerSnapshot()
172+ staleIncarnation := source.incarnation
173+ staleIncarnation[0]++
174+ err := d.movePane(movePaneRequest{
175+ Source: moveSessionLocator{ID: source.id, Incarnation: staleIncarnation},
176+ SourceTabID: domain.TabStableID(sourceTab.stableID),
177+ SourcePaneID: domain.PaneStableID(moved.stableID),
178+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
179+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
180+ })
181+ require.ErrorIs(t, err, errMovePaneInvalid)
182+ require.Equal(t, beforeSource, sourceTab.tree)
183+ require.Equal(t, beforeDestination, destinationTab.tree)
184+ require.Same(t, beforeOwner, moved.ownerSnapshot())
185+ require.Same(t, moved, sourceTab.panes[moved.id])
186+ require.NotSame(t, moved, destinationTab.panes[moved.id])
187+}
188+
189+func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) {
190+ movedPTY, releaseMoved := newBlockingPTY(t)
191+ destinationPTY, releaseDestination := newBlockingPTY(t)
192+ defer releaseMoved()
193+ defer releaseDestination()
194+
195+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
196+ sourceTab := source.tabs[0]
197+ sourceTab.stableID = "source-tab"
198+ moved := sourceTab.focusedPane()
199+ destination := &session{
200+ id: "destination", name: "destination", ephemeral: true,
201+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
202+ }
203+ destinationTab := destination.tabs[0]
204+ publishTiledPaneOwners(destination, destinationTab)
205+ d.mu.Lock()
206+ d.sessions[destination.id] = destination
207+ d.mu.Unlock()
208+
209+ beforeDestination := destinationTab.tree.Clone()
210+ beforeOwner := moved.ownerSnapshot()
211+ d.afterMovePaneSourceSnapshot = func() {
212+ sourceTab.mu.Lock()
213+ sourceTab.layoutGeneration++
214+ sourceTab.mu.Unlock()
215+ }
216+ defer func() { d.afterMovePaneSourceSnapshot = nil }()
217+
218+ err := d.movePane(movePaneRequest{
219+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
220+ SourceTabID: domain.TabStableID(sourceTab.stableID),
221+ SourcePaneID: domain.PaneStableID(moved.stableID),
222+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
223+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
224+ })
225+ require.Error(t, err)
226+ require.Equal(t, beforeDestination, destinationTab.tree)
227+ require.Same(t, beforeOwner, moved.ownerSnapshot())
228+ require.Same(t, moved, sourceTab.panes[moved.id])
229+}
A internal/usecase/daemon/move_atomicity_test.go +187 -0
@@ -0,0 +1,187 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+type moveCommitObservation struct {
13+ sourceRegistered bool
14+ sourceTabMember bool
15+ sourcePaneMember bool
16+ destinationTabMember bool
17+ destinationPaneMember bool
18+ sourceActive *tab
19+ destinationActive *tab
20+ sourceFocus layout.PaneID
21+ destinationFocus layout.PaneID
22+ ownerSession *session
23+ ownerTab *tab
24+ sourceRole attachmentRole
25+ destinationRole attachmentRole
26+ sourceClientSession *session
27+ destinationClientSession *session
28+}
29+
30+// readMoveCommitObservation follows the same architecture lock order as the
31+// move commit. Every field is therefore one supported reader's coherent view,
32+// rather than a collection of lock-free observations that could straddle a
33+// publication boundary.
34+func readMoveCommitObservation(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, sourceClient *attachedClient) moveCommitObservation {
35+ d.mu.Lock()
36+ d.notices.routingMu.Lock()
37+ unlockSessions := lockAttachmentSessions(source, destination)
38+ unlockTabs := lockMoveTabs(sourceTab, destinationTab)
39+ movedPane.mu.Lock()
40+
41+ owner := movedPane.ownerSnapshot()
42+ observation := moveCommitObservation{
43+ sourceRegistered: d.sessions[source.id] == source,
44+ sourceTabMember: moveTabMemberLocked(source, sourceTab),
45+ sourcePaneMember: sourceTab.panes[movedPane.id] == movedPane,
46+ destinationTabMember: moveTabMemberLocked(destination, destinationTab),
47+ destinationPaneMember: destinationTab.panes[movedPane.id] == movedPane,
48+ sourceActive: activeMoveTabLocked(source),
49+ destinationActive: activeMoveTabLocked(destination),
50+ sourceFocus: sourceTab.tree.Focus,
51+ destinationFocus: destinationTab.tree.Focus,
52+ ownerSession: nil,
53+ ownerTab: nil,
54+ sourceRole: source.attachmentRoleLocked(sourceClient),
55+ destinationRole: destination.attachmentRoleLocked(sourceClient),
56+ sourceClientSession: sourceClient.currentSession(),
57+ destinationClientSession: sourceClient.currentSession(),
58+ }
59+ if owner != nil {
60+ observation.ownerSession = owner.session
61+ observation.ownerTab = owner.tab
62+ }
63+ movedPane.mu.Unlock()
64+ unlockTabs()
65+ unlockSessions()
66+ d.notices.routingMu.Unlock()
67+ d.mu.Unlock()
68+ return observation
69+}
70+
71+func activeMoveTabLocked(sess *session) *tab {
72+ if sess == nil || sess.active < 0 || sess.active >= len(sess.tabs) {
73+ return nil
74+ }
75+ return sess.tabs[sess.active]
76+}
77+
78+func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) {
79+ p1, release1 := newBlockingPTY(t)
80+ p2, release2 := newBlockingPTY(t)
81+ defer release1()
82+ defer release2()
83+
84+ d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1)
85+ sourceTab := source.tabs[0]
86+ sourceTab.stableID = "source-tab"
87+ movedPane := sourceTab.focusedPane()
88+
89+ destination := &session{
90+ id: "destination",
91+ name: "destination",
92+ ephemeral: true,
93+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
94+ active: 0,
95+ }
96+ destinationTab := destination.tabs[0]
97+ publishTiledPaneOwners(destination, destinationTab)
98+ d.mu.Lock()
99+ d.sessions[destination.id] = destination
100+ d.mu.Unlock()
101+ require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true))
102+
103+ commitEntered := make(chan struct{})
104+ releaseCommit := make(chan struct{})
105+ d.beforeMovePaneCommit = func() {
106+ close(commitEntered)
107+ <-releaseCommit
108+ }
109+
110+ moveDone := make(chan error, 1)
111+ go func() {
112+ moveDone <- d.movePane(movePaneRequest{
113+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
114+ SourceTabID: domain.TabStableID(sourceTab.stableID),
115+ SourcePaneID: domain.PaneStableID(movedPane.stableID),
116+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
117+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
118+ })
119+ }()
120+ <-commitEntered
121+
122+ type reader struct {
123+ name string
124+ read func(moveCommitObservation) bool
125+ }
126+ readers := []reader{
127+ {name: "source membership", read: func(o moveCommitObservation) bool {
128+ return o.sourceRegistered || o.sourceTabMember || o.sourcePaneMember
129+ }},
130+ {name: "destination membership", read: func(o moveCommitObservation) bool { return o.destinationTabMember && !o.destinationPaneMember }},
131+ {name: "owner", read: func(o moveCommitObservation) bool { return o.ownerSession == source && o.ownerTab == sourceTab }},
132+ {name: "active tab and focus", read: func(o moveCommitObservation) bool {
133+ return o.sourceActive == sourceTab || o.sourceFocus != layout.PaneID("pane-1") || o.destinationActive != destinationTab || o.destinationFocus != layout.PaneID("pane-2")
134+ }},
135+ {name: "attachment roles", read: func(o moveCommitObservation) bool {
136+ return o.sourceRole == attachmentActive && o.destinationRole == attachmentDetached && o.sourceClientSession == source && o.destinationClientSession == source
137+ }},
138+ }
139+ started := make(chan string, len(readers))
140+ done := make(chan struct {
141+ name string
142+ observation moveCommitObservation
143+ }, len(readers))
144+ for _, current := range readers {
145+ go func(current reader) {
146+ started <- current.name
147+ observation := readMoveCommitObservation(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
148+ done <- struct {
149+ name string
150+ observation moveCommitObservation
151+ }{name: current.name, observation: observation}
152+ }(current)
153+ }
154+ for range readers {
155+ <-started
156+ }
157+ for range readers {
158+ select {
159+ case result := <-done:
160+ t.Fatalf("%s reader completed while the move commit was paused: %#v", result.name, result.observation)
161+ default:
162+ }
163+ }
164+
165+ close(releaseCommit)
166+ require.NoError(t, <-moveDone)
167+ for _, current := range readers {
168+ result := <-done
169+ require.False(t, current.read(result.observation), "%s observed partial move state: %#v", current.name, result.observation)
170+ }
171+
172+ observation := readMoveCommitObservation(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
173+ require.False(t, observation.sourceRegistered)
174+ require.False(t, observation.sourceTabMember)
175+ require.False(t, observation.sourcePaneMember)
176+ require.True(t, observation.destinationTabMember)
177+ require.True(t, observation.destinationPaneMember)
178+ require.Nil(t, observation.sourceActive)
179+ require.Equal(t, layout.PaneID("pane-1"), observation.sourceFocus)
180+ require.Same(t, destinationTab, observation.destinationActive)
181+ require.Equal(t, layout.PaneID("pane-2"), observation.destinationFocus)
182+ require.Same(t, destination, observation.ownerSession)
183+ require.Same(t, destinationTab, observation.ownerTab)
184+ require.Equal(t, attachmentDetached, observation.sourceRole)
185+ require.Equal(t, attachmentActive, observation.destinationRole)
186+ require.Same(t, destination, observation.sourceClientSession)
187+}
@@ -0,0 +1,187 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+type moveCommitObservation struct {
13+ sourceRegistered bool
14+ sourceTabMember bool
15+ sourcePaneMember bool
16+ destinationTabMember bool
17+ destinationPaneMember bool
18+ sourceActive *tab
19+ destinationActive *tab
20+ sourceFocus layout.PaneID
21+ destinationFocus layout.PaneID
22+ ownerSession *session
23+ ownerTab *tab
24+ sourceRole attachmentRole
25+ destinationRole attachmentRole
26+ sourceClientSession *session
27+ destinationClientSession *session
28+}
29+
30+// readMoveCommitObservation follows the same architecture lock order as the
31+// move commit. Every field is therefore one supported reader's coherent view,
32+// rather than a collection of lock-free observations that could straddle a
33+// publication boundary.
34+func readMoveCommitObservation(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, sourceClient *attachedClient) moveCommitObservation {
35+ d.mu.Lock()
36+ d.notices.routingMu.Lock()
37+ unlockSessions := lockAttachmentSessions(source, destination)
38+ unlockTabs := lockMoveTabs(sourceTab, destinationTab)
39+ movedPane.mu.Lock()
40+
41+ owner := movedPane.ownerSnapshot()
42+ observation := moveCommitObservation{
43+ sourceRegistered: d.sessions[source.id] == source,
44+ sourceTabMember: moveTabMemberLocked(source, sourceTab),
45+ sourcePaneMember: sourceTab.panes[movedPane.id] == movedPane,
46+ destinationTabMember: moveTabMemberLocked(destination, destinationTab),
47+ destinationPaneMember: destinationTab.panes[movedPane.id] == movedPane,
48+ sourceActive: activeMoveTabLocked(source),
49+ destinationActive: activeMoveTabLocked(destination),
50+ sourceFocus: sourceTab.tree.Focus,
51+ destinationFocus: destinationTab.tree.Focus,
52+ ownerSession: nil,
53+ ownerTab: nil,
54+ sourceRole: source.attachmentRoleLocked(sourceClient),
55+ destinationRole: destination.attachmentRoleLocked(sourceClient),
56+ sourceClientSession: sourceClient.currentSession(),
57+ destinationClientSession: sourceClient.currentSession(),
58+ }
59+ if owner != nil {
60+ observation.ownerSession = owner.session
61+ observation.ownerTab = owner.tab
62+ }
63+ movedPane.mu.Unlock()
64+ unlockTabs()
65+ unlockSessions()
66+ d.notices.routingMu.Unlock()
67+ d.mu.Unlock()
68+ return observation
69+}
70+
71+func activeMoveTabLocked(sess *session) *tab {
72+ if sess == nil || sess.active < 0 || sess.active >= len(sess.tabs) {
73+ return nil
74+ }
75+ return sess.tabs[sess.active]
76+}
77+
78+func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) {
79+ p1, release1 := newBlockingPTY(t)
80+ p2, release2 := newBlockingPTY(t)
81+ defer release1()
82+ defer release2()
83+
84+ d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1)
85+ sourceTab := source.tabs[0]
86+ sourceTab.stableID = "source-tab"
87+ movedPane := sourceTab.focusedPane()
88+
89+ destination := &session{
90+ id: "destination",
91+ name: "destination",
92+ ephemeral: true,
93+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
94+ active: 0,
95+ }
96+ destinationTab := destination.tabs[0]
97+ publishTiledPaneOwners(destination, destinationTab)
98+ d.mu.Lock()
99+ d.sessions[destination.id] = destination
100+ d.mu.Unlock()
101+ require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true))
102+
103+ commitEntered := make(chan struct{})
104+ releaseCommit := make(chan struct{})
105+ d.beforeMovePaneCommit = func() {
106+ close(commitEntered)
107+ <-releaseCommit
108+ }
109+
110+ moveDone := make(chan error, 1)
111+ go func() {
112+ moveDone <- d.movePane(movePaneRequest{
113+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
114+ SourceTabID: domain.TabStableID(sourceTab.stableID),
115+ SourcePaneID: domain.PaneStableID(movedPane.stableID),
116+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
117+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
118+ })
119+ }()
120+ <-commitEntered
121+
122+ type reader struct {
123+ name string
124+ read func(moveCommitObservation) bool
125+ }
126+ readers := []reader{
127+ {name: "source membership", read: func(o moveCommitObservation) bool {
128+ return o.sourceRegistered || o.sourceTabMember || o.sourcePaneMember
129+ }},
130+ {name: "destination membership", read: func(o moveCommitObservation) bool { return o.destinationTabMember && !o.destinationPaneMember }},
131+ {name: "owner", read: func(o moveCommitObservation) bool { return o.ownerSession == source && o.ownerTab == sourceTab }},
132+ {name: "active tab and focus", read: func(o moveCommitObservation) bool {
133+ return o.sourceActive == sourceTab || o.sourceFocus != layout.PaneID("pane-1") || o.destinationActive != destinationTab || o.destinationFocus != layout.PaneID("pane-2")
134+ }},
135+ {name: "attachment roles", read: func(o moveCommitObservation) bool {
136+ return o.sourceRole == attachmentActive && o.destinationRole == attachmentDetached && o.sourceClientSession == source && o.destinationClientSession == source
137+ }},
138+ }
139+ started := make(chan string, len(readers))
140+ done := make(chan struct {
141+ name string
142+ observation moveCommitObservation
143+ }, len(readers))
144+ for _, current := range readers {
145+ go func(current reader) {
146+ started <- current.name
147+ observation := readMoveCommitObservation(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
148+ done <- struct {
149+ name string
150+ observation moveCommitObservation
151+ }{name: current.name, observation: observation}
152+ }(current)
153+ }
154+ for range readers {
155+ <-started
156+ }
157+ for range readers {
158+ select {
159+ case result := <-done:
160+ t.Fatalf("%s reader completed while the move commit was paused: %#v", result.name, result.observation)
161+ default:
162+ }
163+ }
164+
165+ close(releaseCommit)
166+ require.NoError(t, <-moveDone)
167+ for _, current := range readers {
168+ result := <-done
169+ require.False(t, current.read(result.observation), "%s observed partial move state: %#v", current.name, result.observation)
170+ }
171+
172+ observation := readMoveCommitObservation(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
173+ require.False(t, observation.sourceRegistered)
174+ require.False(t, observation.sourceTabMember)
175+ require.False(t, observation.sourcePaneMember)
176+ require.True(t, observation.destinationTabMember)
177+ require.True(t, observation.destinationPaneMember)
178+ require.Nil(t, observation.sourceActive)
179+ require.Equal(t, layout.PaneID("pane-1"), observation.sourceFocus)
180+ require.Same(t, destinationTab, observation.destinationActive)
181+ require.Equal(t, layout.PaneID("pane-2"), observation.destinationFocus)
182+ require.Same(t, destination, observation.ownerSession)
183+ require.Same(t, destinationTab, observation.ownerTab)
184+ require.Equal(t, attachmentDetached, observation.sourceRole)
185+ require.Equal(t, attachmentActive, observation.destinationRole)
186+ require.Same(t, destination, observation.sourceClientSession)
187+}
A internal/usecase/daemon/move_cleanup_test.go +185 -0
@@ -0,0 +1,185 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ scopy "github.com/bnema/vev/internal/usecase/copy"
10+)
11+
12+func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
13+ movedPTY, releaseMoved := newBlockingPTY(t)
14+ remainingPTY, releaseRemaining := newBlockingPTY(t)
15+ destinationPTY, releaseDestination := newBlockingPTY(t)
16+ defer releaseMoved()
17+ defer releaseRemaining()
18+ defer releaseDestination()
19+
20+ d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
21+ sourceTab := source.tabs[0]
22+ sourceTab.stableID = "source-tab"
23+ moved := sourceTab.focusedPane()
24+ client.sendMu.Lock()
25+ client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
26+ client.sendMu.Unlock()
27+
28+ destination := &session{
29+ id: "destination", name: "destination", ephemeral: true,
30+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
31+ active: 0,
32+ }
33+ destinationTab := destination.tabs[0]
34+ publishTiledPaneOwners(destination, destinationTab)
35+ d.mu.Lock()
36+ d.sessions[destination.id] = destination
37+ d.mu.Unlock()
38+
39+ moved.mu.Lock()
40+ owner := moved.effectLeaseLocked()
41+ moved.mu.Unlock()
42+ members := []resizeMember{{session: source, tab: sourceTab, pane: moved, owner: owner}}
43+
44+ sendLocked := make(chan struct{})
45+ allowResizeFence := make(chan struct{})
46+ d.afterResizeCommitSendLocked = func() {
47+ close(sendLocked)
48+ <-allowResizeFence
49+ }
50+ defer func() { d.afterResizeCommitSendLocked = nil }()
51+ resizeDone := make(chan bool, 1)
52+ go func() {
53+ resizeDone <- d.publishResizeCommit(members, source, client, nil, 0, nil, client.size)
54+ }()
55+ awaitTestCompletion(t, sendLocked, "resize commit did not acquire sendMu")
56+
57+ insideMoveCommit := make(chan struct{})
58+ allowMoveCommit := make(chan struct{})
59+ d.beforeMovePaneCommit = func() {
60+ close(insideMoveCommit)
61+ <-allowMoveCommit
62+ }
63+ defer func() { d.beforeMovePaneCommit = nil }()
64+ moveDone := make(chan error, 1)
65+ go func() {
66+ moveDone <- d.movePane(movePaneRequest{
67+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
68+ SourceTabID: domain.TabStableID(sourceTab.stableID),
69+ SourcePaneID: domain.PaneStableID(moved.stableID),
70+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
71+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
72+ })
73+ }()
74+ awaitTestCompletion(t, insideMoveCommit, "move did not reach its commit section")
75+
76+ // Resize now owns sendMu and waits for the moved pane fence. The move may
77+ // finish only if it releases every resize fence before capture cleanup tries
78+ // to acquire sendMu.
79+ close(allowResizeFence)
80+ close(allowMoveCommit)
81+ require.NoError(t, awaitTestValue(t, moveDone, "move deadlocked against resize commit"))
82+ require.False(t, awaitTestValue(t, resizeDone, "resize commit did not drop stale owner"))
83+}
84+
85+func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
86+ movedPTY, releaseMoved := newBlockingPTY(t)
87+ remainingPTY, releaseRemaining := newBlockingPTY(t)
88+ destinationPTY, releaseDestination := newBlockingPTY(t)
89+ defer releaseMoved()
90+ defer releaseRemaining()
91+ defer releaseDestination()
92+
93+ d, source, displaced, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
94+ sourceTab := source.tabs[0]
95+ remainingTab := source.tabs[1]
96+ sourceTab.stableID = "source-tab"
97+ remainingTab.stableID = "remaining-tab"
98+ moved := sourceTab.focusedPane()
99+ require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))
100+
101+ destination := &session{
102+ id: "destination", name: "destination", ephemeral: true,
103+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
104+ active: 0,
105+ }
106+ destinationTab := destination.tabs[0]
107+ publishTiledPaneOwners(destination, destinationTab)
108+ d.mu.Lock()
109+ d.sessions[destination.id] = destination
110+ d.mu.Unlock()
111+
112+ // Both clients carry state tied specifically to the pane. The displaced
113+ // client was captured before the fence wait; the replacement is the only
114+ // attachment that may be cleaned after commit.
115+ displaced.overlays.copyMu.Lock()
116+ displaced.overlays.copyPane = moved
117+ displaced.overlays.copyMode = &scopy.Mode{}
118+ displaced.overlays.copyMu.Unlock()
119+ displaced.sendMu.Lock()
120+ displaced.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
121+ displaced.sendMu.Unlock()
122+
123+ replacementTransport, _ := newCapturingTransport(t)
124+ replacement := &attachedClient{
125+ tr: replacementTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24},
126+ }
127+ replacement.initOverlays()
128+ replacement.setSession(source)
129+ replacement.overlays.copyMu.Lock()
130+ replacement.overlays.copyPane = moved
131+ replacement.overlays.copyMode = &scopy.Mode{}
132+ replacement.overlays.copyMu.Unlock()
133+ replacement.sendMu.Lock()
134+ replacement.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
135+ replacement.sendMu.Unlock()
136+
137+ // Keep the source layout fence occupied while the move has captured its
138+ // pre-fence source client. Replacing the client at this barrier reproduces
139+ // the stale cleanup race without sleeps.
140+ source.layoutApplyMu.Lock()
141+ snapshot := make(chan struct{})
142+ releaseSnapshot := make(chan struct{})
143+ d.afterMovePaneSourceSnapshot = func() {
144+ close(snapshot)
145+ <-releaseSnapshot
146+ }
147+ defer func() { d.afterMovePaneSourceSnapshot = nil }()
148+
149+ moveDone := make(chan error, 1)
150+ go func() {
151+ moveDone <- d.movePane(movePaneRequest{
152+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
153+ SourceTabID: domain.TabStableID(sourceTab.stableID),
154+ SourcePaneID: domain.PaneStableID(moved.stableID),
155+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
156+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
157+ })
158+ }()
159+ awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment")
160+
161+ source.mu.Lock()
162+ source.client = replacement
163+ source.mu.Unlock()
164+ close(releaseSnapshot)
165+ source.layoutApplyMu.Unlock()
166+ require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release"))
167+
168+ displaced.overlays.copyMu.Lock()
169+ require.NotNil(t, displaced.overlays.copyMode, "displaced source client was incorrectly cleaned")
170+ require.Same(t, moved, displaced.overlays.copyPane)
171+ displaced.overlays.copyMu.Unlock()
172+ displaced.sendMu.Lock()
173+ _, displacedCaptured := displaced.captureFrames[moved]
174+ displaced.sendMu.Unlock()
175+ require.True(t, displacedCaptured, "displaced source capture was incorrectly pruned")
176+
177+ replacement.overlays.copyMu.Lock()
178+ require.Nil(t, replacement.overlays.copyMode, "current source client copy mode was not cleaned")
179+ require.Nil(t, replacement.overlays.copyPane)
180+ replacement.overlays.copyMu.Unlock()
181+ replacement.sendMu.Lock()
182+ _, replacementCaptured := replacement.captureFrames[moved]
183+ replacement.sendMu.Unlock()
184+ require.False(t, replacementCaptured, "current source capture was not pruned")
185+}
@@ -0,0 +1,185 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ scopy "github.com/bnema/vev/internal/usecase/copy"
10+)
11+
12+func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
13+ movedPTY, releaseMoved := newBlockingPTY(t)
14+ remainingPTY, releaseRemaining := newBlockingPTY(t)
15+ destinationPTY, releaseDestination := newBlockingPTY(t)
16+ defer releaseMoved()
17+ defer releaseRemaining()
18+ defer releaseDestination()
19+
20+ d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
21+ sourceTab := source.tabs[0]
22+ sourceTab.stableID = "source-tab"
23+ moved := sourceTab.focusedPane()
24+ client.sendMu.Lock()
25+ client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
26+ client.sendMu.Unlock()
27+
28+ destination := &session{
29+ id: "destination", name: "destination", ephemeral: true,
30+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
31+ active: 0,
32+ }
33+ destinationTab := destination.tabs[0]
34+ publishTiledPaneOwners(destination, destinationTab)
35+ d.mu.Lock()
36+ d.sessions[destination.id] = destination
37+ d.mu.Unlock()
38+
39+ moved.mu.Lock()
40+ owner := moved.effectLeaseLocked()
41+ moved.mu.Unlock()
42+ members := []resizeMember{{session: source, tab: sourceTab, pane: moved, owner: owner}}
43+
44+ sendLocked := make(chan struct{})
45+ allowResizeFence := make(chan struct{})
46+ d.afterResizeCommitSendLocked = func() {
47+ close(sendLocked)
48+ <-allowResizeFence
49+ }
50+ defer func() { d.afterResizeCommitSendLocked = nil }()
51+ resizeDone := make(chan bool, 1)
52+ go func() {
53+ resizeDone <- d.publishResizeCommit(members, source, client, nil, 0, nil, client.size)
54+ }()
55+ awaitTestCompletion(t, sendLocked, "resize commit did not acquire sendMu")
56+
57+ insideMoveCommit := make(chan struct{})
58+ allowMoveCommit := make(chan struct{})
59+ d.beforeMovePaneCommit = func() {
60+ close(insideMoveCommit)
61+ <-allowMoveCommit
62+ }
63+ defer func() { d.beforeMovePaneCommit = nil }()
64+ moveDone := make(chan error, 1)
65+ go func() {
66+ moveDone <- d.movePane(movePaneRequest{
67+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
68+ SourceTabID: domain.TabStableID(sourceTab.stableID),
69+ SourcePaneID: domain.PaneStableID(moved.stableID),
70+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
71+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
72+ })
73+ }()
74+ awaitTestCompletion(t, insideMoveCommit, "move did not reach its commit section")
75+
76+ // Resize now owns sendMu and waits for the moved pane fence. The move may
77+ // finish only if it releases every resize fence before capture cleanup tries
78+ // to acquire sendMu.
79+ close(allowResizeFence)
80+ close(allowMoveCommit)
81+ require.NoError(t, awaitTestValue(t, moveDone, "move deadlocked against resize commit"))
82+ require.False(t, awaitTestValue(t, resizeDone, "resize commit did not drop stale owner"))
83+}
84+
85+func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
86+ movedPTY, releaseMoved := newBlockingPTY(t)
87+ remainingPTY, releaseRemaining := newBlockingPTY(t)
88+ destinationPTY, releaseDestination := newBlockingPTY(t)
89+ defer releaseMoved()
90+ defer releaseRemaining()
91+ defer releaseDestination()
92+
93+ d, source, displaced, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
94+ sourceTab := source.tabs[0]
95+ remainingTab := source.tabs[1]
96+ sourceTab.stableID = "source-tab"
97+ remainingTab.stableID = "remaining-tab"
98+ moved := sourceTab.focusedPane()
99+ require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))
100+
101+ destination := &session{
102+ id: "destination", name: "destination", ephemeral: true,
103+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
104+ active: 0,
105+ }
106+ destinationTab := destination.tabs[0]
107+ publishTiledPaneOwners(destination, destinationTab)
108+ d.mu.Lock()
109+ d.sessions[destination.id] = destination
110+ d.mu.Unlock()
111+
112+ // Both clients carry state tied specifically to the pane. The displaced
113+ // client was captured before the fence wait; the replacement is the only
114+ // attachment that may be cleaned after commit.
115+ displaced.overlays.copyMu.Lock()
116+ displaced.overlays.copyPane = moved
117+ displaced.overlays.copyMode = &scopy.Mode{}
118+ displaced.overlays.copyMu.Unlock()
119+ displaced.sendMu.Lock()
120+ displaced.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
121+ displaced.sendMu.Unlock()
122+
123+ replacementTransport, _ := newCapturingTransport(t)
124+ replacement := &attachedClient{
125+ tr: replacementTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24},
126+ }
127+ replacement.initOverlays()
128+ replacement.setSession(source)
129+ replacement.overlays.copyMu.Lock()
130+ replacement.overlays.copyPane = moved
131+ replacement.overlays.copyMode = &scopy.Mode{}
132+ replacement.overlays.copyMu.Unlock()
133+ replacement.sendMu.Lock()
134+ replacement.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
135+ replacement.sendMu.Unlock()
136+
137+ // Keep the source layout fence occupied while the move has captured its
138+ // pre-fence source client. Replacing the client at this barrier reproduces
139+ // the stale cleanup race without sleeps.
140+ source.layoutApplyMu.Lock()
141+ snapshot := make(chan struct{})
142+ releaseSnapshot := make(chan struct{})
143+ d.afterMovePaneSourceSnapshot = func() {
144+ close(snapshot)
145+ <-releaseSnapshot
146+ }
147+ defer func() { d.afterMovePaneSourceSnapshot = nil }()
148+
149+ moveDone := make(chan error, 1)
150+ go func() {
151+ moveDone <- d.movePane(movePaneRequest{
152+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
153+ SourceTabID: domain.TabStableID(sourceTab.stableID),
154+ SourcePaneID: domain.PaneStableID(moved.stableID),
155+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
156+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
157+ })
158+ }()
159+ awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment")
160+
161+ source.mu.Lock()
162+ source.client = replacement
163+ source.mu.Unlock()
164+ close(releaseSnapshot)
165+ source.layoutApplyMu.Unlock()
166+ require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release"))
167+
168+ displaced.overlays.copyMu.Lock()
169+ require.NotNil(t, displaced.overlays.copyMode, "displaced source client was incorrectly cleaned")
170+ require.Same(t, moved, displaced.overlays.copyPane)
171+ displaced.overlays.copyMu.Unlock()
172+ displaced.sendMu.Lock()
173+ _, displacedCaptured := displaced.captureFrames[moved]
174+ displaced.sendMu.Unlock()
175+ require.True(t, displacedCaptured, "displaced source capture was incorrectly pruned")
176+
177+ replacement.overlays.copyMu.Lock()
178+ require.Nil(t, replacement.overlays.copyMode, "current source client copy mode was not cleaned")
179+ require.Nil(t, replacement.overlays.copyPane)
180+ replacement.overlays.copyMu.Unlock()
181+ replacement.sendMu.Lock()
182+ _, replacementCaptured := replacement.captureFrames[moved]
183+ replacement.sendMu.Unlock()
184+ require.False(t, replacementCaptured, "current source capture was not pruned")
185+}
A internal/usecase/daemon/move_gate_race_test.go +160 -0
@@ -0,0 +1,160 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *session, *tab, func()) {
14+ t.Helper()
15+ movedPTY, releaseMoved := newBlockingPTY(t)
16+ destinationPTY, releaseDestination := newBlockingPTY(t)
17+ d, source, client, _ := newManualSessionWithPTYs(t, movedPTY)
18+ sourceTab := source.tabs[0]
19+ sourceTab.stableID = "source-tab"
20+ moved := sourceTab.focusedPane()
21+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
22+
23+ destination := &session{
24+ id: "destination", name: "destination", ephemeral: true,
25+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
26+ active: 0,
27+ }
28+ destinationTab := destination.tabs[0]
29+ publishTiledPaneOwners(destination, destinationTab)
30+ d.mu.Lock()
31+ d.sessions[destination.id] = destination
32+ d.mu.Unlock()
33+ return d, source, sourceTab, moved, destination, destinationTab, func() {
34+ releaseMoved()
35+ releaseDestination()
36+ }
37+}
38+
39+func moveGateRaceRequest(source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) movePaneRequest {
40+ return movePaneRequest{
41+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
42+ SourceTabID: domain.TabStableID(sourceTab.stableID),
43+ SourcePaneID: domain.PaneStableID(moved.stableID),
44+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
45+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
46+ }
47+}
48+
49+func waitMoveRace[T any](t *testing.T, ch <-chan T, name string) T {
50+ t.Helper()
51+ select {
52+ case value := <-ch:
53+ return value
54+ case <-timeAfterMoveRace():
55+ t.Fatalf("%s did not finish", name)
56+ var zero T
57+ return zero
58+ }
59+}
60+
61+// timeAfterMoveRace is kept in one helper so these barrier tests use a
62+// deterministic bounded wait rather than sleeps.
63+func timeAfterMoveRace() <-chan time.Time {
64+ return time.After(testWaitTimeout)
65+}
66+
67+func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
68+ d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
69+ defer releasePTYs()
70+
71+ moveReserved := make(chan struct{})
72+ releaseMoveAdmission := make(chan struct{})
73+ d.afterMoveLifecycleReserved = func() {
74+ close(moveReserved)
75+ <-releaseMoveAdmission
76+ }
77+ killFrozen := make(chan struct{})
78+ releaseKill := make(chan struct{})
79+ d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
80+ if action != "" {
81+ return
82+ }
83+ close(killFrozen)
84+ <-releaseKill
85+ }
86+ defer func() {
87+ d.afterMoveLifecycleReserved = nil
88+ d.afterRoleEffectGateFrozen = nil
89+ }()
90+
91+ moveDone := make(chan error, 1)
92+ go func() {
93+ moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
94+ }()
95+ waitMoveRace(t, moveReserved, "move reservation")
96+
97+ killDone := make(chan error, 1)
98+ go func() { killDone <- d.killSession(source, ports.ReasonSessionKilled, false) }()
99+ waitMoveRace(t, killFrozen, "kill gate freeze")
100+ close(releaseMoveAdmission)
101+ moveErr := waitMoveRace(t, moveDone, "move abort after kill gate acquisition")
102+ close(releaseKill)
103+ killErr := waitMoveRace(t, killDone, "kill teardown")
104+
105+ require.ErrorIs(t, moveErr, errMovePaneInvalid)
106+ require.NoError(t, killErr)
107+ d.mu.Lock()
108+ _, sourceLive := d.sessions[source.id]
109+ _, destinationLive := d.sessions[destination.id]
110+ d.mu.Unlock()
111+ require.False(t, sourceLive, "kill winner must retire the source")
112+ require.True(t, destinationLive)
113+ require.NotSame(t, moved, destination.tabs[0].panes[moved.id], "kill winner must not publish a partial destination move")
114+}
115+
116+func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
117+ d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
118+ defer releasePTYs()
119+
120+ moveGateFrozen := make(chan struct{})
121+ releaseMove := make(chan struct{})
122+ d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
123+ if action != "move-pane" {
124+ return
125+ }
126+ close(moveGateFrozen)
127+ <-releaseMove
128+ }
129+ killSnapshotted := make(chan struct{})
130+ d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
131+ if action == "" {
132+ close(killSnapshotted)
133+ }
134+ }
135+ defer func() {
136+ d.afterRoleEffectGateFrozen = nil
137+ d.afterRoleEffectParticipantsSnapshotted = nil
138+ }()
139+
140+ moveDone := make(chan error, 1)
141+ go func() {
142+ moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
143+ }()
144+ waitMoveRace(t, moveGateFrozen, "move gate freeze")
145+
146+ killDone := make(chan error, 1)
147+ go func() { killDone <- d.killSession(source, ports.ReasonSessionKilled, false) }()
148+ waitMoveRace(t, killSnapshotted, "kill participant snapshot")
149+ close(releaseMove)
150+
151+ require.NoError(t, waitMoveRace(t, moveDone, "move commit"))
152+ require.NoError(t, waitMoveRace(t, killDone, "kill loser"))
153+ d.mu.Lock()
154+ _, sourceLive := d.sessions[source.id]
155+ _, destinationLive := d.sessions[destination.id]
156+ d.mu.Unlock()
157+ require.False(t, sourceLive)
158+ require.True(t, destinationLive)
159+ require.Same(t, moved, destinationTab.panes[moved.id])
160+}
@@ -0,0 +1,160 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *session, *tab, func()) {
14+ t.Helper()
15+ movedPTY, releaseMoved := newBlockingPTY(t)
16+ destinationPTY, releaseDestination := newBlockingPTY(t)
17+ d, source, client, _ := newManualSessionWithPTYs(t, movedPTY)
18+ sourceTab := source.tabs[0]
19+ sourceTab.stableID = "source-tab"
20+ moved := sourceTab.focusedPane()
21+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
22+
23+ destination := &session{
24+ id: "destination", name: "destination", ephemeral: true,
25+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
26+ active: 0,
27+ }
28+ destinationTab := destination.tabs[0]
29+ publishTiledPaneOwners(destination, destinationTab)
30+ d.mu.Lock()
31+ d.sessions[destination.id] = destination
32+ d.mu.Unlock()
33+ return d, source, sourceTab, moved, destination, destinationTab, func() {
34+ releaseMoved()
35+ releaseDestination()
36+ }
37+}
38+
39+func moveGateRaceRequest(source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) movePaneRequest {
40+ return movePaneRequest{
41+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
42+ SourceTabID: domain.TabStableID(sourceTab.stableID),
43+ SourcePaneID: domain.PaneStableID(moved.stableID),
44+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
45+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
46+ }
47+}
48+
49+func waitMoveRace[T any](t *testing.T, ch <-chan T, name string) T {
50+ t.Helper()
51+ select {
52+ case value := <-ch:
53+ return value
54+ case <-timeAfterMoveRace():
55+ t.Fatalf("%s did not finish", name)
56+ var zero T
57+ return zero
58+ }
59+}
60+
61+// timeAfterMoveRace is kept in one helper so these barrier tests use a
62+// deterministic bounded wait rather than sleeps.
63+func timeAfterMoveRace() <-chan time.Time {
64+ return time.After(testWaitTimeout)
65+}
66+
67+func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
68+ d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
69+ defer releasePTYs()
70+
71+ moveReserved := make(chan struct{})
72+ releaseMoveAdmission := make(chan struct{})
73+ d.afterMoveLifecycleReserved = func() {
74+ close(moveReserved)
75+ <-releaseMoveAdmission
76+ }
77+ killFrozen := make(chan struct{})
78+ releaseKill := make(chan struct{})
79+ d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
80+ if action != "" {
81+ return
82+ }
83+ close(killFrozen)
84+ <-releaseKill
85+ }
86+ defer func() {
87+ d.afterMoveLifecycleReserved = nil
88+ d.afterRoleEffectGateFrozen = nil
89+ }()
90+
91+ moveDone := make(chan error, 1)
92+ go func() {
93+ moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
94+ }()
95+ waitMoveRace(t, moveReserved, "move reservation")
96+
97+ killDone := make(chan error, 1)
98+ go func() { killDone <- d.killSession(source, ports.ReasonSessionKilled, false) }()
99+ waitMoveRace(t, killFrozen, "kill gate freeze")
100+ close(releaseMoveAdmission)
101+ moveErr := waitMoveRace(t, moveDone, "move abort after kill gate acquisition")
102+ close(releaseKill)
103+ killErr := waitMoveRace(t, killDone, "kill teardown")
104+
105+ require.ErrorIs(t, moveErr, errMovePaneInvalid)
106+ require.NoError(t, killErr)
107+ d.mu.Lock()
108+ _, sourceLive := d.sessions[source.id]
109+ _, destinationLive := d.sessions[destination.id]
110+ d.mu.Unlock()
111+ require.False(t, sourceLive, "kill winner must retire the source")
112+ require.True(t, destinationLive)
113+ require.NotSame(t, moved, destination.tabs[0].panes[moved.id], "kill winner must not publish a partial destination move")
114+}
115+
116+func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
117+ d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
118+ defer releasePTYs()
119+
120+ moveGateFrozen := make(chan struct{})
121+ releaseMove := make(chan struct{})
122+ d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
123+ if action != "move-pane" {
124+ return
125+ }
126+ close(moveGateFrozen)
127+ <-releaseMove
128+ }
129+ killSnapshotted := make(chan struct{})
130+ d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
131+ if action == "" {
132+ close(killSnapshotted)
133+ }
134+ }
135+ defer func() {
136+ d.afterRoleEffectGateFrozen = nil
137+ d.afterRoleEffectParticipantsSnapshotted = nil
138+ }()
139+
140+ moveDone := make(chan error, 1)
141+ go func() {
142+ moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
143+ }()
144+ waitMoveRace(t, moveGateFrozen, "move gate freeze")
145+
146+ killDone := make(chan error, 1)
147+ go func() { killDone <- d.killSession(source, ports.ReasonSessionKilled, false) }()
148+ waitMoveRace(t, killSnapshotted, "kill participant snapshot")
149+ close(releaseMove)
150+
151+ require.NoError(t, waitMoveRace(t, moveDone, "move commit"))
152+ require.NoError(t, waitMoveRace(t, killDone, "kill loser"))
153+ d.mu.Lock()
154+ _, sourceLive := d.sessions[source.id]
155+ _, destinationLive := d.sessions[destination.id]
156+ d.mu.Unlock()
157+ require.False(t, sourceLive)
158+ require.True(t, destinationLive)
159+ require.Same(t, moved, destinationTab.panes[moved.id])
160+}
A internal/usecase/daemon/move_handoff_failure_test.go +253 -0
@@ -0,0 +1,253 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "maps"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+type moveTabState struct {
15+ ptr *tab
16+ stableID string
17+ tree *layout.Tree
18+ panes map[layout.PaneID]*pane
19+ nextPaneID int
20+ layoutGeneration uint64
21+ floatingState floatingState
22+ floatingPane *pane
23+ floatingVisible bool
24+ floatingGeneration uint64
25+ ctxDone bool
26+}
27+
28+type moveSessionState struct {
29+ ptr *session
30+ id domain.SessionID
31+ name string
32+ incarnation domain.IncarnationID
33+ tabs []*tab
34+ active int
35+ client *attachedClient
36+ snatched map[*attachedClient]struct{}
37+ moveReservations uint
38+ snapshotGeneration uint64
39+ snapshotPublished uint64
40+ snapshotDirty bool
41+ snapshotEligible bool
42+ ctxDone bool
43+}
44+
45+type moveLeaseState struct {
46+ lease *attachmentLease
47+ attachment *attachedClient
48+ ready bool
49+ active bool
50+}
51+
52+type moveClientState struct {
53+ transport transportSnapshot
54+ roleGeneration uint64
55+ currentSession *session
56+ sourceRole attachmentRole
57+ destinationRole attachmentRole
58+ sourceLease moveLeaseState
59+ destinationLease moveLeaseState
60+ roleEffectPhase roleEffectPhase
61+}
62+
63+type movePaneState struct {
64+ id layout.PaneID
65+ stableID string
66+ owner *paneOwner
67+ ownerGeneration uint64
68+ ctxDone bool
69+}
70+
71+type moveState struct {
72+ closing bool
73+ registry map[domain.SessionID]*session
74+ source moveSessionState
75+ destination moveSessionState
76+ sourceTab moveTabState
77+ destinationTab moveTabState
78+ movedPane movePaneState
79+ client moveClientState
80+}
81+
82+func captureMoveState(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, client *attachedClient) moveState {
83+ d.mu.Lock()
84+ d.notices.routingMu.Lock()
85+ unlockSessions := lockAttachmentSessions(source, destination)
86+ unlockTabs := lockMoveTabs(sourceTab, destinationTab)
87+ movedPane.mu.Lock()
88+
89+ registry := make(map[domain.SessionID]*session, len(d.sessions))
90+ maps.Copy(registry, d.sessions)
91+ state := moveState{
92+ closing: d.closing,
93+ registry: registry,
94+ source: captureMoveSessionStateLocked(source),
95+ destination: captureMoveSessionStateLocked(destination),
96+ sourceTab: captureMoveTabStateLocked(sourceTab),
97+ destinationTab: captureMoveTabStateLocked(destinationTab),
98+ movedPane: movePaneState{
99+ id: movedPane.id,
100+ stableID: movedPane.stableID,
101+ owner: movedPane.ownerSnapshot(),
102+ ownerGeneration: movedPane.ownerGeneration,
103+ ctxDone: moveContextDone(movedPane.ctx),
104+ },
105+ client: captureMoveClientStateLocked(source, destination, client),
106+ }
107+
108+ movedPane.mu.Unlock()
109+ unlockTabs()
110+ unlockSessions()
111+ d.notices.routingMu.Unlock()
112+ d.mu.Unlock()
113+ return state
114+}
115+
116+func captureMoveSessionStateLocked(sess *session) moveSessionState {
117+ snatched := make(map[*attachedClient]struct{}, len(sess.snatched))
118+ maps.Copy(snatched, sess.snatched)
119+ return moveSessionState{
120+ ptr: sess,
121+ id: sess.id,
122+ name: sess.name,
123+ incarnation: sess.incarnation,
124+ tabs: append([]*tab(nil), sess.tabs...),
125+ active: sess.active,
126+ client: sess.client,
127+ snatched: snatched,
128+ moveReservations: sess.moveReservations,
129+ snapshotGeneration: sess.snapshotGeneration,
130+ snapshotPublished: sess.snapshotPublishedGeneration,
131+ snapshotDirty: sess.snapDirty.Load(),
132+ snapshotEligible: sess.snapEligible.Load(),
133+ ctxDone: moveContextDone(sess.ctx),
134+ }
135+}
136+
137+func captureMoveTabStateLocked(tb *tab) moveTabState {
138+ panes := make(map[layout.PaneID]*pane, len(tb.panes))
139+ maps.Copy(panes, tb.panes)
140+ return moveTabState{
141+ ptr: tb,
142+ stableID: tb.stableID,
143+ tree: tb.tree.Clone(),
144+ panes: panes,
145+ nextPaneID: tb.nextPaneID,
146+ layoutGeneration: tb.layoutGeneration,
147+ floatingState: tb.floating.state,
148+ floatingPane: tb.floating.pane,
149+ floatingVisible: tb.floating.desiredVisible,
150+ floatingGeneration: tb.floating.generation,
151+ ctxDone: moveContextDone(tb.ctx),
152+ }
153+}
154+
155+func captureMoveClientStateLocked(source, destination *session, client *attachedClient) moveClientState {
156+ return moveClientState{
157+ transport: client.transportSnapshot(),
158+ roleGeneration: client.roleGeneration.Load(),
159+ currentSession: client.currentSession(),
160+ sourceRole: source.attachmentRoleLocked(client),
161+ destinationRole: destination.attachmentRoleLocked(client),
162+ sourceLease: captureMoveLeaseState(source, client),
163+ destinationLease: captureMoveLeaseState(destination, client),
164+ roleEffectPhase: captureMoveRoleEffectPhase(client),
165+ }
166+}
167+
168+func captureMoveLeaseState(sess *session, client *attachedClient) moveLeaseState {
169+ coordinator := sess.renderCoordinator()
170+ if coordinator == nil {
171+ return moveLeaseState{}
172+ }
173+ coordinator.mu.Lock()
174+ defer coordinator.mu.Unlock()
175+ lease := coordinator.lease
176+ if lease == nil || lease.attachment != client {
177+ return moveLeaseState{lease: lease}
178+ }
179+ return moveLeaseState{lease: lease, attachment: lease.attachment, ready: lease.ready, active: lease.active}
180+}
181+
182+func captureMoveRoleEffectPhase(client *attachedClient) roleEffectPhase {
183+ client.roleEffects.mu.Lock()
184+ defer client.roleEffects.mu.Unlock()
185+ return client.roleEffects.phase
186+}
187+
188+func moveContextDone(ctx context.Context) bool {
189+ return ctx != nil && ctx.Err() != nil
190+}
191+
192+func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
193+ p1, release1 := newBlockingPTY(t)
194+ p2, release2 := newBlockingPTY(t)
195+ defer release1()
196+ defer release2()
197+
198+ d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1)
199+ sourceTab := source.tabs[0]
200+ sourceTab.stableID = "source-tab"
201+ movedPane := sourceTab.focusedPane()
202+
203+ destination := &session{
204+ id: "destination",
205+ name: "destination",
206+ ephemeral: true,
207+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
208+ active: 0,
209+ }
210+ destinationTab := destination.tabs[0]
211+ publishTiledPaneOwners(destination, destinationTab)
212+ d.mu.Lock()
213+ d.sessions[destination.id] = destination
214+ d.mu.Unlock()
215+ require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true))
216+
217+ before := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
218+ originalTransport := before.client.transport
219+ staleTransport := &closeTrackingTransport{}
220+ staleInjected := make(chan struct{})
221+ d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) {
222+ if action != "move-pane" || client != sourceClient {
223+ return
224+ }
225+ client.replaceTransport(staleTransport)
226+ close(staleInjected)
227+ }
228+
229+ moveDone := make(chan error, 1)
230+ go func() {
231+ moveDone <- d.movePane(movePaneRequest{
232+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
233+ SourceTabID: domain.TabStableID(sourceTab.stableID),
234+ SourcePaneID: domain.PaneStableID(movedPane.stableID),
235+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
236+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
237+ })
238+ }()
239+ <-staleInjected
240+ err := <-moveDone
241+
242+ // Restore the exact test-injected transport incarnation before comparing the
243+ // complete state. The move itself must not be able to alter this state.
244+ sourceClient.linkMu.Lock()
245+ sourceClient.tr = originalTransport.transport
246+ sourceClient.transportIncarnation = originalTransport.incarnation
247+ sourceClient.linkMu.Unlock()
248+ d.afterRoleEffectGateFrozen = nil
249+
250+ require.ErrorIs(t, err, errAttachmentTransition)
251+ after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
252+ require.Equal(t, before, after, "stale handoff validation must reject before any move publication")
253+}
@@ -0,0 +1,253 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "maps"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+type moveTabState struct {
15+ ptr *tab
16+ stableID string
17+ tree *layout.Tree
18+ panes map[layout.PaneID]*pane
19+ nextPaneID int
20+ layoutGeneration uint64
21+ floatingState floatingState
22+ floatingPane *pane
23+ floatingVisible bool
24+ floatingGeneration uint64
25+ ctxDone bool
26+}
27+
28+type moveSessionState struct {
29+ ptr *session
30+ id domain.SessionID
31+ name string
32+ incarnation domain.IncarnationID
33+ tabs []*tab
34+ active int
35+ client *attachedClient
36+ snatched map[*attachedClient]struct{}
37+ moveReservations uint
38+ snapshotGeneration uint64
39+ snapshotPublished uint64
40+ snapshotDirty bool
41+ snapshotEligible bool
42+ ctxDone bool
43+}
44+
45+type moveLeaseState struct {
46+ lease *attachmentLease
47+ attachment *attachedClient
48+ ready bool
49+ active bool
50+}
51+
52+type moveClientState struct {
53+ transport transportSnapshot
54+ roleGeneration uint64
55+ currentSession *session
56+ sourceRole attachmentRole
57+ destinationRole attachmentRole
58+ sourceLease moveLeaseState
59+ destinationLease moveLeaseState
60+ roleEffectPhase roleEffectPhase
61+}
62+
63+type movePaneState struct {
64+ id layout.PaneID
65+ stableID string
66+ owner *paneOwner
67+ ownerGeneration uint64
68+ ctxDone bool
69+}
70+
71+type moveState struct {
72+ closing bool
73+ registry map[domain.SessionID]*session
74+ source moveSessionState
75+ destination moveSessionState
76+ sourceTab moveTabState
77+ destinationTab moveTabState
78+ movedPane movePaneState
79+ client moveClientState
80+}
81+
82+func captureMoveState(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, client *attachedClient) moveState {
83+ d.mu.Lock()
84+ d.notices.routingMu.Lock()
85+ unlockSessions := lockAttachmentSessions(source, destination)
86+ unlockTabs := lockMoveTabs(sourceTab, destinationTab)
87+ movedPane.mu.Lock()
88+
89+ registry := make(map[domain.SessionID]*session, len(d.sessions))
90+ maps.Copy(registry, d.sessions)
91+ state := moveState{
92+ closing: d.closing,
93+ registry: registry,
94+ source: captureMoveSessionStateLocked(source),
95+ destination: captureMoveSessionStateLocked(destination),
96+ sourceTab: captureMoveTabStateLocked(sourceTab),
97+ destinationTab: captureMoveTabStateLocked(destinationTab),
98+ movedPane: movePaneState{
99+ id: movedPane.id,
100+ stableID: movedPane.stableID,
101+ owner: movedPane.ownerSnapshot(),
102+ ownerGeneration: movedPane.ownerGeneration,
103+ ctxDone: moveContextDone(movedPane.ctx),
104+ },
105+ client: captureMoveClientStateLocked(source, destination, client),
106+ }
107+
108+ movedPane.mu.Unlock()
109+ unlockTabs()
110+ unlockSessions()
111+ d.notices.routingMu.Unlock()
112+ d.mu.Unlock()
113+ return state
114+}
115+
116+func captureMoveSessionStateLocked(sess *session) moveSessionState {
117+ snatched := make(map[*attachedClient]struct{}, len(sess.snatched))
118+ maps.Copy(snatched, sess.snatched)
119+ return moveSessionState{
120+ ptr: sess,
121+ id: sess.id,
122+ name: sess.name,
123+ incarnation: sess.incarnation,
124+ tabs: append([]*tab(nil), sess.tabs...),
125+ active: sess.active,
126+ client: sess.client,
127+ snatched: snatched,
128+ moveReservations: sess.moveReservations,
129+ snapshotGeneration: sess.snapshotGeneration,
130+ snapshotPublished: sess.snapshotPublishedGeneration,
131+ snapshotDirty: sess.snapDirty.Load(),
132+ snapshotEligible: sess.snapEligible.Load(),
133+ ctxDone: moveContextDone(sess.ctx),
134+ }
135+}
136+
137+func captureMoveTabStateLocked(tb *tab) moveTabState {
138+ panes := make(map[layout.PaneID]*pane, len(tb.panes))
139+ maps.Copy(panes, tb.panes)
140+ return moveTabState{
141+ ptr: tb,
142+ stableID: tb.stableID,
143+ tree: tb.tree.Clone(),
144+ panes: panes,
145+ nextPaneID: tb.nextPaneID,
146+ layoutGeneration: tb.layoutGeneration,
147+ floatingState: tb.floating.state,
148+ floatingPane: tb.floating.pane,
149+ floatingVisible: tb.floating.desiredVisible,
150+ floatingGeneration: tb.floating.generation,
151+ ctxDone: moveContextDone(tb.ctx),
152+ }
153+}
154+
155+func captureMoveClientStateLocked(source, destination *session, client *attachedClient) moveClientState {
156+ return moveClientState{
157+ transport: client.transportSnapshot(),
158+ roleGeneration: client.roleGeneration.Load(),
159+ currentSession: client.currentSession(),
160+ sourceRole: source.attachmentRoleLocked(client),
161+ destinationRole: destination.attachmentRoleLocked(client),
162+ sourceLease: captureMoveLeaseState(source, client),
163+ destinationLease: captureMoveLeaseState(destination, client),
164+ roleEffectPhase: captureMoveRoleEffectPhase(client),
165+ }
166+}
167+
168+func captureMoveLeaseState(sess *session, client *attachedClient) moveLeaseState {
169+ coordinator := sess.renderCoordinator()
170+ if coordinator == nil {
171+ return moveLeaseState{}
172+ }
173+ coordinator.mu.Lock()
174+ defer coordinator.mu.Unlock()
175+ lease := coordinator.lease
176+ if lease == nil || lease.attachment != client {
177+ return moveLeaseState{lease: lease}
178+ }
179+ return moveLeaseState{lease: lease, attachment: lease.attachment, ready: lease.ready, active: lease.active}
180+}
181+
182+func captureMoveRoleEffectPhase(client *attachedClient) roleEffectPhase {
183+ client.roleEffects.mu.Lock()
184+ defer client.roleEffects.mu.Unlock()
185+ return client.roleEffects.phase
186+}
187+
188+func moveContextDone(ctx context.Context) bool {
189+ return ctx != nil && ctx.Err() != nil
190+}
191+
192+func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
193+ p1, release1 := newBlockingPTY(t)
194+ p2, release2 := newBlockingPTY(t)
195+ defer release1()
196+ defer release2()
197+
198+ d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1)
199+ sourceTab := source.tabs[0]
200+ sourceTab.stableID = "source-tab"
201+ movedPane := sourceTab.focusedPane()
202+
203+ destination := &session{
204+ id: "destination",
205+ name: "destination",
206+ ephemeral: true,
207+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
208+ active: 0,
209+ }
210+ destinationTab := destination.tabs[0]
211+ publishTiledPaneOwners(destination, destinationTab)
212+ d.mu.Lock()
213+ d.sessions[destination.id] = destination
214+ d.mu.Unlock()
215+ require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true))
216+
217+ before := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
218+ originalTransport := before.client.transport
219+ staleTransport := &closeTrackingTransport{}
220+ staleInjected := make(chan struct{})
221+ d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) {
222+ if action != "move-pane" || client != sourceClient {
223+ return
224+ }
225+ client.replaceTransport(staleTransport)
226+ close(staleInjected)
227+ }
228+
229+ moveDone := make(chan error, 1)
230+ go func() {
231+ moveDone <- d.movePane(movePaneRequest{
232+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
233+ SourceTabID: domain.TabStableID(sourceTab.stableID),
234+ SourcePaneID: domain.PaneStableID(movedPane.stableID),
235+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
236+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
237+ })
238+ }()
239+ <-staleInjected
240+ err := <-moveDone
241+
242+ // Restore the exact test-injected transport incarnation before comparing the
243+ // complete state. The move itself must not be able to alter this state.
244+ sourceClient.linkMu.Lock()
245+ sourceClient.tr = originalTransport.transport
246+ sourceClient.transportIncarnation = originalTransport.incarnation
247+ sourceClient.linkMu.Unlock()
248+ d.afterRoleEffectGateFrozen = nil
249+
250+ require.ErrorIs(t, err, errAttachmentTransition)
251+ after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
252+ require.Equal(t, before, after, "stale handoff validation must reject before any move publication")
253+}
A internal/usecase/daemon/move_pane_admission.go +71 -0
@@ -0,0 +1,71 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/usecase/layout"
5+)
6+
7+// movePaneAdmission is an immutable snapshot of the exact live objects and
8+// generations a move may later commit. Resize fences are acquired only after
9+// this snapshot; the locked commit revalidates every field before publication.
10+type movePaneAdmission struct {
11+ source *session
12+ destination *session
13+ sourceTab *tab
14+ destinationTab *tab
15+ movedPane *pane
16+ sourceClient *attachedClient
17+ destinationClient *attachedClient
18+ sourceSnatched int
19+ sourceGeneration uint64
20+ destinationGeneration uint64
21+ finalSourceTab bool
22+ sourceTabWasActive bool
23+}
24+
25+func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destination *session) (*movePaneAdmission, error) {
26+ d.mu.Lock()
27+ defer d.mu.Unlock()
28+ if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination {
29+ return nil, errMovePaneInvalid
30+ }
31+ unlockSessions := lockAttachmentSessions(source, destination)
32+ defer unlockSessions()
33+ if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
34+ return nil, errMovePaneInvalid
35+ }
36+
37+ sourceTab := findMoveTabLocked(source, req.SourceTabID)
38+ destinationTab := findMoveTabLocked(destination, req.DestinationTabID)
39+ if sourceTab == nil || destinationTab == nil || sourceTab == destinationTab {
40+ return nil, errMovePaneInvalid
41+ }
42+ unlockTabs := lockMoveTabs(sourceTab, destinationTab)
43+ defer unlockTabs()
44+
45+ var movedPane *pane
46+ for _, candidate := range sourceTab.panes {
47+ if candidate != nil && candidate.stableID == string(req.SourcePaneID) {
48+ movedPane = candidate
49+ break
50+ }
51+ }
52+ if movedPane == nil {
53+ return nil, errMovePaneInvalid
54+ }
55+
56+ return &movePaneAdmission{
57+ source: source,
58+ destination: destination,
59+ sourceTab: sourceTab,
60+ destinationTab: destinationTab,
61+ movedPane: movedPane,
62+ sourceClient: source.client,
63+ destinationClient: destination.client,
64+ sourceSnatched: len(source.snatched),
65+ sourceGeneration: sourceTab.layoutGeneration,
66+ destinationGeneration: destinationTab.layoutGeneration,
67+ finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&
68+ sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1,
69+ sourceTabWasActive: source.active >= 0 && source.active < len(source.tabs) && source.tabs[source.active] == sourceTab,
70+ }, nil
71+}
@@ -0,0 +1,71 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/usecase/layout"
5+)
6+
7+// movePaneAdmission is an immutable snapshot of the exact live objects and
8+// generations a move may later commit. Resize fences are acquired only after
9+// this snapshot; the locked commit revalidates every field before publication.
10+type movePaneAdmission struct {
11+ source *session
12+ destination *session
13+ sourceTab *tab
14+ destinationTab *tab
15+ movedPane *pane
16+ sourceClient *attachedClient
17+ destinationClient *attachedClient
18+ sourceSnatched int
19+ sourceGeneration uint64
20+ destinationGeneration uint64
21+ finalSourceTab bool
22+ sourceTabWasActive bool
23+}
24+
25+func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destination *session) (*movePaneAdmission, error) {
26+ d.mu.Lock()
27+ defer d.mu.Unlock()
28+ if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination {
29+ return nil, errMovePaneInvalid
30+ }
31+ unlockSessions := lockAttachmentSessions(source, destination)
32+ defer unlockSessions()
33+ if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
34+ return nil, errMovePaneInvalid
35+ }
36+
37+ sourceTab := findMoveTabLocked(source, req.SourceTabID)
38+ destinationTab := findMoveTabLocked(destination, req.DestinationTabID)
39+ if sourceTab == nil || destinationTab == nil || sourceTab == destinationTab {
40+ return nil, errMovePaneInvalid
41+ }
42+ unlockTabs := lockMoveTabs(sourceTab, destinationTab)
43+ defer unlockTabs()
44+
45+ var movedPane *pane
46+ for _, candidate := range sourceTab.panes {
47+ if candidate != nil && candidate.stableID == string(req.SourcePaneID) {
48+ movedPane = candidate
49+ break
50+ }
51+ }
52+ if movedPane == nil {
53+ return nil, errMovePaneInvalid
54+ }
55+
56+ return &movePaneAdmission{
57+ source: source,
58+ destination: destination,
59+ sourceTab: sourceTab,
60+ destinationTab: destinationTab,
61+ movedPane: movedPane,
62+ sourceClient: source.client,
63+ destinationClient: destination.client,
64+ sourceSnatched: len(source.snatched),
65+ sourceGeneration: sourceTab.layoutGeneration,
66+ destinationGeneration: destinationTab.layoutGeneration,
67+ finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&
68+ sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1,
69+ sourceTabWasActive: source.active >= 0 && source.active < len(source.tabs) && source.tabs[source.active] == sourceTab,
70+ }, nil
71+}
A internal/usecase/daemon/move_pane_candidate.go +108 -0
@@ -0,0 +1,108 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "math"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+var (
13+ errMovePaneFloatingSibling = errors.New("cannot remove a source tab with a floating pane")
14+ errMovePaneIDExhausted = errors.New("destination pane IDs exhausted")
15+ errMovePaneInvalid = errors.New("move pane request is no longer valid")
16+)
17+
18+// movePaneCandidate contains only unpublished topology and identity changes.
19+// The caller must revalidate the live tabs before committing these values.
20+type movePaneCandidate struct {
21+ pane *pane
22+
23+ sourceID layout.PaneID
24+ destinationID layout.PaneID
25+ removeSourceTab bool
26+
27+ sourceTree *layout.Tree
28+ sourcePlacements []layout.Placement
29+ destinationTree *layout.Tree
30+ destinationPlacements []layout.Placement
31+ destinationNextPaneID int
32+ sourceGeneration uint64
33+ destinationGeneration uint64
34+}
35+
36+// prepareMovePaneCandidate builds both affected layouts without publishing any
37+// tab or pane state. The caller is responsible for holding and later
38+// revalidating the source and destination tab state.
39+func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneCandidate, error) {
40+ if source == nil || destination == nil || source == destination || moved == nil ||
41+ source.tree == nil || source.tree.Root == nil || source.panes[moved.id] != moved ||
42+ !layout.ContainsLeaf(source.tree.Root, moved.id) || destination.tree == nil || destination.tree.Root == nil ||
43+ destination.panes[destination.tree.Focus] == nil || !layout.ContainsLeaf(destination.tree.Root, destination.tree.Focus) {
44+ return nil, layout.ErrNotFound
45+ }
46+
47+ candidate := &movePaneCandidate{
48+ pane: moved,
49+ sourceID: moved.id,
50+ sourceGeneration: source.layoutGeneration,
51+ destinationGeneration: destination.layoutGeneration,
52+ }
53+
54+ sourceLeaves := layout.LeafIDs(source.tree.Root)
55+ if len(sourceLeaves) == 1 {
56+ if source.floating.state != floatingUninitialized {
57+ return nil, errMovePaneFloatingSibling
58+ }
59+ candidate.removeSourceTab = true
60+ } else {
61+ candidate.sourceTree = source.tree.Clone()
62+ if err := candidate.sourceTree.Close(moved.id); err != nil {
63+ return nil, err
64+ }
65+ var ok bool
66+ candidate.sourcePlacements, ok = layout.Solve(candidate.sourceTree.Root, tabArea(source))
67+ if !ok || candidate.sourceTree.Focus == "" || !layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus) {
68+ return nil, layout.ErrTooSmall
69+ }
70+ }
71+
72+ var err error
73+ candidate.destinationID, candidate.destinationNextPaneID, err = allocateMovePaneID(destination, moved.id)
74+ if err != nil {
75+ return nil, err
76+ }
77+ candidate.destinationTree = destination.tree.Clone()
78+ area := tabArea(destination)
79+ if err := candidate.destinationTree.Split(destination.tree.Focus, layout.Right, true, candidate.destinationID, area); err != nil {
80+ return nil, err
81+ }
82+ var ok bool
83+ candidate.destinationPlacements, ok = layout.Solve(candidate.destinationTree.Root, area)
84+ if !ok {
85+ return nil, layout.ErrTooSmall
86+ }
87+ return candidate, nil
88+}
89+
90+func allocateMovePaneID(destination *tab, sourceID layout.PaneID) (layout.PaneID, int, error) {
91+ next := max(destination.nextPaneID, 1)
92+ for {
93+ id := layout.PaneID(fmt.Sprintf("pane-%d", next))
94+ _, mapped := destination.panes[id]
95+ available := id != sourceID && !mapped && !layout.ContainsLeaf(destination.tree.Root, id)
96+ if next == math.MaxInt {
97+ return "", 0, errMovePaneIDExhausted
98+ }
99+ if available {
100+ return id, next + 1, nil
101+ }
102+ next++
103+ }
104+}
105+
106+func tabArea(tb *tab) domain.Rect {
107+ return domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
108+}
@@ -0,0 +1,108 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "math"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+var (
13+ errMovePaneFloatingSibling = errors.New("cannot remove a source tab with a floating pane")
14+ errMovePaneIDExhausted = errors.New("destination pane IDs exhausted")
15+ errMovePaneInvalid = errors.New("move pane request is no longer valid")
16+)
17+
18+// movePaneCandidate contains only unpublished topology and identity changes.
19+// The caller must revalidate the live tabs before committing these values.
20+type movePaneCandidate struct {
21+ pane *pane
22+
23+ sourceID layout.PaneID
24+ destinationID layout.PaneID
25+ removeSourceTab bool
26+
27+ sourceTree *layout.Tree
28+ sourcePlacements []layout.Placement
29+ destinationTree *layout.Tree
30+ destinationPlacements []layout.Placement
31+ destinationNextPaneID int
32+ sourceGeneration uint64
33+ destinationGeneration uint64
34+}
35+
36+// prepareMovePaneCandidate builds both affected layouts without publishing any
37+// tab or pane state. The caller is responsible for holding and later
38+// revalidating the source and destination tab state.
39+func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneCandidate, error) {
40+ if source == nil || destination == nil || source == destination || moved == nil ||
41+ source.tree == nil || source.tree.Root == nil || source.panes[moved.id] != moved ||
42+ !layout.ContainsLeaf(source.tree.Root, moved.id) || destination.tree == nil || destination.tree.Root == nil ||
43+ destination.panes[destination.tree.Focus] == nil || !layout.ContainsLeaf(destination.tree.Root, destination.tree.Focus) {
44+ return nil, layout.ErrNotFound
45+ }
46+
47+ candidate := &movePaneCandidate{
48+ pane: moved,
49+ sourceID: moved.id,
50+ sourceGeneration: source.layoutGeneration,
51+ destinationGeneration: destination.layoutGeneration,
52+ }
53+
54+ sourceLeaves := layout.LeafIDs(source.tree.Root)
55+ if len(sourceLeaves) == 1 {
56+ if source.floating.state != floatingUninitialized {
57+ return nil, errMovePaneFloatingSibling
58+ }
59+ candidate.removeSourceTab = true
60+ } else {
61+ candidate.sourceTree = source.tree.Clone()
62+ if err := candidate.sourceTree.Close(moved.id); err != nil {
63+ return nil, err
64+ }
65+ var ok bool
66+ candidate.sourcePlacements, ok = layout.Solve(candidate.sourceTree.Root, tabArea(source))
67+ if !ok || candidate.sourceTree.Focus == "" || !layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus) {
68+ return nil, layout.ErrTooSmall
69+ }
70+ }
71+
72+ var err error
73+ candidate.destinationID, candidate.destinationNextPaneID, err = allocateMovePaneID(destination, moved.id)
74+ if err != nil {
75+ return nil, err
76+ }
77+ candidate.destinationTree = destination.tree.Clone()
78+ area := tabArea(destination)
79+ if err := candidate.destinationTree.Split(destination.tree.Focus, layout.Right, true, candidate.destinationID, area); err != nil {
80+ return nil, err
81+ }
82+ var ok bool
83+ candidate.destinationPlacements, ok = layout.Solve(candidate.destinationTree.Root, area)
84+ if !ok {
85+ return nil, layout.ErrTooSmall
86+ }
87+ return candidate, nil
88+}
89+
90+func allocateMovePaneID(destination *tab, sourceID layout.PaneID) (layout.PaneID, int, error) {
91+ next := max(destination.nextPaneID, 1)
92+ for {
93+ id := layout.PaneID(fmt.Sprintf("pane-%d", next))
94+ _, mapped := destination.panes[id]
95+ available := id != sourceID && !mapped && !layout.ContainsLeaf(destination.tree.Root, id)
96+ if next == math.MaxInt {
97+ return "", 0, errMovePaneIDExhausted
98+ }
99+ if available {
100+ return id, next + 1, nil
101+ }
102+ next++
103+ }
104+}
105+
106+func tabArea(tb *tab) domain.Rect {
107+ return domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
108+}
A internal/usecase/daemon/move_pane_commit.go +168 -0
@@ -0,0 +1,168 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+// movePaneCommit owns the single architecture-locked publication section. Its
9+// inputs are immutable admission/fence snapshots; outputs form the postcommit
10+// plan. publishLocked performs all fallible checks before its first write.
11+type movePaneCommit struct {
12+ req movePaneRequest
13+ source, destination *session
14+ sourceTab, destinationTab *tab
15+ movedPane *pane
16+ sourceClient *attachedClient
17+ sourceSnatchedCount int
18+ sourceGeneration uint64
19+ destinationGeneration uint64
20+ handoffFrozen bool
21+ sourceRolesFrozen bool
22+ handoffReq attachmentTransitionRequest
23+ handoffPublication *attachmentPublication
24+ err error
25+
26+ handoffResult attachmentTransitionResult
27+ syncCleanup syncTimerCleanup
28+ sourceCleanupToken attachmentRoleToken
29+ sourceEmpty bool
30+ sourceTabRemoved bool
31+ retiredParked []parkedAttachmentRetirement
32+ retiredAttachments []detachedAttachmentSnapshot
33+ sourceMetadata domain.CatalogueMetadataUpdate
34+ sourceMetadataValid bool
35+ destinationMetadata domain.CatalogueMetadataUpdate
36+ destinationMetadataValid bool
37+}
38+
39+func (c *movePaneCommit) releasePublication() {
40+ if c != nil && c.handoffPublication != nil {
41+ c.handoffPublication.unlockCoordinators()
42+ c.handoffPublication = nil
43+ }
44+}
45+
46+func (c *movePaneCommit) publishLocked(d *Daemon) bool {
47+ var candidate *movePaneCandidate
48+ var err error
49+ d.mu.Lock()
50+ if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {
51+ d.mu.Unlock()
52+ return false
53+ }
54+ d.notices.routingMu.Lock()
55+ unlockSessions := lockAttachmentSessions(c.source, c.destination)
56+ defer unlockSessions()
57+ defer d.notices.routingMu.Unlock()
58+ defer d.mu.Unlock()
59+
60+ if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
61+ return false
62+ }
63+ if c.handoffFrozen {
64+ if c.source.client != c.sourceClient || len(c.source.snatched) != c.sourceSnatchedCount ||
65+ c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) ||
66+ c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab {
67+ return false
68+ }
69+ c.handoffPublication, err = d.validateAttachmentTransitionPrelocked(c.handoffReq)
70+ if err != nil {
71+ c.err = err
72+ return false
73+ }
74+ defer c.releasePublication()
75+ }
76+
77+ unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab)
78+ c.movedPane.mu.Lock()
79+ defer c.movedPane.mu.Unlock()
80+ defer unlockTabs()
81+
82+ if !moveTabMemberLocked(c.source, c.sourceTab) || !moveTabMemberLocked(c.destination, c.destinationTab) || c.sourceTab == c.destinationTab ||
83+ c.sourceTab.layoutGeneration != c.sourceGeneration || c.destinationTab.layoutGeneration != c.destinationGeneration ||
84+ c.movedPane.stableID != string(c.req.SourcePaneID) || c.sourceTab.panes[c.movedPane.id] != c.movedPane ||
85+ c.sourceTab.tree == nil || c.sourceTab.tree.Root == nil || !layout.ContainsLeaf(c.sourceTab.tree.Root, c.movedPane.id) ||
86+ c.destinationTab.tree == nil || c.destinationTab.tree.Root == nil {
87+ return false
88+ }
89+ owner := c.movedPane.ownerSnapshot()
90+ if owner != nil && (owner.session != c.source || owner.tab != c.sourceTab) {
91+ return false
92+ }
93+ candidate, err = prepareMovePaneCandidate(c.sourceTab, c.destinationTab, c.movedPane)
94+ if err != nil {
95+ c.err = err
96+ return false
97+ }
98+ if candidate.sourceGeneration != c.sourceGeneration || candidate.destinationGeneration != c.destinationGeneration {
99+ return false
100+ }
101+
102+ sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1
103+ needsHandoff := sourceWillEmpty && c.source != c.destination && c.sourceClient != nil
104+ if needsHandoff != c.handoffFrozen || sourceWillEmpty && c.source != c.destination && c.source.client != nil && !needsHandoff {
105+ return false
106+ }
107+ if c.sourceRolesFrozen && len(c.source.snatched) != c.sourceSnatchedCount {
108+ return false
109+ }
110+
111+ // Everything above is fallible. Topology, owner, lifecycle registry, and
112+ // optional attachment roles now become visible under one lock section.
113+ if candidate.removeSourceTab {
114+ idx := indexMoveTabLocked(c.source, c.sourceTab)
115+ if idx < 0 {
116+ return false
117+ }
118+ c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
119+ c.sourceTabRemoved = true
120+ if len(c.source.tabs) == 0 {
121+ c.source.active = -1
122+ } else if c.source.active > idx {
123+ c.source.active--
124+ } else if c.source.active >= len(c.source.tabs) {
125+ c.source.active = len(c.source.tabs) - 1
126+ }
127+ } else {
128+ c.sourceTab.tree = candidate.sourceTree
129+ c.sourceTab.bumpLayoutGenerationLocked()
130+ }
131+ delete(c.sourceTab.panes, candidate.sourceID)
132+ if d.beforeMovePaneCommit != nil {
133+ d.beforeMovePaneCommit()
134+ }
135+ c.destinationTab.tree = candidate.destinationTree
136+ c.destinationTab.nextPaneID = candidate.destinationNextPaneID
137+ c.destinationTab.panes[candidate.destinationID] = c.movedPane
138+ c.destinationTab.bumpLayoutGenerationLocked()
139+ c.movedPane.id = candidate.destinationID
140+ oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0)
141+ if c.handoffPublication != nil {
142+ c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication)
143+ c.handoffPublication.unlockCoordinators()
144+ c.handoffPublication = nil
145+ }
146+ c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner))
147+ if c.handoffResult.published.ac != nil {
148+ c.sourceCleanupToken = c.handoffResult.published
149+ } else if c.source.client != nil {
150+ c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)
151+ }
152+
153+ if candidate.removeSourceTab && len(c.source.tabs) == 0 {
154+ delete(d.sessions, c.source.id)
155+ c.retiredAttachments = retireEmptyMoveSessionLocked(c.source)
156+ c.retiredParked = d.purgeParkedForSessionLocked(c.source)
157+ }
158+ c.sourceEmpty = len(c.source.tabs) == 0
159+ if !c.source.ephemeral {
160+ c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate()
161+ c.sourceMetadataValid = true
162+ }
163+ if !c.destination.ephemeral && c.destination != c.source {
164+ c.destinationMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate()
165+ c.destinationMetadataValid = true
166+ }
167+ return true
168+}
@@ -0,0 +1,168 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+// movePaneCommit owns the single architecture-locked publication section. Its
9+// inputs are immutable admission/fence snapshots; outputs form the postcommit
10+// plan. publishLocked performs all fallible checks before its first write.
11+type movePaneCommit struct {
12+ req movePaneRequest
13+ source, destination *session
14+ sourceTab, destinationTab *tab
15+ movedPane *pane
16+ sourceClient *attachedClient
17+ sourceSnatchedCount int
18+ sourceGeneration uint64
19+ destinationGeneration uint64
20+ handoffFrozen bool
21+ sourceRolesFrozen bool
22+ handoffReq attachmentTransitionRequest
23+ handoffPublication *attachmentPublication
24+ err error
25+
26+ handoffResult attachmentTransitionResult
27+ syncCleanup syncTimerCleanup
28+ sourceCleanupToken attachmentRoleToken
29+ sourceEmpty bool
30+ sourceTabRemoved bool
31+ retiredParked []parkedAttachmentRetirement
32+ retiredAttachments []detachedAttachmentSnapshot
33+ sourceMetadata domain.CatalogueMetadataUpdate
34+ sourceMetadataValid bool
35+ destinationMetadata domain.CatalogueMetadataUpdate
36+ destinationMetadataValid bool
37+}
38+
39+func (c *movePaneCommit) releasePublication() {
40+ if c != nil && c.handoffPublication != nil {
41+ c.handoffPublication.unlockCoordinators()
42+ c.handoffPublication = nil
43+ }
44+}
45+
46+func (c *movePaneCommit) publishLocked(d *Daemon) bool {
47+ var candidate *movePaneCandidate
48+ var err error
49+ d.mu.Lock()
50+ if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {
51+ d.mu.Unlock()
52+ return false
53+ }
54+ d.notices.routingMu.Lock()
55+ unlockSessions := lockAttachmentSessions(c.source, c.destination)
56+ defer unlockSessions()
57+ defer d.notices.routingMu.Unlock()
58+ defer d.mu.Unlock()
59+
60+ if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
61+ return false
62+ }
63+ if c.handoffFrozen {
64+ if c.source.client != c.sourceClient || len(c.source.snatched) != c.sourceSnatchedCount ||
65+ c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) ||
66+ c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab {
67+ return false
68+ }
69+ c.handoffPublication, err = d.validateAttachmentTransitionPrelocked(c.handoffReq)
70+ if err != nil {
71+ c.err = err
72+ return false
73+ }
74+ defer c.releasePublication()
75+ }
76+
77+ unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab)
78+ c.movedPane.mu.Lock()
79+ defer c.movedPane.mu.Unlock()
80+ defer unlockTabs()
81+
82+ if !moveTabMemberLocked(c.source, c.sourceTab) || !moveTabMemberLocked(c.destination, c.destinationTab) || c.sourceTab == c.destinationTab ||
83+ c.sourceTab.layoutGeneration != c.sourceGeneration || c.destinationTab.layoutGeneration != c.destinationGeneration ||
84+ c.movedPane.stableID != string(c.req.SourcePaneID) || c.sourceTab.panes[c.movedPane.id] != c.movedPane ||
85+ c.sourceTab.tree == nil || c.sourceTab.tree.Root == nil || !layout.ContainsLeaf(c.sourceTab.tree.Root, c.movedPane.id) ||
86+ c.destinationTab.tree == nil || c.destinationTab.tree.Root == nil {
87+ return false
88+ }
89+ owner := c.movedPane.ownerSnapshot()
90+ if owner != nil && (owner.session != c.source || owner.tab != c.sourceTab) {
91+ return false
92+ }
93+ candidate, err = prepareMovePaneCandidate(c.sourceTab, c.destinationTab, c.movedPane)
94+ if err != nil {
95+ c.err = err
96+ return false
97+ }
98+ if candidate.sourceGeneration != c.sourceGeneration || candidate.destinationGeneration != c.destinationGeneration {
99+ return false
100+ }
101+
102+ sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1
103+ needsHandoff := sourceWillEmpty && c.source != c.destination && c.sourceClient != nil
104+ if needsHandoff != c.handoffFrozen || sourceWillEmpty && c.source != c.destination && c.source.client != nil && !needsHandoff {
105+ return false
106+ }
107+ if c.sourceRolesFrozen && len(c.source.snatched) != c.sourceSnatchedCount {
108+ return false
109+ }
110+
111+ // Everything above is fallible. Topology, owner, lifecycle registry, and
112+ // optional attachment roles now become visible under one lock section.
113+ if candidate.removeSourceTab {
114+ idx := indexMoveTabLocked(c.source, c.sourceTab)
115+ if idx < 0 {
116+ return false
117+ }
118+ c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
119+ c.sourceTabRemoved = true
120+ if len(c.source.tabs) == 0 {
121+ c.source.active = -1
122+ } else if c.source.active > idx {
123+ c.source.active--
124+ } else if c.source.active >= len(c.source.tabs) {
125+ c.source.active = len(c.source.tabs) - 1
126+ }
127+ } else {
128+ c.sourceTab.tree = candidate.sourceTree
129+ c.sourceTab.bumpLayoutGenerationLocked()
130+ }
131+ delete(c.sourceTab.panes, candidate.sourceID)
132+ if d.beforeMovePaneCommit != nil {
133+ d.beforeMovePaneCommit()
134+ }
135+ c.destinationTab.tree = candidate.destinationTree
136+ c.destinationTab.nextPaneID = candidate.destinationNextPaneID
137+ c.destinationTab.panes[candidate.destinationID] = c.movedPane
138+ c.destinationTab.bumpLayoutGenerationLocked()
139+ c.movedPane.id = candidate.destinationID
140+ oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0)
141+ if c.handoffPublication != nil {
142+ c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication)
143+ c.handoffPublication.unlockCoordinators()
144+ c.handoffPublication = nil
145+ }
146+ c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner))
147+ if c.handoffResult.published.ac != nil {
148+ c.sourceCleanupToken = c.handoffResult.published
149+ } else if c.source.client != nil {
150+ c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)
151+ }
152+
153+ if candidate.removeSourceTab && len(c.source.tabs) == 0 {
154+ delete(d.sessions, c.source.id)
155+ c.retiredAttachments = retireEmptyMoveSessionLocked(c.source)
156+ c.retiredParked = d.purgeParkedForSessionLocked(c.source)
157+ }
158+ c.sourceEmpty = len(c.source.tabs) == 0
159+ if !c.source.ephemeral {
160+ c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate()
161+ c.sourceMetadataValid = true
162+ }
163+ if !c.destination.ephemeral && c.destination != c.source {
164+ c.destinationMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate()
165+ c.destinationMetadataValid = true
166+ }
167+ return true
168+}
A internal/usecase/daemon/move_pane_postcommit.go +196 -0
@@ -0,0 +1,196 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "os"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+// movePanePostcommitPlan is captured while the move's architecture and owner
12+// fences still protect publication. It contains only immutable pointers,
13+// generations, and exact attachment tokens needed by fallible follow-up work.
14+type movePanePostcommitPlan struct {
15+ source *session
16+ destination *session
17+ sourceTab *tab
18+ destinationTab *tab
19+ movedPane *pane
20+ destinationClient *attachedClient
21+ sourceCleanupToken attachmentRoleToken
22+ handoffResult attachmentTransitionResult
23+ syncCleanup syncTimerCleanup
24+ frozenRoles frozenRoleEffectGates
25+ rolesFrozen bool
26+ unlockDispatch func()
27+ reservation *moveLifecycleReservation
28+
29+ sourceTabWasActive bool
30+ sourceTabRemoved bool
31+ sourceEmpty bool
32+
33+ retiredParked []parkedAttachmentRetirement
34+ retiredAttachments []detachedAttachmentSnapshot
35+ sourceMetadata domain.CatalogueMetadataUpdate
36+ sourceMetadataValid bool
37+ destinationMetadata domain.CatalogueMetadataUpdate
38+ destinationMetadataValid bool
39+}
40+
41+// execute runs only after every resize-owner fence and architecture lock is
42+// released. Generation-bound tokens make stale effects drop rather than route
43+// back to the retired source.
44+func (p movePanePostcommitPlan) execute(d *Daemon) {
45+ if p.sourceTabRemoved && p.sourceTab.cancel != nil {
46+ p.sourceTab.cancel()
47+ }
48+ if p.sourceCleanupToken.current() {
49+ cleanupClient := p.sourceCleanupToken.ac
50+ if cleanupClient.overlays != nil {
51+ cleanupClient.overlays.clearCopyModeForPane(p.movedPane)
52+ }
53+ cleanupClient.pruneCaptureFrames(p.movedPane)
54+ }
55+
56+ // Snapshot dirtiness is admitted while lifecycle reservations still pin
57+ // both sessions. Repository publication remains outside every lock below.
58+ markSnapshotDirty(p.destination)
59+ if !p.sourceEmpty {
60+ markSnapshotDirty(p.source)
61+ }
62+ p.syncCleanup.finish()
63+ if p.rolesFrozen {
64+ p.frozenRoles.unfreeze()
65+ }
66+ p.unlockDispatch()
67+ p.reservation.Release()
68+
69+ if p.sourceTabWasActive && !moveSessionStillEmpty(d, p.source) {
70+ d.activateTab(p.source, p.source.activeTab())
71+ }
72+ if !moveSessionStillEmpty(d, p.source) {
73+ sourceLayoutTab := p.sourceTab
74+ if p.sourceTabRemoved {
75+ sourceLayoutTab = p.source.activeTab()
76+ }
77+ if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab {
78+ d.applyTabLayout(p.source, sourceLayoutTab)
79+ }
80+ }
81+ d.applyTabLayout(p.destination, p.destinationTab)
82+ if p.sourceMetadataValid && !p.sourceEmpty {
83+ d.markCatalogueDirty(p.sourceMetadata)
84+ }
85+ if p.destinationMetadataValid {
86+ d.markCatalogueDirty(p.destinationMetadata)
87+ }
88+ if p.destinationClient != nil && sessionClientIs(p.destination, p.destinationClient) && destinationTabIsActive(p.destination, p.destinationTab) {
89+ d.invalidateRender(p.destination, p.destinationClient, true, "move.go")
90+ }
91+ if p.sourceCleanupToken.ac != nil && !p.sourceEmpty && p.sourceTabWasActive && sessionClientIs(p.source, p.sourceCleanupToken.ac) {
92+ d.invalidateRender(p.source, p.sourceCleanupToken.ac, true, "move.go")
93+ }
94+ if p.handoffResult.published.ac != nil {
95+ follower := p.handoffResult.published.ac
96+ d.applyHostTheme(p.destination, follower, follower.getClientTheme(), false)
97+ follower.recordPreviousSession(p.source)
98+ d.deferAttachmentTransitionCleanups(p.handoffResult)
99+ d.firstPaintForTransition(p.handoffResult.published)
100+ }
101+ if p.sourceEmpty {
102+ retireEmptySessionAfterMove(d, p.source)
103+ }
104+ for _, attachment := range p.retiredAttachments {
105+ d.unregisterPreview(attachment.ac)
106+ attachment.ac.clearPreviousSession()
107+ attachment.ac.clearCaptureFrames()
108+ d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
109+ }
110+ for _, retirement := range p.retiredParked {
111+ if retirement.parked == nil || retirement.parked.ac == nil {
112+ continue
113+ }
114+ d.unregisterPreview(retirement.parked.ac)
115+ retirement.parked.ac.clearCaptureFrames()
116+ }
117+ finishParkedAttachmentRetirements(p.retiredParked)
118+ if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled {
119+ if err := d.beginSnapshotPurge(p.source.name, p.source.incarnation); err == nil {
120+ if err := d.finishSnapshotPurge(d.serveCtx, p.source.name, p.source.incarnation); err != nil {
121+ d.log.Warn("moving final pane source purge failed", "err", err, "session", p.source.name)
122+ }
123+ }
124+ }
125+}
126+
127+// retireEmptyMoveSessionLocked removes all source-owned attachment roles that
128+// were not transferred. It runs inside the move commit, after the moved pane
129+// has already acquired its destination owner, and performs no external work.
130+func retireEmptyMoveSessionLocked(sess *session) []detachedAttachmentSnapshot {
131+ if sess == nil || sess.client != nil {
132+ return nil
133+ }
134+ retired := make([]detachedAttachmentSnapshot, 0, len(sess.snatched))
135+ for ac := range sess.snatched {
136+ retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()})
137+ ac.roleGeneration.Add(1)
138+ ac.setSession(nil)
139+ ac.invalidateFrozenRoleCapability()
140+ }
141+ clear(sess.snatched)
142+ sess.tabs = nil
143+ sess.active = -1
144+ return retired
145+}
146+
147+func moveSessionStillEmpty(d *Daemon, sess *session) bool {
148+ if d == nil || sess == nil {
149+ return true
150+ }
151+ d.mu.Lock()
152+ _, live := d.sessions[sess.id]
153+ d.mu.Unlock()
154+ return !live
155+}
156+
157+func destinationTabIsActive(sess *session, target *tab) bool {
158+ if sess == nil || target == nil {
159+ return false
160+ }
161+ sess.mu.Lock()
162+ defer sess.mu.Unlock()
163+ return sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == target
164+}
165+
166+func sessionClientIs(sess *session, client *attachedClient) bool {
167+ if sess == nil || client == nil {
168+ return false
169+ }
170+ sess.mu.Lock()
171+ defer sess.mu.Unlock()
172+ return sess.client == client
173+}
174+
175+// retireEmptySessionAfterMove completes only source-owned lifecycle work. The
176+// moved pane has already been removed from source.tabs, so stopInMemoryLifecycle
177+// cannot cancel or close the transferred PTY.
178+func retireEmptySessionAfterMove(d *Daemon, sess *session) {
179+ if d == nil || sess == nil {
180+ return
181+ }
182+ sess.stopInMemoryLifecycle()
183+ if coordinator := sess.renderCoordinator(); coordinator != nil {
184+ coordinator.beginSessionTeardown().finish()
185+ coordinator.waitForTimerWorkers()
186+ }
187+ sess.mu.Lock()
188+ clipFiles := append([]string(nil), sess.clipFiles...)
189+ sess.clipFiles = nil
190+ sess.mu.Unlock()
191+ for _, path := range clipFiles {
192+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
193+ d.log.Warn("removing clipboard temp file after move failed", "err", err, "path", path)
194+ }
195+ }
196+}
@@ -0,0 +1,196 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "os"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+// movePanePostcommitPlan is captured while the move's architecture and owner
12+// fences still protect publication. It contains only immutable pointers,
13+// generations, and exact attachment tokens needed by fallible follow-up work.
14+type movePanePostcommitPlan struct {
15+ source *session
16+ destination *session
17+ sourceTab *tab
18+ destinationTab *tab
19+ movedPane *pane
20+ destinationClient *attachedClient
21+ sourceCleanupToken attachmentRoleToken
22+ handoffResult attachmentTransitionResult
23+ syncCleanup syncTimerCleanup
24+ frozenRoles frozenRoleEffectGates
25+ rolesFrozen bool
26+ unlockDispatch func()
27+ reservation *moveLifecycleReservation
28+
29+ sourceTabWasActive bool
30+ sourceTabRemoved bool
31+ sourceEmpty bool
32+
33+ retiredParked []parkedAttachmentRetirement
34+ retiredAttachments []detachedAttachmentSnapshot
35+ sourceMetadata domain.CatalogueMetadataUpdate
36+ sourceMetadataValid bool
37+ destinationMetadata domain.CatalogueMetadataUpdate
38+ destinationMetadataValid bool
39+}
40+
41+// execute runs only after every resize-owner fence and architecture lock is
42+// released. Generation-bound tokens make stale effects drop rather than route
43+// back to the retired source.
44+func (p movePanePostcommitPlan) execute(d *Daemon) {
45+ if p.sourceTabRemoved && p.sourceTab.cancel != nil {
46+ p.sourceTab.cancel()
47+ }
48+ if p.sourceCleanupToken.current() {
49+ cleanupClient := p.sourceCleanupToken.ac
50+ if cleanupClient.overlays != nil {
51+ cleanupClient.overlays.clearCopyModeForPane(p.movedPane)
52+ }
53+ cleanupClient.pruneCaptureFrames(p.movedPane)
54+ }
55+
56+ // Snapshot dirtiness is admitted while lifecycle reservations still pin
57+ // both sessions. Repository publication remains outside every lock below.
58+ markSnapshotDirty(p.destination)
59+ if !p.sourceEmpty {
60+ markSnapshotDirty(p.source)
61+ }
62+ p.syncCleanup.finish()
63+ if p.rolesFrozen {
64+ p.frozenRoles.unfreeze()
65+ }
66+ p.unlockDispatch()
67+ p.reservation.Release()
68+
69+ if p.sourceTabWasActive && !moveSessionStillEmpty(d, p.source) {
70+ d.activateTab(p.source, p.source.activeTab())
71+ }
72+ if !moveSessionStillEmpty(d, p.source) {
73+ sourceLayoutTab := p.sourceTab
74+ if p.sourceTabRemoved {
75+ sourceLayoutTab = p.source.activeTab()
76+ }
77+ if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab {
78+ d.applyTabLayout(p.source, sourceLayoutTab)
79+ }
80+ }
81+ d.applyTabLayout(p.destination, p.destinationTab)
82+ if p.sourceMetadataValid && !p.sourceEmpty {
83+ d.markCatalogueDirty(p.sourceMetadata)
84+ }
85+ if p.destinationMetadataValid {
86+ d.markCatalogueDirty(p.destinationMetadata)
87+ }
88+ if p.destinationClient != nil && sessionClientIs(p.destination, p.destinationClient) && destinationTabIsActive(p.destination, p.destinationTab) {
89+ d.invalidateRender(p.destination, p.destinationClient, true, "move.go")
90+ }
91+ if p.sourceCleanupToken.ac != nil && !p.sourceEmpty && p.sourceTabWasActive && sessionClientIs(p.source, p.sourceCleanupToken.ac) {
92+ d.invalidateRender(p.source, p.sourceCleanupToken.ac, true, "move.go")
93+ }
94+ if p.handoffResult.published.ac != nil {
95+ follower := p.handoffResult.published.ac
96+ d.applyHostTheme(p.destination, follower, follower.getClientTheme(), false)
97+ follower.recordPreviousSession(p.source)
98+ d.deferAttachmentTransitionCleanups(p.handoffResult)
99+ d.firstPaintForTransition(p.handoffResult.published)
100+ }
101+ if p.sourceEmpty {
102+ retireEmptySessionAfterMove(d, p.source)
103+ }
104+ for _, attachment := range p.retiredAttachments {
105+ d.unregisterPreview(attachment.ac)
106+ attachment.ac.clearPreviousSession()
107+ attachment.ac.clearCaptureFrames()
108+ d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
109+ }
110+ for _, retirement := range p.retiredParked {
111+ if retirement.parked == nil || retirement.parked.ac == nil {
112+ continue
113+ }
114+ d.unregisterPreview(retirement.parked.ac)
115+ retirement.parked.ac.clearCaptureFrames()
116+ }
117+ finishParkedAttachmentRetirements(p.retiredParked)
118+ if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled {
119+ if err := d.beginSnapshotPurge(p.source.name, p.source.incarnation); err == nil {
120+ if err := d.finishSnapshotPurge(d.serveCtx, p.source.name, p.source.incarnation); err != nil {
121+ d.log.Warn("moving final pane source purge failed", "err", err, "session", p.source.name)
122+ }
123+ }
124+ }
125+}
126+
127+// retireEmptyMoveSessionLocked removes all source-owned attachment roles that
128+// were not transferred. It runs inside the move commit, after the moved pane
129+// has already acquired its destination owner, and performs no external work.
130+func retireEmptyMoveSessionLocked(sess *session) []detachedAttachmentSnapshot {
131+ if sess == nil || sess.client != nil {
132+ return nil
133+ }
134+ retired := make([]detachedAttachmentSnapshot, 0, len(sess.snatched))
135+ for ac := range sess.snatched {
136+ retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()})
137+ ac.roleGeneration.Add(1)
138+ ac.setSession(nil)
139+ ac.invalidateFrozenRoleCapability()
140+ }
141+ clear(sess.snatched)
142+ sess.tabs = nil
143+ sess.active = -1
144+ return retired
145+}
146+
147+func moveSessionStillEmpty(d *Daemon, sess *session) bool {
148+ if d == nil || sess == nil {
149+ return true
150+ }
151+ d.mu.Lock()
152+ _, live := d.sessions[sess.id]
153+ d.mu.Unlock()
154+ return !live
155+}
156+
157+func destinationTabIsActive(sess *session, target *tab) bool {
158+ if sess == nil || target == nil {
159+ return false
160+ }
161+ sess.mu.Lock()
162+ defer sess.mu.Unlock()
163+ return sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == target
164+}
165+
166+func sessionClientIs(sess *session, client *attachedClient) bool {
167+ if sess == nil || client == nil {
168+ return false
169+ }
170+ sess.mu.Lock()
171+ defer sess.mu.Unlock()
172+ return sess.client == client
173+}
174+
175+// retireEmptySessionAfterMove completes only source-owned lifecycle work. The
176+// moved pane has already been removed from source.tabs, so stopInMemoryLifecycle
177+// cannot cancel or close the transferred PTY.
178+func retireEmptySessionAfterMove(d *Daemon, sess *session) {
179+ if d == nil || sess == nil {
180+ return
181+ }
182+ sess.stopInMemoryLifecycle()
183+ if coordinator := sess.renderCoordinator(); coordinator != nil {
184+ coordinator.beginSessionTeardown().finish()
185+ coordinator.waitForTimerWorkers()
186+ }
187+ sess.mu.Lock()
188+ clipFiles := append([]string(nil), sess.clipFiles...)
189+ sess.clipFiles = nil
190+ sess.mu.Unlock()
191+ for _, path := range clipFiles {
192+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
193+ d.log.Warn("removing clipboard temp file after move failed", "err", err, "path", path)
194+ }
195+ }
196+}
A internal/usecase/daemon/move_stale_destination_test.go +48 -0
@@ -0,0 +1,48 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T) {
12+ movedPTY, releaseMoved := newBlockingPTY(t)
13+ destinationPTY, releaseDestination := newBlockingPTY(t)
14+ defer releaseMoved()
15+ defer releaseDestination()
16+
17+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
18+ sourceTab := source.tabs[0]
19+ sourceTab.stableID = "source-tab"
20+ moved := sourceTab.focusedPane()
21+ destination := &session{
22+ id: "destination", name: "destination", ephemeral: true,
23+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
24+ }
25+ destinationTab := destination.tabs[0]
26+ publishTiledPaneOwners(destination, destinationTab)
27+ d.mu.Lock()
28+ d.sessions[destination.id] = destination
29+ d.mu.Unlock()
30+
31+ beforeSource := sourceTab.tree.Clone()
32+ beforeDestination := destinationTab.tree.Clone()
33+ beforeOwner := moved.ownerSnapshot()
34+ staleIncarnation := destination.incarnation
35+ staleIncarnation[0]++
36+ err := d.movePane(movePaneRequest{
37+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
38+ SourceTabID: domain.TabStableID(sourceTab.stableID),
39+ SourcePaneID: domain.PaneStableID(moved.stableID),
40+ Destination: moveSessionLocator{ID: destination.id, Incarnation: staleIncarnation},
41+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
42+ })
43+ require.ErrorIs(t, err, errMovePaneInvalid)
44+ require.Equal(t, beforeSource, sourceTab.tree)
45+ require.Equal(t, beforeDestination, destinationTab.tree)
46+ require.Same(t, beforeOwner, moved.ownerSnapshot())
47+ require.Same(t, moved, sourceTab.panes[moved.id])
48+}
@@ -0,0 +1,48 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T) {
12+ movedPTY, releaseMoved := newBlockingPTY(t)
13+ destinationPTY, releaseDestination := newBlockingPTY(t)
14+ defer releaseMoved()
15+ defer releaseDestination()
16+
17+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
18+ sourceTab := source.tabs[0]
19+ sourceTab.stableID = "source-tab"
20+ moved := sourceTab.focusedPane()
21+ destination := &session{
22+ id: "destination", name: "destination", ephemeral: true,
23+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
24+ }
25+ destinationTab := destination.tabs[0]
26+ publishTiledPaneOwners(destination, destinationTab)
27+ d.mu.Lock()
28+ d.sessions[destination.id] = destination
29+ d.mu.Unlock()
30+
31+ beforeSource := sourceTab.tree.Clone()
32+ beforeDestination := destinationTab.tree.Clone()
33+ beforeOwner := moved.ownerSnapshot()
34+ staleIncarnation := destination.incarnation
35+ staleIncarnation[0]++
36+ err := d.movePane(movePaneRequest{
37+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
38+ SourceTabID: domain.TabStableID(sourceTab.stableID),
39+ SourcePaneID: domain.PaneStableID(moved.stableID),
40+ Destination: moveSessionLocator{ID: destination.id, Incarnation: staleIncarnation},
41+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
42+ })
43+ require.ErrorIs(t, err, errMovePaneInvalid)
44+ require.Equal(t, beforeSource, sourceTab.tree)
45+ require.Equal(t, beforeDestination, destinationTab.tree)
46+ require.Same(t, beforeOwner, moved.ownerSnapshot())
47+ require.Same(t, moved, sourceTab.panes[moved.id])
48+}
A internal/usecase/daemon/move_test.go +391 -0
@@ -0,0 +1,391 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+func TestMovePaneMutationSameSession(t *testing.T) {
15+ p1, release1 := newBlockingPTY(t)
16+ p2, release2 := newBlockingPTY(t)
17+ defer release1()
18+ defer release2()
19+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
20+ sourceTab, destinationTab := sess.tabs[0], sess.tabs[1]
21+ sourceTab.stableID = "source-tab"
22+ destinationTab.stableID = "destination-tab"
23+ moved := sourceTab.focusedPane()
24+ movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
25+ moved.ctx, moved.cancel = movedCtx, movedCancel
26+
27+ err := d.movePane(movePaneRequest{
28+ Source: moveSessionLocator{ID: sess.id},
29+ SourceTabID: domain.TabStableID(sourceTab.stableID),
30+ SourcePaneID: domain.PaneStableID(moved.stableID),
31+ Destination: moveSessionLocator{ID: sess.id},
32+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
33+ })
34+ require.NoError(t, err)
35+ require.Same(t, moved, destinationTab.panes[moved.id])
36+ require.Equal(t, layout.PaneID("pane-2"), moved.id)
37+ require.Equal(t, layout.PaneID("pane-2"), destinationTab.tree.Focus)
38+ require.NotContains(t, sourceTab.panes, layout.PaneID("pane-1"))
39+ require.Len(t, sess.tabs, 1)
40+ require.Same(t, destinationTab, sess.tabs[0])
41+ require.NoError(t, moved.ctx.Err(), "a same-session move must not retire the moved pane process")
42+}
43+
44+func TestMovePaneMutationCrossSession(t *testing.T) {
45+ p1, release1 := newBlockingPTY(t)
46+ p2, release2 := newBlockingPTY(t)
47+ defer release1()
48+ defer release2()
49+ d, source, _, _ := newManualSessionWithPTYs(t, p1)
50+ source.mu.Lock()
51+ source.client = nil
52+ source.mu.Unlock()
53+ sourceTab := source.tabs[0]
54+ sourceTab.stableID = "source-tab"
55+ moved := sourceTab.focusedPane()
56+ movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
57+ moved.ctx, moved.cancel = movedCtx, movedCancel
58+
59+ destination := &session{id: "destination", name: "destination", ephemeral: true, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
60+ destinationTab := destination.tabs[0]
61+ destinationTab.stableID = "destination-tab"
62+ publishTiledPaneOwners(destination, destinationTab)
63+ d.mu.Lock()
64+ d.sessions[destination.id] = destination
65+ d.mu.Unlock()
66+
67+ err := d.movePane(movePaneRequest{
68+ Source: moveSessionLocator{ID: source.id},
69+ SourceTabID: domain.TabStableID(sourceTab.stableID),
70+ SourcePaneID: domain.PaneStableID(moved.stableID),
71+ Destination: moveSessionLocator{ID: destination.id},
72+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
73+ })
74+ require.NoError(t, err)
75+ require.Nil(t, source.tabs)
76+ require.Same(t, moved, destinationTab.panes[moved.id])
77+ require.Equal(t, destinationTab, destination.tabs[0])
78+ require.Same(t, destination, moved.ownerSnapshot().session)
79+ require.Same(t, destinationTab, moved.ownerSnapshot().tab)
80+ require.NoError(t, moved.ctx.Err(), "retiring the source must not cancel the transferred pane process")
81+}
82+
83+func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
84+ p1, release1 := newBlockingPTY(t)
85+ p2, release2 := newBlockingPTY(t)
86+ defer release1()
87+ defer release2()
88+ d, source, client, _ := newManualSessionWithPTYs(t, p1)
89+ sourceTab := source.tabs[0]
90+ sourceTab.stableID = "source-tab"
91+ moved := sourceTab.focusedPane()
92+ movedTabCtx, movedTabCancel := context.WithCancel(d.paneProcessCtx)
93+ moved.ctx, moved.cancel = movedTabCtx, movedTabCancel
94+
95+ rc := d.attachCoordinator(source, nil, client, true)
96+ require.NotNil(t, rc)
97+ rebased := false
98+ client.renderStages.handoffRebase = func() { rebased = true }
99+ destination := &session{id: "destination", name: "destination", tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
100+ destinationTab := destination.tabs[0]
101+ destinationTab.stableID = "destination-tab"
102+ publishTiledPaneOwners(destination, destinationTab)
103+ d.mu.Lock()
104+ d.sessions[destination.id] = destination
105+ d.mu.Unlock()
106+
107+ err := d.movePane(movePaneRequest{
108+ Source: moveSessionLocator{ID: source.id},
109+ SourceTabID: domain.TabStableID(sourceTab.stableID),
110+ SourcePaneID: domain.PaneStableID(moved.stableID),
111+ Destination: moveSessionLocator{ID: destination.id},
112+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
113+ })
114+ require.NoError(t, err)
115+ require.Nil(t, source.tabs)
116+ require.Same(t, destination, client.currentSession())
117+ require.Same(t, client, destination.client)
118+ require.Equal(t, attachmentActive, destination.attachmentRole(client))
119+ require.Same(t, destinationTab, destination.tabs[destination.active])
120+ require.True(t, rebased, "final-source follower must rebase output before its first paint")
121+ require.True(t, moved.ctx.Err() == nil)
122+}
123+
124+func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) {
125+ movedPTY := newQuietPTY()
126+ d, source, active, _ := newManualSessionWithPTYs(t, movedPTY)
127+ clip, err := os.CreateTemp(t.TempDir(), "move-clip-")
128+ require.NoError(t, err)
129+ require.NoError(t, clip.Close())
130+ source.mu.Lock()
131+ source.client = nil
132+ source.clipFiles = []string{clip.Name()}
133+ source.mu.Unlock()
134+ active.setSession(nil)
135+ moved := source.tabs[0].focusedPane()
136+ movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
137+ moved.ctx, moved.cancel = movedCtx, movedCancel
138+ movedTab := source.tabs[0]
139+ movedTab.stableID = "source-tab"
140+
141+ destinationPTY := newQuietPTY()
142+ destination := &session{
143+ id: "destination", name: "destination", ephemeral: true,
144+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
145+ active: 0,
146+ }
147+ publishTiledPaneOwners(destination, destination.tabs[0])
148+ d.mu.Lock()
149+ d.sessions[destination.id] = destination
150+ d.mu.Unlock()
151+
152+ err = d.movePane(movePaneRequest{
153+ Source: moveSessionLocator{ID: source.id},
154+ SourceTabID: domain.TabStableID(movedTab.stableID),
155+ SourcePaneID: domain.PaneStableID(moved.stableID),
156+ Destination: moveSessionLocator{ID: destination.id},
157+ DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),
158+ })
159+ require.NoError(t, err)
160+ select {
161+ case <-movedPTY.done:
162+ t.Fatal("retiring the source closed the transferred PTY")
163+ default:
164+ }
165+ select {
166+ case <-moved.ctx.Done():
167+ t.Fatal("retiring the source cancelled the transferred pane process")
168+ default:
169+ }
170+ select {
171+ case <-source.ctx.Done():
172+ default:
173+ t.Fatal("empty source lifecycle was not cancelled")
174+ }
175+ _, err = os.Stat(clip.Name())
176+ require.ErrorIs(t, err, os.ErrNotExist, "retired source clipboard resources must be removed")
177+}
178+
179+func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
180+ movedPTY := newQuietPTY()
181+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
182+ source.mu.Lock()
183+ source.client = nil
184+ source.mu.Unlock()
185+ moved := source.tabs[0].focusedPane()
186+ movedTab := source.tabs[0]
187+ movedTab.stableID = "source-tab"
188+
189+ transport := &closeTrackingTransport{}
190+ parked := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}, resumeCapable: true}
191+ parked.initOverlays()
192+ require.True(t, d.parkAttachment(source, parked))
193+ destinationPTY := newQuietPTY()
194+ destination := &session{
195+ id: "destination", name: "destination", ephemeral: true,
196+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
197+ active: 0,
198+ }
199+ publishTiledPaneOwners(destination, destination.tabs[0])
200+ d.mu.Lock()
201+ d.sessions[destination.id] = destination
202+ d.mu.Unlock()
203+
204+ require.NoError(t, d.movePane(movePaneRequest{
205+ Source: moveSessionLocator{ID: source.id},
206+ SourceTabID: domain.TabStableID(movedTab.stableID),
207+ SourcePaneID: domain.PaneStableID(moved.stableID),
208+ Destination: moveSessionLocator{ID: destination.id},
209+ DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),
210+ }))
211+ d.mu.Lock()
212+ require.Empty(t, d.parked, "retired source parked clients must leave the daemon registry")
213+ d.mu.Unlock()
214+ require.True(t, transport.Closed(), "retired source parked transport was not closed")
215+}
216+
217+func TestPrepareMovePaneCandidateIsPure(t *testing.T) {
218+ t.Parallel()
219+
220+ newPaneRef := func(id layout.PaneID, stableID string) *pane {
221+ return &pane{id: id, stableID: stableID}
222+ }
223+ horizontal := func(ids ...layout.PaneID) *layout.Node {
224+ children := make([]*layout.Node, 0, len(ids))
225+ for _, id := range ids {
226+ children = append(children, layout.NewLeaf(id))
227+ }
228+ return &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: children}
229+ }
230+ stack := func(ids ...layout.PaneID) *layout.Node {
231+ children := make([]*layout.Node, 0, len(ids))
232+ for _, id := range ids {
233+ children = append(children, layout.NewLeaf(id))
234+ }
235+ return &layout.Node{Kind: layout.Stack, Children: children, Expanded: ids[0]}
236+ }
237+
238+ tests := []struct {
239+ name string
240+ sourceTree *layout.Tree
241+ sourceFloating floatingState
242+ destinationTree *layout.Tree
243+ destinationSize domain.Size
244+ destinationNext int
245+ wantErr error
246+ wantRemoveTab bool
247+ wantSourceFocus layout.PaneID
248+ wantDestination layout.PaneID
249+ wantDestinationNext int
250+ wantDestinationIDs []layout.PaneID
251+ }{
252+ {
253+ name: "split source closes on clone and destination inserts right of focus",
254+ sourceTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-2"},
255+ destinationTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-1"},
256+ destinationSize: domain.Size{Cols: 100, Rows: 4},
257+ destinationNext: 2,
258+ wantSourceFocus: "pane-1",
259+ wantDestination: "pane-3",
260+ wantDestinationNext: 4,
261+ wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3", "pane-2"},
262+ },
263+ {
264+ name: "stack source remains valid after close",
265+ sourceTree: &layout.Tree{Root: stack("pane-2", "pane-1"), Focus: "pane-2"},
266+ destinationTree: layout.NewTree("pane-1"),
267+ destinationSize: domain.Size{Cols: 41, Rows: 4},
268+ destinationNext: 2,
269+ wantSourceFocus: "pane-1",
270+ wantDestination: "pane-3",
271+ wantDestinationNext: 4,
272+ wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3"},
273+ },
274+ {
275+ name: "final tiled pane requests source tab removal",
276+ sourceTree: layout.NewTree("pane-2"),
277+ destinationTree: layout.NewTree("pane-1"),
278+ destinationSize: domain.Size{Cols: 41, Rows: 2},
279+ destinationNext: 2,
280+ wantRemoveTab: true,
281+ wantDestination: "pane-3",
282+ wantDestinationNext: 4,
283+ wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3"},
284+ },
285+ {
286+ name: "final tiled pane rejects warming floating sibling",
287+ sourceTree: layout.NewTree("pane-2"),
288+ sourceFloating: floatingWarming,
289+ destinationTree: layout.NewTree("pane-1"),
290+ destinationSize: domain.Size{Cols: 41, Rows: 2},
291+ destinationNext: 2,
292+ wantErr: errMovePaneFloatingSibling,
293+ },
294+ {
295+ name: "final tiled pane rejects hidden floating sibling",
296+ sourceTree: layout.NewTree("pane-2"),
297+ sourceFloating: floatingHidden,
298+ destinationTree: layout.NewTree("pane-1"),
299+ destinationSize: domain.Size{Cols: 41, Rows: 2},
300+ destinationNext: 2,
301+ wantErr: errMovePaneFloatingSibling,
302+ },
303+ {
304+ name: "final tiled pane rejects visible floating sibling",
305+ sourceTree: layout.NewTree("pane-2"),
306+ sourceFloating: floatingVisible,
307+ destinationTree: layout.NewTree("pane-1"),
308+ destinationSize: domain.Size{Cols: 41, Rows: 2},
309+ destinationNext: 2,
310+ wantErr: errMovePaneFloatingSibling,
311+ },
312+ {
313+ name: "destination too small rejects candidate",
314+ sourceTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-2"},
315+ destinationTree: layout.NewTree("pane-1"),
316+ destinationSize: domain.Size{Cols: 40, Rows: 2},
317+ destinationNext: 2,
318+ wantErr: layout.ErrTooSmall,
319+ },
320+ }
321+
322+ for _, tt := range tests {
323+ t.Run(tt.name, func(t *testing.T) {
324+ moved := newPaneRef("pane-2", "stable-moved")
325+ sourcePanes := map[layout.PaneID]*pane{"pane-2": moved}
326+ for _, id := range layout.LeafIDs(tt.sourceTree.Root) {
327+ if id != moved.id {
328+ sourcePanes[id] = newPaneRef(id, "source-"+string(id))
329+ }
330+ }
331+ destinationPanes := make(map[layout.PaneID]*pane)
332+ for _, id := range layout.LeafIDs(tt.destinationTree.Root) {
333+ destinationPanes[id] = newPaneRef(id, "destination-"+string(id))
334+ }
335+ source := &tab{
336+ tree: tt.sourceTree,
337+ panes: sourcePanes,
338+ nextPaneID: 17,
339+ size: domain.Size{Cols: 80, Rows: 4},
340+ floating: floatingSlot{state: tt.sourceFloating, generation: 9},
341+ }
342+ destination := &tab{
343+ tree: tt.destinationTree,
344+ panes: destinationPanes,
345+ nextPaneID: tt.destinationNext,
346+ size: tt.destinationSize,
347+ }
348+ sourceBefore := source.tree.Clone()
349+ destinationBefore := destination.tree.Clone()
350+ sourceNextBefore, destinationNextBefore := source.nextPaneID, destination.nextPaneID
351+ movedIDBefore, movedStableIDBefore := moved.id, moved.stableID
352+
353+ candidate, err := prepareMovePaneCandidate(source, destination, moved)
354+
355+ require.ErrorIs(t, err, tt.wantErr)
356+ require.Equal(t, sourceBefore, source.tree, "preparation must not mutate the source tree")
357+ require.Equal(t, destinationBefore, destination.tree, "preparation must not mutate the destination tree")
358+ require.Equal(t, sourceNextBefore, source.nextPaneID)
359+ require.Equal(t, destinationNextBefore, destination.nextPaneID, "preparation must not advance the destination counter")
360+ require.Equal(t, movedIDBefore, moved.id, "preparation must not publish the new local ID")
361+ require.Equal(t, movedStableIDBefore, moved.stableID)
362+ require.Same(t, moved, source.panes[movedIDBefore])
363+ for id, p := range destinationPanes {
364+ require.Same(t, p, destination.panes[id])
365+ }
366+ if tt.wantErr != nil {
367+ require.Nil(t, candidate)
368+ return
369+ }
370+
371+ require.Same(t, moved, candidate.pane)
372+ require.Equal(t, movedIDBefore, candidate.sourceID)
373+ require.Equal(t, tt.wantDestination, candidate.destinationID)
374+ require.NotEqual(t, candidate.sourceID, candidate.destinationID)
375+ require.Equal(t, tt.wantRemoveTab, candidate.removeSourceTab)
376+ require.Equal(t, tt.wantDestinationNext, candidate.destinationNextPaneID, "candidate counter should follow the allocated ID")
377+ require.Equal(t, tt.wantDestinationIDs, layout.LeafIDs(candidate.destinationTree.Root))
378+ require.Equal(t, tt.wantDestination, candidate.destinationTree.Focus)
379+ require.NotEmpty(t, candidate.destinationPlacements)
380+ if tt.wantRemoveTab {
381+ require.Nil(t, candidate.sourceTree)
382+ require.Nil(t, candidate.sourcePlacements)
383+ } else {
384+ require.Equal(t, tt.wantSourceFocus, candidate.sourceTree.Focus)
385+ require.True(t, layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus))
386+ require.NotEmpty(t, candidate.sourcePlacements)
387+ }
388+ require.Equal(t, "stable-moved", candidate.pane.stableID)
389+ })
390+ }
391+}
@@ -0,0 +1,391 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+func TestMovePaneMutationSameSession(t *testing.T) {
15+ p1, release1 := newBlockingPTY(t)
16+ p2, release2 := newBlockingPTY(t)
17+ defer release1()
18+ defer release2()
19+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
20+ sourceTab, destinationTab := sess.tabs[0], sess.tabs[1]
21+ sourceTab.stableID = "source-tab"
22+ destinationTab.stableID = "destination-tab"
23+ moved := sourceTab.focusedPane()
24+ movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
25+ moved.ctx, moved.cancel = movedCtx, movedCancel
26+
27+ err := d.movePane(movePaneRequest{
28+ Source: moveSessionLocator{ID: sess.id},
29+ SourceTabID: domain.TabStableID(sourceTab.stableID),
30+ SourcePaneID: domain.PaneStableID(moved.stableID),
31+ Destination: moveSessionLocator{ID: sess.id},
32+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
33+ })
34+ require.NoError(t, err)
35+ require.Same(t, moved, destinationTab.panes[moved.id])
36+ require.Equal(t, layout.PaneID("pane-2"), moved.id)
37+ require.Equal(t, layout.PaneID("pane-2"), destinationTab.tree.Focus)
38+ require.NotContains(t, sourceTab.panes, layout.PaneID("pane-1"))
39+ require.Len(t, sess.tabs, 1)
40+ require.Same(t, destinationTab, sess.tabs[0])
41+ require.NoError(t, moved.ctx.Err(), "a same-session move must not retire the moved pane process")
42+}
43+
44+func TestMovePaneMutationCrossSession(t *testing.T) {
45+ p1, release1 := newBlockingPTY(t)
46+ p2, release2 := newBlockingPTY(t)
47+ defer release1()
48+ defer release2()
49+ d, source, _, _ := newManualSessionWithPTYs(t, p1)
50+ source.mu.Lock()
51+ source.client = nil
52+ source.mu.Unlock()
53+ sourceTab := source.tabs[0]
54+ sourceTab.stableID = "source-tab"
55+ moved := sourceTab.focusedPane()
56+ movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
57+ moved.ctx, moved.cancel = movedCtx, movedCancel
58+
59+ destination := &session{id: "destination", name: "destination", ephemeral: true, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
60+ destinationTab := destination.tabs[0]
61+ destinationTab.stableID = "destination-tab"
62+ publishTiledPaneOwners(destination, destinationTab)
63+ d.mu.Lock()
64+ d.sessions[destination.id] = destination
65+ d.mu.Unlock()
66+
67+ err := d.movePane(movePaneRequest{
68+ Source: moveSessionLocator{ID: source.id},
69+ SourceTabID: domain.TabStableID(sourceTab.stableID),
70+ SourcePaneID: domain.PaneStableID(moved.stableID),
71+ Destination: moveSessionLocator{ID: destination.id},
72+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
73+ })
74+ require.NoError(t, err)
75+ require.Nil(t, source.tabs)
76+ require.Same(t, moved, destinationTab.panes[moved.id])
77+ require.Equal(t, destinationTab, destination.tabs[0])
78+ require.Same(t, destination, moved.ownerSnapshot().session)
79+ require.Same(t, destinationTab, moved.ownerSnapshot().tab)
80+ require.NoError(t, moved.ctx.Err(), "retiring the source must not cancel the transferred pane process")
81+}
82+
83+func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
84+ p1, release1 := newBlockingPTY(t)
85+ p2, release2 := newBlockingPTY(t)
86+ defer release1()
87+ defer release2()
88+ d, source, client, _ := newManualSessionWithPTYs(t, p1)
89+ sourceTab := source.tabs[0]
90+ sourceTab.stableID = "source-tab"
91+ moved := sourceTab.focusedPane()
92+ movedTabCtx, movedTabCancel := context.WithCancel(d.paneProcessCtx)
93+ moved.ctx, moved.cancel = movedTabCtx, movedTabCancel
94+
95+ rc := d.attachCoordinator(source, nil, client, true)
96+ require.NotNil(t, rc)
97+ rebased := false
98+ client.renderStages.handoffRebase = func() { rebased = true }
99+ destination := &session{id: "destination", name: "destination", tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
100+ destinationTab := destination.tabs[0]
101+ destinationTab.stableID = "destination-tab"
102+ publishTiledPaneOwners(destination, destinationTab)
103+ d.mu.Lock()
104+ d.sessions[destination.id] = destination
105+ d.mu.Unlock()
106+
107+ err := d.movePane(movePaneRequest{
108+ Source: moveSessionLocator{ID: source.id},
109+ SourceTabID: domain.TabStableID(sourceTab.stableID),
110+ SourcePaneID: domain.PaneStableID(moved.stableID),
111+ Destination: moveSessionLocator{ID: destination.id},
112+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
113+ })
114+ require.NoError(t, err)
115+ require.Nil(t, source.tabs)
116+ require.Same(t, destination, client.currentSession())
117+ require.Same(t, client, destination.client)
118+ require.Equal(t, attachmentActive, destination.attachmentRole(client))
119+ require.Same(t, destinationTab, destination.tabs[destination.active])
120+ require.True(t, rebased, "final-source follower must rebase output before its first paint")
121+ require.True(t, moved.ctx.Err() == nil)
122+}
123+
124+func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) {
125+ movedPTY := newQuietPTY()
126+ d, source, active, _ := newManualSessionWithPTYs(t, movedPTY)
127+ clip, err := os.CreateTemp(t.TempDir(), "move-clip-")
128+ require.NoError(t, err)
129+ require.NoError(t, clip.Close())
130+ source.mu.Lock()
131+ source.client = nil
132+ source.clipFiles = []string{clip.Name()}
133+ source.mu.Unlock()
134+ active.setSession(nil)
135+ moved := source.tabs[0].focusedPane()
136+ movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
137+ moved.ctx, moved.cancel = movedCtx, movedCancel
138+ movedTab := source.tabs[0]
139+ movedTab.stableID = "source-tab"
140+
141+ destinationPTY := newQuietPTY()
142+ destination := &session{
143+ id: "destination", name: "destination", ephemeral: true,
144+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
145+ active: 0,
146+ }
147+ publishTiledPaneOwners(destination, destination.tabs[0])
148+ d.mu.Lock()
149+ d.sessions[destination.id] = destination
150+ d.mu.Unlock()
151+
152+ err = d.movePane(movePaneRequest{
153+ Source: moveSessionLocator{ID: source.id},
154+ SourceTabID: domain.TabStableID(movedTab.stableID),
155+ SourcePaneID: domain.PaneStableID(moved.stableID),
156+ Destination: moveSessionLocator{ID: destination.id},
157+ DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),
158+ })
159+ require.NoError(t, err)
160+ select {
161+ case <-movedPTY.done:
162+ t.Fatal("retiring the source closed the transferred PTY")
163+ default:
164+ }
165+ select {
166+ case <-moved.ctx.Done():
167+ t.Fatal("retiring the source cancelled the transferred pane process")
168+ default:
169+ }
170+ select {
171+ case <-source.ctx.Done():
172+ default:
173+ t.Fatal("empty source lifecycle was not cancelled")
174+ }
175+ _, err = os.Stat(clip.Name())
176+ require.ErrorIs(t, err, os.ErrNotExist, "retired source clipboard resources must be removed")
177+}
178+
179+func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
180+ movedPTY := newQuietPTY()
181+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
182+ source.mu.Lock()
183+ source.client = nil
184+ source.mu.Unlock()
185+ moved := source.tabs[0].focusedPane()
186+ movedTab := source.tabs[0]
187+ movedTab.stableID = "source-tab"
188+
189+ transport := &closeTrackingTransport{}
190+ parked := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}, resumeCapable: true}
191+ parked.initOverlays()
192+ require.True(t, d.parkAttachment(source, parked))
193+ destinationPTY := newQuietPTY()
194+ destination := &session{
195+ id: "destination", name: "destination", ephemeral: true,
196+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
197+ active: 0,
198+ }
199+ publishTiledPaneOwners(destination, destination.tabs[0])
200+ d.mu.Lock()
201+ d.sessions[destination.id] = destination
202+ d.mu.Unlock()
203+
204+ require.NoError(t, d.movePane(movePaneRequest{
205+ Source: moveSessionLocator{ID: source.id},
206+ SourceTabID: domain.TabStableID(movedTab.stableID),
207+ SourcePaneID: domain.PaneStableID(moved.stableID),
208+ Destination: moveSessionLocator{ID: destination.id},
209+ DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),
210+ }))
211+ d.mu.Lock()
212+ require.Empty(t, d.parked, "retired source parked clients must leave the daemon registry")
213+ d.mu.Unlock()
214+ require.True(t, transport.Closed(), "retired source parked transport was not closed")
215+}
216+
217+func TestPrepareMovePaneCandidateIsPure(t *testing.T) {
218+ t.Parallel()
219+
220+ newPaneRef := func(id layout.PaneID, stableID string) *pane {
221+ return &pane{id: id, stableID: stableID}
222+ }
223+ horizontal := func(ids ...layout.PaneID) *layout.Node {
224+ children := make([]*layout.Node, 0, len(ids))
225+ for _, id := range ids {
226+ children = append(children, layout.NewLeaf(id))
227+ }
228+ return &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: children}
229+ }
230+ stack := func(ids ...layout.PaneID) *layout.Node {
231+ children := make([]*layout.Node, 0, len(ids))
232+ for _, id := range ids {
233+ children = append(children, layout.NewLeaf(id))
234+ }
235+ return &layout.Node{Kind: layout.Stack, Children: children, Expanded: ids[0]}
236+ }
237+
238+ tests := []struct {
239+ name string
240+ sourceTree *layout.Tree
241+ sourceFloating floatingState
242+ destinationTree *layout.Tree
243+ destinationSize domain.Size
244+ destinationNext int
245+ wantErr error
246+ wantRemoveTab bool
247+ wantSourceFocus layout.PaneID
248+ wantDestination layout.PaneID
249+ wantDestinationNext int
250+ wantDestinationIDs []layout.PaneID
251+ }{
252+ {
253+ name: "split source closes on clone and destination inserts right of focus",
254+ sourceTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-2"},
255+ destinationTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-1"},
256+ destinationSize: domain.Size{Cols: 100, Rows: 4},
257+ destinationNext: 2,
258+ wantSourceFocus: "pane-1",
259+ wantDestination: "pane-3",
260+ wantDestinationNext: 4,
261+ wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3", "pane-2"},
262+ },
263+ {
264+ name: "stack source remains valid after close",
265+ sourceTree: &layout.Tree{Root: stack("pane-2", "pane-1"), Focus: "pane-2"},
266+ destinationTree: layout.NewTree("pane-1"),
267+ destinationSize: domain.Size{Cols: 41, Rows: 4},
268+ destinationNext: 2,
269+ wantSourceFocus: "pane-1",
270+ wantDestination: "pane-3",
271+ wantDestinationNext: 4,
272+ wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3"},
273+ },
274+ {
275+ name: "final tiled pane requests source tab removal",
276+ sourceTree: layout.NewTree("pane-2"),
277+ destinationTree: layout.NewTree("pane-1"),
278+ destinationSize: domain.Size{Cols: 41, Rows: 2},
279+ destinationNext: 2,
280+ wantRemoveTab: true,
281+ wantDestination: "pane-3",
282+ wantDestinationNext: 4,
283+ wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3"},
284+ },
285+ {
286+ name: "final tiled pane rejects warming floating sibling",
287+ sourceTree: layout.NewTree("pane-2"),
288+ sourceFloating: floatingWarming,
289+ destinationTree: layout.NewTree("pane-1"),
290+ destinationSize: domain.Size{Cols: 41, Rows: 2},
291+ destinationNext: 2,
292+ wantErr: errMovePaneFloatingSibling,
293+ },
294+ {
295+ name: "final tiled pane rejects hidden floating sibling",
296+ sourceTree: layout.NewTree("pane-2"),
297+ sourceFloating: floatingHidden,
298+ destinationTree: layout.NewTree("pane-1"),
299+ destinationSize: domain.Size{Cols: 41, Rows: 2},
300+ destinationNext: 2,
301+ wantErr: errMovePaneFloatingSibling,
302+ },
303+ {
304+ name: "final tiled pane rejects visible floating sibling",
305+ sourceTree: layout.NewTree("pane-2"),
306+ sourceFloating: floatingVisible,
307+ destinationTree: layout.NewTree("pane-1"),
308+ destinationSize: domain.Size{Cols: 41, Rows: 2},
309+ destinationNext: 2,
310+ wantErr: errMovePaneFloatingSibling,
311+ },
312+ {
313+ name: "destination too small rejects candidate",
314+ sourceTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-2"},
315+ destinationTree: layout.NewTree("pane-1"),
316+ destinationSize: domain.Size{Cols: 40, Rows: 2},
317+ destinationNext: 2,
318+ wantErr: layout.ErrTooSmall,
319+ },
320+ }
321+
322+ for _, tt := range tests {
323+ t.Run(tt.name, func(t *testing.T) {
324+ moved := newPaneRef("pane-2", "stable-moved")
325+ sourcePanes := map[layout.PaneID]*pane{"pane-2": moved}
326+ for _, id := range layout.LeafIDs(tt.sourceTree.Root) {
327+ if id != moved.id {
328+ sourcePanes[id] = newPaneRef(id, "source-"+string(id))
329+ }
330+ }
331+ destinationPanes := make(map[layout.PaneID]*pane)
332+ for _, id := range layout.LeafIDs(tt.destinationTree.Root) {
333+ destinationPanes[id] = newPaneRef(id, "destination-"+string(id))
334+ }
335+ source := &tab{
336+ tree: tt.sourceTree,
337+ panes: sourcePanes,
338+ nextPaneID: 17,
339+ size: domain.Size{Cols: 80, Rows: 4},
340+ floating: floatingSlot{state: tt.sourceFloating, generation: 9},
341+ }
342+ destination := &tab{
343+ tree: tt.destinationTree,
344+ panes: destinationPanes,
345+ nextPaneID: tt.destinationNext,
346+ size: tt.destinationSize,
347+ }
348+ sourceBefore := source.tree.Clone()
349+ destinationBefore := destination.tree.Clone()
350+ sourceNextBefore, destinationNextBefore := source.nextPaneID, destination.nextPaneID
351+ movedIDBefore, movedStableIDBefore := moved.id, moved.stableID
352+
353+ candidate, err := prepareMovePaneCandidate(source, destination, moved)
354+
355+ require.ErrorIs(t, err, tt.wantErr)
356+ require.Equal(t, sourceBefore, source.tree, "preparation must not mutate the source tree")
357+ require.Equal(t, destinationBefore, destination.tree, "preparation must not mutate the destination tree")
358+ require.Equal(t, sourceNextBefore, source.nextPaneID)
359+ require.Equal(t, destinationNextBefore, destination.nextPaneID, "preparation must not advance the destination counter")
360+ require.Equal(t, movedIDBefore, moved.id, "preparation must not publish the new local ID")
361+ require.Equal(t, movedStableIDBefore, moved.stableID)
362+ require.Same(t, moved, source.panes[movedIDBefore])
363+ for id, p := range destinationPanes {
364+ require.Same(t, p, destination.panes[id])
365+ }
366+ if tt.wantErr != nil {
367+ require.Nil(t, candidate)
368+ return
369+ }
370+
371+ require.Same(t, moved, candidate.pane)
372+ require.Equal(t, movedIDBefore, candidate.sourceID)
373+ require.Equal(t, tt.wantDestination, candidate.destinationID)
374+ require.NotEqual(t, candidate.sourceID, candidate.destinationID)
375+ require.Equal(t, tt.wantRemoveTab, candidate.removeSourceTab)
376+ require.Equal(t, tt.wantDestinationNext, candidate.destinationNextPaneID, "candidate counter should follow the allocated ID")
377+ require.Equal(t, tt.wantDestinationIDs, layout.LeafIDs(candidate.destinationTree.Root))
378+ require.Equal(t, tt.wantDestination, candidate.destinationTree.Focus)
379+ require.NotEmpty(t, candidate.destinationPlacements)
380+ if tt.wantRemoveTab {
381+ require.Nil(t, candidate.sourceTree)
382+ require.Nil(t, candidate.sourcePlacements)
383+ } else {
384+ require.Equal(t, tt.wantSourceFocus, candidate.sourceTree.Focus)
385+ require.True(t, layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus))
386+ require.NotEmpty(t, candidate.sourcePlacements)
387+ }
388+ require.Equal(t, "stable-moved", candidate.pane.stableID)
389+ })
390+ }
391+}
M internal/usecase/daemon/role_effect_freeze.go +19 -2
@@ -95,6 +95,14 @@ func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterru
9595 return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...)
9696 }
9797
98+// tryFreezeRoleEffectGatesInterruptingObserved establishes transition priority
99+// without waiting behind a gate already owned by another lifecycle operation.
100+// Move reservations use this form: killSession freezes role gates before it
101+// waits for the reservation, so waiting here would invert the two protocols.
102+func tryFreezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
103+ return freezeRoleEffectGatesInterruptingObservedMode(interrupts, nil, afterFrozen, true, clients...)
104+}
105+
98106 // freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound
99107 // for ordered acquisition and drain. Acquisition is all-or-nothing: if the
100108 // bound expires behind another owner, only gates acquired by this call are
@@ -103,8 +111,12 @@ func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterru
103111 // against an invalidated capability without pinning the session owner
104112 // indefinitely.
105113 func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
114+ return freezeRoleEffectGatesInterruptingObservedMode(interrupts, done, afterFrozen, false, clients...)
115+}
116+
117+func freezeRoleEffectGatesInterruptingObservedMode(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool, clients ...*attachedClient) frozenRoleEffectGates {
106118 ordered := orderedRoleEffectClients(clients)
107- frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen)
119+ frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen, nonblocking)
108120 if !acquired {
109121 return frozenRoleEffectGates{}
110122 }
@@ -165,13 +177,18 @@ func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{
165177 // acquireRoleEffectGates establishes exclusive transition ownership in order.
166178 // It must run without architecture locks. Deadline rollback releases only this
167179 // call's partial acquisition and does so in reverse order.
168-func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient)) (frozenRoleEffectGates, bool) {
180+func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenRoleEffectGates, bool) {
169181 frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))}
170182 for _, ac := range ordered {
171183 g := &ac.roleEffects
172184 g.mu.Lock()
173185 g.initLocked()
174186 for g.phase == roleEffectsFrozen {
187+ if nonblocking {
188+ g.mu.Unlock()
189+ frozen.unfreeze()
190+ return frozenRoleEffectGates{}, false
191+ }
175192 if waitForRoleEffectChangeLocked(g, done) {
176193 g.mu.Unlock()
177194 frozen.unfreeze()
@@ -95,6 +95,14 @@ func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterru
95 return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...)95 return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...)
96 }96 }
97 97
98+// tryFreezeRoleEffectGatesInterruptingObserved establishes transition priority
99+// without waiting behind a gate already owned by another lifecycle operation.
100+// Move reservations use this form: killSession freezes role gates before it
101+// waits for the reservation, so waiting here would invert the two protocols.
102+func tryFreezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
103+ return freezeRoleEffectGatesInterruptingObservedMode(interrupts, nil, afterFrozen, true, clients...)
104+}
105+
98 // freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound106 // freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound
99 // for ordered acquisition and drain. Acquisition is all-or-nothing: if the107 // for ordered acquisition and drain. Acquisition is all-or-nothing: if the
100 // bound expires behind another owner, only gates acquired by this call are108 // bound expires behind another owner, only gates acquired by this call are
@@ -103,8 +111,12 @@ func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterru
103 // against an invalidated capability without pinning the session owner111 // against an invalidated capability without pinning the session owner
104 // indefinitely.112 // indefinitely.
105 func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {113 func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
114+ return freezeRoleEffectGatesInterruptingObservedMode(interrupts, done, afterFrozen, false, clients...)
115+}
116+
117+func freezeRoleEffectGatesInterruptingObservedMode(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool, clients ...*attachedClient) frozenRoleEffectGates {
106 ordered := orderedRoleEffectClients(clients)118 ordered := orderedRoleEffectClients(clients)
107- frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen)119+ frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen, nonblocking)
108 if !acquired {120 if !acquired {
109 return frozenRoleEffectGates{}121 return frozenRoleEffectGates{}
110 }122 }
@@ -165,13 +177,18 @@ func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{
165 // acquireRoleEffectGates establishes exclusive transition ownership in order.177 // acquireRoleEffectGates establishes exclusive transition ownership in order.
166 // It must run without architecture locks. Deadline rollback releases only this178 // It must run without architecture locks. Deadline rollback releases only this
167 // call's partial acquisition and does so in reverse order.179 // call's partial acquisition and does so in reverse order.
168-func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient)) (frozenRoleEffectGates, bool) {180+func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenRoleEffectGates, bool) {
169 frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))}181 frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))}
170 for _, ac := range ordered {182 for _, ac := range ordered {
171 g := &ac.roleEffects183 g := &ac.roleEffects
172 g.mu.Lock()184 g.mu.Lock()
173 g.initLocked()185 g.initLocked()
174 for g.phase == roleEffectsFrozen {186 for g.phase == roleEffectsFrozen {
187+ if nonblocking {
188+ g.mu.Unlock()
189+ frozen.unfreeze()
190+ return frozenRoleEffectGates{}, false
191+ }
175 if waitForRoleEffectChangeLocked(g, done) {192 if waitForRoleEffectChangeLocked(g, done) {
176 g.mu.Unlock()193 g.mu.Unlock()
177 frozen.unfreeze()194 frozen.unfreeze()
M internal/usecase/daemon/session.go +3 -1
@@ -1209,7 +1209,9 @@ func (s *session) stopInMemoryLifecycle() {
12091209 }
12101210 s.lifecycleStopOnce.Do(func() {
12111211 s.stopFloatingLaunches()
1212- s.cancel()
1212+ if s.cancel != nil {
1213+ s.cancel()
1214+ }
12131215 s.mu.Lock()
12141216 tabs := append([]*tab(nil), s.tabs...)
12151217 s.mu.Unlock()
@@ -1209,7 +1209,9 @@ func (s *session) stopInMemoryLifecycle() {
1209 }1209 }
1210 s.lifecycleStopOnce.Do(func() {1210 s.lifecycleStopOnce.Do(func() {
1211 s.stopFloatingLaunches()1211 s.stopFloatingLaunches()
1212- s.cancel()1212+ if s.cancel != nil {
1213+ s.cancel()
1214+ }
1213 s.mu.Lock()1215 s.mu.Lock()
1214 tabs := append([]*tab(nil), s.tabs...)1216 tabs := append([]*tab(nil), s.tabs...)
1215 s.mu.Unlock()1217 s.mu.Unlock()
M internal/usecase/daemon/transactional_resize.go +3 -0
@@ -805,6 +805,9 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *
805805 d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
806806 if ac != nil {
807807 ac.sendMu.Lock()
808+ if d.afterResizeCommitSendLocked != nil {
809+ d.afterResizeCommitSendLocked()
810+ }
808811 }
809812 fences := acquireResizeOwnerPostEffectFences(members)
810813 if fences == nil {
@@ -805,6 +805,9 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *
805 d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)805 d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
806 if ac != nil {806 if ac != nil {
807 ac.sendMu.Lock()807 ac.sendMu.Lock()
808+ if d.afterResizeCommitSendLocked != nil {
809+ d.afterResizeCommitSendLocked()
810+ }
808 }811 }
809 fences := acquireResizeOwnerPostEffectFences(members)812 fences := acquireResizeOwnerPostEffectFences(members)
810 if fences == nil {813 if fences == nil {

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