feat(daemon): move panes between live tabs
- commit
- bf32e20341ae69397e041b69fd4e8df035b23e66
- author
- brice · · 2026-07-28 09:36 UTC
- parent
- f0438ea
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 60 | 60 | 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 | 90 | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |
| 64 | 91 | if s == nil || ac == nil || tr == nil { | |
| 65 | 92 | return attachmentRoleToken{} | |
| @@ -60,6 +60,33 @@ type attachmentRoleToken struct { | |||||
| 60 | rebase bool | 60 | 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 | |||
| 69 | 69 | return d.transitionAttachmentRoutedLocked(req) | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 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. | |
| 74 | 75 | type attachmentPublication struct { | |
| 75 | 76 | req attachmentTransitionRequest | |
| 76 | 77 | source *session | |
| 77 | 78 | old *attachedClient | |
| 78 | 79 | sourceCoordinator *renderCoordinator | |
| 79 | 80 | targetCoordinator *renderCoordinator | |
| 80 | - | unlockSessions func() | |
| 81 | - | unlockCoordinators func() | |
| 81 | + | releaseCoordinators func() | |
| 82 | 82 | displacedTransport transportSnapshot | |
| 83 | 83 | nextGeneration uint64 | |
| 84 | 84 | displacedGeneration uint64 | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 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() | |
| 92 | 89 | } | |
| 93 | 90 | ||
| 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) { | |
| 99 | 97 | source := req.source | |
| 100 | 98 | if source == nil { | |
| 101 | 99 | source = req.target | |
| @@ -104,51 +102,38 @@ func (d *Daemon) preflightAttachmentPublicationLocked(req attachmentTransitionRe | |||
| 104 | 102 | !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) || | |
| 105 | 103 | source == nil || req.target == nil || req.next == nil || d.closing || | |
| 106 | 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 | 107 | 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 | 110 | var targetCoordinator *renderCoordinator | |
| 114 | 111 | if req.targetRole == attachmentActive { | |
| 115 | - | targetCoordinator = d.ensureRenderCoordinator(req.target) | |
| 112 | + | targetCoordinator = d.ensureRenderCoordinatorPrelocked(req.target) | |
| 116 | 113 | } | |
| 117 | 114 | sourceCoordinator := source.renderCoordinator() | |
| 118 | - | unlockSessions := lockAttachmentSessions(source, req.target) | |
| 119 | 115 | if source.attachmentRoleLocked(req.next) != req.expectedRole || | |
| 120 | 116 | !req.next.transportSnapshotCurrent(req.expectedTransport) { | |
| 121 | - | unlockSessions() | |
| 122 | 117 | return nil, errAttachmentTransition | |
| 123 | 118 | } | |
| 124 | 119 | ||
| 125 | 120 | old := req.target.client | |
| 126 | 121 | if old != req.expectedTargetCurrent || source != req.target && old == req.next { | |
| 127 | - | unlockSessions() | |
| 128 | 122 | return nil, errAttachmentTransition | |
| 129 | 123 | } | |
| 130 | 124 | publication := &attachmentPublication{ | |
| 131 | 125 | req: req, source: source, old: old, | |
| 132 | 126 | 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() | |
| 152 | 137 | return nil, errAttachmentTransition | |
| 153 | 138 | } | |
| 154 | 139 | return publication, nil | |
| @@ -245,22 +230,37 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att | |||
| 245 | 230 | return result | |
| 246 | 231 | } | |
| 247 | 232 | ||
| 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. | |
| 252 | 247 | 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 | |
| 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) { | |
| 260 | - | return attachmentTransitionResult{published: *req.sourceToken}, nil | |
| 258 | + | publication, err := d.validateAttachmentTransitionPrelocked(req) | |
| 259 | + | if err != nil { | |
| 260 | + | return attachmentTransitionResult{}, err | |
| 261 | 261 | } | |
| 262 | - | publishAttachmentOwnershipLocked(publication) | |
| 263 | - | return buildAttachmentPostcommitPlanLocked(publication), nil | |
| 262 | + | defer publication.unlockCoordinators() | |
| 263 | + | return d.publishAttachmentTransitionPrelocked(publication), nil | |
| 264 | 264 | } | |
| 265 | 265 | ||
| 266 | 266 | 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 and | 72 | + | // 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 attachmentTransitionRequest | 76 | req attachmentTransitionRequest | ||
| 76 | source *session | 77 | source *session | ||
| 77 | old *attachedClient | 78 | old *attachedClient | ||
| 78 | sourceCoordinator *renderCoordinator | 79 | sourceCoordinator *renderCoordinator | ||
| 79 | targetCoordinator *renderCoordinator | 80 | targetCoordinator *renderCoordinator | ||
| 80 | - | unlockSessions func() | 81 | + | releaseCoordinators func() |
| 81 | - | unlockCoordinators func() | |||
| 82 | displacedTransport transportSnapshot | 82 | displacedTransport transportSnapshot | ||
| 83 | nextGeneration uint64 | 83 | nextGeneration uint64 | ||
| 84 | displacedGeneration uint64 | 84 | displacedGeneration uint64 | ||
| 85 | } | 85 | } | ||
| 86 | 86 | ||||
| 87 | - | // unlock releases coordinator locks before the ordered session locks. The | 87 | + | 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 authority | 91 | + | // validateAttachmentTransitionPrelocked performs every fallible transition |
| 95 | - | // and acquires every lock needed by publication. Caller holds d.mu and | 92 | + | // check without acquiring d.mu, notices.routingMu, or either session lock. |
| 96 | - | // notices.routingMu. On success, the returned guard owns ordered session and | 93 | + | // 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.source | 97 | source := req.source | ||
| 100 | if source == nil { | 98 | if source == nil { | ||
| 101 | source = req.target | 99 | 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, errAttachmentTransition | 107 | 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 *renderCoordinator | 110 | 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, errAttachmentTransition | 117 | return nil, errAttachmentTransition | ||
| 123 | } | 118 | } | ||
| 124 | 119 | ||||
| 125 | old := req.target.client | 120 | 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, errAttachmentTransition | 122 | 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, errAttachmentTransition | 133 | + | (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, errAttachmentTransition | 137 | return nil, errAttachmentTransition | ||
| 153 | } | 138 | } | ||
| 154 | return publication, nil | 139 | return publication, nil | ||
| @@ -245,22 +230,37 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att | |||||
| 245 | return result | 230 | 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, and | 234 | + | // ownership, generation, and coordinator-lease publication. The caller retains |
| 250 | - | // transport check occurs before session.client is changed. Coordinator | 235 | + | // 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{}, err | 250 | + | 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}, nil | 259 | + | if err != nil { |
| 260 | + | return attachmentTransitionResult{}, err | |||
| 261 | } | 261 | } | ||
| 262 | - | publishAttachmentOwnershipLocked(publication) | 262 | + | defer publication.unlockCoordinators() |
| 263 | - | return buildAttachmentPostcommitPlanLocked(publication), nil | 263 | + | 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 | |||
| 527 | 527 | func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator { | |
| 528 | 528 | sess.mu.Lock() | |
| 529 | 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 | 536 | if rc := sess.renderCoordinator(); rc != nil { | |
| 531 | 537 | return rc | |
| 532 | 538 | } | |
| @@ -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 rc | 537 | return rc | ||
| 532 | } | 538 | } | ||
M internal/usecase/daemon/daemon.go +12 -0
| @@ -129,6 +129,10 @@ type Daemon struct { | |||
| 129 | 129 | // and immediately before a post-commit effect is published. Tests use it to | |
| 130 | 130 | // move a pane through the ordered resize fences at the former TOCTOU window. | |
| 131 | 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 | 136 | // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs | |
| 133 | 137 | // after one overlay-family mutex is released and before the next is taken. | |
| 134 | 138 | afterSnatchOverlayFamily func(string) | |
| @@ -151,6 +155,14 @@ type Daemon struct { | |||
| 151 | 155 | // afterRoleEffectsFrozen observes the lock-free boundary after all affected | |
| 152 | 156 | // attachment gates are frozen and drained, before architecture publication. | |
| 153 | 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 | 166 | // afterDetachRoleEffectsFrozen observes terminal detach after it wins the | |
| 155 | 167 | // attachment gate but before it checks session ownership. | |
| 156 | 168 | afterDetachRoleEffectsFrozen func() | |
| @@ -129,6 +129,10 @@ type Daemon struct { | |||||
| 129 | // and immediately before a post-commit effect is published. Tests use it to | 129 | // 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 runs | 136 | // 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 affected | 155 | // 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 the | 166 | // 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 | |||
| 95 | 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 | 106 | // freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound | |
| 99 | 107 | // for ordered acquisition and drain. Acquisition is all-or-nothing: if the | |
| 100 | 108 | // bound expires behind another owner, only gates acquired by this call are | |
| @@ -103,8 +111,12 @@ func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterru | |||
| 103 | 111 | // against an invalidated capability without pinning the session owner | |
| 104 | 112 | // indefinitely. | |
| 105 | 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 | 118 | ordered := orderedRoleEffectClients(clients) | |
| 107 | - | frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen) | |
| 119 | + | frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen, nonblocking) | |
| 108 | 120 | if !acquired { | |
| 109 | 121 | return frozenRoleEffectGates{} | |
| 110 | 122 | } | |
| @@ -165,13 +177,18 @@ func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{ | |||
| 165 | 177 | // acquireRoleEffectGates establishes exclusive transition ownership in order. | |
| 166 | 178 | // It must run without architecture locks. Deadline rollback releases only this | |
| 167 | 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 | 181 | frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))} | |
| 170 | 182 | for _, ac := range ordered { | |
| 171 | 183 | g := &ac.roleEffects | |
| 172 | 184 | g.mu.Lock() | |
| 173 | 185 | g.initLocked() | |
| 174 | 186 | for g.phase == roleEffectsFrozen { | |
| 187 | + | if nonblocking { | |
| 188 | + | g.mu.Unlock() | |
| 189 | + | frozen.unfreeze() | |
| 190 | + | return frozenRoleEffectGates{}, false | |
| 191 | + | } | |
| 175 | 192 | if waitForRoleEffectChangeLocked(g, done) { | |
| 176 | 193 | g.mu.Unlock() | |
| 177 | 194 | 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 bound | 106 | // freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound | ||
| 99 | // for ordered acquisition and drain. Acquisition is all-or-nothing: if the | 107 | // for ordered acquisition and drain. Acquisition is all-or-nothing: if the | ||
| 100 | // bound expires behind another owner, only gates acquired by this call are | 108 | // 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 owner | 111 | // 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 this | 178 | // 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.roleEffects | 183 | 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() { | |||
| 1209 | 1209 | } | |
| 1210 | 1210 | s.lifecycleStopOnce.Do(func() { | |
| 1211 | 1211 | s.stopFloatingLaunches() | |
| 1212 | - | s.cancel() | |
| 1212 | + | if s.cancel != nil { | |
| 1213 | + | s.cancel() | |
| 1214 | + | } | |
| 1213 | 1215 | s.mu.Lock() | |
| 1214 | 1216 | tabs := append([]*tab(nil), s.tabs...) | |
| 1215 | 1217 | 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 * | |||
| 805 | 805 | d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication) | |
| 806 | 806 | if ac != nil { | |
| 807 | 807 | ac.sendMu.Lock() | |
| 808 | + | if d.afterResizeCommitSendLocked != nil { | |
| 809 | + | d.afterResizeCommitSendLocked() | |
| 810 | + | } | |
| 808 | 811 | } | |
| 809 | 812 | fences := acquireResizeOwnerPostEffectFences(members) | |
| 810 | 813 | 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 { | ||