refactor(daemon): remove obsolete session membership fields
- commit
- 409ae781e8fc500839b4ca7ca7c83b1aaf2d663d
- author
- brice · · 2026-08-03 00:38 UTC
- committer
- brice · · 2026-08-03 20:53 UTC
- parent
- a65e2c7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
44 files · +233 -599
M internal/usecase/daemon/attachment_parking_test.go +1 -1
| @@ -41,7 +41,7 @@ func TestAttachmentLossParksOnlyThatAttachment(t *testing.T) { | |||
| 41 | 41 | sess.mu.Unlock() | |
| 42 | 42 | require.False(t, firstAttached) | |
| 43 | 43 | require.True(t, secondAttached) | |
| 44 | - | require.Same(t, second, sess.client) | |
| 44 | + | require.Contains(t, sess.snapshotAttachments(), second) | |
| 45 | 45 | require.False(t, secondTransport.Closed()) | |
| 46 | 46 | } | |
| 47 | 47 | ||
| @@ -41,7 +41,7 @@ func TestAttachmentLossParksOnlyThatAttachment(t *testing.T) { | |||||
| 41 | sess.mu.Unlock() | 41 | sess.mu.Unlock() | ||
| 42 | require.False(t, firstAttached) | 42 | require.False(t, firstAttached) | ||
| 43 | require.True(t, secondAttached) | 43 | require.True(t, secondAttached) | ||
| 44 | - | require.Same(t, second, sess.client) | 44 | + | require.Contains(t, sess.snapshotAttachments(), second) |
| 45 | require.False(t, secondTransport.Closed()) | 45 | require.False(t, secondTransport.Closed()) | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
M internal/usecase/daemon/attachment_role.go +0 -11
| @@ -46,15 +46,6 @@ func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) at | |||
| 46 | 46 | return attachmentDetached | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | - | func addSnatchedLocked(entry attachmentSession, ac *attachedClient) { | |
| 50 | - | // Role-preserving parking is attachment-local. Session membership is enough | |
| 51 | - | // to admit the connection; there is no session-wide snatched owner. | |
| 52 | - | if entry == nil || ac == nil || entry.core() == nil { | |
| 53 | - | return | |
| 54 | - | } | |
| 55 | - | entry.core().registerAttachmentLocked(ac) | |
| 56 | - | } | |
| 57 | - | ||
| 58 | 49 | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | |
| 59 | 50 | return attachmentSessionRole(s, ac) | |
| 60 | 51 | } | |
| @@ -63,8 +54,6 @@ func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole { | |||
| 63 | 54 | return attachmentSessionRoleLocked(s, ac) | |
| 64 | 55 | } | |
| 65 | 56 | ||
| 66 | - | func (s *session) addSnatchedLocked(ac *attachedClient) { addSnatchedLocked(s, ac) } | |
| 67 | - | ||
| 68 | 57 | type attachmentRoleToken struct { | |
| 69 | 58 | sess attachmentSession | |
| 70 | 59 | ac *attachedClient | |
| @@ -46,15 +46,6 @@ func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) at | |||||
| 46 | return attachmentDetached | 46 | return attachmentDetached | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | - | func addSnatchedLocked(entry attachmentSession, ac *attachedClient) { | |||
| 50 | - | // Role-preserving parking is attachment-local. Session membership is enough | |||
| 51 | - | // to admit the connection; there is no session-wide snatched owner. | |||
| 52 | - | if entry == nil || ac == nil || entry.core() == nil { | |||
| 53 | - | return | |||
| 54 | - | } | |||
| 55 | - | entry.core().registerAttachmentLocked(ac) | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | 49 | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | ||
| 59 | return attachmentSessionRole(s, ac) | 50 | return attachmentSessionRole(s, ac) | ||
| 60 | } | 51 | } | ||
| @@ -63,8 +54,6 @@ func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole { | |||||
| 63 | return attachmentSessionRoleLocked(s, ac) | 54 | return attachmentSessionRoleLocked(s, ac) | ||
| 64 | } | 55 | } | ||
| 65 | 56 | ||||
| 66 | - | func (s *session) addSnatchedLocked(ac *attachedClient) { addSnatchedLocked(s, ac) } | |||
| 67 | - | ||||
| 68 | type attachmentRoleToken struct { | 57 | type attachmentRoleToken struct { | ||
| 69 | sess attachmentSession | 58 | sess attachmentSession | ||
| 70 | ac *attachedClient | 59 | ac *attachedClient | ||
M internal/usecase/daemon/attachment_transition_prelocked_test.go +4 -112
| @@ -2,120 +2,12 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | - | "time" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/stretchr/testify/require" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/vev/internal/domain" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 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{sessionCore: sessionCore{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 | 11 | func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure(t *testing.T) { | |
| 120 | 12 | t.Parallel() | |
| 121 | 13 | ||
| @@ -124,10 +16,10 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure | |||
| 124 | 16 | target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}, active: 0} | |
| 125 | 17 | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 126 | 18 | next.setSession(source) | |
| 127 | - | source.addSnatchedLocked(next) | |
| 19 | + | source.registerAttachmentLocked(next) | |
| 128 | 20 | old := &attachedClient{tr: &closeTrackingTransport{}} | |
| 129 | 21 | old.setSession(target) | |
| 130 | - | target.client = old | |
| 22 | + | target.registerAttachment(old) | |
| 131 | 23 | d.sessions[source.id] = source | |
| 132 | 24 | d.sessions[target.id] = target | |
| 133 | 25 | ||
| @@ -140,7 +32,7 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure | |||
| 140 | 32 | source: source, | |
| 141 | 33 | target: target, | |
| 142 | 34 | next: next, | |
| 143 | - | expectedRole: attachmentSnatched, | |
| 35 | + | expectedRole: attachmentActive, | |
| 144 | 36 | targetRole: attachmentActive, | |
| 145 | 37 | expectedTransport: next.transportSnapshot(), | |
| 146 | 38 | expectedTargetCurrent: old, | |
| @@ -160,7 +52,7 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure | |||
| 160 | 52 | ||
| 161 | 53 | require.ErrorIs(t, err, errAttachmentTransition) | |
| 162 | 54 | require.Nil(t, publication) | |
| 163 | - | require.Equal(t, attachmentSnatched, source.attachmentRole(next)) | |
| 55 | + | require.Equal(t, attachmentActive, source.attachmentRole(next)) | |
| 164 | 56 | require.Equal(t, attachmentDetached, target.attachmentRole(next)) | |
| 165 | 57 | require.Equal(t, attachmentActive, target.attachmentRole(old)) | |
| 166 | 58 | require.Same(t, source, next.currentSession()) | |
| @@ -2,120 +2,12 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | - | "time" | |||
| 6 | 5 | ||||
| 7 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 8 | 7 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 10 | ) | 9 | ) | ||
| 11 | 10 | ||||
| 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{sessionCore: sessionCore{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) { | 11 | func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure(t *testing.T) { | ||
| 120 | t.Parallel() | 12 | t.Parallel() | ||
| 121 | 13 | ||||
| @@ -124,10 +16,10 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure | |||||
| 124 | target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}, active: 0} | 16 | target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}, active: 0} | ||
| 125 | next := &attachedClient{tr: &closeTrackingTransport{}} | 17 | next := &attachedClient{tr: &closeTrackingTransport{}} | ||
| 126 | next.setSession(source) | 18 | next.setSession(source) | ||
| 127 | - | source.addSnatchedLocked(next) | 19 | + | source.registerAttachmentLocked(next) |
| 128 | old := &attachedClient{tr: &closeTrackingTransport{}} | 20 | old := &attachedClient{tr: &closeTrackingTransport{}} | ||
| 129 | old.setSession(target) | 21 | old.setSession(target) | ||
| 130 | - | target.client = old | 22 | + | target.registerAttachment(old) |
| 131 | d.sessions[source.id] = source | 23 | d.sessions[source.id] = source | ||
| 132 | d.sessions[target.id] = target | 24 | d.sessions[target.id] = target | ||
| 133 | 25 | ||||
| @@ -140,7 +32,7 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure | |||||
| 140 | source: source, | 32 | source: source, | ||
| 141 | target: target, | 33 | target: target, | ||
| 142 | next: next, | 34 | next: next, | ||
| 143 | - | expectedRole: attachmentSnatched, | 35 | + | expectedRole: attachmentActive, |
| 144 | targetRole: attachmentActive, | 36 | targetRole: attachmentActive, | ||
| 145 | expectedTransport: next.transportSnapshot(), | 37 | expectedTransport: next.transportSnapshot(), | ||
| 146 | expectedTargetCurrent: old, | 38 | expectedTargetCurrent: old, | ||
| @@ -160,7 +52,7 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure | |||||
| 160 | 52 | ||||
| 161 | require.ErrorIs(t, err, errAttachmentTransition) | 53 | require.ErrorIs(t, err, errAttachmentTransition) | ||
| 162 | require.Nil(t, publication) | 54 | require.Nil(t, publication) | ||
| 163 | - | require.Equal(t, attachmentSnatched, source.attachmentRole(next)) | 55 | + | require.Equal(t, attachmentActive, source.attachmentRole(next)) |
| 164 | require.Equal(t, attachmentDetached, target.attachmentRole(next)) | 56 | require.Equal(t, attachmentDetached, target.attachmentRole(next)) | ||
| 165 | require.Equal(t, attachmentActive, target.attachmentRole(old)) | 57 | require.Equal(t, attachmentActive, target.attachmentRole(old)) | ||
| 166 | require.Same(t, source, next.currentSession()) | 58 | require.Same(t, source, next.currentSession()) | ||
M internal/usecase/daemon/attention_test.go +6 -6
| @@ -176,7 +176,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) { | |||
| 176 | 176 | sctxW, cancelW := context.WithCancel(d.serveCtx) | |
| 177 | 177 | t.Cleanup(cancelW) | |
| 178 | 178 | tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW) | |
| 179 | - | sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", client: acW}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}} | |
| 179 | + | sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", attachments: map[*attachedClient]struct{}{acW: {}}}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}} | |
| 180 | 180 | acW.setSession(sessW) | |
| 181 | 181 | acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil) | |
| 182 | 182 | d.sessions[sessW.id] = sessW | |
| @@ -446,7 +446,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||
| 446 | 446 | sctxB, cancelB := context.WithCancel(d.serveCtx) | |
| 447 | 447 | t.Cleanup(cancelB) | |
| 448 | 448 | tbB := newTestTabWithContext(pB, sctxB, cancelB) | |
| 449 | - | sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", client: acB}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}} | |
| 449 | + | sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", attachments: map[*attachedClient]struct{}{acB: {}}}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}} | |
| 450 | 450 | acB.setSession(sessB) | |
| 451 | 451 | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | |
| 452 | 452 | d.sessions[sessB.id] = sessB | |
| @@ -465,7 +465,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||
| 465 | 465 | func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | |
| 466 | 466 | d, sess, ac, sends, releases := newManualTabSession(t, 3) | |
| 467 | 467 | sess.mu.Lock() | |
| 468 | - | sess.client = ac | |
| 468 | + | sess.registerAttachmentLocked(ac) | |
| 469 | 469 | sess.mu.Unlock() | |
| 470 | 470 | d.ptys = newBlockingOpenFactory(t, d) | |
| 471 | 471 | defer releases[0]() | |
| @@ -512,8 +512,8 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) { | |||
| 512 | 512 | require.NoError(t, d.jumpAttention(sess1, ac)) | |
| 513 | 513 | ||
| 514 | 514 | require.Same(t, sess2, ac.currentSession()) | |
| 515 | - | require.Same(t, ac, sess2.client) | |
| 516 | - | require.Nil(t, sess1.client) | |
| 515 | + | require.Contains(t, sess2.snapshotAttachments(), ac) | |
| 516 | + | require.Empty(t, sess1.snapshotAttachments()) | |
| 517 | 517 | require.Equal(t, 1, activeTabIndex(sess2)) | |
| 518 | 518 | _ = mustOutputData(t, sends) | |
| 519 | 519 | } | |
| @@ -556,7 +556,7 @@ func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) { | |||
| 556 | 556 | d.sessions[sess2.id] = sess2 | |
| 557 | 557 | ||
| 558 | 558 | sess1.mu.Lock() | |
| 559 | - | sess1.client = nil | |
| 559 | + | clearAttachmentsForTestLocked(sess1) | |
| 560 | 560 | sess1.mu.Unlock() | |
| 561 | 561 | ||
| 562 | 562 | err := d.jumpAttention(sess1, ac) | |
| @@ -176,7 +176,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) { | |||||
| 176 | sctxW, cancelW := context.WithCancel(d.serveCtx) | 176 | sctxW, cancelW := context.WithCancel(d.serveCtx) | ||
| 177 | t.Cleanup(cancelW) | 177 | t.Cleanup(cancelW) | ||
| 178 | tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW) | 178 | tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW) | ||
| 179 | - | sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", client: acW}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}} | 179 | + | sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", attachments: map[*attachedClient]struct{}{acW: {}}}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}} |
| 180 | acW.setSession(sessW) | 180 | acW.setSession(sessW) | ||
| 181 | acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil) | 181 | acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil) | ||
| 182 | d.sessions[sessW.id] = sessW | 182 | d.sessions[sessW.id] = sessW | ||
| @@ -446,7 +446,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||||
| 446 | sctxB, cancelB := context.WithCancel(d.serveCtx) | 446 | sctxB, cancelB := context.WithCancel(d.serveCtx) | ||
| 447 | t.Cleanup(cancelB) | 447 | t.Cleanup(cancelB) | ||
| 448 | tbB := newTestTabWithContext(pB, sctxB, cancelB) | 448 | tbB := newTestTabWithContext(pB, sctxB, cancelB) | ||
| 449 | - | sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", client: acB}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}} | 449 | + | sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", attachments: map[*attachedClient]struct{}{acB: {}}}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}} |
| 450 | acB.setSession(sessB) | 450 | acB.setSession(sessB) | ||
| 451 | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | 451 | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | ||
| 452 | d.sessions[sessB.id] = sessB | 452 | d.sessions[sessB.id] = sessB | ||
| @@ -465,7 +465,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||||
| 465 | func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | 465 | func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | ||
| 466 | d, sess, ac, sends, releases := newManualTabSession(t, 3) | 466 | d, sess, ac, sends, releases := newManualTabSession(t, 3) | ||
| 467 | sess.mu.Lock() | 467 | sess.mu.Lock() | ||
| 468 | - | sess.client = ac | 468 | + | sess.registerAttachmentLocked(ac) |
| 469 | sess.mu.Unlock() | 469 | sess.mu.Unlock() | ||
| 470 | d.ptys = newBlockingOpenFactory(t, d) | 470 | d.ptys = newBlockingOpenFactory(t, d) | ||
| 471 | defer releases[0]() | 471 | defer releases[0]() | ||
| @@ -512,8 +512,8 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) { | |||||
| 512 | require.NoError(t, d.jumpAttention(sess1, ac)) | 512 | require.NoError(t, d.jumpAttention(sess1, ac)) | ||
| 513 | 513 | ||||
| 514 | require.Same(t, sess2, ac.currentSession()) | 514 | require.Same(t, sess2, ac.currentSession()) | ||
| 515 | - | require.Same(t, ac, sess2.client) | 515 | + | require.Contains(t, sess2.snapshotAttachments(), ac) |
| 516 | - | require.Nil(t, sess1.client) | 516 | + | require.Empty(t, sess1.snapshotAttachments()) |
| 517 | require.Equal(t, 1, activeTabIndex(sess2)) | 517 | require.Equal(t, 1, activeTabIndex(sess2)) | ||
| 518 | _ = mustOutputData(t, sends) | 518 | _ = mustOutputData(t, sends) | ||
| 519 | } | 519 | } | ||
| @@ -556,7 +556,7 @@ func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) { | |||||
| 556 | d.sessions[sess2.id] = sess2 | 556 | d.sessions[sess2.id] = sess2 | ||
| 557 | 557 | ||||
| 558 | sess1.mu.Lock() | 558 | sess1.mu.Lock() | ||
| 559 | - | sess1.client = nil | 559 | + | clearAttachmentsForTestLocked(sess1) |
| 560 | sess1.mu.Unlock() | 560 | sess1.mu.Unlock() | ||
| 561 | 561 | ||||
| 562 | err := d.jumpAttention(sess1, ac) | 562 | err := d.jumpAttention(sess1, ac) | ||
M internal/usecase/daemon/bar_refresh_test.go +14 -14
| @@ -64,7 +64,7 @@ func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) | |||
| 64 | 64 | r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}} | |
| 65 | 65 | d := newBarRefreshTestDaemon(r, 100*time.Millisecond) | |
| 66 | 66 | sess := newBarRefreshTestSession() | |
| 67 | - | sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}} | |
| 67 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 120, Rows: 24}}) | |
| 68 | 68 | ||
| 69 | 69 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | |
| 70 | 70 | waitBarRefreshIdle(t, d) | |
| @@ -91,7 +91,7 @@ func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) { | |||
| 91 | 91 | sess := newBarRefreshTestSession() | |
| 92 | 92 | sess.name = "work" | |
| 93 | 93 | sess.cwd = "/repo" | |
| 94 | - | sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}} | |
| 94 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 132, Rows: 40}}) | |
| 95 | 95 | sess.active = 1 | |
| 96 | 96 | sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38} | |
| 97 | 97 | active := sess.tabs[1].focusedPane() | |
| @@ -131,7 +131,7 @@ func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) { | |||
| 131 | 131 | d.barScripts.cfg.topRight = "" | |
| 132 | 132 | d.barScripts.cfg.bottomRight = "" | |
| 133 | 133 | sess := newBarRefreshTestSession() | |
| 134 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 134 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 135 | 135 | d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"} | |
| 136 | 136 | ||
| 137 | 137 | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| @@ -146,7 +146,7 @@ func TestDefaultBarConfigDoesNotInvokeRunner(t *testing.T) { | |||
| 146 | 146 | d := newBarRefreshTestDaemon(r, time.Second) | |
| 147 | 147 | d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar) | |
| 148 | 148 | sess := newBarRefreshTestSession() | |
| 149 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 149 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 150 | 150 | ||
| 151 | 151 | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 152 | 152 | require.Empty(t, r.calls) | |
| @@ -156,7 +156,7 @@ func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) { | |||
| 156 | 156 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 157 | 157 | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 158 | 158 | sess := newBarRefreshTestSession() | |
| 159 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 159 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 160 | 160 | ||
| 161 | 161 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 162 | 162 | waitBarRefreshIdle(t, d) | |
| @@ -173,7 +173,7 @@ func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) { | |||
| 173 | 173 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 174 | 174 | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 175 | 175 | sess := newBarRefreshTestSession() | |
| 176 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 176 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 177 | 177 | ||
| 178 | 178 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false)) | |
| 179 | 179 | waitBarRefreshIdle(t, d) | |
| @@ -191,7 +191,7 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | |||
| 191 | 191 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 192 | 192 | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 193 | 193 | sess := newBarRefreshTestSession() | |
| 194 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 194 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 195 | 195 | ||
| 196 | 196 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | |
| 197 | 197 | waitBarRefreshIdle(t, d) | |
| @@ -219,7 +219,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) { | |||
| 219 | 219 | r := &fakeBarRunner{outs: []string{"top1", "bottom1"}} | |
| 220 | 220 | d := newBarRefreshTestDaemon(r, 60*time.Second) | |
| 221 | 221 | sess := newBarRefreshTestSession() | |
| 222 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 222 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 223 | 223 | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} | |
| 224 | 224 | ||
| 225 | 225 | // Verifies refreshBarScriptsAllSessions iterates every live session and | |
| @@ -252,7 +252,7 @@ func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | |||
| 252 | 252 | d := newBarRefreshTestDaemon(r, time.Second) | |
| 253 | 253 | d.log = slog.New(slog.NewTextHandler(&logs, nil)) | |
| 254 | 254 | sess := newBarRefreshTestSession() | |
| 255 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 255 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 256 | 256 | ||
| 257 | 257 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 258 | 258 | waitBarRefreshIdle(t, d) | |
| @@ -287,7 +287,7 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) { | |||
| 287 | 287 | r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"}) | |
| 288 | 288 | d := newBarRefreshTestDaemon(r, time.Second) | |
| 289 | 289 | sess := newBarRefreshTestSession() | |
| 290 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 290 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 291 | 291 | ||
| 292 | 292 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 293 | 293 | select { | |
| @@ -337,7 +337,7 @@ func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) { | |||
| 337 | 337 | d.log = logger | |
| 338 | 338 | d.baseEnv = []string{"PATH=/usr/bin"} | |
| 339 | 339 | sess := newBarRefreshTestSession() | |
| 340 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 340 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 341 | 341 | ||
| 342 | 342 | readBuf := func() string { | |
| 343 | 343 | mu.Lock() | |
| @@ -420,11 +420,11 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||
| 420 | 420 | a := newBarRefreshTestSession() | |
| 421 | 421 | a.id = "a" | |
| 422 | 422 | a.name = "a" | |
| 423 | - | a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 423 | + | a.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 424 | 424 | b := newBarRefreshTestSession() | |
| 425 | 425 | b.id = "b" | |
| 426 | 426 | b.name = "b" | |
| 427 | - | b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 427 | + | b.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 428 | 428 | ||
| 429 | 429 | require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false)) | |
| 430 | 430 | waitBarRefreshIdle(t, d) | |
| @@ -645,7 +645,7 @@ func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) { | |||
| 645 | 645 | d.baseEnv = tc.baseEnv | |
| 646 | 646 | sess := newBarRefreshTestSession() | |
| 647 | 647 | sess.env = tc.sessEnv | |
| 648 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 648 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 649 | 649 | ||
| 650 | 650 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 651 | 651 | waitBarRefreshIdle(t, d) | |
| @@ -64,7 +64,7 @@ func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) | |||||
| 64 | r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}} | 64 | r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}} | ||
| 65 | d := newBarRefreshTestDaemon(r, 100*time.Millisecond) | 65 | d := newBarRefreshTestDaemon(r, 100*time.Millisecond) | ||
| 66 | sess := newBarRefreshTestSession() | 66 | sess := newBarRefreshTestSession() | ||
| 67 | - | sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}} | 67 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 120, Rows: 24}}) |
| 68 | 68 | ||||
| 69 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | 69 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | ||
| 70 | waitBarRefreshIdle(t, d) | 70 | waitBarRefreshIdle(t, d) | ||
| @@ -91,7 +91,7 @@ func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) { | |||||
| 91 | sess := newBarRefreshTestSession() | 91 | sess := newBarRefreshTestSession() | ||
| 92 | sess.name = "work" | 92 | sess.name = "work" | ||
| 93 | sess.cwd = "/repo" | 93 | sess.cwd = "/repo" | ||
| 94 | - | sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}} | 94 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 132, Rows: 40}}) |
| 95 | sess.active = 1 | 95 | sess.active = 1 | ||
| 96 | sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38} | 96 | sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38} | ||
| 97 | active := sess.tabs[1].focusedPane() | 97 | active := sess.tabs[1].focusedPane() | ||
| @@ -131,7 +131,7 @@ func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) { | |||||
| 131 | d.barScripts.cfg.topRight = "" | 131 | d.barScripts.cfg.topRight = "" | ||
| 132 | d.barScripts.cfg.bottomRight = "" | 132 | d.barScripts.cfg.bottomRight = "" | ||
| 133 | sess := newBarRefreshTestSession() | 133 | sess := newBarRefreshTestSession() | ||
| 134 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 134 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) |
| 135 | d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"} | 135 | d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"} | ||
| 136 | 136 | ||||
| 137 | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | 137 | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | ||
| @@ -146,7 +146,7 @@ func TestDefaultBarConfigDoesNotInvokeRunner(t *testing.T) { | |||||
| 146 | d := newBarRefreshTestDaemon(r, time.Second) | 146 | d := newBarRefreshTestDaemon(r, time.Second) | ||
| 147 | d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar) | 147 | d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar) | ||
| 148 | sess := newBarRefreshTestSession() | 148 | sess := newBarRefreshTestSession() | ||
| 149 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 149 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) |
| 150 | 150 | ||||
| 151 | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | 151 | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | ||
| 152 | require.Empty(t, r.calls) | 152 | require.Empty(t, r.calls) | ||
| @@ -156,7 +156,7 @@ func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) { | |||||
| 156 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | 156 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | ||
| 157 | d := newBarRefreshTestDaemon(r, 5*time.Second) | 157 | d := newBarRefreshTestDaemon(r, 5*time.Second) | ||
| 158 | sess := newBarRefreshTestSession() | 158 | sess := newBarRefreshTestSession() | ||
| 159 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 159 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) |
| 160 | 160 | ||||
| 161 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | 161 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | ||
| 162 | waitBarRefreshIdle(t, d) | 162 | waitBarRefreshIdle(t, d) | ||
| @@ -173,7 +173,7 @@ func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) { | |||||
| 173 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | 173 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | ||
| 174 | d := newBarRefreshTestDaemon(r, 5*time.Second) | 174 | d := newBarRefreshTestDaemon(r, 5*time.Second) | ||
| 175 | sess := newBarRefreshTestSession() | 175 | sess := newBarRefreshTestSession() | ||
| 176 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 176 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) |
| 177 | 177 | ||||
| 178 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false)) | 178 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false)) | ||
| 179 | waitBarRefreshIdle(t, d) | 179 | waitBarRefreshIdle(t, d) | ||
| @@ -191,7 +191,7 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | |||||
| 191 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | 191 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | ||
| 192 | d := newBarRefreshTestDaemon(r, 5*time.Second) | 192 | d := newBarRefreshTestDaemon(r, 5*time.Second) | ||
| 193 | sess := newBarRefreshTestSession() | 193 | sess := newBarRefreshTestSession() | ||
| 194 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 194 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) |
| 195 | 195 | ||||
| 196 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | 196 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | ||
| 197 | waitBarRefreshIdle(t, d) | 197 | waitBarRefreshIdle(t, d) | ||
| @@ -219,7 +219,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) { | |||||
| 219 | r := &fakeBarRunner{outs: []string{"top1", "bottom1"}} | 219 | r := &fakeBarRunner{outs: []string{"top1", "bottom1"}} | ||
| 220 | d := newBarRefreshTestDaemon(r, 60*time.Second) | 220 | d := newBarRefreshTestDaemon(r, 60*time.Second) | ||
| 221 | sess := newBarRefreshTestSession() | 221 | sess := newBarRefreshTestSession() | ||
| 222 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 222 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) |
| 223 | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} | 223 | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} | ||
| 224 | 224 | ||||
| 225 | // Verifies refreshBarScriptsAllSessions iterates every live session and | 225 | // Verifies refreshBarScriptsAllSessions iterates every live session and | ||
| @@ -252,7 +252,7 @@ func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | |||||
| 252 | d := newBarRefreshTestDaemon(r, time.Second) | 252 | d := newBarRefreshTestDaemon(r, time.Second) | ||
| 253 | d.log = slog.New(slog.NewTextHandler(&logs, nil)) | 253 | d.log = slog.New(slog.NewTextHandler(&logs, nil)) | ||
| 254 | sess := newBarRefreshTestSession() | 254 | sess := newBarRefreshTestSession() | ||
| 255 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 255 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) |
| 256 | 256 | ||||
| 257 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | 257 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | ||
| 258 | waitBarRefreshIdle(t, d) | 258 | waitBarRefreshIdle(t, d) | ||
| @@ -287,7 +287,7 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) { | |||||
| 287 | r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"}) | 287 | r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"}) | ||
| 288 | d := newBarRefreshTestDaemon(r, time.Second) | 288 | d := newBarRefreshTestDaemon(r, time.Second) | ||
| 289 | sess := newBarRefreshTestSession() | 289 | sess := newBarRefreshTestSession() | ||
| 290 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 290 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) |
| 291 | 291 | ||||
| 292 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | 292 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | ||
| 293 | select { | 293 | select { | ||
| @@ -337,7 +337,7 @@ func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) { | |||||
| 337 | d.log = logger | 337 | d.log = logger | ||
| 338 | d.baseEnv = []string{"PATH=/usr/bin"} | 338 | d.baseEnv = []string{"PATH=/usr/bin"} | ||
| 339 | sess := newBarRefreshTestSession() | 339 | sess := newBarRefreshTestSession() | ||
| 340 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 340 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) |
| 341 | 341 | ||||
| 342 | readBuf := func() string { | 342 | readBuf := func() string { | ||
| 343 | mu.Lock() | 343 | mu.Lock() | ||
| @@ -420,11 +420,11 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||||
| 420 | a := newBarRefreshTestSession() | 420 | a := newBarRefreshTestSession() | ||
| 421 | a.id = "a" | 421 | a.id = "a" | ||
| 422 | a.name = "a" | 422 | a.name = "a" | ||
| 423 | - | a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 423 | + | a.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) |
| 424 | b := newBarRefreshTestSession() | 424 | b := newBarRefreshTestSession() | ||
| 425 | b.id = "b" | 425 | b.id = "b" | ||
| 426 | b.name = "b" | 426 | b.name = "b" | ||
| 427 | - | b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 427 | + | b.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) |
| 428 | 428 | ||||
| 429 | require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false)) | 429 | require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false)) | ||
| 430 | waitBarRefreshIdle(t, d) | 430 | waitBarRefreshIdle(t, d) | ||
| @@ -645,7 +645,7 @@ func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) { | |||||
| 645 | d.baseEnv = tc.baseEnv | 645 | d.baseEnv = tc.baseEnv | ||
| 646 | sess := newBarRefreshTestSession() | 646 | sess := newBarRefreshTestSession() | ||
| 647 | sess.env = tc.sessEnv | 647 | sess.env = tc.sessEnv | ||
| 648 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 648 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) |
| 649 | 649 | ||||
| 650 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | 650 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | ||
| 651 | waitBarRefreshIdle(t, d) | 651 | waitBarRefreshIdle(t, d) | ||
M internal/usecase/daemon/clipboard_test.go +7 -7
| @@ -231,7 +231,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |||
| 231 | 231 | win := newTestTabWithContext(p, sctx, cancel) | |
| 232 | 232 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 233 | 233 | ac.initOverlays() | |
| 234 | - | sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 234 | + | sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 235 | 235 | publishTiledPaneOwners(sess, win) | |
| 236 | 236 | ac.setSession(sess) | |
| 237 | 237 | d.sessions[sess.id] = sess | |
| @@ -265,7 +265,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) { | |||
| 265 | 265 | win := newTestTabWithContext(p, sctx, cancel) | |
| 266 | 266 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 267 | 267 | ac.initOverlays() | |
| 268 | - | sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 268 | + | sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 269 | 269 | publishTiledPaneOwners(sess, win) | |
| 270 | 270 | ac.setSession(sess) | |
| 271 | 271 | d.sessions[sess.id] = sess | |
| @@ -290,7 +290,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) { | |||
| 290 | 290 | win := newTestTabWithContext(p, sctx, cancel) | |
| 291 | 291 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 292 | 292 | ac.initOverlays() | |
| 293 | - | sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 293 | + | sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 294 | 294 | publishTiledPaneOwners(sess, win) | |
| 295 | 295 | ac.setSession(sess) | |
| 296 | 296 | d.sessions[sess.id] = sess | |
| @@ -363,7 +363,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) { | |||
| 363 | 363 | d.clipboardWorker(sess) | |
| 364 | 364 | ||
| 365 | 365 | require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client") | |
| 366 | - | require.Same(t, ac, sess.client, "stale clipboard send handling detached the former owner's client") | |
| 366 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale clipboard send handling detached the former owner's client") | |
| 367 | 367 | } | |
| 368 | 368 | ||
| 369 | 369 | type movingClipboardErrorTransport struct { | |
| @@ -432,7 +432,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing | |||
| 432 | 432 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | |
| 433 | 433 | ||
| 434 | 434 | require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O") | |
| 435 | - | require.Same(t, ac, sess.client) | |
| 435 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 436 | 436 | } | |
| 437 | 437 | ||
| 438 | 438 | func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |
| @@ -461,7 +461,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |||
| 461 | 461 | close(oldTransport.release) | |
| 462 | 462 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | |
| 463 | 463 | ||
| 464 | - | require.Same(t, ac, sess.client, "a send error from the pane's retired owner detached its client") | |
| 464 | + | require.Contains(t, sess.snapshotAttachments(), ac, "a send error from the pane's retired owner detached its client") | |
| 465 | 465 | select { | |
| 466 | 466 | case <-oldTransport.closed: | |
| 467 | 467 | t.Fatal("a send error from the pane's retired owner closed its client transport") | |
| @@ -495,7 +495,7 @@ func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) { | |||
| 495 | 495 | d.clipboardWorker(sess) | |
| 496 | 496 | require.Zero(t, oldTransport.sendCount(), "stale queued work reached its captured failing transport") | |
| 497 | 497 | require.Empty(t, newTransport.Sends(), "stale queued work was redirected to the replacement") | |
| 498 | - | require.Same(t, next, sess.client, "stale clipboard send handling detached the current client") | |
| 498 | + | require.Contains(t, sess.snapshotAttachments(), next, "stale clipboard send handling detached the current client") | |
| 499 | 499 | require.False(t, newTransport.Closed()) | |
| 500 | 500 | } | |
| 501 | 501 | ||
| @@ -231,7 +231,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |||||
| 231 | win := newTestTabWithContext(p, sctx, cancel) | 231 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 232 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 232 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 233 | ac.initOverlays() | 233 | ac.initOverlays() | ||
| 234 | - | sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | 234 | + | sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 235 | publishTiledPaneOwners(sess, win) | 235 | publishTiledPaneOwners(sess, win) | ||
| 236 | ac.setSession(sess) | 236 | ac.setSession(sess) | ||
| 237 | d.sessions[sess.id] = sess | 237 | d.sessions[sess.id] = sess | ||
| @@ -265,7 +265,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) { | |||||
| 265 | win := newTestTabWithContext(p, sctx, cancel) | 265 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 266 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 266 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 267 | ac.initOverlays() | 267 | ac.initOverlays() | ||
| 268 | - | sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | 268 | + | sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 269 | publishTiledPaneOwners(sess, win) | 269 | publishTiledPaneOwners(sess, win) | ||
| 270 | ac.setSession(sess) | 270 | ac.setSession(sess) | ||
| 271 | d.sessions[sess.id] = sess | 271 | d.sessions[sess.id] = sess | ||
| @@ -290,7 +290,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) { | |||||
| 290 | win := newTestTabWithContext(p, sctx, cancel) | 290 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 291 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 291 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 292 | ac.initOverlays() | 292 | ac.initOverlays() | ||
| 293 | - | sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | 293 | + | sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 294 | publishTiledPaneOwners(sess, win) | 294 | publishTiledPaneOwners(sess, win) | ||
| 295 | ac.setSession(sess) | 295 | ac.setSession(sess) | ||
| 296 | d.sessions[sess.id] = sess | 296 | d.sessions[sess.id] = sess | ||
| @@ -363,7 +363,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) { | |||||
| 363 | d.clipboardWorker(sess) | 363 | d.clipboardWorker(sess) | ||
| 364 | 364 | ||||
| 365 | require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client") | 365 | require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client") | ||
| 366 | - | require.Same(t, ac, sess.client, "stale clipboard send handling detached the former owner's client") | 366 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale clipboard send handling detached the former owner's client") |
| 367 | } | 367 | } | ||
| 368 | 368 | ||||
| 369 | type movingClipboardErrorTransport struct { | 369 | type movingClipboardErrorTransport struct { | ||
| @@ -432,7 +432,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing | |||||
| 432 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | 432 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | ||
| 433 | 433 | ||||
| 434 | require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O") | 434 | require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O") | ||
| 435 | - | require.Same(t, ac, sess.client) | 435 | + | require.Contains(t, sess.snapshotAttachments(), ac) |
| 436 | } | 436 | } | ||
| 437 | 437 | ||||
| 438 | func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | 438 | func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | ||
| @@ -461,7 +461,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |||||
| 461 | close(oldTransport.release) | 461 | close(oldTransport.release) | ||
| 462 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | 462 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | ||
| 463 | 463 | ||||
| 464 | - | require.Same(t, ac, sess.client, "a send error from the pane's retired owner detached its client") | 464 | + | require.Contains(t, sess.snapshotAttachments(), ac, "a send error from the pane's retired owner detached its client") |
| 465 | select { | 465 | select { | ||
| 466 | case <-oldTransport.closed: | 466 | case <-oldTransport.closed: | ||
| 467 | t.Fatal("a send error from the pane's retired owner closed its client transport") | 467 | t.Fatal("a send error from the pane's retired owner closed its client transport") | ||
| @@ -495,7 +495,7 @@ func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) { | |||||
| 495 | d.clipboardWorker(sess) | 495 | d.clipboardWorker(sess) | ||
| 496 | require.Zero(t, oldTransport.sendCount(), "stale queued work reached its captured failing transport") | 496 | require.Zero(t, oldTransport.sendCount(), "stale queued work reached its captured failing transport") | ||
| 497 | require.Empty(t, newTransport.Sends(), "stale queued work was redirected to the replacement") | 497 | require.Empty(t, newTransport.Sends(), "stale queued work was redirected to the replacement") | ||
| 498 | - | require.Same(t, next, sess.client, "stale clipboard send handling detached the current client") | 498 | + | require.Contains(t, sess.snapshotAttachments(), next, "stale clipboard send handling detached the current client") |
| 499 | require.False(t, newTransport.Closed()) | 499 | require.False(t, newTransport.Closed()) | ||
| 500 | } | 500 | } | ||
| 501 | 501 | ||||
M internal/usecase/daemon/control_test.go +5 -5
| @@ -93,7 +93,7 @@ func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) { | |||
| 93 | 93 | ac := &attachedClient{} | |
| 94 | 94 | ac.setSession(h.session) | |
| 95 | 95 | h.session.mu.Lock() | |
| 96 | - | h.session.client = ac | |
| 96 | + | h.session.registerAttachmentLocked(ac) | |
| 97 | 97 | h.session.mu.Unlock() | |
| 98 | 98 | invalidations := make(chan renderInvalidation, 1) | |
| 99 | 99 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| @@ -403,7 +403,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) { | |||
| 403 | 403 | require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary") | |
| 404 | 404 | sess.snapshotMu.Unlock() | |
| 405 | 405 | sess.mu.Lock() | |
| 406 | - | require.Nil(t, sess.client, "one-shots must work headless") | |
| 406 | + | require.Empty(t, sess.snapshotAttachmentsLocked(), "one-shots must work headless") | |
| 407 | 407 | sess.mu.Unlock() | |
| 408 | 408 | }) | |
| 409 | 409 | } | |
| @@ -613,7 +613,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T | |||
| 613 | 613 | d.mu.Unlock() | |
| 614 | 614 | require.NotNil(t, created) | |
| 615 | 615 | created.mu.Lock() | |
| 616 | - | require.Nil(t, created.client) | |
| 616 | + | require.Empty(t, created.snapshotAttachmentsLocked()) | |
| 617 | 617 | require.Equal(t, "/tmp/work", created.cwd) | |
| 618 | 618 | require.Equal(t, []string{"INHERITED=yes"}, created.env) | |
| 619 | 619 | require.True(t, created.terminal.TrueColor) | |
| @@ -622,7 +622,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T | |||
| 622 | 622 | tb.mu.Lock() | |
| 623 | 623 | require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size) | |
| 624 | 624 | tb.mu.Unlock() | |
| 625 | - | require.Nil(t, source.client) | |
| 625 | + | require.Empty(t, source.snapshotAttachments()) | |
| 626 | 626 | ||
| 627 | 627 | taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | |
| 628 | 628 | require.False(t, taken.OK) | |
| @@ -674,7 +674,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) { | |||
| 674 | 674 | d := newTestDaemon(t, nil, stubClock{}) | |
| 675 | 675 | work := addControlSession(d, "work", "t_work", "p_work") | |
| 676 | 676 | work.ephemeral = false | |
| 677 | - | work.client = &attachedClient{} | |
| 677 | + | work.registerAttachment(&attachedClient{}) | |
| 678 | 678 | work.mruAt.Store(2) | |
| 679 | 679 | tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 680 | 680 | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | |
| @@ -93,7 +93,7 @@ func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) { | |||||
| 93 | ac := &attachedClient{} | 93 | ac := &attachedClient{} | ||
| 94 | ac.setSession(h.session) | 94 | ac.setSession(h.session) | ||
| 95 | h.session.mu.Lock() | 95 | h.session.mu.Lock() | ||
| 96 | - | h.session.client = ac | 96 | + | h.session.registerAttachmentLocked(ac) |
| 97 | h.session.mu.Unlock() | 97 | h.session.mu.Unlock() | ||
| 98 | invalidations := make(chan renderInvalidation, 1) | 98 | invalidations := make(chan renderInvalidation, 1) | ||
| 99 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | 99 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | ||
| @@ -403,7 +403,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) { | |||||
| 403 | require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary") | 403 | require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary") | ||
| 404 | sess.snapshotMu.Unlock() | 404 | sess.snapshotMu.Unlock() | ||
| 405 | sess.mu.Lock() | 405 | sess.mu.Lock() | ||
| 406 | - | require.Nil(t, sess.client, "one-shots must work headless") | 406 | + | require.Empty(t, sess.snapshotAttachmentsLocked(), "one-shots must work headless") |
| 407 | sess.mu.Unlock() | 407 | sess.mu.Unlock() | ||
| 408 | }) | 408 | }) | ||
| 409 | } | 409 | } | ||
| @@ -613,7 +613,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T | |||||
| 613 | d.mu.Unlock() | 613 | d.mu.Unlock() | ||
| 614 | require.NotNil(t, created) | 614 | require.NotNil(t, created) | ||
| 615 | created.mu.Lock() | 615 | created.mu.Lock() | ||
| 616 | - | require.Nil(t, created.client) | 616 | + | require.Empty(t, created.snapshotAttachmentsLocked()) |
| 617 | require.Equal(t, "/tmp/work", created.cwd) | 617 | require.Equal(t, "/tmp/work", created.cwd) | ||
| 618 | require.Equal(t, []string{"INHERITED=yes"}, created.env) | 618 | require.Equal(t, []string{"INHERITED=yes"}, created.env) | ||
| 619 | require.True(t, created.terminal.TrueColor) | 619 | require.True(t, created.terminal.TrueColor) | ||
| @@ -622,7 +622,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T | |||||
| 622 | tb.mu.Lock() | 622 | tb.mu.Lock() | ||
| 623 | require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size) | 623 | require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size) | ||
| 624 | tb.mu.Unlock() | 624 | tb.mu.Unlock() | ||
| 625 | - | require.Nil(t, source.client) | 625 | + | require.Empty(t, source.snapshotAttachments()) |
| 626 | 626 | ||||
| 627 | taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | 627 | taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | ||
| 628 | require.False(t, taken.OK) | 628 | require.False(t, taken.OK) | ||
| @@ -674,7 +674,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) { | |||||
| 674 | d := newTestDaemon(t, nil, stubClock{}) | 674 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 675 | work := addControlSession(d, "work", "t_work", "p_work") | 675 | work := addControlSession(d, "work", "t_work", "p_work") | ||
| 676 | work.ephemeral = false | 676 | work.ephemeral = false | ||
| 677 | - | work.client = &attachedClient{} | 677 | + | work.registerAttachment(&attachedClient{}) |
| 678 | work.mruAt.Store(2) | 678 | work.mruAt.Store(2) | ||
| 679 | tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | 679 | tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | ||
| 680 | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | 680 | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | ||
M internal/usecase/daemon/copymode_lifecycle_test.go +1 -1
| @@ -98,7 +98,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||
| 98 | 98 | tb.panes["pane-2"] = closing | |
| 99 | 99 | ac := &attachedClient{output: newOutputStateStream()} | |
| 100 | 100 | ac.initOverlays() | |
| 101 | - | sess.client = ac | |
| 101 | + | sess.registerAttachment(ac) | |
| 102 | 102 | ac.setSession(sess) | |
| 103 | 103 | closing.mu.Lock() | |
| 104 | 104 | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| @@ -98,7 +98,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||||
| 98 | tb.panes["pane-2"] = closing | 98 | tb.panes["pane-2"] = closing | ||
| 99 | ac := &attachedClient{output: newOutputStateStream()} | 99 | ac := &attachedClient{output: newOutputStateStream()} | ||
| 100 | ac.initOverlays() | 100 | ac.initOverlays() | ||
| 101 | - | sess.client = ac | 101 | + | sess.registerAttachment(ac) |
| 102 | ac.setSession(sess) | 102 | ac.setSession(sess) | ||
| 103 | closing.mu.Lock() | 103 | closing.mu.Lock() | ||
| 104 | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators) | 104 | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators) | ||
M internal/usecase/daemon/copymode_test.go +3 -3
| @@ -48,7 +48,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin | |||
| 48 | 48 | ac := &attachedClient{output: newOutputStateStream()} | |
| 49 | 49 | failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)} | |
| 50 | 50 | ac.replaceTransport(failed) | |
| 51 | - | sess := &session{sessionCore: sessionCore{name: "work", client: ac}} | |
| 51 | + | sess := &session{sessionCore: sessionCore{name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}} | |
| 52 | 52 | ac.setSession(sess) | |
| 53 | 53 | ||
| 54 | 54 | used, err := d.boundedSendOutputErrTransport(ac, []byte("copy")) | |
| @@ -60,7 +60,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin | |||
| 60 | 60 | require.NoError(t, decodeErr) | |
| 61 | 61 | require.Equal(t, []byte("copy"), out.Data) | |
| 62 | 62 | d.detachOnSendError(sess, ac, used) | |
| 63 | - | require.Same(t, ac, sess.client) | |
| 63 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 64 | 64 | require.False(t, replacement.Closed()) | |
| 65 | 65 | } | |
| 66 | 66 | ||
| @@ -458,7 +458,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 458 | 458 | ||
| 459 | 459 | sess := firstSession(d) | |
| 460 | 460 | require.NotNil(t, sess) | |
| 461 | - | ac := sess.client | |
| 461 | + | ac := sess.snapshotAttachments()[0] | |
| 462 | 462 | require.NotNil(t, ac) | |
| 463 | 463 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 464 | 464 | awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame") | |
| @@ -48,7 +48,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin | |||||
| 48 | ac := &attachedClient{output: newOutputStateStream()} | 48 | ac := &attachedClient{output: newOutputStateStream()} | ||
| 49 | failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)} | 49 | failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)} | ||
| 50 | ac.replaceTransport(failed) | 50 | ac.replaceTransport(failed) | ||
| 51 | - | sess := &session{sessionCore: sessionCore{name: "work", client: ac}} | 51 | + | sess := &session{sessionCore: sessionCore{name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}} |
| 52 | ac.setSession(sess) | 52 | ac.setSession(sess) | ||
| 53 | 53 | ||||
| 54 | used, err := d.boundedSendOutputErrTransport(ac, []byte("copy")) | 54 | used, err := d.boundedSendOutputErrTransport(ac, []byte("copy")) | ||
| @@ -60,7 +60,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin | |||||
| 60 | require.NoError(t, decodeErr) | 60 | require.NoError(t, decodeErr) | ||
| 61 | require.Equal(t, []byte("copy"), out.Data) | 61 | require.Equal(t, []byte("copy"), out.Data) | ||
| 62 | d.detachOnSendError(sess, ac, used) | 62 | d.detachOnSendError(sess, ac, used) | ||
| 63 | - | require.Same(t, ac, sess.client) | 63 | + | require.Contains(t, sess.snapshotAttachments(), ac) |
| 64 | require.False(t, replacement.Closed()) | 64 | require.False(t, replacement.Closed()) | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| @@ -458,7 +458,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 458 | 458 | ||||
| 459 | sess := firstSession(d) | 459 | sess := firstSession(d) | ||
| 460 | require.NotNil(t, sess) | 460 | require.NotNil(t, sess) | ||
| 461 | - | ac := sess.client | 461 | + | ac := sess.snapshotAttachments()[0] |
| 462 | require.NotNil(t, ac) | 462 | require.NotNil(t, ac) | ||
| 463 | d.handleInput(sess, ac, []byte("\x1b ")) | 463 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| 464 | awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame") | 464 | awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame") | ||
M internal/usecase/daemon/daemon_test.go +2 -2
| @@ -413,7 +413,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis | |||
| 413 | 413 | } | |
| 414 | 414 | tabs = append(tabs, tb) | |
| 415 | 415 | } | |
| 416 | - | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac, attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: tabs} | |
| 416 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: tabs} | |
| 417 | 417 | for _, tb := range tabs { | |
| 418 | 418 | publishTiledPaneOwners(sess, tb) | |
| 419 | 419 | } | |
| @@ -537,7 +537,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) { | |||
| 537 | 537 | require.Equal(t, ports.MsgWelcome, welcome.Type) | |
| 538 | 538 | sess := firstSession(d) | |
| 539 | 539 | sess.mu.Lock() | |
| 540 | - | ac := sess.client | |
| 540 | + | ac := sess.snapshotAttachmentsLocked()[0] | |
| 541 | 541 | sess.mu.Unlock() | |
| 542 | 542 | require.NotNil(t, ac, "route must publish the attachment before Welcome returns") | |
| 543 | 543 | ||
| @@ -413,7 +413,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis | |||||
| 413 | } | 413 | } | ||
| 414 | tabs = append(tabs, tb) | 414 | tabs = append(tabs, tb) | ||
| 415 | } | 415 | } | ||
| 416 | - | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac, attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: tabs} | 416 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: tabs} |
| 417 | for _, tb := range tabs { | 417 | for _, tb := range tabs { | ||
| 418 | publishTiledPaneOwners(sess, tb) | 418 | publishTiledPaneOwners(sess, tb) | ||
| 419 | } | 419 | } | ||
| @@ -537,7 +537,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) { | |||||
| 537 | require.Equal(t, ports.MsgWelcome, welcome.Type) | 537 | require.Equal(t, ports.MsgWelcome, welcome.Type) | ||
| 538 | sess := firstSession(d) | 538 | sess := firstSession(d) | ||
| 539 | sess.mu.Lock() | 539 | sess.mu.Lock() | ||
| 540 | - | ac := sess.client | 540 | + | ac := sess.snapshotAttachmentsLocked()[0] |
| 541 | sess.mu.Unlock() | 541 | sess.mu.Unlock() | ||
| 542 | require.NotNil(t, ac, "route must publish the attachment before Welcome returns") | 542 | require.NotNil(t, ac, "route must publish the attachment before Welcome returns") | ||
| 543 | 543 | ||||
M internal/usecase/daemon/floating_open_test.go +3 -3
| @@ -130,11 +130,11 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||
| 130 | 130 | tb.mu.Unlock() | |
| 131 | 131 | require.NotNil(t, floating) | |
| 132 | 132 | sess.mu.Lock() | |
| 133 | - | sess.client = &attachedClient{} | |
| 133 | + | sess.registerAttachmentLocked(&attachedClient{}) | |
| 134 | 134 | sess.mu.Unlock() | |
| 135 | 135 | require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility") | |
| 136 | 136 | sess.mu.Lock() | |
| 137 | - | sess.client = nil | |
| 137 | + | clearAttachmentsForTestLocked(sess) | |
| 138 | 138 | sess.mu.Unlock() | |
| 139 | 139 | select { | |
| 140 | 140 | case <-pty.readerStarted: | |
| @@ -228,7 +228,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing. | |||
| 228 | 228 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 229 | 229 | ac.initOverlays() | |
| 230 | 230 | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 231 | - | sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", client: ac}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} | |
| 231 | + | sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} | |
| 232 | 232 | ac.setSession(sess) | |
| 233 | 233 | ||
| 234 | 234 | // Close must be safe to run before failure publication and without | |
| @@ -130,11 +130,11 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||||
| 130 | tb.mu.Unlock() | 130 | tb.mu.Unlock() | ||
| 131 | require.NotNil(t, floating) | 131 | require.NotNil(t, floating) | ||
| 132 | sess.mu.Lock() | 132 | sess.mu.Lock() | ||
| 133 | - | sess.client = &attachedClient{} | 133 | + | sess.registerAttachmentLocked(&attachedClient{}) |
| 134 | sess.mu.Unlock() | 134 | sess.mu.Unlock() | ||
| 135 | require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility") | 135 | require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility") | ||
| 136 | sess.mu.Lock() | 136 | sess.mu.Lock() | ||
| 137 | - | sess.client = nil | 137 | + | clearAttachmentsForTestLocked(sess) |
| 138 | sess.mu.Unlock() | 138 | sess.mu.Unlock() | ||
| 139 | select { | 139 | select { | ||
| 140 | case <-pty.readerStarted: | 140 | case <-pty.readerStarted: | ||
| @@ -228,7 +228,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing. | |||||
| 228 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 228 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 229 | ac.initOverlays() | 229 | ac.initOverlays() | ||
| 230 | sessCtx, cancelSession := context.WithCancel(t.Context()) | 230 | sessCtx, cancelSession := context.WithCancel(t.Context()) | ||
| 231 | - | sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", client: ac}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} | 231 | + | sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} |
| 232 | ac.setSession(sess) | 232 | ac.setSession(sess) | ||
| 233 | 233 | ||||
| 234 | // Close must be safe to run before failure publication and without | 234 | // Close must be safe to run before failure publication and without | ||
M internal/usecase/daemon/floating_render_test.go +1 -1
| @@ -595,7 +595,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||
| 595 | 595 | installTestFloating(tb, p, true) | |
| 596 | 596 | ac := &attachedClient{} | |
| 597 | 597 | ac.initOverlays() | |
| 598 | - | sess := &session{sessionCore: sessionCore{client: ac}, tabs: []*tab{tb}} | |
| 598 | + | sess := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}} | |
| 599 | 599 | base := barState{} | |
| 600 | 600 | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 601 | 601 | ||
| @@ -595,7 +595,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||||
| 595 | installTestFloating(tb, p, true) | 595 | installTestFloating(tb, p, true) | ||
| 596 | ac := &attachedClient{} | 596 | ac := &attachedClient{} | ||
| 597 | ac.initOverlays() | 597 | ac.initOverlays() | ||
| 598 | - | sess := &session{sessionCore: sessionCore{client: ac}, tabs: []*tab{tb}} | 598 | + | sess := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}} |
| 599 | base := barState{} | 599 | base := barState{} | ||
| 600 | cfg := domain.FloatingConfig{Width: 100, Height: 100} | 600 | cfg := domain.FloatingConfig{Width: 100, Height: 100} | ||
| 601 | 601 | ||||
M internal/usecase/daemon/floating_test.go +3 -3
| @@ -406,8 +406,8 @@ func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) { | |||
| 406 | 406 | sourceClient.initOverlays() | |
| 407 | 407 | destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}} | |
| 408 | 408 | destinationClient.initOverlays() | |
| 409 | - | source.client = sourceClient | |
| 410 | - | destination.client = destinationClient | |
| 409 | + | source.registerAttachment(sourceClient) | |
| 410 | + | destination.registerAttachment(destinationClient) | |
| 411 | 411 | ||
| 412 | 412 | floating.onExit() | |
| 413 | 413 | d.sessWg.Wait() | |
| @@ -770,7 +770,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) { | |||
| 770 | 770 | tr, _ := newCapturingTransport(t) | |
| 771 | 771 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 772 | 772 | ac.initOverlays() | |
| 773 | - | sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", client: ac}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 773 | + | sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 774 | 774 | ac.setSession(sess) | |
| 775 | 775 | ||
| 776 | 776 | d.startFloating(sess, tb, tc.userOpen) | |
| @@ -406,8 +406,8 @@ func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) { | |||||
| 406 | sourceClient.initOverlays() | 406 | sourceClient.initOverlays() | ||
| 407 | destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}} | 407 | destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}} | ||
| 408 | destinationClient.initOverlays() | 408 | destinationClient.initOverlays() | ||
| 409 | - | source.client = sourceClient | 409 | + | source.registerAttachment(sourceClient) |
| 410 | - | destination.client = destinationClient | 410 | + | destination.registerAttachment(destinationClient) |
| 411 | 411 | ||||
| 412 | floating.onExit() | 412 | floating.onExit() | ||
| 413 | d.sessWg.Wait() | 413 | d.sessWg.Wait() | ||
| @@ -770,7 +770,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) { | |||||
| 770 | tr, _ := newCapturingTransport(t) | 770 | tr, _ := newCapturingTransport(t) | ||
| 771 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 771 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 772 | ac.initOverlays() | 772 | ac.initOverlays() | ||
| 773 | - | sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", client: ac}, tabs: []*tab{tb}, ctx: t.Context()} | 773 | + | sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()} |
| 774 | ac.setSession(sess) | 774 | ac.setSession(sess) | ||
| 775 | 775 | ||||
| 776 | d.startFloating(sess, tb, tc.userOpen) | 776 | d.startFloating(sess, tb, tc.userOpen) | ||
M internal/usecase/daemon/input_test.go +5 -5
| @@ -53,7 +53,7 @@ func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) { | |||
| 53 | 53 | ac.setSession(h.session) | |
| 54 | 54 | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | |
| 55 | 55 | h.session.mu.Lock() | |
| 56 | - | h.session.client = ac | |
| 56 | + | h.session.registerAttachmentLocked(ac) | |
| 57 | 57 | h.session.mu.Unlock() | |
| 58 | 58 | invalidations := make(chan renderInvalidation, 1) | |
| 59 | 59 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| @@ -82,7 +82,7 @@ func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) { | |||
| 82 | 82 | ac.setSession(h.session) | |
| 83 | 83 | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | |
| 84 | 84 | h.session.mu.Lock() | |
| 85 | - | h.session.client = ac | |
| 85 | + | h.session.registerAttachmentLocked(ac) | |
| 86 | 86 | h.session.mu.Unlock() | |
| 87 | 87 | invalidations := make(chan renderInvalidation, 1) | |
| 88 | 88 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| @@ -232,7 +232,7 @@ func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) { | |||
| 232 | 232 | rc.attach(ac) | |
| 233 | 233 | } else { | |
| 234 | 234 | sess.mu.Lock() | |
| 235 | - | sess.client = nil | |
| 235 | + | clearAttachmentsForTestLocked(sess) | |
| 236 | 236 | sess.mu.Unlock() | |
| 237 | 237 | } | |
| 238 | 238 | ||
| @@ -1015,7 +1015,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) { | |||
| 1015 | 1015 | // A displaced attachment makes the previously valid handoff stale by the | |
| 1016 | 1016 | // time switchToTarget commits it. | |
| 1017 | 1017 | current.mu.Lock() | |
| 1018 | - | current.client = nil | |
| 1018 | + | clearAttachmentsForTestLocked(current) | |
| 1019 | 1019 | current.mu.Unlock() | |
| 1020 | 1020 | ||
| 1021 | 1021 | d.backSession(current, ac) | |
| @@ -1224,7 +1224,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) { | |||
| 1224 | 1224 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 1225 | 1225 | d.handleInput(sess, ac, []byte("DET\r")) | |
| 1226 | 1226 | ||
| 1227 | - | require.Nil(t, sess.client) | |
| 1227 | + | require.Empty(t, sess.snapshotAttachments()) | |
| 1228 | 1228 | f := awaitFrame(t, sends, ports.MsgDetached) | |
| 1229 | 1229 | det, err := ports.UnmarshalDetached(f.Payload) | |
| 1230 | 1230 | require.NoError(t, err) | |
| @@ -53,7 +53,7 @@ func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) { | |||||
| 53 | ac.setSession(h.session) | 53 | ac.setSession(h.session) | ||
| 54 | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | 54 | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | ||
| 55 | h.session.mu.Lock() | 55 | h.session.mu.Lock() | ||
| 56 | - | h.session.client = ac | 56 | + | h.session.registerAttachmentLocked(ac) |
| 57 | h.session.mu.Unlock() | 57 | h.session.mu.Unlock() | ||
| 58 | invalidations := make(chan renderInvalidation, 1) | 58 | invalidations := make(chan renderInvalidation, 1) | ||
| 59 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | 59 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | ||
| @@ -82,7 +82,7 @@ func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) { | |||||
| 82 | ac.setSession(h.session) | 82 | ac.setSession(h.session) | ||
| 83 | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | 83 | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | ||
| 84 | h.session.mu.Lock() | 84 | h.session.mu.Lock() | ||
| 85 | - | h.session.client = ac | 85 | + | h.session.registerAttachmentLocked(ac) |
| 86 | h.session.mu.Unlock() | 86 | h.session.mu.Unlock() | ||
| 87 | invalidations := make(chan renderInvalidation, 1) | 87 | invalidations := make(chan renderInvalidation, 1) | ||
| 88 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | 88 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | ||
| @@ -232,7 +232,7 @@ func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) { | |||||
| 232 | rc.attach(ac) | 232 | rc.attach(ac) | ||
| 233 | } else { | 233 | } else { | ||
| 234 | sess.mu.Lock() | 234 | sess.mu.Lock() | ||
| 235 | - | sess.client = nil | 235 | + | clearAttachmentsForTestLocked(sess) |
| 236 | sess.mu.Unlock() | 236 | sess.mu.Unlock() | ||
| 237 | } | 237 | } | ||
| 238 | 238 | ||||
| @@ -1015,7 +1015,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) { | |||||
| 1015 | // A displaced attachment makes the previously valid handoff stale by the | 1015 | // A displaced attachment makes the previously valid handoff stale by the | ||
| 1016 | // time switchToTarget commits it. | 1016 | // time switchToTarget commits it. | ||
| 1017 | current.mu.Lock() | 1017 | current.mu.Lock() | ||
| 1018 | - | current.client = nil | 1018 | + | clearAttachmentsForTestLocked(current) |
| 1019 | current.mu.Unlock() | 1019 | current.mu.Unlock() | ||
| 1020 | 1020 | ||||
| 1021 | d.backSession(current, ac) | 1021 | d.backSession(current, ac) | ||
| @@ -1224,7 +1224,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) { | |||||
| 1224 | d.handleInput(sess, ac, []byte("\x1b ")) | 1224 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| 1225 | d.handleInput(sess, ac, []byte("DET\r")) | 1225 | d.handleInput(sess, ac, []byte("DET\r")) | ||
| 1226 | 1226 | ||||
| 1227 | - | require.Nil(t, sess.client) | 1227 | + | require.Empty(t, sess.snapshotAttachments()) |
| 1228 | f := awaitFrame(t, sends, ports.MsgDetached) | 1228 | f := awaitFrame(t, sends, ports.MsgDetached) | ||
| 1229 | det, err := ports.UnmarshalDetached(f.Payload) | 1229 | det, err := ports.UnmarshalDetached(f.Payload) | ||
| 1230 | require.NoError(t, err) | 1230 | require.NoError(t, err) | ||
M internal/usecase/daemon/move_cleanup_test.go +1 -1
| @@ -163,7 +163,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) { | |||
| 163 | 163 | awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment") | |
| 164 | 164 | ||
| 165 | 165 | source.mu.Lock() | |
| 166 | - | source.client = replacement | |
| 166 | + | source.registerAttachmentLocked(replacement) | |
| 167 | 167 | source.mu.Unlock() | |
| 168 | 168 | close(releaseSnapshot) | |
| 169 | 169 | source.layoutApplyMu.Unlock() | |
| @@ -163,7 +163,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) { | |||||
| 163 | awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment") | 163 | awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment") | ||
| 164 | 164 | ||||
| 165 | source.mu.Lock() | 165 | source.mu.Lock() | ||
| 166 | - | source.client = replacement | 166 | + | source.registerAttachmentLocked(replacement) |
| 167 | source.mu.Unlock() | 167 | source.mu.Unlock() | ||
| 168 | close(releaseSnapshot) | 168 | close(releaseSnapshot) | ||
| 169 | source.layoutApplyMu.Unlock() | 169 | source.layoutApplyMu.Unlock() | ||
D internal/usecase/daemon/move_final_source_attachment_race_test.go +0 -211
| @@ -1,211 +0,0 @@ | |||
| 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 | - | type finalMoveAttachmentMutation int | |
| 12 | - | ||
| 13 | - | const ( | |
| 14 | - | installActiveAfterFinalMoveAdmission finalMoveAttachmentMutation = iota | |
| 15 | - | installSnatchedAfterFinalMoveAdmission | |
| 16 | - | replaceSnatchedAfterFinalMoveAdmission | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | func newFinalMoveRaceClient(sess *session) *attachedClient { | |
| 20 | - | ac := &attachedClient{ | |
| 21 | - | tr: &closeTrackingTransport{}, | |
| 22 | - | output: newOutputStateStream(), | |
| 23 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 24 | - | } | |
| 25 | - | ac.initOverlays() | |
| 26 | - | ac.setSession(sess) | |
| 27 | - | return ac | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | func makeFinalMoveSourceHeadless(source *session, original *attachedClient) { | |
| 31 | - | source.mu.Lock() | |
| 32 | - | source.client = nil | |
| 33 | - | source.mu.Unlock() | |
| 34 | - | original.setSession(nil) | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | func mutateFinalMoveAttachments(source *session, mutation finalMoveAttachmentMutation) (current, replaced *attachedClient) { | |
| 38 | - | current = newFinalMoveRaceClient(source) | |
| 39 | - | source.mu.Lock() | |
| 40 | - | switch mutation { | |
| 41 | - | case installActiveAfterFinalMoveAdmission: | |
| 42 | - | source.client = current | |
| 43 | - | case installSnatchedAfterFinalMoveAdmission: | |
| 44 | - | source.addSnatchedLocked(current) | |
| 45 | - | case replaceSnatchedAfterFinalMoveAdmission: | |
| 46 | - | for ac := range source.snatched { | |
| 47 | - | replaced = ac | |
| 48 | - | delete(source.snatched, ac) | |
| 49 | - | ac.setSession(nil) | |
| 50 | - | break | |
| 51 | - | } | |
| 52 | - | source.addSnatchedLocked(current) | |
| 53 | - | } | |
| 54 | - | source.mu.Unlock() | |
| 55 | - | return current, replaced | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | func assertRejectedFinalMoveAttachmentPreserved(t *testing.T, source *session, current, replaced *attachedClient, mutation finalMoveAttachmentMutation) { | |
| 59 | - | t.Helper() | |
| 60 | - | require.Same(t, source, current.currentSession(), "the attachment admitted during the race must not be orphaned") | |
| 61 | - | switch mutation { | |
| 62 | - | case installActiveAfterFinalMoveAdmission: | |
| 63 | - | require.Equal(t, attachmentActive, source.attachmentRole(current)) | |
| 64 | - | case installSnatchedAfterFinalMoveAdmission, replaceSnatchedAfterFinalMoveAdmission: | |
| 65 | - | require.Equal(t, attachmentSnatched, source.attachmentRole(current)) | |
| 66 | - | } | |
| 67 | - | if replaced != nil { | |
| 68 | - | require.Nil(t, replaced.currentSession()) | |
| 69 | - | require.Equal(t, attachmentDetached, source.attachmentRole(replaced)) | |
| 70 | - | } | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | func TestMovePaneFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) { | |
| 74 | - | for _, tc := range []struct { | |
| 75 | - | name string | |
| 76 | - | mutation finalMoveAttachmentMutation | |
| 77 | - | existing bool | |
| 78 | - | }{ | |
| 79 | - | {name: "new active", mutation: installActiveAfterFinalMoveAdmission}, | |
| 80 | - | {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission}, | |
| 81 | - | {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true}, | |
| 82 | - | } { | |
| 83 | - | t.Run(tc.name, func(t *testing.T) { | |
| 84 | - | d, source, sourceTab, moved, destination, destinationTab, release := newMoveGateRaceFixture(t) | |
| 85 | - | defer release() | |
| 86 | - | original := source.client | |
| 87 | - | makeFinalMoveSourceHeadless(source, original) | |
| 88 | - | if tc.existing { | |
| 89 | - | existing := newFinalMoveRaceClient(source) | |
| 90 | - | source.mu.Lock() | |
| 91 | - | source.addSnatchedLocked(existing) | |
| 92 | - | source.mu.Unlock() | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | sourceGeneration := sourceTab.layoutGeneration | |
| 96 | - | destinationGeneration := destinationTab.layoutGeneration | |
| 97 | - | destinationPaneAtMovedID := destinationTab.panes[moved.id] | |
| 98 | - | owner := moved.ownerSnapshot() | |
| 99 | - | barrier := make(chan struct{}) | |
| 100 | - | releaseMove := make(chan struct{}) | |
| 101 | - | d.afterMovePaneSourceSnapshot = func() { | |
| 102 | - | close(barrier) | |
| 103 | - | <-releaseMove | |
| 104 | - | } | |
| 105 | - | defer func() { d.afterMovePaneSourceSnapshot = nil }() | |
| 106 | - | ||
| 107 | - | moveDone := make(chan error, 1) | |
| 108 | - | go func() { | |
| 109 | - | moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab)) | |
| 110 | - | }() | |
| 111 | - | waitMoveRace(t, barrier, "move pane admission") | |
| 112 | - | current, replaced := mutateFinalMoveAttachments(source, tc.mutation) | |
| 113 | - | currentGeneration := current.roleGeneration.Load() | |
| 114 | - | close(releaseMove) | |
| 115 | - | err := waitMoveRace(t, moveDone, "move pane rejection") | |
| 116 | - | ||
| 117 | - | require.ErrorIs(t, err, errMoveStaleTarget) | |
| 118 | - | d.mu.Lock() | |
| 119 | - | require.Same(t, source, d.sessions[source.id]) | |
| 120 | - | require.Same(t, destination, d.sessions[destination.id]) | |
| 121 | - | d.mu.Unlock() | |
| 122 | - | source.mu.Lock() | |
| 123 | - | require.Equal(t, []*tab{sourceTab}, source.tabs) | |
| 124 | - | source.mu.Unlock() | |
| 125 | - | sourceTab.mu.Lock() | |
| 126 | - | require.Same(t, moved, sourceTab.panes[moved.id]) | |
| 127 | - | require.Equal(t, sourceGeneration, sourceTab.layoutGeneration) | |
| 128 | - | sourceTab.mu.Unlock() | |
| 129 | - | destinationTab.mu.Lock() | |
| 130 | - | require.Same(t, destinationPaneAtMovedID, destinationTab.panes[moved.id]) | |
| 131 | - | require.Equal(t, destinationGeneration, destinationTab.layoutGeneration) | |
| 132 | - | destinationTab.mu.Unlock() | |
| 133 | - | require.Equal(t, owner, moved.ownerSnapshot()) | |
| 134 | - | require.Equal(t, currentGeneration, current.roleGeneration.Load()) | |
| 135 | - | assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation) | |
| 136 | - | }) | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | func TestMoveTabFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) { | |
| 141 | - | for _, tc := range []struct { | |
| 142 | - | name string | |
| 143 | - | mutation finalMoveAttachmentMutation | |
| 144 | - | existing bool | |
| 145 | - | }{ | |
| 146 | - | {name: "new active", mutation: installActiveAfterFinalMoveAdmission}, | |
| 147 | - | {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission}, | |
| 148 | - | {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true}, | |
| 149 | - | } { | |
| 150 | - | t.Run(tc.name, func(t *testing.T) { | |
| 151 | - | d, source, original, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 152 | - | moved := source.tabs[0] | |
| 153 | - | moved.stableID = "moved-tab" | |
| 154 | - | makeFinalMoveSourceHeadless(source, original) | |
| 155 | - | if tc.existing { | |
| 156 | - | existing := newFinalMoveRaceClient(source) | |
| 157 | - | source.mu.Lock() | |
| 158 | - | source.addSnatchedLocked(existing) | |
| 159 | - | source.mu.Unlock() | |
| 160 | - | } | |
| 161 | - | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 162 | - | destinationTabs := append([]*tab(nil), destination.tabs...) | |
| 163 | - | sourceGeneration := moved.layoutGeneration | |
| 164 | - | owners := make(map[*pane]*paneOwner, len(moved.panes)) | |
| 165 | - | for _, p := range moved.panes { | |
| 166 | - | owners[p] = p.ownerSnapshot() | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | barrier := make(chan struct{}) | |
| 170 | - | releaseMove := make(chan struct{}) | |
| 171 | - | d.afterMoveTabSourceSnapshot = func() { | |
| 172 | - | close(barrier) | |
| 173 | - | <-releaseMove | |
| 174 | - | } | |
| 175 | - | defer func() { d.afterMoveTabSourceSnapshot = nil }() | |
| 176 | - | moveDone := make(chan error, 1) | |
| 177 | - | go func() { | |
| 178 | - | moveDone <- d.moveTab(moveTabRequest{ | |
| 179 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 180 | - | SourceTabID: domain.TabStableID(moved.stableID), | |
| 181 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 182 | - | }) | |
| 183 | - | }() | |
| 184 | - | waitMoveRace(t, barrier, "move tab admission") | |
| 185 | - | current, replaced := mutateFinalMoveAttachments(source, tc.mutation) | |
| 186 | - | currentGeneration := current.roleGeneration.Load() | |
| 187 | - | close(releaseMove) | |
| 188 | - | err := waitMoveRace(t, moveDone, "move tab rejection") | |
| 189 | - | ||
| 190 | - | require.ErrorIs(t, err, errMoveStaleTarget) | |
| 191 | - | d.mu.Lock() | |
| 192 | - | require.Same(t, source, d.sessions[source.id]) | |
| 193 | - | require.Same(t, destination, d.sessions[destination.id]) | |
| 194 | - | d.mu.Unlock() | |
| 195 | - | source.mu.Lock() | |
| 196 | - | require.Equal(t, []*tab{moved}, source.tabs) | |
| 197 | - | source.mu.Unlock() | |
| 198 | - | destination.mu.Lock() | |
| 199 | - | require.Equal(t, destinationTabs, destination.tabs) | |
| 200 | - | destination.mu.Unlock() | |
| 201 | - | moved.mu.Lock() | |
| 202 | - | require.Equal(t, sourceGeneration, moved.layoutGeneration) | |
| 203 | - | moved.mu.Unlock() | |
| 204 | - | for p, owner := range owners { | |
| 205 | - | require.Equal(t, owner, p.ownerSnapshot()) | |
| 206 | - | } | |
| 207 | - | require.Equal(t, currentGeneration, current.roleGeneration.Load()) | |
| 208 | - | assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation) | |
| 209 | - | }) | |
| 210 | - | } | |
| 211 | - | } | |
| @@ -1,211 +0,0 @@ | |||||
| 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 | - | type finalMoveAttachmentMutation int | |||
| 12 | - | ||||
| 13 | - | const ( | |||
| 14 | - | installActiveAfterFinalMoveAdmission finalMoveAttachmentMutation = iota | |||
| 15 | - | installSnatchedAfterFinalMoveAdmission | |||
| 16 | - | replaceSnatchedAfterFinalMoveAdmission | |||
| 17 | - | ) | |||
| 18 | - | ||||
| 19 | - | func newFinalMoveRaceClient(sess *session) *attachedClient { | |||
| 20 | - | ac := &attachedClient{ | |||
| 21 | - | tr: &closeTrackingTransport{}, | |||
| 22 | - | output: newOutputStateStream(), | |||
| 23 | - | size: domain.Size{Cols: 80, Rows: 24}, | |||
| 24 | - | } | |||
| 25 | - | ac.initOverlays() | |||
| 26 | - | ac.setSession(sess) | |||
| 27 | - | return ac | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | func makeFinalMoveSourceHeadless(source *session, original *attachedClient) { | |||
| 31 | - | source.mu.Lock() | |||
| 32 | - | source.client = nil | |||
| 33 | - | source.mu.Unlock() | |||
| 34 | - | original.setSession(nil) | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | func mutateFinalMoveAttachments(source *session, mutation finalMoveAttachmentMutation) (current, replaced *attachedClient) { | |||
| 38 | - | current = newFinalMoveRaceClient(source) | |||
| 39 | - | source.mu.Lock() | |||
| 40 | - | switch mutation { | |||
| 41 | - | case installActiveAfterFinalMoveAdmission: | |||
| 42 | - | source.client = current | |||
| 43 | - | case installSnatchedAfterFinalMoveAdmission: | |||
| 44 | - | source.addSnatchedLocked(current) | |||
| 45 | - | case replaceSnatchedAfterFinalMoveAdmission: | |||
| 46 | - | for ac := range source.snatched { | |||
| 47 | - | replaced = ac | |||
| 48 | - | delete(source.snatched, ac) | |||
| 49 | - | ac.setSession(nil) | |||
| 50 | - | break | |||
| 51 | - | } | |||
| 52 | - | source.addSnatchedLocked(current) | |||
| 53 | - | } | |||
| 54 | - | source.mu.Unlock() | |||
| 55 | - | return current, replaced | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | func assertRejectedFinalMoveAttachmentPreserved(t *testing.T, source *session, current, replaced *attachedClient, mutation finalMoveAttachmentMutation) { | |||
| 59 | - | t.Helper() | |||
| 60 | - | require.Same(t, source, current.currentSession(), "the attachment admitted during the race must not be orphaned") | |||
| 61 | - | switch mutation { | |||
| 62 | - | case installActiveAfterFinalMoveAdmission: | |||
| 63 | - | require.Equal(t, attachmentActive, source.attachmentRole(current)) | |||
| 64 | - | case installSnatchedAfterFinalMoveAdmission, replaceSnatchedAfterFinalMoveAdmission: | |||
| 65 | - | require.Equal(t, attachmentSnatched, source.attachmentRole(current)) | |||
| 66 | - | } | |||
| 67 | - | if replaced != nil { | |||
| 68 | - | require.Nil(t, replaced.currentSession()) | |||
| 69 | - | require.Equal(t, attachmentDetached, source.attachmentRole(replaced)) | |||
| 70 | - | } | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | func TestMovePaneFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) { | |||
| 74 | - | for _, tc := range []struct { | |||
| 75 | - | name string | |||
| 76 | - | mutation finalMoveAttachmentMutation | |||
| 77 | - | existing bool | |||
| 78 | - | }{ | |||
| 79 | - | {name: "new active", mutation: installActiveAfterFinalMoveAdmission}, | |||
| 80 | - | {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission}, | |||
| 81 | - | {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true}, | |||
| 82 | - | } { | |||
| 83 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 84 | - | d, source, sourceTab, moved, destination, destinationTab, release := newMoveGateRaceFixture(t) | |||
| 85 | - | defer release() | |||
| 86 | - | original := source.client | |||
| 87 | - | makeFinalMoveSourceHeadless(source, original) | |||
| 88 | - | if tc.existing { | |||
| 89 | - | existing := newFinalMoveRaceClient(source) | |||
| 90 | - | source.mu.Lock() | |||
| 91 | - | source.addSnatchedLocked(existing) | |||
| 92 | - | source.mu.Unlock() | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | sourceGeneration := sourceTab.layoutGeneration | |||
| 96 | - | destinationGeneration := destinationTab.layoutGeneration | |||
| 97 | - | destinationPaneAtMovedID := destinationTab.panes[moved.id] | |||
| 98 | - | owner := moved.ownerSnapshot() | |||
| 99 | - | barrier := make(chan struct{}) | |||
| 100 | - | releaseMove := make(chan struct{}) | |||
| 101 | - | d.afterMovePaneSourceSnapshot = func() { | |||
| 102 | - | close(barrier) | |||
| 103 | - | <-releaseMove | |||
| 104 | - | } | |||
| 105 | - | defer func() { d.afterMovePaneSourceSnapshot = nil }() | |||
| 106 | - | ||||
| 107 | - | moveDone := make(chan error, 1) | |||
| 108 | - | go func() { | |||
| 109 | - | moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab)) | |||
| 110 | - | }() | |||
| 111 | - | waitMoveRace(t, barrier, "move pane admission") | |||
| 112 | - | current, replaced := mutateFinalMoveAttachments(source, tc.mutation) | |||
| 113 | - | currentGeneration := current.roleGeneration.Load() | |||
| 114 | - | close(releaseMove) | |||
| 115 | - | err := waitMoveRace(t, moveDone, "move pane rejection") | |||
| 116 | - | ||||
| 117 | - | require.ErrorIs(t, err, errMoveStaleTarget) | |||
| 118 | - | d.mu.Lock() | |||
| 119 | - | require.Same(t, source, d.sessions[source.id]) | |||
| 120 | - | require.Same(t, destination, d.sessions[destination.id]) | |||
| 121 | - | d.mu.Unlock() | |||
| 122 | - | source.mu.Lock() | |||
| 123 | - | require.Equal(t, []*tab{sourceTab}, source.tabs) | |||
| 124 | - | source.mu.Unlock() | |||
| 125 | - | sourceTab.mu.Lock() | |||
| 126 | - | require.Same(t, moved, sourceTab.panes[moved.id]) | |||
| 127 | - | require.Equal(t, sourceGeneration, sourceTab.layoutGeneration) | |||
| 128 | - | sourceTab.mu.Unlock() | |||
| 129 | - | destinationTab.mu.Lock() | |||
| 130 | - | require.Same(t, destinationPaneAtMovedID, destinationTab.panes[moved.id]) | |||
| 131 | - | require.Equal(t, destinationGeneration, destinationTab.layoutGeneration) | |||
| 132 | - | destinationTab.mu.Unlock() | |||
| 133 | - | require.Equal(t, owner, moved.ownerSnapshot()) | |||
| 134 | - | require.Equal(t, currentGeneration, current.roleGeneration.Load()) | |||
| 135 | - | assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation) | |||
| 136 | - | }) | |||
| 137 | - | } | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | func TestMoveTabFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) { | |||
| 141 | - | for _, tc := range []struct { | |||
| 142 | - | name string | |||
| 143 | - | mutation finalMoveAttachmentMutation | |||
| 144 | - | existing bool | |||
| 145 | - | }{ | |||
| 146 | - | {name: "new active", mutation: installActiveAfterFinalMoveAdmission}, | |||
| 147 | - | {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission}, | |||
| 148 | - | {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true}, | |||
| 149 | - | } { | |||
| 150 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 151 | - | d, source, original, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 152 | - | moved := source.tabs[0] | |||
| 153 | - | moved.stableID = "moved-tab" | |||
| 154 | - | makeFinalMoveSourceHeadless(source, original) | |||
| 155 | - | if tc.existing { | |||
| 156 | - | existing := newFinalMoveRaceClient(source) | |||
| 157 | - | source.mu.Lock() | |||
| 158 | - | source.addSnatchedLocked(existing) | |||
| 159 | - | source.mu.Unlock() | |||
| 160 | - | } | |||
| 161 | - | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |||
| 162 | - | destinationTabs := append([]*tab(nil), destination.tabs...) | |||
| 163 | - | sourceGeneration := moved.layoutGeneration | |||
| 164 | - | owners := make(map[*pane]*paneOwner, len(moved.panes)) | |||
| 165 | - | for _, p := range moved.panes { | |||
| 166 | - | owners[p] = p.ownerSnapshot() | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | - | barrier := make(chan struct{}) | |||
| 170 | - | releaseMove := make(chan struct{}) | |||
| 171 | - | d.afterMoveTabSourceSnapshot = func() { | |||
| 172 | - | close(barrier) | |||
| 173 | - | <-releaseMove | |||
| 174 | - | } | |||
| 175 | - | defer func() { d.afterMoveTabSourceSnapshot = nil }() | |||
| 176 | - | moveDone := make(chan error, 1) | |||
| 177 | - | go func() { | |||
| 178 | - | moveDone <- d.moveTab(moveTabRequest{ | |||
| 179 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |||
| 180 | - | SourceTabID: domain.TabStableID(moved.stableID), | |||
| 181 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 182 | - | }) | |||
| 183 | - | }() | |||
| 184 | - | waitMoveRace(t, barrier, "move tab admission") | |||
| 185 | - | current, replaced := mutateFinalMoveAttachments(source, tc.mutation) | |||
| 186 | - | currentGeneration := current.roleGeneration.Load() | |||
| 187 | - | close(releaseMove) | |||
| 188 | - | err := waitMoveRace(t, moveDone, "move tab rejection") | |||
| 189 | - | ||||
| 190 | - | require.ErrorIs(t, err, errMoveStaleTarget) | |||
| 191 | - | d.mu.Lock() | |||
| 192 | - | require.Same(t, source, d.sessions[source.id]) | |||
| 193 | - | require.Same(t, destination, d.sessions[destination.id]) | |||
| 194 | - | d.mu.Unlock() | |||
| 195 | - | source.mu.Lock() | |||
| 196 | - | require.Equal(t, []*tab{moved}, source.tabs) | |||
| 197 | - | source.mu.Unlock() | |||
| 198 | - | destination.mu.Lock() | |||
| 199 | - | require.Equal(t, destinationTabs, destination.tabs) | |||
| 200 | - | destination.mu.Unlock() | |||
| 201 | - | moved.mu.Lock() | |||
| 202 | - | require.Equal(t, sourceGeneration, moved.layoutGeneration) | |||
| 203 | - | moved.mu.Unlock() | |||
| 204 | - | for p, owner := range owners { | |||
| 205 | - | require.Equal(t, owner, p.ownerSnapshot()) | |||
| 206 | - | } | |||
| 207 | - | require.Equal(t, currentGeneration, current.roleGeneration.Load()) | |||
| 208 | - | assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation) | |||
| 209 | - | }) | |||
| 210 | - | } | |||
| 211 | - | } | |||
M internal/usecase/daemon/move_persistence_test.go +1 -1
| @@ -177,7 +177,7 @@ func TestMoveTabPersistenceMatrix(t *testing.T) { | |||
| 177 | 177 | } | |
| 178 | 178 | d, source, client, _ := newManualSessionWithPTYs(t, ptys...) | |
| 179 | 179 | if tt.finalSource { | |
| 180 | - | source.client = nil | |
| 180 | + | clearAttachmentsForTest(source) | |
| 181 | 181 | client.setSession(nil) | |
| 182 | 182 | } | |
| 183 | 183 | source.incarnation = domain.IncarnationID{1} | |
| @@ -177,7 +177,7 @@ func TestMoveTabPersistenceMatrix(t *testing.T) { | |||||
| 177 | } | 177 | } | ||
| 178 | d, source, client, _ := newManualSessionWithPTYs(t, ptys...) | 178 | d, source, client, _ := newManualSessionWithPTYs(t, ptys...) | ||
| 179 | if tt.finalSource { | 179 | if tt.finalSource { | ||
| 180 | - | source.client = nil | 180 | + | clearAttachmentsForTest(source) |
| 181 | client.setSession(nil) | 181 | client.setSession(nil) | ||
| 182 | } | 182 | } | ||
| 183 | source.incarnation = domain.IncarnationID{1} | 183 | source.incarnation = domain.IncarnationID{1} | ||
M internal/usecase/daemon/move_picker_test.go +4 -4
| @@ -109,7 +109,7 @@ func TestMovePickerEnterCommitsMovePane(t *testing.T) { | |||
| 109 | 109 | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |
| 110 | 110 | defer releaseAll(releases) | |
| 111 | 111 | source.mu.Lock() | |
| 112 | - | source.client = nil | |
| 112 | + | clearAttachmentsForTestLocked(source) | |
| 113 | 113 | source.mu.Unlock() | |
| 114 | 114 | sourceTab := source.tabs[0] | |
| 115 | 115 | moved := sourceTab.focusedPane() | |
| @@ -130,7 +130,7 @@ func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) { | |||
| 130 | 130 | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |
| 131 | 131 | defer releaseAll(releases) | |
| 132 | 132 | source.mu.Lock() | |
| 133 | - | source.client = nil | |
| 133 | + | clearAttachmentsForTestLocked(source) | |
| 134 | 134 | source.mu.Unlock() | |
| 135 | 135 | sourceTab := source.tabs[0] | |
| 136 | 136 | moved := sourceTab.focusedPane() | |
| @@ -157,7 +157,7 @@ func TestMovePickerEnterCommitsMoveTab(t *testing.T) { | |||
| 157 | 157 | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 158 | 158 | defer releaseAll(releases) | |
| 159 | 159 | source.mu.Lock() | |
| 160 | - | source.client = nil | |
| 160 | + | clearAttachmentsForTestLocked(source) | |
| 161 | 161 | source.mu.Unlock() | |
| 162 | 162 | movedTab := source.tabs[0] | |
| 163 | 163 | movedTab.stableID = "moved-tab" | |
| @@ -265,7 +265,7 @@ func TestMovePickerSourceClientReplacementInvalidatesAction(t *testing.T) { | |||
| 265 | 265 | replacement.initOverlays() | |
| 266 | 266 | replacement.setSession(source) | |
| 267 | 267 | source.mu.Lock() | |
| 268 | - | source.client = replacement | |
| 268 | + | source.registerAttachmentLocked(replacement) | |
| 269 | 269 | source.mu.Unlock() | |
| 270 | 270 | ||
| 271 | 271 | d.handlePickerInput(ac, []byte("\r")) | |
| @@ -109,7 +109,7 @@ func TestMovePickerEnterCommitsMovePane(t *testing.T) { | |||||
| 109 | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | 109 | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | ||
| 110 | defer releaseAll(releases) | 110 | defer releaseAll(releases) | ||
| 111 | source.mu.Lock() | 111 | source.mu.Lock() | ||
| 112 | - | source.client = nil | 112 | + | clearAttachmentsForTestLocked(source) |
| 113 | source.mu.Unlock() | 113 | source.mu.Unlock() | ||
| 114 | sourceTab := source.tabs[0] | 114 | sourceTab := source.tabs[0] | ||
| 115 | moved := sourceTab.focusedPane() | 115 | moved := sourceTab.focusedPane() | ||
| @@ -130,7 +130,7 @@ func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) { | |||||
| 130 | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | 130 | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | ||
| 131 | defer releaseAll(releases) | 131 | defer releaseAll(releases) | ||
| 132 | source.mu.Lock() | 132 | source.mu.Lock() | ||
| 133 | - | source.client = nil | 133 | + | clearAttachmentsForTestLocked(source) |
| 134 | source.mu.Unlock() | 134 | source.mu.Unlock() | ||
| 135 | sourceTab := source.tabs[0] | 135 | sourceTab := source.tabs[0] | ||
| 136 | moved := sourceTab.focusedPane() | 136 | moved := sourceTab.focusedPane() | ||
| @@ -157,7 +157,7 @@ func TestMovePickerEnterCommitsMoveTab(t *testing.T) { | |||||
| 157 | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | 157 | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | ||
| 158 | defer releaseAll(releases) | 158 | defer releaseAll(releases) | ||
| 159 | source.mu.Lock() | 159 | source.mu.Lock() | ||
| 160 | - | source.client = nil | 160 | + | clearAttachmentsForTestLocked(source) |
| 161 | source.mu.Unlock() | 161 | source.mu.Unlock() | ||
| 162 | movedTab := source.tabs[0] | 162 | movedTab := source.tabs[0] | ||
| 163 | movedTab.stableID = "moved-tab" | 163 | movedTab.stableID = "moved-tab" | ||
| @@ -265,7 +265,7 @@ func TestMovePickerSourceClientReplacementInvalidatesAction(t *testing.T) { | |||||
| 265 | replacement.initOverlays() | 265 | replacement.initOverlays() | ||
| 266 | replacement.setSession(source) | 266 | replacement.setSession(source) | ||
| 267 | source.mu.Lock() | 267 | source.mu.Lock() | ||
| 268 | - | source.client = replacement | 268 | + | source.registerAttachmentLocked(replacement) |
| 269 | source.mu.Unlock() | 269 | source.mu.Unlock() | ||
| 270 | 270 | ||||
| 271 | d.handlePickerInput(ac, []byte("\r")) | 271 | d.handlePickerInput(ac, []byte("\r")) | ||
M internal/usecase/daemon/move_tab_test.go +2 -2
| @@ -116,7 +116,7 @@ func TestMoveTabFinalSourceVisibleFloatingPreservesRuntimeAndFollowsClient(t *te | |||
| 116 | 116 | })) | |
| 117 | 117 | ||
| 118 | 118 | require.Same(t, moved, destination.tabs[destination.active]) | |
| 119 | - | require.Same(t, client, destination.client) | |
| 119 | + | require.Contains(t, destination.snapshotAttachments(), client) | |
| 120 | 120 | require.Same(t, destination, client.currentSession()) | |
| 121 | 121 | moved.mu.Lock() | |
| 122 | 122 | require.Equal(t, floatingVisible, moved.floating.state) | |
| @@ -159,7 +159,7 @@ func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) { | |||
| 159 | 159 | ||
| 160 | 160 | require.Nil(t, source.tabs) | |
| 161 | 161 | require.Same(t, destination, client.currentSession()) | |
| 162 | - | require.Same(t, client, destination.client) | |
| 162 | + | require.Contains(t, destination.snapshotAttachments(), client) | |
| 163 | 163 | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | |
| 164 | 164 | require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs) | |
| 165 | 165 | require.Same(t, moved, destination.tabs[destination.active], "final-source follower activates the moved tab") | |
| @@ -116,7 +116,7 @@ func TestMoveTabFinalSourceVisibleFloatingPreservesRuntimeAndFollowsClient(t *te | |||||
| 116 | })) | 116 | })) | ||
| 117 | 117 | ||||
| 118 | require.Same(t, moved, destination.tabs[destination.active]) | 118 | require.Same(t, moved, destination.tabs[destination.active]) | ||
| 119 | - | require.Same(t, client, destination.client) | 119 | + | require.Contains(t, destination.snapshotAttachments(), client) |
| 120 | require.Same(t, destination, client.currentSession()) | 120 | require.Same(t, destination, client.currentSession()) | ||
| 121 | moved.mu.Lock() | 121 | moved.mu.Lock() | ||
| 122 | require.Equal(t, floatingVisible, moved.floating.state) | 122 | require.Equal(t, floatingVisible, moved.floating.state) | ||
| @@ -159,7 +159,7 @@ func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) { | |||||
| 159 | 159 | ||||
| 160 | require.Nil(t, source.tabs) | 160 | require.Nil(t, source.tabs) | ||
| 161 | require.Same(t, destination, client.currentSession()) | 161 | require.Same(t, destination, client.currentSession()) | ||
| 162 | - | require.Same(t, client, destination.client) | 162 | + | require.Contains(t, destination.snapshotAttachments(), client) |
| 163 | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | 163 | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | ||
| 164 | require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs) | 164 | require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs) | ||
| 165 | require.Same(t, moved, destination.tabs[destination.active], "final-source follower activates the moved tab") | 165 | require.Same(t, moved, destination.tabs[destination.active], "final-source follower activates the moved tab") | ||
M internal/usecase/daemon/move_test.go +4 -4
| @@ -49,7 +49,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) { | |||
| 49 | 49 | defer release2() | |
| 50 | 50 | d, source, _, _ := newManualSessionWithPTYs(t, p1) | |
| 51 | 51 | source.mu.Lock() | |
| 52 | - | source.client = nil | |
| 52 | + | clearAttachmentsForTestLocked(source) | |
| 53 | 53 | source.mu.Unlock() | |
| 54 | 54 | sourceTab := source.tabs[0] | |
| 55 | 55 | sourceTab.stableID = "source-tab" | |
| @@ -115,7 +115,7 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) { | |||
| 115 | 115 | require.NoError(t, err) | |
| 116 | 116 | require.Nil(t, source.tabs) | |
| 117 | 117 | require.Same(t, destination, client.currentSession()) | |
| 118 | - | require.Same(t, client, destination.client) | |
| 118 | + | require.Contains(t, destination.snapshotAttachments(), client) | |
| 119 | 119 | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | |
| 120 | 120 | require.Same(t, destinationTab, destination.tabs[destination.active]) | |
| 121 | 121 | require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint") | |
| @@ -129,7 +129,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing | |||
| 129 | 129 | require.NoError(t, err) | |
| 130 | 130 | require.NoError(t, clip.Close()) | |
| 131 | 131 | source.mu.Lock() | |
| 132 | - | source.client = nil | |
| 132 | + | clearAttachmentsForTestLocked(source) | |
| 133 | 133 | source.clipFiles = []string{clip.Name()} | |
| 134 | 134 | source.mu.Unlock() | |
| 135 | 135 | active.setSession(nil) | |
| @@ -179,7 +179,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) { | |||
| 179 | 179 | movedPTY := newQuietPTY() | |
| 180 | 180 | d, source, _, _ := newManualSessionWithPTYs(t, movedPTY) | |
| 181 | 181 | source.mu.Lock() | |
| 182 | - | source.client = nil | |
| 182 | + | clearAttachmentsForTestLocked(source) | |
| 183 | 183 | source.mu.Unlock() | |
| 184 | 184 | moved := source.tabs[0].focusedPane() | |
| 185 | 185 | movedTab := source.tabs[0] | |
| @@ -49,7 +49,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) { | |||||
| 49 | defer release2() | 49 | defer release2() | ||
| 50 | d, source, _, _ := newManualSessionWithPTYs(t, p1) | 50 | d, source, _, _ := newManualSessionWithPTYs(t, p1) | ||
| 51 | source.mu.Lock() | 51 | source.mu.Lock() | ||
| 52 | - | source.client = nil | 52 | + | clearAttachmentsForTestLocked(source) |
| 53 | source.mu.Unlock() | 53 | source.mu.Unlock() | ||
| 54 | sourceTab := source.tabs[0] | 54 | sourceTab := source.tabs[0] | ||
| 55 | sourceTab.stableID = "source-tab" | 55 | sourceTab.stableID = "source-tab" | ||
| @@ -115,7 +115,7 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) { | |||||
| 115 | require.NoError(t, err) | 115 | require.NoError(t, err) | ||
| 116 | require.Nil(t, source.tabs) | 116 | require.Nil(t, source.tabs) | ||
| 117 | require.Same(t, destination, client.currentSession()) | 117 | require.Same(t, destination, client.currentSession()) | ||
| 118 | - | require.Same(t, client, destination.client) | 118 | + | require.Contains(t, destination.snapshotAttachments(), client) |
| 119 | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | 119 | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | ||
| 120 | require.Same(t, destinationTab, destination.tabs[destination.active]) | 120 | require.Same(t, destinationTab, destination.tabs[destination.active]) | ||
| 121 | require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint") | 121 | require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint") | ||
| @@ -129,7 +129,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing | |||||
| 129 | require.NoError(t, err) | 129 | require.NoError(t, err) | ||
| 130 | require.NoError(t, clip.Close()) | 130 | require.NoError(t, clip.Close()) | ||
| 131 | source.mu.Lock() | 131 | source.mu.Lock() | ||
| 132 | - | source.client = nil | 132 | + | clearAttachmentsForTestLocked(source) |
| 133 | source.clipFiles = []string{clip.Name()} | 133 | source.clipFiles = []string{clip.Name()} | ||
| 134 | source.mu.Unlock() | 134 | source.mu.Unlock() | ||
| 135 | active.setSession(nil) | 135 | active.setSession(nil) | ||
| @@ -179,7 +179,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) { | |||||
| 179 | movedPTY := newQuietPTY() | 179 | movedPTY := newQuietPTY() | ||
| 180 | d, source, _, _ := newManualSessionWithPTYs(t, movedPTY) | 180 | d, source, _, _ := newManualSessionWithPTYs(t, movedPTY) | ||
| 181 | source.mu.Lock() | 181 | source.mu.Lock() | ||
| 182 | - | source.client = nil | 182 | + | clearAttachmentsForTestLocked(source) |
| 183 | source.mu.Unlock() | 183 | source.mu.Unlock() | ||
| 184 | moved := source.tabs[0].focusedPane() | 184 | moved := source.tabs[0].focusedPane() | ||
| 185 | movedTab := source.tabs[0] | 185 | movedTab := source.tabs[0] | ||
M internal/usecase/daemon/navigation_overflow_test.go +12 -12
| @@ -149,11 +149,11 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||
| 149 | 149 | } | |
| 150 | 150 | d.mu.Unlock() | |
| 151 | 151 | alpha.mu.Lock() | |
| 152 | - | attached := alpha.client | |
| 152 | + | attached := alpha.snapshotAttachmentsLocked() | |
| 153 | 153 | alpha.mu.Unlock() | |
| 154 | 154 | require.True(t, stopped, "vertical overflow leaves the stopped session stopped") | |
| 155 | 155 | require.False(t, bravoLive, "the stopped session is absent from the live registry") | |
| 156 | - | require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning") | |
| 156 | + | require.Contains(t, attached, ac, "the source session owns the genuinely attached client after returning") | |
| 157 | 157 | } | |
| 158 | 158 | ||
| 159 | 159 | func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) { | |
| @@ -172,10 +172,10 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) { | |||
| 172 | 172 | ||
| 173 | 173 | require.Same(t, alpha, ac.currentSession()) | |
| 174 | 174 | alpha.mu.Lock() | |
| 175 | - | require.Same(t, ac, alpha.client) | |
| 175 | + | require.Contains(t, alpha.snapshotAttachmentsLocked(), ac) | |
| 176 | 176 | alpha.mu.Unlock() | |
| 177 | 177 | charlie.mu.Lock() | |
| 178 | - | require.Nil(t, charlie.client) | |
| 178 | + | require.Empty(t, charlie.snapshotAttachmentsLocked()) | |
| 179 | 179 | charlie.mu.Unlock() | |
| 180 | 180 | } | |
| 181 | 181 | ||
| @@ -196,10 +196,10 @@ func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) { | |||
| 196 | 196 | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor) | |
| 197 | 197 | require.Same(t, alpha, ac.currentSession()) | |
| 198 | 198 | alpha.mu.Lock() | |
| 199 | - | require.Same(t, ac, alpha.client) | |
| 199 | + | require.Contains(t, alpha.snapshotAttachmentsLocked(), ac) | |
| 200 | 200 | alpha.mu.Unlock() | |
| 201 | 201 | charlie.mu.Lock() | |
| 202 | - | require.Nil(t, charlie.client) | |
| 202 | + | require.Empty(t, charlie.snapshotAttachmentsLocked()) | |
| 203 | 203 | charlie.mu.Unlock() | |
| 204 | 204 | } | |
| 205 | 205 | ||
| @@ -218,10 +218,10 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) { | |||
| 218 | 218 | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor) | |
| 219 | 219 | require.Same(t, alpha, ac.currentSession()) | |
| 220 | 220 | alpha.mu.Lock() | |
| 221 | - | require.Same(t, ac, alpha.client) | |
| 221 | + | require.Contains(t, alpha.snapshotAttachmentsLocked(), ac) | |
| 222 | 222 | alpha.mu.Unlock() | |
| 223 | 223 | charlie.mu.Lock() | |
| 224 | - | require.Nil(t, charlie.client) | |
| 224 | + | require.Empty(t, charlie.snapshotAttachmentsLocked()) | |
| 225 | 225 | charlie.mu.Unlock() | |
| 226 | 226 | } | |
| 227 | 227 | ||
| @@ -251,16 +251,16 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) { | |||
| 251 | 251 | require.True(t, ok) | |
| 252 | 252 | replacement := &attachedClient{} | |
| 253 | 253 | alpha.mu.Lock() | |
| 254 | - | alpha.client = replacement | |
| 254 | + | alpha.registerAttachmentLocked(replacement) | |
| 255 | 255 | alpha.mu.Unlock() | |
| 256 | 256 | ||
| 257 | 257 | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor) | |
| 258 | 258 | require.Same(t, alpha, ac.currentSession()) | |
| 259 | 259 | alpha.mu.Lock() | |
| 260 | - | require.Same(t, replacement, alpha.client) | |
| 260 | + | require.Contains(t, alpha.snapshotAttachmentsLocked(), replacement) | |
| 261 | 261 | alpha.mu.Unlock() | |
| 262 | 262 | charlie.mu.Lock() | |
| 263 | - | require.Nil(t, charlie.client) | |
| 263 | + | require.Empty(t, charlie.snapshotAttachmentsLocked()) | |
| 264 | 264 | charlie.mu.Unlock() | |
| 265 | 265 | } | |
| 266 | 266 | ||
| @@ -314,7 +314,7 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |||
| 314 | 314 | continue | |
| 315 | 315 | } | |
| 316 | 316 | candidate.mu.Lock() | |
| 317 | - | if candidate.client == ac { | |
| 317 | + | if attachmentRegistered(candidate, ac) { | |
| 318 | 318 | owners = append(owners, candidate) | |
| 319 | 319 | } | |
| 320 | 320 | candidate.mu.Unlock() | |
| @@ -149,11 +149,11 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||||
| 149 | } | 149 | } | ||
| 150 | d.mu.Unlock() | 150 | d.mu.Unlock() | ||
| 151 | alpha.mu.Lock() | 151 | alpha.mu.Lock() | ||
| 152 | - | attached := alpha.client | 152 | + | attached := alpha.snapshotAttachmentsLocked() |
| 153 | alpha.mu.Unlock() | 153 | alpha.mu.Unlock() | ||
| 154 | require.True(t, stopped, "vertical overflow leaves the stopped session stopped") | 154 | require.True(t, stopped, "vertical overflow leaves the stopped session stopped") | ||
| 155 | require.False(t, bravoLive, "the stopped session is absent from the live registry") | 155 | require.False(t, bravoLive, "the stopped session is absent from the live registry") | ||
| 156 | - | require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning") | 156 | + | require.Contains(t, attached, ac, "the source session owns the genuinely attached client after returning") |
| 157 | } | 157 | } | ||
| 158 | 158 | ||||
| 159 | func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) { | 159 | func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) { | ||
| @@ -172,10 +172,10 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) { | |||||
| 172 | 172 | ||||
| 173 | require.Same(t, alpha, ac.currentSession()) | 173 | require.Same(t, alpha, ac.currentSession()) | ||
| 174 | alpha.mu.Lock() | 174 | alpha.mu.Lock() | ||
| 175 | - | require.Same(t, ac, alpha.client) | 175 | + | require.Contains(t, alpha.snapshotAttachmentsLocked(), ac) |
| 176 | alpha.mu.Unlock() | 176 | alpha.mu.Unlock() | ||
| 177 | charlie.mu.Lock() | 177 | charlie.mu.Lock() | ||
| 178 | - | require.Nil(t, charlie.client) | 178 | + | require.Empty(t, charlie.snapshotAttachmentsLocked()) |
| 179 | charlie.mu.Unlock() | 179 | charlie.mu.Unlock() | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| @@ -196,10 +196,10 @@ func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) { | |||||
| 196 | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor) | 196 | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor) | ||
| 197 | require.Same(t, alpha, ac.currentSession()) | 197 | require.Same(t, alpha, ac.currentSession()) | ||
| 198 | alpha.mu.Lock() | 198 | alpha.mu.Lock() | ||
| 199 | - | require.Same(t, ac, alpha.client) | 199 | + | require.Contains(t, alpha.snapshotAttachmentsLocked(), ac) |
| 200 | alpha.mu.Unlock() | 200 | alpha.mu.Unlock() | ||
| 201 | charlie.mu.Lock() | 201 | charlie.mu.Lock() | ||
| 202 | - | require.Nil(t, charlie.client) | 202 | + | require.Empty(t, charlie.snapshotAttachmentsLocked()) |
| 203 | charlie.mu.Unlock() | 203 | charlie.mu.Unlock() | ||
| 204 | } | 204 | } | ||
| 205 | 205 | ||||
| @@ -218,10 +218,10 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) { | |||||
| 218 | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor) | 218 | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor) | ||
| 219 | require.Same(t, alpha, ac.currentSession()) | 219 | require.Same(t, alpha, ac.currentSession()) | ||
| 220 | alpha.mu.Lock() | 220 | alpha.mu.Lock() | ||
| 221 | - | require.Same(t, ac, alpha.client) | 221 | + | require.Contains(t, alpha.snapshotAttachmentsLocked(), ac) |
| 222 | alpha.mu.Unlock() | 222 | alpha.mu.Unlock() | ||
| 223 | charlie.mu.Lock() | 223 | charlie.mu.Lock() | ||
| 224 | - | require.Nil(t, charlie.client) | 224 | + | require.Empty(t, charlie.snapshotAttachmentsLocked()) |
| 225 | charlie.mu.Unlock() | 225 | charlie.mu.Unlock() | ||
| 226 | } | 226 | } | ||
| 227 | 227 | ||||
| @@ -251,16 +251,16 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) { | |||||
| 251 | require.True(t, ok) | 251 | require.True(t, ok) | ||
| 252 | replacement := &attachedClient{} | 252 | replacement := &attachedClient{} | ||
| 253 | alpha.mu.Lock() | 253 | alpha.mu.Lock() | ||
| 254 | - | alpha.client = replacement | 254 | + | alpha.registerAttachmentLocked(replacement) |
| 255 | alpha.mu.Unlock() | 255 | alpha.mu.Unlock() | ||
| 256 | 256 | ||||
| 257 | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor) | 257 | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor) | ||
| 258 | require.Same(t, alpha, ac.currentSession()) | 258 | require.Same(t, alpha, ac.currentSession()) | ||
| 259 | alpha.mu.Lock() | 259 | alpha.mu.Lock() | ||
| 260 | - | require.Same(t, replacement, alpha.client) | 260 | + | require.Contains(t, alpha.snapshotAttachmentsLocked(), replacement) |
| 261 | alpha.mu.Unlock() | 261 | alpha.mu.Unlock() | ||
| 262 | charlie.mu.Lock() | 262 | charlie.mu.Lock() | ||
| 263 | - | require.Nil(t, charlie.client) | 263 | + | require.Empty(t, charlie.snapshotAttachmentsLocked()) |
| 264 | charlie.mu.Unlock() | 264 | charlie.mu.Unlock() | ||
| 265 | } | 265 | } | ||
| 266 | 266 | ||||
| @@ -314,7 +314,7 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |||||
| 314 | continue | 314 | continue | ||
| 315 | } | 315 | } | ||
| 316 | candidate.mu.Lock() | 316 | candidate.mu.Lock() | ||
| 317 | - | if candidate.client == ac { | 317 | + | if attachmentRegistered(candidate, ac) { |
| 318 | owners = append(owners, candidate) | 318 | owners = append(owners, candidate) | ||
| 319 | } | 319 | } | ||
| 320 | candidate.mu.Unlock() | 320 | candidate.mu.Unlock() | ||
M internal/usecase/daemon/navigation_role_handoff_test.go +2 -2
| @@ -132,10 +132,10 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 132 | 132 | ||
| 133 | 133 | require.Same(t, source, old.currentSession()) | |
| 134 | 134 | source.mu.Lock() | |
| 135 | - | require.Same(t, old, source.client) | |
| 135 | + | require.Contains(t, source.snapshotAttachmentsLocked(), old) | |
| 136 | 136 | source.mu.Unlock() | |
| 137 | 137 | target.mu.Lock() | |
| 138 | - | require.Nil(t, target.client, "stale handoff attached to the target") | |
| 138 | + | require.Empty(t, target.snapshotAttachmentsLocked(), "stale handoff attached to the target") | |
| 139 | 139 | require.Zero(t, target.active, "stale handoff changed target tab focus") | |
| 140 | 140 | target.mu.Unlock() | |
| 141 | 141 | if tt.check != nil { | |
| @@ -132,10 +132,10 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||||
| 132 | 132 | ||||
| 133 | require.Same(t, source, old.currentSession()) | 133 | require.Same(t, source, old.currentSession()) | ||
| 134 | source.mu.Lock() | 134 | source.mu.Lock() | ||
| 135 | - | require.Same(t, old, source.client) | 135 | + | require.Contains(t, source.snapshotAttachmentsLocked(), old) |
| 136 | source.mu.Unlock() | 136 | source.mu.Unlock() | ||
| 137 | target.mu.Lock() | 137 | target.mu.Lock() | ||
| 138 | - | require.Nil(t, target.client, "stale handoff attached to the target") | 138 | + | require.Empty(t, target.snapshotAttachmentsLocked(), "stale handoff attached to the target") |
| 139 | require.Zero(t, target.active, "stale handoff changed target tab focus") | 139 | require.Zero(t, target.active, "stale handoff changed target tab focus") | ||
| 140 | target.mu.Unlock() | 140 | target.mu.Unlock() | ||
| 141 | if tt.check != nil { | 141 | if tt.check != nil { | ||
M internal/usecase/daemon/navigation_role_incarnation_test.go +1 -1
| @@ -73,7 +73,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||
| 73 | 73 | ||
| 74 | 74 | require.Same(t, source, ac.currentSession()) | |
| 75 | 75 | target.mu.Lock() | |
| 76 | - | require.Nil(t, target.client) | |
| 76 | + | require.Empty(t, target.snapshotAttachmentsLocked()) | |
| 77 | 77 | require.Zero(t, target.active) | |
| 78 | 78 | target.mu.Unlock() | |
| 79 | 79 | }) | |
| @@ -73,7 +73,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||||
| 73 | 73 | ||||
| 74 | require.Same(t, source, ac.currentSession()) | 74 | require.Same(t, source, ac.currentSession()) | ||
| 75 | target.mu.Lock() | 75 | target.mu.Lock() | ||
| 76 | - | require.Nil(t, target.client) | 76 | + | require.Empty(t, target.snapshotAttachmentsLocked()) |
| 77 | require.Zero(t, target.active) | 77 | require.Zero(t, target.active) | ||
| 78 | target.mu.Unlock() | 78 | target.mu.Unlock() | ||
| 79 | }) | 79 | }) | ||
M internal/usecase/daemon/notify_test.go +18 -18
| @@ -106,7 +106,7 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) { | |||
| 106 | 106 | newToasts, _ := visibleToasts(current) | |
| 107 | 107 | require.Empty(t, newToasts) | |
| 108 | 108 | sess.mu.Lock() | |
| 109 | - | require.Same(t, current, sess.client) | |
| 109 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), current) | |
| 110 | 110 | sess.mu.Unlock() | |
| 111 | 111 | } | |
| 112 | 112 | ||
| @@ -379,7 +379,7 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach | |||
| 379 | 379 | for _, pane := range tb.panes { | |
| 380 | 380 | pane.ctx, pane.cancel = wctx, wcancel | |
| 381 | 381 | } | |
| 382 | - | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}} | |
| 382 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}} | |
| 383 | 383 | ac.setSession(sess) | |
| 384 | 384 | d.sessions[sess.id] = sess | |
| 385 | 385 | t.Cleanup(cancel) | |
| @@ -521,7 +521,7 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) { | |||
| 521 | 521 | ||
| 522 | 522 | other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 523 | 523 | other.initOverlays() | |
| 524 | - | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: other}, ctx: sess.ctx, cancel: func() {}} | |
| 524 | + | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{other: {}}}, ctx: sess.ctx, cancel: func() {}} | |
| 525 | 525 | other.setSession(sess2) | |
| 526 | 526 | d.mu.Lock() | |
| 527 | 527 | d.sessions[sess2.id] = sess2 | |
| @@ -606,7 +606,7 @@ func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) { | |||
| 606 | 606 | func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |
| 607 | 607 | d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock()) | |
| 608 | 608 | sessA.mu.Lock() | |
| 609 | - | sessA.client = nil | |
| 609 | + | clearAttachmentsForTestLocked(sessA) | |
| 610 | 610 | sessA.mu.Unlock() | |
| 611 | 611 | ||
| 612 | 612 | clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| @@ -628,7 +628,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||
| 628 | 628 | require.Empty(t, oldToasts, "a detached session must not paint a toast") | |
| 629 | 629 | ||
| 630 | 630 | sessB.mu.Lock() | |
| 631 | - | sessB.client = clientB | |
| 631 | + | sessB.registerAttachmentLocked(clientB) | |
| 632 | 632 | sessB.mu.Unlock() | |
| 633 | 633 | d.drainPendingForFirstPaint(sessB, clientB) | |
| 634 | 634 | bToasts, _ := visibleToasts(clientB) | |
| @@ -638,7 +638,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||
| 638 | 638 | require.Equal(t, "global announcement", bToasts[0].Message) | |
| 639 | 639 | ||
| 640 | 640 | sessA.mu.Lock() | |
| 641 | - | sessA.client = oldA | |
| 641 | + | sessA.registerAttachmentLocked(oldA) | |
| 642 | 642 | sessA.mu.Unlock() | |
| 643 | 643 | d.drainPendingForFirstPaint(sessA, oldA) | |
| 644 | 644 | aToasts, _ := visibleToasts(oldA) | |
| @@ -652,7 +652,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||
| 652 | 652 | secondA.initOverlays() | |
| 653 | 653 | secondA.setSession(sessA) | |
| 654 | 654 | sessA.mu.Lock() | |
| 655 | - | sessA.client = secondA | |
| 655 | + | sessA.registerAttachmentLocked(secondA) | |
| 656 | 656 | sessA.mu.Unlock() | |
| 657 | 657 | d.drainPendingForFirstPaint(sessA, secondA) | |
| 658 | 658 | secondToasts, _ := visibleToasts(secondA) | |
| @@ -664,7 +664,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |||
| 664 | 664 | ||
| 665 | 665 | second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 666 | 666 | second.initOverlays() | |
| 667 | - | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: second}, ctx: sess.ctx, cancel: func() {}} | |
| 667 | + | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{second: {}}}, ctx: sess.ctx, cancel: func() {}} | |
| 668 | 668 | second.setSession(sess2) | |
| 669 | 669 | d.mu.Lock() | |
| 670 | 670 | d.sessions[sess2.id] = sess2 | |
| @@ -682,7 +682,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |||
| 682 | 682 | func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) { | |
| 683 | 683 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 684 | 684 | sess.mu.Lock() | |
| 685 | - | sess.client = nil | |
| 685 | + | clearAttachmentsForTestLocked(sess) | |
| 686 | 686 | sess.mu.Unlock() | |
| 687 | 687 | ||
| 688 | 688 | routeObserved := make(chan struct{}) | |
| @@ -701,7 +701,7 @@ func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) { | |||
| 701 | 701 | // Publish the attachment and start firstPaint while global routing still | |
| 702 | 702 | // owns its gate. firstPaint must wait for the queue, then drain it. | |
| 703 | 703 | sess.mu.Lock() | |
| 704 | - | sess.client = ac | |
| 704 | + | sess.registerAttachmentLocked(ac) | |
| 705 | 705 | sess.mu.Unlock() | |
| 706 | 706 | paintDone := make(chan struct{}) | |
| 707 | 707 | go func() { | |
| @@ -744,7 +744,7 @@ func TestNotifyGlobalSerializesDetachWithDelivery(t *testing.T) { | |||
| 744 | 744 | toasts, _ := visibleToasts(ac) | |
| 745 | 745 | require.Len(t, toasts, 1) | |
| 746 | 746 | sess.mu.Lock() | |
| 747 | - | require.Nil(t, sess.client) | |
| 747 | + | require.Empty(t, sess.snapshotAttachmentsLocked()) | |
| 748 | 748 | sess.mu.Unlock() | |
| 749 | 749 | } | |
| 750 | 750 | ||
| @@ -782,14 +782,14 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) { | |||
| 782 | 782 | newToasts, _ := visibleToasts(current) | |
| 783 | 783 | require.Empty(t, newToasts) | |
| 784 | 784 | sess.mu.Lock() | |
| 785 | - | require.Same(t, current, sess.client) | |
| 785 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), current) | |
| 786 | 786 | sess.mu.Unlock() | |
| 787 | 787 | } | |
| 788 | 788 | ||
| 789 | 789 | func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) { | |
| 790 | 790 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 791 | 791 | sess.mu.Lock() | |
| 792 | - | sess.client = nil | |
| 792 | + | clearAttachmentsForTestLocked(sess) | |
| 793 | 793 | sess.mu.Unlock() | |
| 794 | 794 | ||
| 795 | 795 | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil) | |
| @@ -797,7 +797,7 @@ func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing. | |||
| 797 | 797 | d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil) | |
| 798 | 798 | ||
| 799 | 799 | sess.mu.Lock() | |
| 800 | - | sess.client = ac | |
| 800 | + | sess.registerAttachmentLocked(ac) | |
| 801 | 801 | sess.mu.Unlock() | |
| 802 | 802 | d.drainPendingForFirstPaint(sess, ac) | |
| 803 | 803 | ||
| @@ -813,7 +813,7 @@ func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing. | |||
| 813 | 813 | func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |
| 814 | 814 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 815 | 815 | sess.mu.Lock() | |
| 816 | - | sess.client = nil | |
| 816 | + | clearAttachmentsForTestLocked(sess) | |
| 817 | 817 | sess.mu.Unlock() | |
| 818 | 818 | ||
| 819 | 819 | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil) | |
| @@ -824,7 +824,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||
| 824 | 824 | ||
| 825 | 825 | // Re-attach and let firstPaint drain the pending queue. | |
| 826 | 826 | sess.mu.Lock() | |
| 827 | - | sess.client = ac | |
| 827 | + | sess.registerAttachmentLocked(ac) | |
| 828 | 828 | sess.mu.Unlock() | |
| 829 | 829 | d.firstPaint(sess, ac, domain.Size{}) | |
| 830 | 830 | ||
| @@ -841,7 +841,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||
| 841 | 841 | func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | |
| 842 | 842 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 843 | 843 | sess.mu.Lock() | |
| 844 | - | sess.client = nil | |
| 844 | + | clearAttachmentsForTestLocked(sess) | |
| 845 | 845 | sess.mu.Unlock() | |
| 846 | 846 | ||
| 847 | 847 | cause := errors.New("open store.db: permission denied") | |
| @@ -854,7 +854,7 @@ func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | |||
| 854 | 854 | ||
| 855 | 855 | // Re-attach and let firstPaint drain the pending queue. | |
| 856 | 856 | sess.mu.Lock() | |
| 857 | - | sess.client = ac | |
| 857 | + | sess.registerAttachmentLocked(ac) | |
| 858 | 858 | sess.mu.Unlock() | |
| 859 | 859 | d.firstPaint(sess, ac, domain.Size{}) | |
| 860 | 860 | ||
| @@ -106,7 +106,7 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) { | |||||
| 106 | newToasts, _ := visibleToasts(current) | 106 | newToasts, _ := visibleToasts(current) | ||
| 107 | require.Empty(t, newToasts) | 107 | require.Empty(t, newToasts) | ||
| 108 | sess.mu.Lock() | 108 | sess.mu.Lock() | ||
| 109 | - | require.Same(t, current, sess.client) | 109 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), current) |
| 110 | sess.mu.Unlock() | 110 | sess.mu.Unlock() | ||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| @@ -379,7 +379,7 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach | |||||
| 379 | for _, pane := range tb.panes { | 379 | for _, pane := range tb.panes { | ||
| 380 | pane.ctx, pane.cancel = wctx, wcancel | 380 | pane.ctx, pane.cancel = wctx, wcancel | ||
| 381 | } | 381 | } | ||
| 382 | - | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}} | 382 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}} |
| 383 | ac.setSession(sess) | 383 | ac.setSession(sess) | ||
| 384 | d.sessions[sess.id] = sess | 384 | d.sessions[sess.id] = sess | ||
| 385 | t.Cleanup(cancel) | 385 | t.Cleanup(cancel) | ||
| @@ -521,7 +521,7 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) { | |||||
| 521 | 521 | ||||
| 522 | other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 522 | other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 523 | other.initOverlays() | 523 | other.initOverlays() | ||
| 524 | - | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: other}, ctx: sess.ctx, cancel: func() {}} | 524 | + | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{other: {}}}, ctx: sess.ctx, cancel: func() {}} |
| 525 | other.setSession(sess2) | 525 | other.setSession(sess2) | ||
| 526 | d.mu.Lock() | 526 | d.mu.Lock() | ||
| 527 | d.sessions[sess2.id] = sess2 | 527 | d.sessions[sess2.id] = sess2 | ||
| @@ -606,7 +606,7 @@ func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) { | |||||
| 606 | func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | 606 | func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | ||
| 607 | d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock()) | 607 | d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock()) | ||
| 608 | sessA.mu.Lock() | 608 | sessA.mu.Lock() | ||
| 609 | - | sessA.client = nil | 609 | + | clearAttachmentsForTestLocked(sessA) |
| 610 | sessA.mu.Unlock() | 610 | sessA.mu.Unlock() | ||
| 611 | 611 | ||||
| 612 | clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 612 | clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| @@ -628,7 +628,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||||
| 628 | require.Empty(t, oldToasts, "a detached session must not paint a toast") | 628 | require.Empty(t, oldToasts, "a detached session must not paint a toast") | ||
| 629 | 629 | ||||
| 630 | sessB.mu.Lock() | 630 | sessB.mu.Lock() | ||
| 631 | - | sessB.client = clientB | 631 | + | sessB.registerAttachmentLocked(clientB) |
| 632 | sessB.mu.Unlock() | 632 | sessB.mu.Unlock() | ||
| 633 | d.drainPendingForFirstPaint(sessB, clientB) | 633 | d.drainPendingForFirstPaint(sessB, clientB) | ||
| 634 | bToasts, _ := visibleToasts(clientB) | 634 | bToasts, _ := visibleToasts(clientB) | ||
| @@ -638,7 +638,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||||
| 638 | require.Equal(t, "global announcement", bToasts[0].Message) | 638 | require.Equal(t, "global announcement", bToasts[0].Message) | ||
| 639 | 639 | ||||
| 640 | sessA.mu.Lock() | 640 | sessA.mu.Lock() | ||
| 641 | - | sessA.client = oldA | 641 | + | sessA.registerAttachmentLocked(oldA) |
| 642 | sessA.mu.Unlock() | 642 | sessA.mu.Unlock() | ||
| 643 | d.drainPendingForFirstPaint(sessA, oldA) | 643 | d.drainPendingForFirstPaint(sessA, oldA) | ||
| 644 | aToasts, _ := visibleToasts(oldA) | 644 | aToasts, _ := visibleToasts(oldA) | ||
| @@ -652,7 +652,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||||
| 652 | secondA.initOverlays() | 652 | secondA.initOverlays() | ||
| 653 | secondA.setSession(sessA) | 653 | secondA.setSession(sessA) | ||
| 654 | sessA.mu.Lock() | 654 | sessA.mu.Lock() | ||
| 655 | - | sessA.client = secondA | 655 | + | sessA.registerAttachmentLocked(secondA) |
| 656 | sessA.mu.Unlock() | 656 | sessA.mu.Unlock() | ||
| 657 | d.drainPendingForFirstPaint(sessA, secondA) | 657 | d.drainPendingForFirstPaint(sessA, secondA) | ||
| 658 | secondToasts, _ := visibleToasts(secondA) | 658 | secondToasts, _ := visibleToasts(secondA) | ||
| @@ -664,7 +664,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |||||
| 664 | 664 | ||||
| 665 | second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 665 | second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 666 | second.initOverlays() | 666 | second.initOverlays() | ||
| 667 | - | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: second}, ctx: sess.ctx, cancel: func() {}} | 667 | + | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{second: {}}}, ctx: sess.ctx, cancel: func() {}} |
| 668 | second.setSession(sess2) | 668 | second.setSession(sess2) | ||
| 669 | d.mu.Lock() | 669 | d.mu.Lock() | ||
| 670 | d.sessions[sess2.id] = sess2 | 670 | d.sessions[sess2.id] = sess2 | ||
| @@ -682,7 +682,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |||||
| 682 | func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) { | 682 | func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) { | ||
| 683 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | 683 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | ||
| 684 | sess.mu.Lock() | 684 | sess.mu.Lock() | ||
| 685 | - | sess.client = nil | 685 | + | clearAttachmentsForTestLocked(sess) |
| 686 | sess.mu.Unlock() | 686 | sess.mu.Unlock() | ||
| 687 | 687 | ||||
| 688 | routeObserved := make(chan struct{}) | 688 | routeObserved := make(chan struct{}) | ||
| @@ -701,7 +701,7 @@ func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) { | |||||
| 701 | // Publish the attachment and start firstPaint while global routing still | 701 | // Publish the attachment and start firstPaint while global routing still | ||
| 702 | // owns its gate. firstPaint must wait for the queue, then drain it. | 702 | // owns its gate. firstPaint must wait for the queue, then drain it. | ||
| 703 | sess.mu.Lock() | 703 | sess.mu.Lock() | ||
| 704 | - | sess.client = ac | 704 | + | sess.registerAttachmentLocked(ac) |
| 705 | sess.mu.Unlock() | 705 | sess.mu.Unlock() | ||
| 706 | paintDone := make(chan struct{}) | 706 | paintDone := make(chan struct{}) | ||
| 707 | go func() { | 707 | go func() { | ||
| @@ -744,7 +744,7 @@ func TestNotifyGlobalSerializesDetachWithDelivery(t *testing.T) { | |||||
| 744 | toasts, _ := visibleToasts(ac) | 744 | toasts, _ := visibleToasts(ac) | ||
| 745 | require.Len(t, toasts, 1) | 745 | require.Len(t, toasts, 1) | ||
| 746 | sess.mu.Lock() | 746 | sess.mu.Lock() | ||
| 747 | - | require.Nil(t, sess.client) | 747 | + | require.Empty(t, sess.snapshotAttachmentsLocked()) |
| 748 | sess.mu.Unlock() | 748 | sess.mu.Unlock() | ||
| 749 | } | 749 | } | ||
| 750 | 750 | ||||
| @@ -782,14 +782,14 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) { | |||||
| 782 | newToasts, _ := visibleToasts(current) | 782 | newToasts, _ := visibleToasts(current) | ||
| 783 | require.Empty(t, newToasts) | 783 | require.Empty(t, newToasts) | ||
| 784 | sess.mu.Lock() | 784 | sess.mu.Lock() | ||
| 785 | - | require.Same(t, current, sess.client) | 785 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), current) |
| 786 | sess.mu.Unlock() | 786 | sess.mu.Unlock() | ||
| 787 | } | 787 | } | ||
| 788 | 788 | ||||
| 789 | func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) { | 789 | func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) { | ||
| 790 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | 790 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | ||
| 791 | sess.mu.Lock() | 791 | sess.mu.Lock() | ||
| 792 | - | sess.client = nil | 792 | + | clearAttachmentsForTestLocked(sess) |
| 793 | sess.mu.Unlock() | 793 | sess.mu.Unlock() | ||
| 794 | 794 | ||||
| 795 | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil) | 795 | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil) | ||
| @@ -797,7 +797,7 @@ func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing. | |||||
| 797 | d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil) | 797 | d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil) | ||
| 798 | 798 | ||||
| 799 | sess.mu.Lock() | 799 | sess.mu.Lock() | ||
| 800 | - | sess.client = ac | 800 | + | sess.registerAttachmentLocked(ac) |
| 801 | sess.mu.Unlock() | 801 | sess.mu.Unlock() | ||
| 802 | d.drainPendingForFirstPaint(sess, ac) | 802 | d.drainPendingForFirstPaint(sess, ac) | ||
| 803 | 803 | ||||
| @@ -813,7 +813,7 @@ func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing. | |||||
| 813 | func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | 813 | func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | ||
| 814 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | 814 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | ||
| 815 | sess.mu.Lock() | 815 | sess.mu.Lock() | ||
| 816 | - | sess.client = nil | 816 | + | clearAttachmentsForTestLocked(sess) |
| 817 | sess.mu.Unlock() | 817 | sess.mu.Unlock() | ||
| 818 | 818 | ||||
| 819 | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil) | 819 | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil) | ||
| @@ -824,7 +824,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||||
| 824 | 824 | ||||
| 825 | // Re-attach and let firstPaint drain the pending queue. | 825 | // Re-attach and let firstPaint drain the pending queue. | ||
| 826 | sess.mu.Lock() | 826 | sess.mu.Lock() | ||
| 827 | - | sess.client = ac | 827 | + | sess.registerAttachmentLocked(ac) |
| 828 | sess.mu.Unlock() | 828 | sess.mu.Unlock() | ||
| 829 | d.firstPaint(sess, ac, domain.Size{}) | 829 | d.firstPaint(sess, ac, domain.Size{}) | ||
| 830 | 830 | ||||
| @@ -841,7 +841,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||||
| 841 | func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | 841 | func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | ||
| 842 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | 842 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | ||
| 843 | sess.mu.Lock() | 843 | sess.mu.Lock() | ||
| 844 | - | sess.client = nil | 844 | + | clearAttachmentsForTestLocked(sess) |
| 845 | sess.mu.Unlock() | 845 | sess.mu.Unlock() | ||
| 846 | 846 | ||||
| 847 | cause := errors.New("open store.db: permission denied") | 847 | cause := errors.New("open store.db: permission denied") | ||
| @@ -854,7 +854,7 @@ func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | |||||
| 854 | 854 | ||||
| 855 | // Re-attach and let firstPaint drain the pending queue. | 855 | // Re-attach and let firstPaint drain the pending queue. | ||
| 856 | sess.mu.Lock() | 856 | sess.mu.Lock() | ||
| 857 | - | sess.client = ac | 857 | + | sess.registerAttachmentLocked(ac) |
| 858 | sess.mu.Unlock() | 858 | sess.mu.Unlock() | ||
| 859 | d.firstPaint(sess, ac, domain.Size{}) | 859 | d.firstPaint(sess, ac, domain.Size{}) | ||
| 860 | 860 | ||||
M internal/usecase/daemon/palette_test.go +7 -7
| @@ -94,7 +94,7 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||
| 94 | 94 | p2, release2 := newBlockingPTY(t) | |
| 95 | 95 | d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2) | |
| 96 | 96 | sess.mu.Lock() | |
| 97 | - | sess.client = ac | |
| 97 | + | sess.registerAttachmentLocked(ac) | |
| 98 | 98 | sess.mu.Unlock() | |
| 99 | 99 | d.ptys = newBlockingOpenFactory(t, d) | |
| 100 | 100 | defer release1() | |
| @@ -317,7 +317,7 @@ func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *t | |||
| 317 | 317 | d.handleInput(current, ac, []byte("recent\r")) | |
| 318 | 318 | ||
| 319 | 319 | require.Same(t, target, ac.currentSession()) | |
| 320 | - | require.Same(t, ac, target.client, "canonical handoff reuses the attached client") | |
| 320 | + | require.Contains(t, target.snapshotAttachments(), ac, "canonical handoff reuses the attached client") | |
| 321 | 321 | require.False(t, ac.overlays.paletteActive()) | |
| 322 | 322 | require.Empty(t, d.paletteRecent, "session selections are not command recency") | |
| 323 | 323 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -340,7 +340,7 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te | |||
| 340 | 340 | ||
| 341 | 341 | require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment") | |
| 342 | 342 | current.mu.Lock() | |
| 343 | - | require.Same(t, ac, current.client) | |
| 343 | + | require.Contains(t, current.snapshotAttachmentsLocked(), ac) | |
| 344 | 344 | current.mu.Unlock() | |
| 345 | 345 | require.True(t, ac.overlays.paletteActive()) | |
| 346 | 346 | ac.overlays.paletteMu.Lock() | |
| @@ -517,7 +517,7 @@ func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) { | |||
| 517 | 517 | // no-op BSK command succeeds, but its post-execution beginRoleEffect is | |
| 518 | 518 | // deterministically denied. | |
| 519 | 519 | sess.mu.Lock() | |
| 520 | - | sess.client = nil | |
| 520 | + | clearAttachmentsForTestLocked(sess) | |
| 521 | 521 | sess.mu.Unlock() | |
| 522 | 522 | ||
| 523 | 523 | d.handlePaletteInput(ac, []byte("\r")) | |
| @@ -685,13 +685,13 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||
| 685 | 685 | require.NoError(t, err) | |
| 686 | 686 | require.False(t, ac.overlays.promptActive()) | |
| 687 | 687 | require.Equal(t, 2, sessionCount(d)) | |
| 688 | - | require.Nil(t, sess.client) | |
| 688 | + | require.Empty(t, sess.snapshotAttachments()) | |
| 689 | 689 | newSess := ac.currentSession() | |
| 690 | 690 | require.NotNil(t, newSess) | |
| 691 | 691 | require.NotSame(t, sess, newSess) | |
| 692 | 692 | require.Equal(t, "scratch", newSess.name) | |
| 693 | 693 | require.False(t, newSess.ephemeral) | |
| 694 | - | require.Same(t, ac, newSess.client) | |
| 694 | + | require.Contains(t, newSess.snapshotAttachments(), ac) | |
| 695 | 695 | require.Equal(t, attachmentActive, newSess.attachmentRole(ac)) | |
| 696 | 696 | require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition") | |
| 697 | 697 | require.Contains(t, string(finalOutput.Data), "scratch") | |
| @@ -701,7 +701,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||
| 701 | 701 | func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) { | |
| 702 | 702 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 703 | 703 | sess.mu.Lock() | |
| 704 | - | sess.client = ac | |
| 704 | + | sess.registerAttachmentLocked(ac) | |
| 705 | 705 | sess.mu.Unlock() | |
| 706 | 706 | defer releases[0]() | |
| 707 | 707 | defer releases[1]() | |
| @@ -94,7 +94,7 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||||
| 94 | p2, release2 := newBlockingPTY(t) | 94 | p2, release2 := newBlockingPTY(t) | ||
| 95 | d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2) | 95 | d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2) | ||
| 96 | sess.mu.Lock() | 96 | sess.mu.Lock() | ||
| 97 | - | sess.client = ac | 97 | + | sess.registerAttachmentLocked(ac) |
| 98 | sess.mu.Unlock() | 98 | sess.mu.Unlock() | ||
| 99 | d.ptys = newBlockingOpenFactory(t, d) | 99 | d.ptys = newBlockingOpenFactory(t, d) | ||
| 100 | defer release1() | 100 | defer release1() | ||
| @@ -317,7 +317,7 @@ func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *t | |||||
| 317 | d.handleInput(current, ac, []byte("recent\r")) | 317 | d.handleInput(current, ac, []byte("recent\r")) | ||
| 318 | 318 | ||||
| 319 | require.Same(t, target, ac.currentSession()) | 319 | require.Same(t, target, ac.currentSession()) | ||
| 320 | - | require.Same(t, ac, target.client, "canonical handoff reuses the attached client") | 320 | + | require.Contains(t, target.snapshotAttachments(), ac, "canonical handoff reuses the attached client") |
| 321 | require.False(t, ac.overlays.paletteActive()) | 321 | require.False(t, ac.overlays.paletteActive()) | ||
| 322 | require.Empty(t, d.paletteRecent, "session selections are not command recency") | 322 | require.Empty(t, d.paletteRecent, "session selections are not command recency") | ||
| 323 | awaitFrame(t, sends, ports.MsgOutput) | 323 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -340,7 +340,7 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te | |||||
| 340 | 340 | ||||
| 341 | require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment") | 341 | require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment") | ||
| 342 | current.mu.Lock() | 342 | current.mu.Lock() | ||
| 343 | - | require.Same(t, ac, current.client) | 343 | + | require.Contains(t, current.snapshotAttachmentsLocked(), ac) |
| 344 | current.mu.Unlock() | 344 | current.mu.Unlock() | ||
| 345 | require.True(t, ac.overlays.paletteActive()) | 345 | require.True(t, ac.overlays.paletteActive()) | ||
| 346 | ac.overlays.paletteMu.Lock() | 346 | ac.overlays.paletteMu.Lock() | ||
| @@ -517,7 +517,7 @@ func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) { | |||||
| 517 | // no-op BSK command succeeds, but its post-execution beginRoleEffect is | 517 | // no-op BSK command succeeds, but its post-execution beginRoleEffect is | ||
| 518 | // deterministically denied. | 518 | // deterministically denied. | ||
| 519 | sess.mu.Lock() | 519 | sess.mu.Lock() | ||
| 520 | - | sess.client = nil | 520 | + | clearAttachmentsForTestLocked(sess) |
| 521 | sess.mu.Unlock() | 521 | sess.mu.Unlock() | ||
| 522 | 522 | ||||
| 523 | d.handlePaletteInput(ac, []byte("\r")) | 523 | d.handlePaletteInput(ac, []byte("\r")) | ||
| @@ -685,13 +685,13 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||||
| 685 | require.NoError(t, err) | 685 | require.NoError(t, err) | ||
| 686 | require.False(t, ac.overlays.promptActive()) | 686 | require.False(t, ac.overlays.promptActive()) | ||
| 687 | require.Equal(t, 2, sessionCount(d)) | 687 | require.Equal(t, 2, sessionCount(d)) | ||
| 688 | - | require.Nil(t, sess.client) | 688 | + | require.Empty(t, sess.snapshotAttachments()) |
| 689 | newSess := ac.currentSession() | 689 | newSess := ac.currentSession() | ||
| 690 | require.NotNil(t, newSess) | 690 | require.NotNil(t, newSess) | ||
| 691 | require.NotSame(t, sess, newSess) | 691 | require.NotSame(t, sess, newSess) | ||
| 692 | require.Equal(t, "scratch", newSess.name) | 692 | require.Equal(t, "scratch", newSess.name) | ||
| 693 | require.False(t, newSess.ephemeral) | 693 | require.False(t, newSess.ephemeral) | ||
| 694 | - | require.Same(t, ac, newSess.client) | 694 | + | require.Contains(t, newSess.snapshotAttachments(), ac) |
| 695 | require.Equal(t, attachmentActive, newSess.attachmentRole(ac)) | 695 | require.Equal(t, attachmentActive, newSess.attachmentRole(ac)) | ||
| 696 | require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition") | 696 | require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition") | ||
| 697 | require.Contains(t, string(finalOutput.Data), "scratch") | 697 | require.Contains(t, string(finalOutput.Data), "scratch") | ||
| @@ -701,7 +701,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||||
| 701 | func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) { | 701 | func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) { | ||
| 702 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | 702 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | ||
| 703 | sess.mu.Lock() | 703 | sess.mu.Lock() | ||
| 704 | - | sess.client = ac | 704 | + | sess.registerAttachmentLocked(ac) |
| 705 | sess.mu.Unlock() | 705 | sess.mu.Unlock() | ||
| 706 | defer releases[0]() | 706 | defer releases[0]() | ||
| 707 | defer releases[1]() | 707 | defer releases[1]() | ||
M internal/usecase/daemon/pane_owner_reader_test.go +9 -10
| @@ -79,23 +79,22 @@ func TestPTYReaderRoutesEffectsToOwnerPublishedForEachRead(t *testing.T) { | |||
| 79 | 79 | source.snapEligible.Store(true) | |
| 80 | 80 | ||
| 81 | 81 | destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 82 | + | destinationAttachment := &attachedClient{} | |
| 82 | 83 | destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"), | |
| 83 | - | name: "destination", | |
| 84 | - | ||
| 85 | - | client: &attachedClient{}}, tabs: []*tab{destinationTab}, | |
| 86 | - | ||
| 87 | - | snapEligible: atomic.Bool{}, | |
| 88 | - | } | |
| 84 | + | name: "destination", | |
| 85 | + | attachments: map[*attachedClient]struct{}{destinationAttachment: {}}, | |
| 86 | + | }, tabs: []*tab{destinationTab}, snapEligible: atomic.Bool{}} | |
| 89 | 87 | destination.snapEligible.Store(true) | |
| 90 | - | destination.client.initOverlays() | |
| 91 | - | destination.client.setSession(destination) | |
| 88 | + | destinationAttachment.initOverlays() | |
| 89 | + | destinationAttachment.setSession(destination) | |
| 92 | 90 | d.sessions[destination.id] = destination | |
| 93 | 91 | ||
| 94 | 92 | sourceInvalidations := make(chan renderInvalidation, 2) | |
| 95 | 93 | destinationInvalidations := make(chan renderInvalidation, 2) | |
| 96 | - | sourceCoordinator := d.attachCoordinator(source, nil, source.client, true) | |
| 94 | + | sourceAttachment := source.snapshotAttachments()[0] | |
| 95 | + | sourceCoordinator := d.attachCoordinator(source, nil, sourceAttachment, true) | |
| 97 | 96 | sourceCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { sourceInvalidations <- invalidation } | |
| 98 | - | destinationCoordinator := d.attachCoordinator(destination, nil, destination.client, true) | |
| 97 | + | destinationCoordinator := d.attachCoordinator(destination, nil, destinationAttachment, true) | |
| 99 | 98 | destinationCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { destinationInvalidations <- invalidation } | |
| 100 | 99 | ||
| 101 | 100 | d.startPaneGoroutines(source, source.activeTab(), pane) | |
| @@ -79,23 +79,22 @@ func TestPTYReaderRoutesEffectsToOwnerPublishedForEachRead(t *testing.T) { | |||||
| 79 | source.snapEligible.Store(true) | 79 | source.snapEligible.Store(true) | ||
| 80 | 80 | ||||
| 81 | destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | 81 | destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | ||
| 82 | + | destinationAttachment := &attachedClient{} | |||
| 82 | destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"), | 83 | destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"), | ||
| 83 | - | name: "destination", | 84 | + | name: "destination", |
| 84 | - | 85 | + | attachments: map[*attachedClient]struct{}{destinationAttachment: {}}, | |
| 85 | - | client: &attachedClient{}}, tabs: []*tab{destinationTab}, | 86 | + | }, tabs: []*tab{destinationTab}, snapEligible: atomic.Bool{}} |
| 86 | - | ||||
| 87 | - | snapEligible: atomic.Bool{}, | |||
| 88 | - | } | |||
| 89 | destination.snapEligible.Store(true) | 87 | destination.snapEligible.Store(true) | ||
| 90 | - | destination.client.initOverlays() | 88 | + | destinationAttachment.initOverlays() |
| 91 | - | destination.client.setSession(destination) | 89 | + | destinationAttachment.setSession(destination) |
| 92 | d.sessions[destination.id] = destination | 90 | d.sessions[destination.id] = destination | ||
| 93 | 91 | ||||
| 94 | sourceInvalidations := make(chan renderInvalidation, 2) | 92 | sourceInvalidations := make(chan renderInvalidation, 2) | ||
| 95 | destinationInvalidations := make(chan renderInvalidation, 2) | 93 | destinationInvalidations := make(chan renderInvalidation, 2) | ||
| 96 | - | sourceCoordinator := d.attachCoordinator(source, nil, source.client, true) | 94 | + | sourceAttachment := source.snapshotAttachments()[0] |
| 95 | + | sourceCoordinator := d.attachCoordinator(source, nil, sourceAttachment, true) | |||
| 97 | sourceCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { sourceInvalidations <- invalidation } | 96 | sourceCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { sourceInvalidations <- invalidation } | ||
| 98 | - | destinationCoordinator := d.attachCoordinator(destination, nil, destination.client, true) | 97 | + | destinationCoordinator := d.attachCoordinator(destination, nil, destinationAttachment, true) |
| 99 | destinationCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { destinationInvalidations <- invalidation } | 98 | destinationCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { destinationInvalidations <- invalidation } | ||
| 100 | 99 | ||||
| 101 | d.startPaneGoroutines(source, source.activeTab(), pane) | 100 | d.startPaneGoroutines(source, source.activeTab(), pane) | ||
M internal/usecase/daemon/pane_rearrange_test.go +3 -3
| @@ -185,7 +185,7 @@ func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) { | |||
| 185 | 185 | ac := &attachedClient{} | |
| 186 | 186 | ac.setSession(h.session) | |
| 187 | 187 | h.session.mu.Lock() | |
| 188 | - | h.session.client = ac | |
| 188 | + | h.session.registerAttachmentLocked(ac) | |
| 189 | 189 | h.session.mu.Unlock() | |
| 190 | 190 | invalidations := make(chan renderInvalidation, 2) | |
| 191 | 191 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| @@ -270,7 +270,7 @@ func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) { | |||
| 270 | 270 | ac := &attachedClient{} | |
| 271 | 271 | ac.setSession(h.session) | |
| 272 | 272 | h.session.mu.Lock() | |
| 273 | - | h.session.client = ac | |
| 273 | + | h.session.registerAttachmentLocked(ac) | |
| 274 | 274 | h.session.mu.Unlock() | |
| 275 | 275 | invalidations := make(chan renderInvalidation, 2) | |
| 276 | 276 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| @@ -406,7 +406,7 @@ func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) { | |||
| 406 | 406 | ac := &attachedClient{} | |
| 407 | 407 | ac.setSession(h.session) | |
| 408 | 408 | h.session.mu.Lock() | |
| 409 | - | h.session.client = ac | |
| 409 | + | h.session.registerAttachmentLocked(ac) | |
| 410 | 410 | h.session.mu.Unlock() | |
| 411 | 411 | invalidations := make(chan renderInvalidation, 2) | |
| 412 | 412 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| @@ -185,7 +185,7 @@ func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) { | |||||
| 185 | ac := &attachedClient{} | 185 | ac := &attachedClient{} | ||
| 186 | ac.setSession(h.session) | 186 | ac.setSession(h.session) | ||
| 187 | h.session.mu.Lock() | 187 | h.session.mu.Lock() | ||
| 188 | - | h.session.client = ac | 188 | + | h.session.registerAttachmentLocked(ac) |
| 189 | h.session.mu.Unlock() | 189 | h.session.mu.Unlock() | ||
| 190 | invalidations := make(chan renderInvalidation, 2) | 190 | invalidations := make(chan renderInvalidation, 2) | ||
| 191 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | 191 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | ||
| @@ -270,7 +270,7 @@ func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) { | |||||
| 270 | ac := &attachedClient{} | 270 | ac := &attachedClient{} | ||
| 271 | ac.setSession(h.session) | 271 | ac.setSession(h.session) | ||
| 272 | h.session.mu.Lock() | 272 | h.session.mu.Lock() | ||
| 273 | - | h.session.client = ac | 273 | + | h.session.registerAttachmentLocked(ac) |
| 274 | h.session.mu.Unlock() | 274 | h.session.mu.Unlock() | ||
| 275 | invalidations := make(chan renderInvalidation, 2) | 275 | invalidations := make(chan renderInvalidation, 2) | ||
| 276 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | 276 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | ||
| @@ -406,7 +406,7 @@ func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) { | |||||
| 406 | ac := &attachedClient{} | 406 | ac := &attachedClient{} | ||
| 407 | ac.setSession(h.session) | 407 | ac.setSession(h.session) | ||
| 408 | h.session.mu.Lock() | 408 | h.session.mu.Lock() | ||
| 409 | - | h.session.client = ac | 409 | + | h.session.registerAttachmentLocked(ac) |
| 410 | h.session.mu.Unlock() | 410 | h.session.mu.Unlock() | ||
| 411 | invalidations := make(chan renderInvalidation, 2) | 411 | invalidations := make(chan renderInvalidation, 2) | ||
| 412 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | 412 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | ||
M internal/usecase/daemon/pane_sync_owner_test.go +2 -2
| @@ -27,8 +27,8 @@ func newPaneSyncOwnerFixture(t *testing.T) paneSyncOwnerFixture { | |||
| 27 | 27 | size := domain.Size{Cols: 80, Rows: 23} | |
| 28 | 28 | sourceTab := newTab(newQuietPTY(), size) | |
| 29 | 29 | targetTab := newTab(newQuietPTY(), size) | |
| 30 | - | source := &session{sessionCore: sessionCore{id: "sync-source", client: &attachedClient{}}, tabs: []*tab{sourceTab}, active: 0} | |
| 31 | - | destination := &session{sessionCore: sessionCore{id: "sync-destination", client: &attachedClient{}}, tabs: []*tab{targetTab}, active: 0} | |
| 30 | + | source := &session{sessionCore: sessionCore{id: "sync-source", attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}}, tabs: []*tab{sourceTab}, active: 0} | |
| 31 | + | destination := &session{sessionCore: sessionCore{id: "sync-destination", attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}}, tabs: []*tab{targetTab}, active: 0} | |
| 32 | 32 | d := &Daemon{sessions: map[domain.SessionID]attachmentSession{source.id: source, destination.id: destination}} | |
| 33 | 33 | p := sourceTab.focusedPane() | |
| 34 | 34 | publishPaneOwner(p, source, sourceTab, 0) | |
| @@ -27,8 +27,8 @@ func newPaneSyncOwnerFixture(t *testing.T) paneSyncOwnerFixture { | |||||
| 27 | size := domain.Size{Cols: 80, Rows: 23} | 27 | size := domain.Size{Cols: 80, Rows: 23} | ||
| 28 | sourceTab := newTab(newQuietPTY(), size) | 28 | sourceTab := newTab(newQuietPTY(), size) | ||
| 29 | targetTab := newTab(newQuietPTY(), size) | 29 | targetTab := newTab(newQuietPTY(), size) | ||
| 30 | - | source := &session{sessionCore: sessionCore{id: "sync-source", client: &attachedClient{}}, tabs: []*tab{sourceTab}, active: 0} | 30 | + | source := &session{sessionCore: sessionCore{id: "sync-source", attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}}, tabs: []*tab{sourceTab}, active: 0} |
| 31 | - | destination := &session{sessionCore: sessionCore{id: "sync-destination", client: &attachedClient{}}, tabs: []*tab{targetTab}, active: 0} | 31 | + | destination := &session{sessionCore: sessionCore{id: "sync-destination", attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}}, tabs: []*tab{targetTab}, active: 0} |
| 32 | d := &Daemon{sessions: map[domain.SessionID]attachmentSession{source.id: source, destination.id: destination}} | 32 | d := &Daemon{sessions: map[domain.SessionID]attachmentSession{source.id: source, destination.id: destination}} | ||
| 33 | p := sourceTab.focusedPane() | 33 | p := sourceTab.focusedPane() | ||
| 34 | publishPaneOwner(p, source, sourceTab, 0) | 34 | publishPaneOwner(p, source, sourceTab, 0) | ||
M internal/usecase/daemon/picker_test.go +15 -15
| @@ -200,7 +200,7 @@ func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) { | |||
| 200 | 200 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 201 | 201 | defer releaseAll(releases) | |
| 202 | 202 | sess.mu.Lock() | |
| 203 | - | sess.client = ac | |
| 203 | + | sess.registerAttachmentLocked(ac) | |
| 204 | 204 | secondTabID := domain.TabStableID(sess.tabs[1].stableID) | |
| 205 | 205 | sess.mu.Unlock() | |
| 206 | 206 | sess.mruAt.Store(1) | |
| @@ -601,7 +601,7 @@ func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) { | |||
| 601 | 601 | defer release1() | |
| 602 | 602 | defer release2() | |
| 603 | 603 | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 604 | - | current.client = &attachedClient{} | |
| 604 | + | current.registerAttachment(&attachedClient{}) | |
| 605 | 605 | ||
| 606 | 606 | require.Error(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) | |
| 607 | 607 | require.Equal(t, 0, current.active) | |
| @@ -829,7 +829,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |||
| 829 | 829 | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |
| 830 | 830 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 831 | 831 | sess.mu.Lock() | |
| 832 | - | sess.client = ac | |
| 832 | + | sess.registerAttachmentLocked(ac) | |
| 833 | 833 | sess.mu.Unlock() | |
| 834 | 834 | d.ptys = newBlockingOpenFactory(t, d) | |
| 835 | 835 | defer func() { | |
| @@ -962,8 +962,8 @@ func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) { | |||
| 962 | 962 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 963 | 963 | defer cancel1() | |
| 964 | 964 | defer cancel2() | |
| 965 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 966 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 965 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 966 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 967 | 967 | ac1.setSession(sess1) | |
| 968 | 968 | ac2.setSession(sess2) | |
| 969 | 969 | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil) | |
| @@ -978,8 +978,8 @@ func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) { | |||
| 978 | 978 | d.handleInput(sess1, ac1, []byte("\r")) | |
| 979 | 979 | ||
| 980 | 980 | require.Same(t, sess2, ac1.currentSession()) | |
| 981 | - | require.Same(t, ac1, sess2.client) | |
| 982 | - | require.Nil(t, sess1.client) | |
| 981 | + | require.Contains(t, sess2.snapshotAttachments(), ac1) | |
| 982 | + | require.Empty(t, sess1.snapshotAttachments()) | |
| 983 | 983 | require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch") | |
| 984 | 984 | ||
| 985 | 985 | d.attachmentCleanupWg.Wait() | |
| @@ -1016,8 +1016,8 @@ func TestPickerDisplacementCancelsSupersededResize(t *testing.T) { | |||
| 1016 | 1016 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1017 | 1017 | defer cancel1() | |
| 1018 | 1018 | defer cancel2() | |
| 1019 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1020 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1019 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1020 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1021 | 1021 | ac1.setSession(sess1) | |
| 1022 | 1022 | ac2.setSession(sess2) | |
| 1023 | 1023 | d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2 | |
| @@ -1051,8 +1051,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||
| 1051 | 1051 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1052 | 1052 | defer cancel1() | |
| 1053 | 1053 | defer cancel2() | |
| 1054 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1055 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1054 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1055 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1056 | 1056 | ac1.setSession(sess1) | |
| 1057 | 1057 | ac2.setSession(sess2) | |
| 1058 | 1058 | d.sessions[sess1.id] = sess1 | |
| @@ -1102,7 +1102,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing | |||
| 1102 | 1102 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1103 | 1103 | defer cancel1() | |
| 1104 | 1104 | defer cancel2() | |
| 1105 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1105 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1106 | 1106 | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1107 | 1107 | ac.setSession(sess1) | |
| 1108 | 1108 | d.sessions[sess1.id] = sess1 | |
| @@ -1182,8 +1182,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||
| 1182 | 1182 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1183 | 1183 | defer cancel1() | |
| 1184 | 1184 | defer cancel2() | |
| 1185 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}} | |
| 1186 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}} | |
| 1185 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", attachments: map[*attachedClient]struct{}{ac1: {}}}, cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}} | |
| 1186 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}} | |
| 1187 | 1187 | ac1.setSession(sess1) | |
| 1188 | 1188 | ac2.setSession(sess2) | |
| 1189 | 1189 | d.sessions[sess1.id] = sess1 | |
| @@ -1700,7 +1700,7 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) | |||
| 1700 | 1700 | target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |
| 1701 | 1701 | d.sessions[target.id] = target | |
| 1702 | 1702 | current.mu.Lock() | |
| 1703 | - | current.client = ac | |
| 1703 | + | current.registerAttachmentLocked(ac) | |
| 1704 | 1704 | current.mu.Unlock() | |
| 1705 | 1705 | require.NotNil(t, d.attachCoordinator(current, nil, ac, true)) | |
| 1706 | 1706 | ||
| @@ -200,7 +200,7 @@ func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) { | |||||
| 200 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | 200 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | ||
| 201 | defer releaseAll(releases) | 201 | defer releaseAll(releases) | ||
| 202 | sess.mu.Lock() | 202 | sess.mu.Lock() | ||
| 203 | - | sess.client = ac | 203 | + | sess.registerAttachmentLocked(ac) |
| 204 | secondTabID := domain.TabStableID(sess.tabs[1].stableID) | 204 | secondTabID := domain.TabStableID(sess.tabs[1].stableID) | ||
| 205 | sess.mu.Unlock() | 205 | sess.mu.Unlock() | ||
| 206 | sess.mruAt.Store(1) | 206 | sess.mruAt.Store(1) | ||
| @@ -601,7 +601,7 @@ func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) { | |||||
| 601 | defer release1() | 601 | defer release1() | ||
| 602 | defer release2() | 602 | defer release2() | ||
| 603 | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | 603 | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | ||
| 604 | - | current.client = &attachedClient{} | 604 | + | current.registerAttachment(&attachedClient{}) |
| 605 | 605 | ||||
| 606 | require.Error(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) | 606 | require.Error(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) | ||
| 607 | require.Equal(t, 0, current.active) | 607 | require.Equal(t, 0, current.active) | ||
| @@ -829,7 +829,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |||||
| 829 | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | 829 | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | ||
| 830 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | 830 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | ||
| 831 | sess.mu.Lock() | 831 | sess.mu.Lock() | ||
| 832 | - | sess.client = ac | 832 | + | sess.registerAttachmentLocked(ac) |
| 833 | sess.mu.Unlock() | 833 | sess.mu.Unlock() | ||
| 834 | d.ptys = newBlockingOpenFactory(t, d) | 834 | d.ptys = newBlockingOpenFactory(t, d) | ||
| 835 | defer func() { | 835 | defer func() { | ||
| @@ -962,8 +962,8 @@ func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) { | |||||
| 962 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 962 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| 963 | defer cancel1() | 963 | defer cancel1() | ||
| 964 | defer cancel2() | 964 | defer cancel2() | ||
| 965 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | 965 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} |
| 966 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | 966 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} |
| 967 | ac1.setSession(sess1) | 967 | ac1.setSession(sess1) | ||
| 968 | ac2.setSession(sess2) | 968 | ac2.setSession(sess2) | ||
| 969 | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil) | 969 | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil) | ||
| @@ -978,8 +978,8 @@ func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) { | |||||
| 978 | d.handleInput(sess1, ac1, []byte("\r")) | 978 | d.handleInput(sess1, ac1, []byte("\r")) | ||
| 979 | 979 | ||||
| 980 | require.Same(t, sess2, ac1.currentSession()) | 980 | require.Same(t, sess2, ac1.currentSession()) | ||
| 981 | - | require.Same(t, ac1, sess2.client) | 981 | + | require.Contains(t, sess2.snapshotAttachments(), ac1) |
| 982 | - | require.Nil(t, sess1.client) | 982 | + | require.Empty(t, sess1.snapshotAttachments()) |
| 983 | require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch") | 983 | require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch") | ||
| 984 | 984 | ||||
| 985 | d.attachmentCleanupWg.Wait() | 985 | d.attachmentCleanupWg.Wait() | ||
| @@ -1016,8 +1016,8 @@ func TestPickerDisplacementCancelsSupersededResize(t *testing.T) { | |||||
| 1016 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 1016 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| 1017 | defer cancel1() | 1017 | defer cancel1() | ||
| 1018 | defer cancel2() | 1018 | defer cancel2() | ||
| 1019 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | 1019 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} |
| 1020 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | 1020 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} |
| 1021 | ac1.setSession(sess1) | 1021 | ac1.setSession(sess1) | ||
| 1022 | ac2.setSession(sess2) | 1022 | ac2.setSession(sess2) | ||
| 1023 | d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2 | 1023 | d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2 | ||
| @@ -1051,8 +1051,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||||
| 1051 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 1051 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| 1052 | defer cancel1() | 1052 | defer cancel1() | ||
| 1053 | defer cancel2() | 1053 | defer cancel2() | ||
| 1054 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | 1054 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} |
| 1055 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | 1055 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} |
| 1056 | ac1.setSession(sess1) | 1056 | ac1.setSession(sess1) | ||
| 1057 | ac2.setSession(sess2) | 1057 | ac2.setSession(sess2) | ||
| 1058 | d.sessions[sess1.id] = sess1 | 1058 | d.sessions[sess1.id] = sess1 | ||
| @@ -1102,7 +1102,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing | |||||
| 1102 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 1102 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| 1103 | defer cancel1() | 1103 | defer cancel1() | ||
| 1104 | defer cancel2() | 1104 | defer cancel2() | ||
| 1105 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | 1105 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} |
| 1106 | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | 1106 | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | ||
| 1107 | ac.setSession(sess1) | 1107 | ac.setSession(sess1) | ||
| 1108 | d.sessions[sess1.id] = sess1 | 1108 | d.sessions[sess1.id] = sess1 | ||
| @@ -1182,8 +1182,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||||
| 1182 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 1182 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| 1183 | defer cancel1() | 1183 | defer cancel1() | ||
| 1184 | defer cancel2() | 1184 | defer cancel2() | ||
| 1185 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}} | 1185 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", attachments: map[*attachedClient]struct{}{ac1: {}}}, cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}} |
| 1186 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}} | 1186 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}} |
| 1187 | ac1.setSession(sess1) | 1187 | ac1.setSession(sess1) | ||
| 1188 | ac2.setSession(sess2) | 1188 | ac2.setSession(sess2) | ||
| 1189 | d.sessions[sess1.id] = sess1 | 1189 | d.sessions[sess1.id] = sess1 | ||
| @@ -1700,7 +1700,7 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) | |||||
| 1700 | target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | 1700 | target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | ||
| 1701 | d.sessions[target.id] = target | 1701 | d.sessions[target.id] = target | ||
| 1702 | current.mu.Lock() | 1702 | current.mu.Lock() | ||
| 1703 | - | current.client = ac | 1703 | + | current.registerAttachmentLocked(ac) |
| 1704 | current.mu.Unlock() | 1704 | current.mu.Unlock() | ||
| 1705 | require.NotNil(t, d.attachCoordinator(current, nil, ac, true)) | 1705 | require.NotNil(t, d.attachCoordinator(current, nil, ac, true)) | ||
| 1706 | 1706 | ||||
M internal/usecase/daemon/proxy_input_test.go +4 -4
| @@ -838,8 +838,8 @@ func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) { | |||
| 838 | 838 | client := newProxyTestTransport() | |
| 839 | 839 | ac := &attachedClient{} | |
| 840 | 840 | ac.replaceTransport(client) | |
| 841 | - | first := &session{sessionCore: sessionCore{client: ac}} | |
| 842 | - | second := &session{sessionCore: sessionCore{client: ac}} | |
| 841 | + | first := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}} | |
| 842 | + | second := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}} | |
| 843 | 843 | ac.setSession(first) | |
| 844 | 844 | ||
| 845 | 845 | const unstableSnapshots = 100 | |
| @@ -866,14 +866,14 @@ func TestConnectionLoopStopsWhenReceiveErrorCleanupMakesNoProgress(t *testing.T) | |||
| 866 | 866 | client := newProxyTestTransport() | |
| 867 | 867 | ac := &attachedClient{} | |
| 868 | 868 | ac.replaceTransport(client) | |
| 869 | - | local := &session{sessionCore: sessionCore{client: ac}} | |
| 869 | + | local := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}} | |
| 870 | 870 | ac.setSession(local) | |
| 871 | 871 | ||
| 872 | 872 | cleanupAttempts := 0 | |
| 873 | 873 | d.beforeClientGoneDetach = func() { | |
| 874 | 874 | cleanupAttempts++ | |
| 875 | 875 | local.mu.Lock() | |
| 876 | - | local.client = &attachedClient{} | |
| 876 | + | local.registerAttachmentLocked(&attachedClient{}) | |
| 877 | 877 | local.mu.Unlock() | |
| 878 | 878 | } | |
| 879 | 879 | snapshots := 0 | |
| @@ -838,8 +838,8 @@ func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) { | |||||
| 838 | client := newProxyTestTransport() | 838 | client := newProxyTestTransport() | ||
| 839 | ac := &attachedClient{} | 839 | ac := &attachedClient{} | ||
| 840 | ac.replaceTransport(client) | 840 | ac.replaceTransport(client) | ||
| 841 | - | first := &session{sessionCore: sessionCore{client: ac}} | 841 | + | first := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}} |
| 842 | - | second := &session{sessionCore: sessionCore{client: ac}} | 842 | + | second := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}} |
| 843 | ac.setSession(first) | 843 | ac.setSession(first) | ||
| 844 | 844 | ||||
| 845 | const unstableSnapshots = 100 | 845 | const unstableSnapshots = 100 | ||
| @@ -866,14 +866,14 @@ func TestConnectionLoopStopsWhenReceiveErrorCleanupMakesNoProgress(t *testing.T) | |||||
| 866 | client := newProxyTestTransport() | 866 | client := newProxyTestTransport() | ||
| 867 | ac := &attachedClient{} | 867 | ac := &attachedClient{} | ||
| 868 | ac.replaceTransport(client) | 868 | ac.replaceTransport(client) | ||
| 869 | - | local := &session{sessionCore: sessionCore{client: ac}} | 869 | + | local := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}} |
| 870 | ac.setSession(local) | 870 | ac.setSession(local) | ||
| 871 | 871 | ||||
| 872 | cleanupAttempts := 0 | 872 | cleanupAttempts := 0 | ||
| 873 | d.beforeClientGoneDetach = func() { | 873 | d.beforeClientGoneDetach = func() { | ||
| 874 | cleanupAttempts++ | 874 | cleanupAttempts++ | ||
| 875 | local.mu.Lock() | 875 | local.mu.Lock() | ||
| 876 | - | local.client = &attachedClient{} | 876 | + | local.registerAttachmentLocked(&attachedClient{}) |
| 877 | local.mu.Unlock() | 877 | local.mu.Unlock() | ||
| 878 | } | 878 | } | ||
| 879 | snapshots := 0 | 879 | snapshots := 0 | ||
M internal/usecase/daemon/proxy_lifecycle_test.go +7 -5
| @@ -255,7 +255,7 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) { | |||
| 255 | 255 | ||
| 256 | 256 | ac := &attachedClient{} | |
| 257 | 257 | proxy.sessionCore.mu.Lock() | |
| 258 | - | proxy.client = ac | |
| 258 | + | proxy.sessionCore.registerAttachmentLocked(ac) | |
| 259 | 259 | proxy.sessionCore.mu.Unlock() | |
| 260 | 260 | d.cancelProxyWarmForAttachment(proxy, ac) | |
| 261 | 261 | awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete") | |
| @@ -266,7 +266,9 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) { | |||
| 266 | 266 | d.mu.Unlock() | |
| 267 | 267 | ||
| 268 | 268 | proxy.sessionCore.mu.Lock() | |
| 269 | - | proxy.client = nil | |
| 269 | + | for _, attachment := range proxy.sessionCore.snapshotAttachmentsLocked() { | |
| 270 | + | proxy.sessionCore.unregisterAttachmentLocked(attachment) | |
| 271 | + | } | |
| 270 | 272 | proxy.sessionCore.mu.Unlock() | |
| 271 | 273 | d.armProxyWarm(proxy) | |
| 272 | 274 | secondTimer := awaitTestValue(t, clock.timers, "second proxy timer was not registered") | |
| @@ -471,7 +473,7 @@ func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) { | |||
| 471 | 473 | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true} | |
| 472 | 474 | old.initOverlays() | |
| 473 | 475 | old.setSession(sess) | |
| 474 | - | sess.client = old | |
| 476 | + | sess.registerAttachment(old) | |
| 475 | 477 | d.mu.Lock() | |
| 476 | 478 | require.True(t, d.registerSessionLocked(sess)) | |
| 477 | 479 | d.mu.Unlock() | |
| @@ -487,7 +489,7 @@ func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) { | |||
| 487 | 489 | }) | |
| 488 | 490 | require.NoError(t, err) | |
| 489 | 491 | require.Nil(t, result.displaced.ac, "a proxied attachment must never enter the snatched role") | |
| 490 | - | require.Same(t, next, sess.client) | |
| 492 | + | require.Contains(t, sess.snapshotAttachments(), next) | |
| 491 | 493 | require.Nil(t, old.currentAttachmentSession()) | |
| 492 | 494 | ||
| 493 | 495 | d.deferAttachmentTransitionCleanups(result) | |
| @@ -1322,7 +1324,7 @@ func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) { | |||
| 1322 | 1324 | ||
| 1323 | 1325 | func setProxyLifecycleClient(p *proxySession, ac *attachedClient) { | |
| 1324 | 1326 | p.sessionCore.mu.Lock() | |
| 1325 | - | p.client = ac | |
| 1327 | + | p.sessionCore.registerAttachmentLocked(ac) | |
| 1326 | 1328 | p.sessionCore.mu.Unlock() | |
| 1327 | 1329 | } | |
| 1328 | 1330 | ||
| @@ -255,7 +255,7 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) { | |||||
| 255 | 255 | ||||
| 256 | ac := &attachedClient{} | 256 | ac := &attachedClient{} | ||
| 257 | proxy.sessionCore.mu.Lock() | 257 | proxy.sessionCore.mu.Lock() | ||
| 258 | - | proxy.client = ac | 258 | + | proxy.sessionCore.registerAttachmentLocked(ac) |
| 259 | proxy.sessionCore.mu.Unlock() | 259 | proxy.sessionCore.mu.Unlock() | ||
| 260 | d.cancelProxyWarmForAttachment(proxy, ac) | 260 | d.cancelProxyWarmForAttachment(proxy, ac) | ||
| 261 | awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete") | 261 | awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete") | ||
| @@ -266,7 +266,9 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) { | |||||
| 266 | d.mu.Unlock() | 266 | d.mu.Unlock() | ||
| 267 | 267 | ||||
| 268 | proxy.sessionCore.mu.Lock() | 268 | proxy.sessionCore.mu.Lock() | ||
| 269 | - | proxy.client = nil | 269 | + | for _, attachment := range proxy.sessionCore.snapshotAttachmentsLocked() { |
| 270 | + | proxy.sessionCore.unregisterAttachmentLocked(attachment) | |||
| 271 | + | } | |||
| 270 | proxy.sessionCore.mu.Unlock() | 272 | proxy.sessionCore.mu.Unlock() | ||
| 271 | d.armProxyWarm(proxy) | 273 | d.armProxyWarm(proxy) | ||
| 272 | secondTimer := awaitTestValue(t, clock.timers, "second proxy timer was not registered") | 274 | secondTimer := awaitTestValue(t, clock.timers, "second proxy timer was not registered") | ||
| @@ -471,7 +473,7 @@ func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) { | |||||
| 471 | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true} | 473 | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true} | ||
| 472 | old.initOverlays() | 474 | old.initOverlays() | ||
| 473 | old.setSession(sess) | 475 | old.setSession(sess) | ||
| 474 | - | sess.client = old | 476 | + | sess.registerAttachment(old) |
| 475 | d.mu.Lock() | 477 | d.mu.Lock() | ||
| 476 | require.True(t, d.registerSessionLocked(sess)) | 478 | require.True(t, d.registerSessionLocked(sess)) | ||
| 477 | d.mu.Unlock() | 479 | d.mu.Unlock() | ||
| @@ -487,7 +489,7 @@ func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) { | |||||
| 487 | }) | 489 | }) | ||
| 488 | require.NoError(t, err) | 490 | require.NoError(t, err) | ||
| 489 | require.Nil(t, result.displaced.ac, "a proxied attachment must never enter the snatched role") | 491 | require.Nil(t, result.displaced.ac, "a proxied attachment must never enter the snatched role") | ||
| 490 | - | require.Same(t, next, sess.client) | 492 | + | require.Contains(t, sess.snapshotAttachments(), next) |
| 491 | require.Nil(t, old.currentAttachmentSession()) | 493 | require.Nil(t, old.currentAttachmentSession()) | ||
| 492 | 494 | ||||
| 493 | d.deferAttachmentTransitionCleanups(result) | 495 | d.deferAttachmentTransitionCleanups(result) | ||
| @@ -1322,7 +1324,7 @@ func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) { | |||||
| 1322 | 1324 | ||||
| 1323 | func setProxyLifecycleClient(p *proxySession, ac *attachedClient) { | 1325 | func setProxyLifecycleClient(p *proxySession, ac *attachedClient) { | ||
| 1324 | p.sessionCore.mu.Lock() | 1326 | p.sessionCore.mu.Lock() | ||
| 1325 | - | p.client = ac | 1327 | + | p.sessionCore.registerAttachmentLocked(ac) |
| 1326 | p.sessionCore.mu.Unlock() | 1328 | p.sessionCore.mu.Unlock() | ||
| 1327 | } | 1329 | } | ||
| 1328 | 1330 | ||||
M internal/usecase/daemon/proxy_render_test.go +4 -4
| @@ -91,7 +91,7 @@ func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) { | |||
| 91 | 91 | ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}} | |
| 92 | 92 | ac.setSession(proxy) | |
| 93 | 93 | proxy.sessionCore.mu.Lock() | |
| 94 | - | proxy.client = ac | |
| 94 | + | proxy.sessionCore.registerAttachmentLocked(ac) | |
| 95 | 95 | proxy.sessionCore.mu.Unlock() | |
| 96 | 96 | ||
| 97 | 97 | d.paint(proxy, ac, true, nil) | |
| @@ -296,7 +296,7 @@ func newAttachedProxyFixture(t *testing.T, d *Daemon, clientTr ports.Transport, | |||
| 296 | 296 | ac := &attachedClient{tr: clientTr, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}} | |
| 297 | 297 | ac.setSession(proxy) | |
| 298 | 298 | proxy.sessionCore.mu.Lock() | |
| 299 | - | proxy.client = ac | |
| 299 | + | proxy.sessionCore.registerAttachmentLocked(ac) | |
| 300 | 300 | proxy.sessionCore.mu.Unlock() | |
| 301 | 301 | return proxy, ac | |
| 302 | 302 | } | |
| @@ -394,9 +394,9 @@ func TestProxySendErrorDetachesProxyAttachment(t *testing.T) { | |||
| 394 | 394 | d.paint(proxy, ac, true, nil) | |
| 395 | 395 | ||
| 396 | 396 | proxy.sessionCore.mu.Lock() | |
| 397 | - | client := proxy.client | |
| 397 | + | attachments := proxy.sessionCore.snapshotAttachmentsLocked() | |
| 398 | 398 | proxy.sessionCore.mu.Unlock() | |
| 399 | - | require.Nil(t, client, "a failed send must release the proxy attachment") | |
| 399 | + | require.Empty(t, attachments, "a failed send must release the proxy attachment") | |
| 400 | 400 | require.Nil(t, ac.currentAttachmentSession(), "the client must no longer point at the proxy") | |
| 401 | 401 | } | |
| 402 | 402 | ||
| @@ -91,7 +91,7 @@ func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) { | |||||
| 91 | ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}} | 91 | ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}} | ||
| 92 | ac.setSession(proxy) | 92 | ac.setSession(proxy) | ||
| 93 | proxy.sessionCore.mu.Lock() | 93 | proxy.sessionCore.mu.Lock() | ||
| 94 | - | proxy.client = ac | 94 | + | proxy.sessionCore.registerAttachmentLocked(ac) |
| 95 | proxy.sessionCore.mu.Unlock() | 95 | proxy.sessionCore.mu.Unlock() | ||
| 96 | 96 | ||||
| 97 | d.paint(proxy, ac, true, nil) | 97 | d.paint(proxy, ac, true, nil) | ||
| @@ -296,7 +296,7 @@ func newAttachedProxyFixture(t *testing.T, d *Daemon, clientTr ports.Transport, | |||||
| 296 | ac := &attachedClient{tr: clientTr, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}} | 296 | ac := &attachedClient{tr: clientTr, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}} | ||
| 297 | ac.setSession(proxy) | 297 | ac.setSession(proxy) | ||
| 298 | proxy.sessionCore.mu.Lock() | 298 | proxy.sessionCore.mu.Lock() | ||
| 299 | - | proxy.client = ac | 299 | + | proxy.sessionCore.registerAttachmentLocked(ac) |
| 300 | proxy.sessionCore.mu.Unlock() | 300 | proxy.sessionCore.mu.Unlock() | ||
| 301 | return proxy, ac | 301 | return proxy, ac | ||
| 302 | } | 302 | } | ||
| @@ -394,9 +394,9 @@ func TestProxySendErrorDetachesProxyAttachment(t *testing.T) { | |||||
| 394 | d.paint(proxy, ac, true, nil) | 394 | d.paint(proxy, ac, true, nil) | ||
| 395 | 395 | ||||
| 396 | proxy.sessionCore.mu.Lock() | 396 | proxy.sessionCore.mu.Lock() | ||
| 397 | - | client := proxy.client | 397 | + | attachments := proxy.sessionCore.snapshotAttachmentsLocked() |
| 398 | proxy.sessionCore.mu.Unlock() | 398 | proxy.sessionCore.mu.Unlock() | ||
| 399 | - | require.Nil(t, client, "a failed send must release the proxy attachment") | 399 | + | require.Empty(t, attachments, "a failed send must release the proxy attachment") |
| 400 | require.Nil(t, ac.currentAttachmentSession(), "the client must no longer point at the proxy") | 400 | require.Nil(t, ac.currentAttachmentSession(), "the client must no longer point at the proxy") | ||
| 401 | } | 401 | } | ||
| 402 | 402 | ||||
M internal/usecase/daemon/remote_picker_test.go +1 -1
| @@ -539,7 +539,7 @@ func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) ( | |||
| 539 | 539 | ac.initOverlays() | |
| 540 | 540 | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 541 | 541 | tb.stableID = string(id) + "-tab" | |
| 542 | - | sess := &session{sessionCore: sessionCore{id: id, name: string(id), client: ac}, tabs: []*tab{tb}, active: 0} | |
| 542 | + | sess := &session{sessionCore: sessionCore{id: id, name: string(id), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, active: 0} | |
| 543 | 543 | publishTiledPaneOwners(sess, tb) | |
| 544 | 544 | ac.setSession(sess) | |
| 545 | 545 | d.sessions[id] = sess | |
| @@ -539,7 +539,7 @@ func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) ( | |||||
| 539 | ac.initOverlays() | 539 | ac.initOverlays() | ||
| 540 | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | 540 | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | ||
| 541 | tb.stableID = string(id) + "-tab" | 541 | tb.stableID = string(id) + "-tab" | ||
| 542 | - | sess := &session{sessionCore: sessionCore{id: id, name: string(id), client: ac}, tabs: []*tab{tb}, active: 0} | 542 | + | sess := &session{sessionCore: sessionCore{id: id, name: string(id), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, active: 0} |
| 543 | publishTiledPaneOwners(sess, tb) | 543 | publishTiledPaneOwners(sess, tb) | ||
| 544 | ac.setSession(sess) | 544 | ac.setSession(sess) | ||
| 545 | d.sessions[id] = sess | 545 | d.sessions[id] = sess | ||
M internal/usecase/daemon/render_coordinator_test.go +3 -3
| @@ -1306,7 +1306,7 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal | |||
| 1306 | 1306 | clock: clock.clock, | |
| 1307 | 1307 | wake: func(w renderWake) { | |
| 1308 | 1308 | // This is the production ownership boundary: composition must use | |
| 1309 | - | // the coordinator's captured attachment, never sess.client. | |
| 1309 | + | // the coordinator's captured attachment, never sess.snapshotAttachments()[0]. | |
| 1310 | 1310 | d.paint(sess, w.lease.attachment, w.reset, w.lease) | |
| 1311 | 1311 | }, | |
| 1312 | 1312 | ackReady: func() bool { return true }, | |
| @@ -1316,10 +1316,10 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal | |||
| 1316 | 1316 | rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true}) | |
| 1317 | 1317 | timer := awaitCoordinatorScheduledTimer(t, clock) | |
| 1318 | 1318 | ||
| 1319 | - | // Model attachClient's publication window exactly: sess.client is new, | |
| 1319 | + | // Model attachClient's publication window exactly: sess.snapshotAttachments()[0] is new, | |
| 1320 | 1320 | // but coordinator replacement has not yet invalidated the old deadline. | |
| 1321 | 1321 | sess.mu.Lock() | |
| 1322 | - | sess.client = replacement | |
| 1322 | + | sess.registerAttachmentLocked(replacement) | |
| 1323 | 1323 | sess.mu.Unlock() | |
| 1324 | 1324 | timer.ch <- time.Time{} | |
| 1325 | 1325 | requireNoCoordinatorOutputFrame(t, replacementSends) | |
| @@ -1306,7 +1306,7 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal | |||||
| 1306 | clock: clock.clock, | 1306 | clock: clock.clock, | ||
| 1307 | wake: func(w renderWake) { | 1307 | wake: func(w renderWake) { | ||
| 1308 | // This is the production ownership boundary: composition must use | 1308 | // This is the production ownership boundary: composition must use | ||
| 1309 | - | // the coordinator's captured attachment, never sess.client. | 1309 | + | // the coordinator's captured attachment, never sess.snapshotAttachments()[0]. |
| 1310 | d.paint(sess, w.lease.attachment, w.reset, w.lease) | 1310 | d.paint(sess, w.lease.attachment, w.reset, w.lease) | ||
| 1311 | }, | 1311 | }, | ||
| 1312 | ackReady: func() bool { return true }, | 1312 | ackReady: func() bool { return true }, | ||
| @@ -1316,10 +1316,10 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal | |||||
| 1316 | rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true}) | 1316 | rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true}) | ||
| 1317 | timer := awaitCoordinatorScheduledTimer(t, clock) | 1317 | timer := awaitCoordinatorScheduledTimer(t, clock) | ||
| 1318 | 1318 | ||||
| 1319 | - | // Model attachClient's publication window exactly: sess.client is new, | 1319 | + | // Model attachClient's publication window exactly: sess.snapshotAttachments()[0] is new, |
| 1320 | // but coordinator replacement has not yet invalidated the old deadline. | 1320 | // but coordinator replacement has not yet invalidated the old deadline. | ||
| 1321 | sess.mu.Lock() | 1321 | sess.mu.Lock() | ||
| 1322 | - | sess.client = replacement | 1322 | + | sess.registerAttachmentLocked(replacement) |
| 1323 | sess.mu.Unlock() | 1323 | sess.mu.Unlock() | ||
| 1324 | timer.ch <- time.Time{} | 1324 | timer.ch <- time.Time{} | ||
| 1325 | requireNoCoordinatorOutputFrame(t, replacementSends) | 1325 | requireNoCoordinatorOutputFrame(t, replacementSends) | ||
M internal/usecase/daemon/render_damage_receipt_test.go +1 -1
| @@ -95,7 +95,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T | |||
| 95 | 95 | fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) }, | |
| 96 | 96 | restore: func(sess *session, ac *attachedClient, healthy ports.Transport) { | |
| 97 | 97 | sess.mu.Lock() | |
| 98 | - | sess.client = ac | |
| 98 | + | sess.registerAttachmentLocked(ac) | |
| 99 | 99 | sess.mu.Unlock() | |
| 100 | 100 | ac.setSession(sess) | |
| 101 | 101 | ac.replaceTransport(healthy) | |
| @@ -95,7 +95,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T | |||||
| 95 | fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) }, | 95 | fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) }, | ||
| 96 | restore: func(sess *session, ac *attachedClient, healthy ports.Transport) { | 96 | restore: func(sess *session, ac *attachedClient, healthy ports.Transport) { | ||
| 97 | sess.mu.Lock() | 97 | sess.mu.Lock() | ||
| 98 | - | sess.client = ac | 98 | + | sess.registerAttachmentLocked(ac) |
| 99 | sess.mu.Unlock() | 99 | sess.mu.Unlock() | ||
| 100 | ac.setSession(sess) | 100 | ac.setSession(sess) | ||
| 101 | ac.replaceTransport(healthy) | 101 | ac.replaceTransport(healthy) | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -1
| @@ -57,7 +57,7 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) { | |||
| 57 | 57 | // its original healthy transport, then retry the pending state. | |
| 58 | 58 | if failure.name == "send" { | |
| 59 | 59 | sess.mu.Lock() | |
| 60 | - | sess.client = ac | |
| 60 | + | sess.registerAttachmentLocked(ac) | |
| 61 | 61 | sess.mu.Unlock() | |
| 62 | 62 | ac.setSession(sess) | |
| 63 | 63 | ac.replaceTransport(healthy) | |
| @@ -57,7 +57,7 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) { | |||||
| 57 | // its original healthy transport, then retry the pending state. | 57 | // its original healthy transport, then retry the pending state. | ||
| 58 | if failure.name == "send" { | 58 | if failure.name == "send" { | ||
| 59 | sess.mu.Lock() | 59 | sess.mu.Lock() | ||
| 60 | - | sess.client = ac | 60 | + | sess.registerAttachmentLocked(ac) |
| 61 | sess.mu.Unlock() | 61 | sess.mu.Unlock() | ||
| 62 | ac.setSession(sess) | 62 | ac.setSession(sess) | ||
| 63 | ac.replaceTransport(healthy) | 63 | ac.replaceTransport(healthy) | ||
M internal/usecase/daemon/render_pipeline_test.go +2 -2
| @@ -127,7 +127,7 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) { | |||
| 127 | 127 | pty, release := newBlockingPTY(t) | |
| 128 | 128 | t.Cleanup(release) | |
| 129 | 129 | d, sess, _, sends := newManualSessionWithPTYs(t, pty) | |
| 130 | - | ac := sess.client | |
| 130 | + | ac := sess.snapshotAttachments()[0] | |
| 131 | 131 | ac.sendMu.Lock() | |
| 132 | 132 | ac.output.next = ac.output.maxOutstanding | |
| 133 | 133 | ac.output.acked = 0 | |
| @@ -240,7 +240,7 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) { | |||
| 240 | 240 | require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow") | |
| 241 | 241 | ||
| 242 | 242 | sess.mu.Lock() | |
| 243 | - | sess.client = ac | |
| 243 | + | sess.registerAttachmentLocked(ac) | |
| 244 | 244 | sess.mu.Unlock() | |
| 245 | 245 | ac.setSession(sess) | |
| 246 | 246 | ac.replaceTransport(healthy) | |
| @@ -127,7 +127,7 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) { | |||||
| 127 | pty, release := newBlockingPTY(t) | 127 | pty, release := newBlockingPTY(t) | ||
| 128 | t.Cleanup(release) | 128 | t.Cleanup(release) | ||
| 129 | d, sess, _, sends := newManualSessionWithPTYs(t, pty) | 129 | d, sess, _, sends := newManualSessionWithPTYs(t, pty) | ||
| 130 | - | ac := sess.client | 130 | + | ac := sess.snapshotAttachments()[0] |
| 131 | ac.sendMu.Lock() | 131 | ac.sendMu.Lock() | ||
| 132 | ac.output.next = ac.output.maxOutstanding | 132 | ac.output.next = ac.output.maxOutstanding | ||
| 133 | ac.output.acked = 0 | 133 | ac.output.acked = 0 | ||
| @@ -240,7 +240,7 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) { | |||||
| 240 | require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow") | 240 | require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow") | ||
| 241 | 241 | ||||
| 242 | sess.mu.Lock() | 242 | sess.mu.Lock() | ||
| 243 | - | sess.client = ac | 243 | + | sess.registerAttachmentLocked(ac) |
| 244 | sess.mu.Unlock() | 244 | sess.mu.Unlock() | ||
| 245 | ac.setSession(sess) | 245 | ac.setSession(sess) | ||
| 246 | ac.replaceTransport(healthy) | 246 | ac.replaceTransport(healthy) | ||
M internal/usecase/daemon/render_test.go +12 -12
| @@ -491,7 +491,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 491 | 491 | viewer.overlays.pickerMu.Lock() | |
| 492 | 492 | viewer.overlays.pickerPreview = target.tabs[0] | |
| 493 | 493 | viewer.overlays.pickerMu.Unlock() | |
| 494 | - | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}} | |
| 494 | + | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}} | |
| 495 | 495 | previews := make(chan renderWake, 2) | |
| 496 | 496 | rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w }) | |
| 497 | 497 | ||
| @@ -502,7 +502,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 502 | 502 | drainCoordinatorTimers(clock) // detach must clear the pending output work. | |
| 503 | 503 | ||
| 504 | 504 | target.mu.Lock() | |
| 505 | - | target.client = nil | |
| 505 | + | clearAttachmentsForTestLocked(target) | |
| 506 | 506 | target.mu.Unlock() | |
| 507 | 507 | rc.noteDetach(targetClient) | |
| 508 | 508 | steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")} | |
| @@ -540,7 +540,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 540 | 540 | replacement := &attachedClient{output: newOutputStateStream()} | |
| 541 | 541 | rc.noteReplace(oldClient, replacement, true) | |
| 542 | 542 | target.mu.Lock() | |
| 543 | - | target.client = replacement | |
| 543 | + | target.registerAttachmentLocked(replacement) | |
| 544 | 544 | target.mu.Unlock() | |
| 545 | 545 | // The replacement's initial full paint remains gated by the batch. Its | |
| 546 | 546 | // reset must survive completion and coalesce with the completion wake. | |
| @@ -575,7 +575,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 575 | 575 | wakes := make(chan renderWake, 1) | |
| 576 | 576 | rc.opts.wake = func(w renderWake) { wakes <- w } | |
| 577 | 577 | target.mu.Lock() | |
| 578 | - | target.client = nil | |
| 578 | + | clearAttachmentsForTestLocked(target) | |
| 579 | 579 | target.mu.Unlock() | |
| 580 | 580 | rc.noteDetach(client) | |
| 581 | 581 | ||
| @@ -628,7 +628,7 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | |||
| 628 | 628 | name string | |
| 629 | 629 | setup func(*Daemon, *session, *tab, *pane) | |
| 630 | 630 | }{ | |
| 631 | - | {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { sess.client = nil }}, | |
| 631 | + | {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { clearAttachmentsForTest(sess) }}, | |
| 632 | 632 | {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) { | |
| 633 | 633 | other := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 634 | 634 | sess.tabs = append([]*tab{other}, sess.tabs...) | |
| @@ -667,13 +667,13 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | |||
| 667 | 667 | require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition") | |
| 668 | 668 | p.screen.ClearDamage() | |
| 669 | 669 | ||
| 670 | - | sess.client = nil | |
| 670 | + | clearAttachmentsForTest(sess) | |
| 671 | 671 | viewer := &attachedClient{} | |
| 672 | 672 | viewer.initOverlays() | |
| 673 | 673 | viewer.overlays.pickerMu.Lock() | |
| 674 | 674 | viewer.overlays.pickerPreview = tb | |
| 675 | 675 | viewer.overlays.pickerMu.Unlock() | |
| 676 | - | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}} | |
| 676 | + | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}} | |
| 677 | 677 | p.screen.Write([]byte("preview")) | |
| 678 | 678 | _ = d.paneRenderable(sess, tb, p) | |
| 679 | 679 | require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition") | |
| @@ -802,7 +802,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||
| 802 | 802 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 803 | 803 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 804 | 804 | ac.initOverlays() | |
| 805 | - | sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}} | |
| 805 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}} | |
| 806 | 806 | ac.setSession(sess) | |
| 807 | 807 | ||
| 808 | 808 | // Client rows are one more than the equivalent case in a single-bar | |
| @@ -1062,7 +1062,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 1062 | 1062 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 1063 | 1063 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 1064 | 1064 | ac.initOverlays() | |
| 1065 | - | sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}} | |
| 1065 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}} | |
| 1066 | 1066 | ac.setSession(sess) | |
| 1067 | 1067 | ||
| 1068 | 1068 | d.resize(sess, ac, newSize) | |
| @@ -1091,7 +1091,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||
| 1091 | 1091 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1092 | 1092 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 1093 | 1093 | ac.initOverlays() | |
| 1094 | - | sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 1094 | + | sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 1095 | 1095 | ac.setSession(sess) | |
| 1096 | 1096 | d.sessions[sess.id] = sess | |
| 1097 | 1097 | ||
| @@ -1103,7 +1103,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||
| 1103 | 1103 | ||
| 1104 | 1104 | require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send") | |
| 1105 | 1105 | sess.mu.Lock() | |
| 1106 | - | require.Nil(t, sess.client) | |
| 1106 | + | require.Empty(t, sess.snapshotAttachmentsLocked()) | |
| 1107 | 1107 | sess.mu.Unlock() | |
| 1108 | 1108 | ||
| 1109 | 1109 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| @@ -1135,7 +1135,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||
| 1135 | 1135 | win := newTestTabWithContext(p, sctx, cancel) | |
| 1136 | 1136 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 1137 | 1137 | ac.initOverlays() | |
| 1138 | - | sess := &session{sessionCore: sessionCore{id: "query", name: "query", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 1138 | + | sess := &session{sessionCore: sessionCore{id: "query", name: "query", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 1139 | 1139 | ac.setSession(sess) | |
| 1140 | 1140 | d.sessions[sess.id] = sess | |
| 1141 | 1141 | d.sessWg.Add(1) | |
| @@ -491,7 +491,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||||
| 491 | viewer.overlays.pickerMu.Lock() | 491 | viewer.overlays.pickerMu.Lock() | ||
| 492 | viewer.overlays.pickerPreview = target.tabs[0] | 492 | viewer.overlays.pickerPreview = target.tabs[0] | ||
| 493 | viewer.overlays.pickerMu.Unlock() | 493 | viewer.overlays.pickerMu.Unlock() | ||
| 494 | - | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}} | 494 | + | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}} |
| 495 | previews := make(chan renderWake, 2) | 495 | previews := make(chan renderWake, 2) | ||
| 496 | rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w }) | 496 | rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w }) | ||
| 497 | 497 | ||||
| @@ -502,7 +502,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||||
| 502 | drainCoordinatorTimers(clock) // detach must clear the pending output work. | 502 | drainCoordinatorTimers(clock) // detach must clear the pending output work. | ||
| 503 | 503 | ||||
| 504 | target.mu.Lock() | 504 | target.mu.Lock() | ||
| 505 | - | target.client = nil | 505 | + | clearAttachmentsForTestLocked(target) |
| 506 | target.mu.Unlock() | 506 | target.mu.Unlock() | ||
| 507 | rc.noteDetach(targetClient) | 507 | rc.noteDetach(targetClient) | ||
| 508 | steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")} | 508 | steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")} | ||
| @@ -540,7 +540,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||||
| 540 | replacement := &attachedClient{output: newOutputStateStream()} | 540 | replacement := &attachedClient{output: newOutputStateStream()} | ||
| 541 | rc.noteReplace(oldClient, replacement, true) | 541 | rc.noteReplace(oldClient, replacement, true) | ||
| 542 | target.mu.Lock() | 542 | target.mu.Lock() | ||
| 543 | - | target.client = replacement | 543 | + | target.registerAttachmentLocked(replacement) |
| 544 | target.mu.Unlock() | 544 | target.mu.Unlock() | ||
| 545 | // The replacement's initial full paint remains gated by the batch. Its | 545 | // The replacement's initial full paint remains gated by the batch. Its | ||
| 546 | // reset must survive completion and coalesce with the completion wake. | 546 | // reset must survive completion and coalesce with the completion wake. | ||
| @@ -575,7 +575,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||||
| 575 | wakes := make(chan renderWake, 1) | 575 | wakes := make(chan renderWake, 1) | ||
| 576 | rc.opts.wake = func(w renderWake) { wakes <- w } | 576 | rc.opts.wake = func(w renderWake) { wakes <- w } | ||
| 577 | target.mu.Lock() | 577 | target.mu.Lock() | ||
| 578 | - | target.client = nil | 578 | + | clearAttachmentsForTestLocked(target) |
| 579 | target.mu.Unlock() | 579 | target.mu.Unlock() | ||
| 580 | rc.noteDetach(client) | 580 | rc.noteDetach(client) | ||
| 581 | 581 | ||||
| @@ -628,7 +628,7 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | |||||
| 628 | name string | 628 | name string | ||
| 629 | setup func(*Daemon, *session, *tab, *pane) | 629 | setup func(*Daemon, *session, *tab, *pane) | ||
| 630 | }{ | 630 | }{ | ||
| 631 | - | {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { sess.client = nil }}, | 631 | + | {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { clearAttachmentsForTest(sess) }}, |
| 632 | {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) { | 632 | {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) { | ||
| 633 | other := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | 633 | other := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | ||
| 634 | sess.tabs = append([]*tab{other}, sess.tabs...) | 634 | sess.tabs = append([]*tab{other}, sess.tabs...) | ||
| @@ -667,13 +667,13 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | |||||
| 667 | require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition") | 667 | require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition") | ||
| 668 | p.screen.ClearDamage() | 668 | p.screen.ClearDamage() | ||
| 669 | 669 | ||||
| 670 | - | sess.client = nil | 670 | + | clearAttachmentsForTest(sess) |
| 671 | viewer := &attachedClient{} | 671 | viewer := &attachedClient{} | ||
| 672 | viewer.initOverlays() | 672 | viewer.initOverlays() | ||
| 673 | viewer.overlays.pickerMu.Lock() | 673 | viewer.overlays.pickerMu.Lock() | ||
| 674 | viewer.overlays.pickerPreview = tb | 674 | viewer.overlays.pickerPreview = tb | ||
| 675 | viewer.overlays.pickerMu.Unlock() | 675 | viewer.overlays.pickerMu.Unlock() | ||
| 676 | - | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}} | 676 | + | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}} |
| 677 | p.screen.Write([]byte("preview")) | 677 | p.screen.Write([]byte("preview")) | ||
| 678 | _ = d.paneRenderable(sess, tb, p) | 678 | _ = d.paneRenderable(sess, tb, p) | ||
| 679 | require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition") | 679 | require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition") | ||
| @@ -802,7 +802,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||||
| 802 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | 802 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 803 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 803 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 804 | ac.initOverlays() | 804 | ac.initOverlays() | ||
| 805 | - | sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}} | 805 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}} |
| 806 | ac.setSession(sess) | 806 | ac.setSession(sess) | ||
| 807 | 807 | ||||
| 808 | // Client rows are one more than the equivalent case in a single-bar | 808 | // Client rows are one more than the equivalent case in a single-bar | ||
| @@ -1062,7 +1062,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||||
| 1062 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | 1062 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 1063 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 1063 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 1064 | ac.initOverlays() | 1064 | ac.initOverlays() | ||
| 1065 | - | sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}} | 1065 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}} |
| 1066 | ac.setSession(sess) | 1066 | ac.setSession(sess) | ||
| 1067 | 1067 | ||||
| 1068 | d.resize(sess, ac, newSize) | 1068 | d.resize(sess, ac, newSize) | ||
| @@ -1091,7 +1091,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||||
| 1091 | sctx, cancel := context.WithCancel(context.Background()) | 1091 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 1092 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 1092 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 1093 | ac.initOverlays() | 1093 | ac.initOverlays() | ||
| 1094 | - | sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | 1094 | + | sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 1095 | ac.setSession(sess) | 1095 | ac.setSession(sess) | ||
| 1096 | d.sessions[sess.id] = sess | 1096 | d.sessions[sess.id] = sess | ||
| 1097 | 1097 | ||||
| @@ -1103,7 +1103,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||||
| 1103 | 1103 | ||||
| 1104 | require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send") | 1104 | require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send") | ||
| 1105 | sess.mu.Lock() | 1105 | sess.mu.Lock() | ||
| 1106 | - | require.Nil(t, sess.client) | 1106 | + | require.Empty(t, sess.snapshotAttachmentsLocked()) |
| 1107 | sess.mu.Unlock() | 1107 | sess.mu.Unlock() | ||
| 1108 | 1108 | ||||
| 1109 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | 1109 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | ||
| @@ -1135,7 +1135,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||||
| 1135 | win := newTestTabWithContext(p, sctx, cancel) | 1135 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 1136 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 1136 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 1137 | ac.initOverlays() | 1137 | ac.initOverlays() | ||
| 1138 | - | sess := &session{sessionCore: sessionCore{id: "query", name: "query", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | 1138 | + | sess := &session{sessionCore: sessionCore{id: "query", name: "query", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 1139 | ac.setSession(sess) | 1139 | ac.setSession(sess) | ||
| 1140 | d.sessions[sess.id] = sess | 1140 | d.sessions[sess.id] = sess | ||
| 1141 | d.sessWg.Add(1) | 1141 | d.sessWg.Add(1) | ||
M internal/usecase/daemon/resume_test.go +28 -28
| @@ -110,7 +110,7 @@ func TestNamedLinkLossParks(t *testing.T) { | |||
| 110 | 110 | ||
| 111 | 111 | d.clientGone(sess, ac, ac.transport(), false) | |
| 112 | 112 | require.Equal(t, 1, sessionCount(d), "named session survives parked link loss") | |
| 113 | - | require.Nil(t, sess.client) | |
| 113 | + | require.Empty(t, sess.snapshotAttachments()) | |
| 114 | 114 | d.mu.Lock() | |
| 115 | 115 | _, parked := d.parked[token] | |
| 116 | 116 | d.mu.Unlock() | |
| @@ -214,7 +214,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) { | |||
| 214 | 214 | welcome := <-tr.sends | |
| 215 | 215 | require.Equal(t, ports.MsgWelcome, welcome.Type) | |
| 216 | 216 | sess.mu.Lock() | |
| 217 | - | resumed := sess.client | |
| 217 | + | resumed := sess.snapshotAttachmentsLocked()[0] | |
| 218 | 218 | sess.mu.Unlock() | |
| 219 | 219 | require.Same(t, ac, resumed) | |
| 220 | 220 | ||
| @@ -253,7 +253,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) { | |||
| 253 | 253 | ||
| 254 | 254 | d.clientGone(sess, ac, ac.transport(), false) | |
| 255 | 255 | require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive") | |
| 256 | - | require.Nil(t, sess.client) | |
| 256 | + | require.Empty(t, sess.snapshotAttachments()) | |
| 257 | 257 | d.mu.Lock() | |
| 258 | 258 | _, parked := d.parked[token] | |
| 259 | 259 | d.mu.Unlock() | |
| @@ -265,7 +265,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) { | |||
| 265 | 265 | require.Same(t, sess, resumedSess) | |
| 266 | 266 | require.Same(t, ac, resumedAC) | |
| 267 | 267 | require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token") | |
| 268 | - | require.Same(t, resumedAC, sess.client) | |
| 268 | + | require.Contains(t, sess.snapshotAttachments(), resumedAC) | |
| 269 | 269 | } | |
| 270 | 270 | ||
| 271 | 271 | // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race | |
| @@ -283,7 +283,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) { | |||
| 283 | 283 | token := ac.resumeToken | |
| 284 | 284 | require.NotZero(t, token) | |
| 285 | 285 | d.clientGone(sess, ac, oldTr, false) | |
| 286 | - | require.Nil(t, sess.client) | |
| 286 | + | require.Empty(t, sess.snapshotAttachments()) | |
| 287 | 287 | d.mu.Lock() | |
| 288 | 288 | parked := d.parked[token] | |
| 289 | 289 | sessionsBefore := len(d.sessions) | |
| @@ -329,7 +329,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) { | |||
| 329 | 329 | require.ErrorAs(t, got.err, &pe) | |
| 330 | 330 | require.Equal(t, ports.ErrNoSuchSession, pe.code) | |
| 331 | 331 | require.Contains(t, pe.Error(), "resume token is no longer valid") | |
| 332 | - | require.Nil(t, sess.client, "lifecycle-race resume must not take over the named session") | |
| 332 | + | require.Empty(t, sess.snapshotAttachments(), "lifecycle-race resume must not take over the named session") | |
| 333 | 333 | require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake") | |
| 334 | 334 | d.mu.Lock() | |
| 335 | 335 | _, stillParked := d.parked[token] | |
| @@ -354,7 +354,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) { | |||
| 354 | 354 | require.NoError(t, err) | |
| 355 | 355 | token := ac.resumeToken | |
| 356 | 356 | require.NotZero(t, token) | |
| 357 | - | require.Same(t, ac, sess.client) | |
| 357 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 358 | 358 | d.mu.Lock() | |
| 359 | 359 | _, parkedAtStart := d.parked[token] | |
| 360 | 360 | sessionsBefore := len(d.sessions) | |
| @@ -403,7 +403,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) { | |||
| 403 | 403 | require.ErrorAs(t, got.err, &pe) | |
| 404 | 404 | require.Equal(t, ports.ErrNoSuchSession, pe.code) | |
| 405 | 405 | require.Contains(t, pe.Error(), "resume token is no longer valid") | |
| 406 | - | require.Nil(t, sess.client, "losing live resume must not take over the named session") | |
| 406 | + | require.Empty(t, sess.snapshotAttachments(), "losing live resume must not take over the named session") | |
| 407 | 407 | require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake") | |
| 408 | 408 | d.mu.Lock() | |
| 409 | 409 | _, stillParked := d.parked[token] | |
| @@ -427,7 +427,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||
| 427 | 427 | require.NoError(t, err) | |
| 428 | 428 | token := ac.resumeToken | |
| 429 | 429 | require.NotZero(t, token) | |
| 430 | - | require.Same(t, ac, sess.client) | |
| 430 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 431 | 431 | ||
| 432 | 432 | reachedGap := make(chan struct{}) | |
| 433 | 433 | releaseGap := make(chan struct{}) | |
| @@ -445,7 +445,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||
| 445 | 445 | }() | |
| 446 | 446 | awaitTestCompletion(t, reachedGap, "teardown did not pause after detach before park") | |
| 447 | 447 | ||
| 448 | - | require.Nil(t, sess.client, "fixture: detach must have cleared the live owner") | |
| 448 | + | require.Empty(t, sess.snapshotAttachments(), "fixture: detach must have cleared the live owner") | |
| 449 | 449 | d.mu.Lock() | |
| 450 | 450 | parkingInGap := d.parking[token] | |
| 451 | 451 | _, parkedInGap := d.parked[token] | |
| @@ -493,9 +493,10 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||
| 493 | 493 | require.NoError(t, got.err, "same-client token must recover across detach-before-park") | |
| 494 | 494 | require.Same(t, sess, got.sess) | |
| 495 | 495 | require.Same(t, ac, got.ac) | |
| 496 | - | require.Same(t, ac, sess.client) | |
| 496 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 497 | 497 | require.Same(t, newTr, ac.transport()) | |
| 498 | 498 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | |
| 499 | + | require.True(t, d.commitResumeClaim(ac), "successful resume must consume its parked credential") | |
| 499 | 500 | require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport") | |
| 500 | 501 | require.False(t, newTr.Closed(), "rebound transport must survive") | |
| 501 | 502 | d.mu.Lock() | |
| @@ -506,7 +507,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||
| 506 | 507 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | |
| 507 | 508 | ||
| 508 | 509 | d.clientGone(sess, ac, oldTr, false) | |
| 509 | - | require.Same(t, ac, sess.client, "stale old-link cleanup must not detach the rebound attachment") | |
| 510 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment") | |
| 510 | 511 | require.Same(t, newTr, ac.transport()) | |
| 511 | 512 | require.False(t, newTr.Closed()) | |
| 512 | 513 | } | |
| @@ -525,7 +526,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 525 | 526 | require.NoError(t, err) | |
| 526 | 527 | token := ac.resumeToken | |
| 527 | 528 | require.NotZero(t, token) | |
| 528 | - | require.Same(t, ac, sess.client) | |
| 529 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 529 | 530 | ||
| 530 | 531 | reachedGap := make(chan struct{}) | |
| 531 | 532 | releaseGap := make(chan struct{}) | |
| @@ -549,7 +550,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 549 | 550 | }() | |
| 550 | 551 | awaitTestCompletion(t, reachedGap, "first live resume did not pause after detach before park") | |
| 551 | 552 | ||
| 552 | - | require.Nil(t, sess.client, "fixture: winning resume must have cleared the live owner") | |
| 553 | + | require.Empty(t, sess.snapshotAttachments(), "fixture: winning resume must have cleared the live owner") | |
| 553 | 554 | d.mu.Lock() | |
| 554 | 555 | parkingInGap := d.parking[token] | |
| 555 | 556 | _, parkedInGap := d.parked[token] | |
| @@ -595,7 +596,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 595 | 596 | ||
| 596 | 597 | require.Same(t, sess, winner.sess) | |
| 597 | 598 | require.Same(t, ac, winner.ac) | |
| 598 | - | require.Same(t, ac, sess.client) | |
| 599 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 599 | 600 | require.Same(t, winnerTr, ac.transport()) | |
| 600 | 601 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | |
| 601 | 602 | require.True(t, oldTr.Closed(), "winning resume must retire the old transport") | |
| @@ -615,7 +616,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 615 | 616 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | |
| 616 | 617 | ||
| 617 | 618 | d.clientGone(sess, ac, oldTr, false) | |
| 618 | - | require.Same(t, ac, sess.client, "stale old-link cleanup must not detach the rebound attachment") | |
| 619 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment") | |
| 619 | 620 | require.Same(t, winnerTr, ac.transport()) | |
| 620 | 621 | require.False(t, winnerTr.Closed()) | |
| 621 | 622 | } | |
| @@ -661,7 +662,7 @@ func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) { | |||
| 661 | 662 | awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned marker") | |
| 662 | 663 | ||
| 663 | 664 | d.clientGone(sess, ac, oldTr, true) | |
| 664 | - | require.Nil(t, sess.client, "explicit detach must clear the live owner") | |
| 665 | + | require.Empty(t, sess.snapshotAttachments(), "explicit detach must clear the live owner") | |
| 665 | 666 | ||
| 666 | 667 | waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after explicit detach") | |
| 667 | 668 | require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared") | |
| @@ -720,7 +721,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||
| 720 | 721 | win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) { | |
| 721 | 722 | t.Helper() | |
| 722 | 723 | d.clientGone(sess, ac, oldTr, true) | |
| 723 | - | require.Nil(t, sess.client, "explicit detach must clear the live owner before late mark") | |
| 724 | + | require.Empty(t, sess.snapshotAttachments(), "explicit detach must clear the live owner before late mark") | |
| 724 | 725 | }, | |
| 725 | 726 | }, | |
| 726 | 727 | { | |
| @@ -746,7 +747,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||
| 746 | 747 | require.NoError(t, err) | |
| 747 | 748 | token := ac.resumeToken | |
| 748 | 749 | require.NotZero(t, token) | |
| 749 | - | require.Same(t, ac, sess.client) | |
| 750 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 750 | 751 | ||
| 751 | 752 | validated := make(chan struct{}) | |
| 752 | 753 | releaseMark := make(chan struct{}) | |
| @@ -803,7 +804,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||
| 803 | 804 | require.NotZero(t, token) | |
| 804 | 805 | ||
| 805 | 806 | require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot())) | |
| 806 | - | require.Nil(t, sess.client) | |
| 807 | + | require.Empty(t, sess.snapshotAttachments()) | |
| 807 | 808 | require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker") | |
| 808 | 809 | ||
| 809 | 810 | d.mu.Lock() | |
| @@ -862,7 +863,7 @@ func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *tes | |||
| 862 | 863 | releaseOnce.Do(func() { close(releaseDetach) }) | |
| 863 | 864 | awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound") | |
| 864 | 865 | ||
| 865 | - | require.Same(t, ac, sess.client, "stale cleanup must not detach the rebound attachment") | |
| 866 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale cleanup must not detach the rebound attachment") | |
| 866 | 867 | require.Same(t, newTr, ac.transport()) | |
| 867 | 868 | require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup") | |
| 868 | 869 | require.True(t, oldTr.Closed(), "live resume retires the captured old transport") | |
| @@ -893,7 +894,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) { | |||
| 893 | 894 | var pe *protoErr | |
| 894 | 895 | require.ErrorAs(t, err, &pe) | |
| 895 | 896 | require.Equal(t, ports.ErrServerShutdown, pe.code) | |
| 896 | - | require.Nil(t, sess.client, "failed live park must leave the session without an owner") | |
| 897 | + | require.Empty(t, sess.snapshotAttachments(), "failed live park must leave the session without an owner") | |
| 897 | 898 | require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport") | |
| 898 | 899 | require.True(t, oldTr.Closed(), "failed live park must close the captured old transport") | |
| 899 | 900 | d.mu.Lock() | |
| @@ -919,7 +920,7 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) { | |||
| 919 | 920 | require.Same(t, sess, resumedSess) | |
| 920 | 921 | require.Same(t, ac, resumedAC) | |
| 921 | 922 | require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token") | |
| 922 | - | require.Same(t, resumedAC, sess.client) | |
| 923 | + | require.Contains(t, sess.snapshotAttachments(), resumedAC) | |
| 923 | 924 | } | |
| 924 | 925 | ||
| 925 | 926 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |
| @@ -1026,8 +1027,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||
| 1026 | 1027 | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | |
| 1027 | 1028 | require.NoError(t, err) | |
| 1028 | 1029 | require.Same(t, ac, resumedAC) | |
| 1029 | - | require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC))) | |
| 1030 | - | d.firstPaint(resumedSess, resumedAC, resumedAC.size) | |
| 1030 | + | d.paint(resumedSess, resumedAC, true, nil) | |
| 1031 | 1031 | ||
| 1032 | 1032 | sends := newTr.Sends() | |
| 1033 | 1033 | require.Len(t, sends, 1) | |
| @@ -1074,7 +1074,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||
| 1074 | 1074 | require.True(t, sess.detachIfCurrent(ac)) | |
| 1075 | 1075 | require.True(t, d.parkAttachment(sess, ac)) | |
| 1076 | 1076 | ||
| 1077 | - | // Headless close cannot find the parked attachment through sess.client. | |
| 1077 | + | // Headless close cannot find the parked attachment through sess.snapshotAttachments()[0]. | |
| 1078 | 1078 | // Its capture must already have been released before the attachment parked. | |
| 1079 | 1079 | require.NoError(t, d.closePane(sess, tb, closed.id, nil, false)) | |
| 1080 | 1080 | ac.sendMu.Lock() | |
| @@ -1270,7 +1270,7 @@ func TestEphemeralParkExpiryKeepsSession(t *testing.T) { | |||
| 1270 | 1270 | ||
| 1271 | 1271 | require.Equal(t, 1, sessionCount(d), "token expiry does not kill ephemeral session") | |
| 1272 | 1272 | sess.mu.Lock() | |
| 1273 | - | require.Nil(t, sess.client) | |
| 1273 | + | require.Empty(t, sess.snapshotAttachmentsLocked()) | |
| 1274 | 1274 | sess.mu.Unlock() | |
| 1275 | 1275 | } | |
| 1276 | 1276 | ||
| @@ -1377,7 +1377,7 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) { | |||
| 1377 | 1377 | ||
| 1378 | 1378 | d.clientGone(sess, ac, oldTr, false) | |
| 1379 | 1379 | ||
| 1380 | - | require.Same(t, ac, sess.client, "stale connection must not detach current client") | |
| 1380 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale connection must not detach current client") | |
| 1381 | 1381 | require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler") | |
| 1382 | 1382 | require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop") | |
| 1383 | 1383 | } | |
| @@ -110,7 +110,7 @@ func TestNamedLinkLossParks(t *testing.T) { | |||||
| 110 | 110 | ||||
| 111 | d.clientGone(sess, ac, ac.transport(), false) | 111 | d.clientGone(sess, ac, ac.transport(), false) | ||
| 112 | require.Equal(t, 1, sessionCount(d), "named session survives parked link loss") | 112 | require.Equal(t, 1, sessionCount(d), "named session survives parked link loss") | ||
| 113 | - | require.Nil(t, sess.client) | 113 | + | require.Empty(t, sess.snapshotAttachments()) |
| 114 | d.mu.Lock() | 114 | d.mu.Lock() | ||
| 115 | _, parked := d.parked[token] | 115 | _, parked := d.parked[token] | ||
| 116 | d.mu.Unlock() | 116 | d.mu.Unlock() | ||
| @@ -214,7 +214,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) { | |||||
| 214 | welcome := <-tr.sends | 214 | welcome := <-tr.sends | ||
| 215 | require.Equal(t, ports.MsgWelcome, welcome.Type) | 215 | require.Equal(t, ports.MsgWelcome, welcome.Type) | ||
| 216 | sess.mu.Lock() | 216 | sess.mu.Lock() | ||
| 217 | - | resumed := sess.client | 217 | + | resumed := sess.snapshotAttachmentsLocked()[0] |
| 218 | sess.mu.Unlock() | 218 | sess.mu.Unlock() | ||
| 219 | require.Same(t, ac, resumed) | 219 | require.Same(t, ac, resumed) | ||
| 220 | 220 | ||||
| @@ -253,7 +253,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) { | |||||
| 253 | 253 | ||||
| 254 | d.clientGone(sess, ac, ac.transport(), false) | 254 | d.clientGone(sess, ac, ac.transport(), false) | ||
| 255 | require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive") | 255 | require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive") | ||
| 256 | - | require.Nil(t, sess.client) | 256 | + | require.Empty(t, sess.snapshotAttachments()) |
| 257 | d.mu.Lock() | 257 | d.mu.Lock() | ||
| 258 | _, parked := d.parked[token] | 258 | _, parked := d.parked[token] | ||
| 259 | d.mu.Unlock() | 259 | d.mu.Unlock() | ||
| @@ -265,7 +265,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) { | |||||
| 265 | require.Same(t, sess, resumedSess) | 265 | require.Same(t, sess, resumedSess) | ||
| 266 | require.Same(t, ac, resumedAC) | 266 | require.Same(t, ac, resumedAC) | ||
| 267 | require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token") | 267 | require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token") | ||
| 268 | - | require.Same(t, resumedAC, sess.client) | 268 | + | require.Contains(t, sess.snapshotAttachments(), resumedAC) |
| 269 | } | 269 | } | ||
| 270 | 270 | ||||
| 271 | // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race | 271 | // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race | ||
| @@ -283,7 +283,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) { | |||||
| 283 | token := ac.resumeToken | 283 | token := ac.resumeToken | ||
| 284 | require.NotZero(t, token) | 284 | require.NotZero(t, token) | ||
| 285 | d.clientGone(sess, ac, oldTr, false) | 285 | d.clientGone(sess, ac, oldTr, false) | ||
| 286 | - | require.Nil(t, sess.client) | 286 | + | require.Empty(t, sess.snapshotAttachments()) |
| 287 | d.mu.Lock() | 287 | d.mu.Lock() | ||
| 288 | parked := d.parked[token] | 288 | parked := d.parked[token] | ||
| 289 | sessionsBefore := len(d.sessions) | 289 | sessionsBefore := len(d.sessions) | ||
| @@ -329,7 +329,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) { | |||||
| 329 | require.ErrorAs(t, got.err, &pe) | 329 | require.ErrorAs(t, got.err, &pe) | ||
| 330 | require.Equal(t, ports.ErrNoSuchSession, pe.code) | 330 | require.Equal(t, ports.ErrNoSuchSession, pe.code) | ||
| 331 | require.Contains(t, pe.Error(), "resume token is no longer valid") | 331 | require.Contains(t, pe.Error(), "resume token is no longer valid") | ||
| 332 | - | require.Nil(t, sess.client, "lifecycle-race resume must not take over the named session") | 332 | + | require.Empty(t, sess.snapshotAttachments(), "lifecycle-race resume must not take over the named session") |
| 333 | require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake") | 333 | require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake") | ||
| 334 | d.mu.Lock() | 334 | d.mu.Lock() | ||
| 335 | _, stillParked := d.parked[token] | 335 | _, stillParked := d.parked[token] | ||
| @@ -354,7 +354,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) { | |||||
| 354 | require.NoError(t, err) | 354 | require.NoError(t, err) | ||
| 355 | token := ac.resumeToken | 355 | token := ac.resumeToken | ||
| 356 | require.NotZero(t, token) | 356 | require.NotZero(t, token) | ||
| 357 | - | require.Same(t, ac, sess.client) | 357 | + | require.Contains(t, sess.snapshotAttachments(), ac) |
| 358 | d.mu.Lock() | 358 | d.mu.Lock() | ||
| 359 | _, parkedAtStart := d.parked[token] | 359 | _, parkedAtStart := d.parked[token] | ||
| 360 | sessionsBefore := len(d.sessions) | 360 | sessionsBefore := len(d.sessions) | ||
| @@ -403,7 +403,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) { | |||||
| 403 | require.ErrorAs(t, got.err, &pe) | 403 | require.ErrorAs(t, got.err, &pe) | ||
| 404 | require.Equal(t, ports.ErrNoSuchSession, pe.code) | 404 | require.Equal(t, ports.ErrNoSuchSession, pe.code) | ||
| 405 | require.Contains(t, pe.Error(), "resume token is no longer valid") | 405 | require.Contains(t, pe.Error(), "resume token is no longer valid") | ||
| 406 | - | require.Nil(t, sess.client, "losing live resume must not take over the named session") | 406 | + | require.Empty(t, sess.snapshotAttachments(), "losing live resume must not take over the named session") |
| 407 | require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake") | 407 | require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake") | ||
| 408 | d.mu.Lock() | 408 | d.mu.Lock() | ||
| 409 | _, stillParked := d.parked[token] | 409 | _, stillParked := d.parked[token] | ||
| @@ -427,7 +427,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||||
| 427 | require.NoError(t, err) | 427 | require.NoError(t, err) | ||
| 428 | token := ac.resumeToken | 428 | token := ac.resumeToken | ||
| 429 | require.NotZero(t, token) | 429 | require.NotZero(t, token) | ||
| 430 | - | require.Same(t, ac, sess.client) | 430 | + | require.Contains(t, sess.snapshotAttachments(), ac) |
| 431 | 431 | ||||
| 432 | reachedGap := make(chan struct{}) | 432 | reachedGap := make(chan struct{}) | ||
| 433 | releaseGap := make(chan struct{}) | 433 | releaseGap := make(chan struct{}) | ||
| @@ -445,7 +445,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||||
| 445 | }() | 445 | }() | ||
| 446 | awaitTestCompletion(t, reachedGap, "teardown did not pause after detach before park") | 446 | awaitTestCompletion(t, reachedGap, "teardown did not pause after detach before park") | ||
| 447 | 447 | ||||
| 448 | - | require.Nil(t, sess.client, "fixture: detach must have cleared the live owner") | 448 | + | require.Empty(t, sess.snapshotAttachments(), "fixture: detach must have cleared the live owner") |
| 449 | d.mu.Lock() | 449 | d.mu.Lock() | ||
| 450 | parkingInGap := d.parking[token] | 450 | parkingInGap := d.parking[token] | ||
| 451 | _, parkedInGap := d.parked[token] | 451 | _, parkedInGap := d.parked[token] | ||
| @@ -493,9 +493,10 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||||
| 493 | require.NoError(t, got.err, "same-client token must recover across detach-before-park") | 493 | require.NoError(t, got.err, "same-client token must recover across detach-before-park") | ||
| 494 | require.Same(t, sess, got.sess) | 494 | require.Same(t, sess, got.sess) | ||
| 495 | require.Same(t, ac, got.ac) | 495 | require.Same(t, ac, got.ac) | ||
| 496 | - | require.Same(t, ac, sess.client) | 496 | + | require.Contains(t, sess.snapshotAttachments(), ac) |
| 497 | require.Same(t, newTr, ac.transport()) | 497 | require.Same(t, newTr, ac.transport()) | ||
| 498 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | 498 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | ||
| 499 | + | require.True(t, d.commitResumeClaim(ac), "successful resume must consume its parked credential") | |||
| 499 | require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport") | 500 | require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport") | ||
| 500 | require.False(t, newTr.Closed(), "rebound transport must survive") | 501 | require.False(t, newTr.Closed(), "rebound transport must survive") | ||
| 501 | d.mu.Lock() | 502 | d.mu.Lock() | ||
| @@ -506,7 +507,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||||
| 506 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | 507 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | ||
| 507 | 508 | ||||
| 508 | d.clientGone(sess, ac, oldTr, false) | 509 | d.clientGone(sess, ac, oldTr, false) | ||
| 509 | - | require.Same(t, ac, sess.client, "stale old-link cleanup must not detach the rebound attachment") | 510 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment") |
| 510 | require.Same(t, newTr, ac.transport()) | 511 | require.Same(t, newTr, ac.transport()) | ||
| 511 | require.False(t, newTr.Closed()) | 512 | require.False(t, newTr.Closed()) | ||
| 512 | } | 513 | } | ||
| @@ -525,7 +526,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||||
| 525 | require.NoError(t, err) | 526 | require.NoError(t, err) | ||
| 526 | token := ac.resumeToken | 527 | token := ac.resumeToken | ||
| 527 | require.NotZero(t, token) | 528 | require.NotZero(t, token) | ||
| 528 | - | require.Same(t, ac, sess.client) | 529 | + | require.Contains(t, sess.snapshotAttachments(), ac) |
| 529 | 530 | ||||
| 530 | reachedGap := make(chan struct{}) | 531 | reachedGap := make(chan struct{}) | ||
| 531 | releaseGap := make(chan struct{}) | 532 | releaseGap := make(chan struct{}) | ||
| @@ -549,7 +550,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||||
| 549 | }() | 550 | }() | ||
| 550 | awaitTestCompletion(t, reachedGap, "first live resume did not pause after detach before park") | 551 | awaitTestCompletion(t, reachedGap, "first live resume did not pause after detach before park") | ||
| 551 | 552 | ||||
| 552 | - | require.Nil(t, sess.client, "fixture: winning resume must have cleared the live owner") | 553 | + | require.Empty(t, sess.snapshotAttachments(), "fixture: winning resume must have cleared the live owner") |
| 553 | d.mu.Lock() | 554 | d.mu.Lock() | ||
| 554 | parkingInGap := d.parking[token] | 555 | parkingInGap := d.parking[token] | ||
| 555 | _, parkedInGap := d.parked[token] | 556 | _, parkedInGap := d.parked[token] | ||
| @@ -595,7 +596,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||||
| 595 | 596 | ||||
| 596 | require.Same(t, sess, winner.sess) | 597 | require.Same(t, sess, winner.sess) | ||
| 597 | require.Same(t, ac, winner.ac) | 598 | require.Same(t, ac, winner.ac) | ||
| 598 | - | require.Same(t, ac, sess.client) | 599 | + | require.Contains(t, sess.snapshotAttachments(), ac) |
| 599 | require.Same(t, winnerTr, ac.transport()) | 600 | require.Same(t, winnerTr, ac.transport()) | ||
| 600 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | 601 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | ||
| 601 | require.True(t, oldTr.Closed(), "winning resume must retire the old transport") | 602 | require.True(t, oldTr.Closed(), "winning resume must retire the old transport") | ||
| @@ -615,7 +616,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||||
| 615 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | 616 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | ||
| 616 | 617 | ||||
| 617 | d.clientGone(sess, ac, oldTr, false) | 618 | d.clientGone(sess, ac, oldTr, false) | ||
| 618 | - | require.Same(t, ac, sess.client, "stale old-link cleanup must not detach the rebound attachment") | 619 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment") |
| 619 | require.Same(t, winnerTr, ac.transport()) | 620 | require.Same(t, winnerTr, ac.transport()) | ||
| 620 | require.False(t, winnerTr.Closed()) | 621 | require.False(t, winnerTr.Closed()) | ||
| 621 | } | 622 | } | ||
| @@ -661,7 +662,7 @@ func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) { | |||||
| 661 | awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned marker") | 662 | awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned marker") | ||
| 662 | 663 | ||||
| 663 | d.clientGone(sess, ac, oldTr, true) | 664 | d.clientGone(sess, ac, oldTr, true) | ||
| 664 | - | require.Nil(t, sess.client, "explicit detach must clear the live owner") | 665 | + | require.Empty(t, sess.snapshotAttachments(), "explicit detach must clear the live owner") |
| 665 | 666 | ||||
| 666 | waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after explicit detach") | 667 | waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after explicit detach") | ||
| 667 | require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared") | 668 | require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared") | ||
| @@ -720,7 +721,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||||
| 720 | win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) { | 721 | win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) { | ||
| 721 | t.Helper() | 722 | t.Helper() | ||
| 722 | d.clientGone(sess, ac, oldTr, true) | 723 | d.clientGone(sess, ac, oldTr, true) | ||
| 723 | - | require.Nil(t, sess.client, "explicit detach must clear the live owner before late mark") | 724 | + | require.Empty(t, sess.snapshotAttachments(), "explicit detach must clear the live owner before late mark") |
| 724 | }, | 725 | }, | ||
| 725 | }, | 726 | }, | ||
| 726 | { | 727 | { | ||
| @@ -746,7 +747,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||||
| 746 | require.NoError(t, err) | 747 | require.NoError(t, err) | ||
| 747 | token := ac.resumeToken | 748 | token := ac.resumeToken | ||
| 748 | require.NotZero(t, token) | 749 | require.NotZero(t, token) | ||
| 749 | - | require.Same(t, ac, sess.client) | 750 | + | require.Contains(t, sess.snapshotAttachments(), ac) |
| 750 | 751 | ||||
| 751 | validated := make(chan struct{}) | 752 | validated := make(chan struct{}) | ||
| 752 | releaseMark := make(chan struct{}) | 753 | releaseMark := make(chan struct{}) | ||
| @@ -803,7 +804,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||||
| 803 | require.NotZero(t, token) | 804 | require.NotZero(t, token) | ||
| 804 | 805 | ||||
| 805 | require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot())) | 806 | require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot())) | ||
| 806 | - | require.Nil(t, sess.client) | 807 | + | require.Empty(t, sess.snapshotAttachments()) |
| 807 | require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker") | 808 | require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker") | ||
| 808 | 809 | ||||
| 809 | d.mu.Lock() | 810 | d.mu.Lock() | ||
| @@ -862,7 +863,7 @@ func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *tes | |||||
| 862 | releaseOnce.Do(func() { close(releaseDetach) }) | 863 | releaseOnce.Do(func() { close(releaseDetach) }) | ||
| 863 | awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound") | 864 | awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound") | ||
| 864 | 865 | ||||
| 865 | - | require.Same(t, ac, sess.client, "stale cleanup must not detach the rebound attachment") | 866 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale cleanup must not detach the rebound attachment") |
| 866 | require.Same(t, newTr, ac.transport()) | 867 | require.Same(t, newTr, ac.transport()) | ||
| 867 | require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup") | 868 | require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup") | ||
| 868 | require.True(t, oldTr.Closed(), "live resume retires the captured old transport") | 869 | require.True(t, oldTr.Closed(), "live resume retires the captured old transport") | ||
| @@ -893,7 +894,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) { | |||||
| 893 | var pe *protoErr | 894 | var pe *protoErr | ||
| 894 | require.ErrorAs(t, err, &pe) | 895 | require.ErrorAs(t, err, &pe) | ||
| 895 | require.Equal(t, ports.ErrServerShutdown, pe.code) | 896 | require.Equal(t, ports.ErrServerShutdown, pe.code) | ||
| 896 | - | require.Nil(t, sess.client, "failed live park must leave the session without an owner") | 897 | + | require.Empty(t, sess.snapshotAttachments(), "failed live park must leave the session without an owner") |
| 897 | require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport") | 898 | require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport") | ||
| 898 | require.True(t, oldTr.Closed(), "failed live park must close the captured old transport") | 899 | require.True(t, oldTr.Closed(), "failed live park must close the captured old transport") | ||
| 899 | d.mu.Lock() | 900 | d.mu.Lock() | ||
| @@ -919,7 +920,7 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) { | |||||
| 919 | require.Same(t, sess, resumedSess) | 920 | require.Same(t, sess, resumedSess) | ||
| 920 | require.Same(t, ac, resumedAC) | 921 | require.Same(t, ac, resumedAC) | ||
| 921 | require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token") | 922 | require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token") | ||
| 922 | - | require.Same(t, resumedAC, sess.client) | 923 | + | require.Contains(t, sess.snapshotAttachments(), resumedAC) |
| 923 | } | 924 | } | ||
| 924 | 925 | ||||
| 925 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | 926 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | ||
| @@ -1026,8 +1027,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||||
| 1026 | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | 1027 | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | ||
| 1027 | require.NoError(t, err) | 1028 | require.NoError(t, err) | ||
| 1028 | require.Same(t, ac, resumedAC) | 1029 | require.Same(t, ac, resumedAC) | ||
| 1029 | - | require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC))) | 1030 | + | d.paint(resumedSess, resumedAC, true, nil) |
| 1030 | - | d.firstPaint(resumedSess, resumedAC, resumedAC.size) | |||
| 1031 | 1031 | ||||
| 1032 | sends := newTr.Sends() | 1032 | sends := newTr.Sends() | ||
| 1033 | require.Len(t, sends, 1) | 1033 | require.Len(t, sends, 1) | ||
| @@ -1074,7 +1074,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||||
| 1074 | require.True(t, sess.detachIfCurrent(ac)) | 1074 | require.True(t, sess.detachIfCurrent(ac)) | ||
| 1075 | require.True(t, d.parkAttachment(sess, ac)) | 1075 | require.True(t, d.parkAttachment(sess, ac)) | ||
| 1076 | 1076 | ||||
| 1077 | - | // Headless close cannot find the parked attachment through sess.client. | 1077 | + | // Headless close cannot find the parked attachment through sess.snapshotAttachments()[0]. |
| 1078 | // Its capture must already have been released before the attachment parked. | 1078 | // Its capture must already have been released before the attachment parked. | ||
| 1079 | require.NoError(t, d.closePane(sess, tb, closed.id, nil, false)) | 1079 | require.NoError(t, d.closePane(sess, tb, closed.id, nil, false)) | ||
| 1080 | ac.sendMu.Lock() | 1080 | ac.sendMu.Lock() | ||
| @@ -1270,7 +1270,7 @@ func TestEphemeralParkExpiryKeepsSession(t *testing.T) { | |||||
| 1270 | 1270 | ||||
| 1271 | require.Equal(t, 1, sessionCount(d), "token expiry does not kill ephemeral session") | 1271 | require.Equal(t, 1, sessionCount(d), "token expiry does not kill ephemeral session") | ||
| 1272 | sess.mu.Lock() | 1272 | sess.mu.Lock() | ||
| 1273 | - | require.Nil(t, sess.client) | 1273 | + | require.Empty(t, sess.snapshotAttachmentsLocked()) |
| 1274 | sess.mu.Unlock() | 1274 | sess.mu.Unlock() | ||
| 1275 | } | 1275 | } | ||
| 1276 | 1276 | ||||
| @@ -1377,7 +1377,7 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) { | |||||
| 1377 | 1377 | ||||
| 1378 | d.clientGone(sess, ac, oldTr, false) | 1378 | d.clientGone(sess, ac, oldTr, false) | ||
| 1379 | 1379 | ||||
| 1380 | - | require.Same(t, ac, sess.client, "stale connection must not detach current client") | 1380 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale connection must not detach current client") |
| 1381 | require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler") | 1381 | require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler") | ||
| 1382 | require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop") | 1382 | require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop") | ||
| 1383 | } | 1383 | } | ||
M internal/usecase/daemon/role_effect_gate_test.go +16 -16
| @@ -161,8 +161,8 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||
| 161 | 161 | later.initOverlays() | |
| 162 | 162 | later.setSession(sess) | |
| 163 | 163 | sess.mu.Lock() | |
| 164 | - | sess.addSnatchedLocked(blocked) | |
| 165 | - | sess.addSnatchedLocked(later) | |
| 164 | + | sess.registerAttachmentLocked(blocked) | |
| 165 | + | sess.registerAttachmentLocked(later) | |
| 166 | 166 | sess.mu.Unlock() | |
| 167 | 167 | ||
| 168 | 168 | rc := d.attachCoordinator(sess, nil, first, true) | |
| @@ -226,9 +226,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||
| 226 | 226 | require.Same(t, sess, d.sessions[sess.id], "partial gate acquisition removed the session registry owner") | |
| 227 | 227 | d.mu.Unlock() | |
| 228 | 228 | sess.mu.Lock() | |
| 229 | - | require.Same(t, first, sess.client) | |
| 230 | - | require.Contains(t, sess.snatched, blocked) | |
| 231 | - | require.Contains(t, sess.snatched, later) | |
| 229 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), first) | |
| 230 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), blocked) | |
| 231 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), later) | |
| 232 | 232 | sess.mu.Unlock() | |
| 233 | 233 | for _, ac := range []*attachedClient{first, blocked, later} { | |
| 234 | 234 | require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership") | |
| @@ -287,7 +287,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||
| 287 | 287 | unrelatedTransport := &closeTrackingTransport{} | |
| 288 | 288 | unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size} | |
| 289 | 289 | unrelatedClient.initOverlays() | |
| 290 | - | unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", client: unrelatedClient}, ctx: sess.ctx, cancel: func() {}} | |
| 290 | + | unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", attachments: map[*attachedClient]struct{}{unrelatedClient: {}}}, ctx: sess.ctx, cancel: func() {}} | |
| 291 | 291 | unrelatedClient.setSession(unrelated) | |
| 292 | 292 | d.mu.Lock() | |
| 293 | 293 | d.sessions[unrelated.id] = unrelated | |
| @@ -318,7 +318,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||
| 318 | 318 | require.True(t, snatchedTransport.Closed()) | |
| 319 | 319 | require.True(t, activeTransport.Closed()) | |
| 320 | 320 | require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted") | |
| 321 | - | require.Same(t, unrelatedClient, unrelated.client) | |
| 321 | + | require.Contains(t, unrelated.snapshotAttachments(), unrelatedClient) | |
| 322 | 322 | require.Same(t, unrelated, unrelatedClient.currentSession()) | |
| 323 | 323 | requireRoleGateRetired(t, snatched) | |
| 324 | 324 | requireRoleGateRetired(t, active) | |
| @@ -348,7 +348,7 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test | |||
| 348 | 348 | require.Same(t, sess, d.sessions[sess.id], "transport incarnation change should abort stale teardown publication") | |
| 349 | 349 | d.mu.Unlock() | |
| 350 | 350 | sess.mu.Lock() | |
| 351 | - | require.Same(t, ac, sess.client) | |
| 351 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), ac) | |
| 352 | 352 | sess.mu.Unlock() | |
| 353 | 353 | requireRoleGateRetired(t, ac) | |
| 354 | 354 | } | |
| @@ -482,7 +482,7 @@ func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing | |||
| 482 | 482 | awaitTestCompletion(t, cleanupTracked, "tracked render failure cleanup did not retire") | |
| 483 | 483 | ||
| 484 | 484 | sess.mu.Lock() | |
| 485 | - | require.Same(t, original, sess.client, "stale render cleanup detached the resumed incarnation") | |
| 485 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), original, "stale render cleanup detached the resumed incarnation") | |
| 486 | 486 | sess.mu.Unlock() | |
| 487 | 487 | require.Equal(t, resumedGeneration, original.roleGeneration.Load()) | |
| 488 | 488 | require.True(t, resumed.published.activeCurrent()) | |
| @@ -518,7 +518,7 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing | |||
| 518 | 518 | awaitTestCompletion(t, waitGroupDone(&d.attachmentCleanupWg), "bounded render cleanup leaked its tracked goroutine") | |
| 519 | 519 | ||
| 520 | 520 | sess.mu.Lock() | |
| 521 | - | require.Same(t, ac, sess.client) | |
| 521 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), ac) | |
| 522 | 522 | sess.mu.Unlock() | |
| 523 | 523 | require.False(t, transport.Closed()) | |
| 524 | 524 | } | |
| @@ -567,13 +567,13 @@ func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t | |||
| 567 | 567 | case <-time.After(time.Second): | |
| 568 | 568 | t.Fatal("closing the exact old transport did not unblock its render") | |
| 569 | 569 | } | |
| 570 | - | require.Same(t, next, sess.client) | |
| 570 | + | require.Contains(t, sess.snapshotAttachments(), next) | |
| 571 | 571 | require.False(t, newTransport.Closed(), "replacement transport was affected by old-link interruption") | |
| 572 | 572 | ||
| 573 | 573 | d.deferAttachmentTransitionCleanups(result) | |
| 574 | 574 | d.attachmentCleanupWg.Wait() | |
| 575 | 575 | require.Nil(t, old.transport(), "the unhealthy old link was not retired") | |
| 576 | - | require.Same(t, next, sess.client) | |
| 576 | + | require.Contains(t, sess.snapshotAttachments(), next) | |
| 577 | 577 | require.False(t, newTransport.Closed()) | |
| 578 | 578 | } | |
| 579 | 579 | ||
| @@ -856,9 +856,9 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T | |||
| 856 | 856 | ||
| 857 | 857 | require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus") | |
| 858 | 858 | target.mu.Lock() | |
| 859 | - | targetClient := target.client | |
| 859 | + | targetAttachments := target.snapshotAttachmentsLocked() | |
| 860 | 860 | target.mu.Unlock() | |
| 861 | - | require.Nil(t, targetClient, "stale handoff attached the replaced initiator") | |
| 861 | + | require.NotContains(t, targetAttachments, next, "stale handoff attached the replaced initiator") | |
| 862 | 862 | d.deferAttachmentTransitionCleanups(result) | |
| 863 | 863 | } | |
| 864 | 864 | ||
| @@ -1123,7 +1123,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) | |||
| 1123 | 1123 | bTransport := newDatagramTestTransport() | |
| 1124 | 1124 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | |
| 1125 | 1125 | b.initOverlays() | |
| 1126 | - | second := &session{sessionCore: sessionCore{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})}} | |
| 1126 | + | second := &session{sessionCore: sessionCore{id: "second", name: "second", attachments: map[*attachedClient]struct{}{b: {}}}} | |
| 1127 | 1127 | b.setSession(second) | |
| 1128 | 1128 | d.mu.Lock() | |
| 1129 | 1129 | d.sessions[second.id] = second | |
| @@ -1181,7 +1181,7 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) { | |||
| 1181 | 1181 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | |
| 1182 | 1182 | b.initOverlays() | |
| 1183 | 1183 | target := &session{sessionCore: sessionCore{id: "picker-delete-target", name: "picker-delete-target", | |
| 1184 | - | client: b, snatched: make(map[*attachedClient]struct{})}, ctx: source.ctx, cancel: func() {}, | |
| 1184 | + | attachments: map[*attachedClient]struct{}{b: {}}}, ctx: source.ctx, cancel: func() {}, | |
| 1185 | 1185 | } | |
| 1186 | 1186 | b.setSession(target) | |
| 1187 | 1187 | d.mu.Lock() | |
| @@ -161,8 +161,8 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||||
| 161 | later.initOverlays() | 161 | later.initOverlays() | ||
| 162 | later.setSession(sess) | 162 | later.setSession(sess) | ||
| 163 | sess.mu.Lock() | 163 | sess.mu.Lock() | ||
| 164 | - | sess.addSnatchedLocked(blocked) | 164 | + | sess.registerAttachmentLocked(blocked) |
| 165 | - | sess.addSnatchedLocked(later) | 165 | + | sess.registerAttachmentLocked(later) |
| 166 | sess.mu.Unlock() | 166 | sess.mu.Unlock() | ||
| 167 | 167 | ||||
| 168 | rc := d.attachCoordinator(sess, nil, first, true) | 168 | rc := d.attachCoordinator(sess, nil, first, true) | ||
| @@ -226,9 +226,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||||
| 226 | require.Same(t, sess, d.sessions[sess.id], "partial gate acquisition removed the session registry owner") | 226 | require.Same(t, sess, d.sessions[sess.id], "partial gate acquisition removed the session registry owner") | ||
| 227 | d.mu.Unlock() | 227 | d.mu.Unlock() | ||
| 228 | sess.mu.Lock() | 228 | sess.mu.Lock() | ||
| 229 | - | require.Same(t, first, sess.client) | 229 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), first) |
| 230 | - | require.Contains(t, sess.snatched, blocked) | 230 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), blocked) |
| 231 | - | require.Contains(t, sess.snatched, later) | 231 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), later) |
| 232 | sess.mu.Unlock() | 232 | sess.mu.Unlock() | ||
| 233 | for _, ac := range []*attachedClient{first, blocked, later} { | 233 | for _, ac := range []*attachedClient{first, blocked, later} { | ||
| 234 | require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership") | 234 | require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership") | ||
| @@ -287,7 +287,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||||
| 287 | unrelatedTransport := &closeTrackingTransport{} | 287 | unrelatedTransport := &closeTrackingTransport{} | ||
| 288 | unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size} | 288 | unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size} | ||
| 289 | unrelatedClient.initOverlays() | 289 | unrelatedClient.initOverlays() | ||
| 290 | - | unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", client: unrelatedClient}, ctx: sess.ctx, cancel: func() {}} | 290 | + | unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", attachments: map[*attachedClient]struct{}{unrelatedClient: {}}}, ctx: sess.ctx, cancel: func() {}} |
| 291 | unrelatedClient.setSession(unrelated) | 291 | unrelatedClient.setSession(unrelated) | ||
| 292 | d.mu.Lock() | 292 | d.mu.Lock() | ||
| 293 | d.sessions[unrelated.id] = unrelated | 293 | d.sessions[unrelated.id] = unrelated | ||
| @@ -318,7 +318,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||||
| 318 | require.True(t, snatchedTransport.Closed()) | 318 | require.True(t, snatchedTransport.Closed()) | ||
| 319 | require.True(t, activeTransport.Closed()) | 319 | require.True(t, activeTransport.Closed()) | ||
| 320 | require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted") | 320 | require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted") | ||
| 321 | - | require.Same(t, unrelatedClient, unrelated.client) | 321 | + | require.Contains(t, unrelated.snapshotAttachments(), unrelatedClient) |
| 322 | require.Same(t, unrelated, unrelatedClient.currentSession()) | 322 | require.Same(t, unrelated, unrelatedClient.currentSession()) | ||
| 323 | requireRoleGateRetired(t, snatched) | 323 | requireRoleGateRetired(t, snatched) | ||
| 324 | requireRoleGateRetired(t, active) | 324 | requireRoleGateRetired(t, active) | ||
| @@ -348,7 +348,7 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test | |||||
| 348 | require.Same(t, sess, d.sessions[sess.id], "transport incarnation change should abort stale teardown publication") | 348 | require.Same(t, sess, d.sessions[sess.id], "transport incarnation change should abort stale teardown publication") | ||
| 349 | d.mu.Unlock() | 349 | d.mu.Unlock() | ||
| 350 | sess.mu.Lock() | 350 | sess.mu.Lock() | ||
| 351 | - | require.Same(t, ac, sess.client) | 351 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), ac) |
| 352 | sess.mu.Unlock() | 352 | sess.mu.Unlock() | ||
| 353 | requireRoleGateRetired(t, ac) | 353 | requireRoleGateRetired(t, ac) | ||
| 354 | } | 354 | } | ||
| @@ -482,7 +482,7 @@ func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing | |||||
| 482 | awaitTestCompletion(t, cleanupTracked, "tracked render failure cleanup did not retire") | 482 | awaitTestCompletion(t, cleanupTracked, "tracked render failure cleanup did not retire") | ||
| 483 | 483 | ||||
| 484 | sess.mu.Lock() | 484 | sess.mu.Lock() | ||
| 485 | - | require.Same(t, original, sess.client, "stale render cleanup detached the resumed incarnation") | 485 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), original, "stale render cleanup detached the resumed incarnation") |
| 486 | sess.mu.Unlock() | 486 | sess.mu.Unlock() | ||
| 487 | require.Equal(t, resumedGeneration, original.roleGeneration.Load()) | 487 | require.Equal(t, resumedGeneration, original.roleGeneration.Load()) | ||
| 488 | require.True(t, resumed.published.activeCurrent()) | 488 | require.True(t, resumed.published.activeCurrent()) | ||
| @@ -518,7 +518,7 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing | |||||
| 518 | awaitTestCompletion(t, waitGroupDone(&d.attachmentCleanupWg), "bounded render cleanup leaked its tracked goroutine") | 518 | awaitTestCompletion(t, waitGroupDone(&d.attachmentCleanupWg), "bounded render cleanup leaked its tracked goroutine") | ||
| 519 | 519 | ||||
| 520 | sess.mu.Lock() | 520 | sess.mu.Lock() | ||
| 521 | - | require.Same(t, ac, sess.client) | 521 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), ac) |
| 522 | sess.mu.Unlock() | 522 | sess.mu.Unlock() | ||
| 523 | require.False(t, transport.Closed()) | 523 | require.False(t, transport.Closed()) | ||
| 524 | } | 524 | } | ||
| @@ -567,13 +567,13 @@ func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t | |||||
| 567 | case <-time.After(time.Second): | 567 | case <-time.After(time.Second): | ||
| 568 | t.Fatal("closing the exact old transport did not unblock its render") | 568 | t.Fatal("closing the exact old transport did not unblock its render") | ||
| 569 | } | 569 | } | ||
| 570 | - | require.Same(t, next, sess.client) | 570 | + | require.Contains(t, sess.snapshotAttachments(), next) |
| 571 | require.False(t, newTransport.Closed(), "replacement transport was affected by old-link interruption") | 571 | require.False(t, newTransport.Closed(), "replacement transport was affected by old-link interruption") | ||
| 572 | 572 | ||||
| 573 | d.deferAttachmentTransitionCleanups(result) | 573 | d.deferAttachmentTransitionCleanups(result) | ||
| 574 | d.attachmentCleanupWg.Wait() | 574 | d.attachmentCleanupWg.Wait() | ||
| 575 | require.Nil(t, old.transport(), "the unhealthy old link was not retired") | 575 | require.Nil(t, old.transport(), "the unhealthy old link was not retired") | ||
| 576 | - | require.Same(t, next, sess.client) | 576 | + | require.Contains(t, sess.snapshotAttachments(), next) |
| 577 | require.False(t, newTransport.Closed()) | 577 | require.False(t, newTransport.Closed()) | ||
| 578 | } | 578 | } | ||
| 579 | 579 | ||||
| @@ -856,9 +856,9 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T | |||||
| 856 | 856 | ||||
| 857 | require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus") | 857 | require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus") | ||
| 858 | target.mu.Lock() | 858 | target.mu.Lock() | ||
| 859 | - | targetClient := target.client | 859 | + | targetAttachments := target.snapshotAttachmentsLocked() |
| 860 | target.mu.Unlock() | 860 | target.mu.Unlock() | ||
| 861 | - | require.Nil(t, targetClient, "stale handoff attached the replaced initiator") | 861 | + | require.NotContains(t, targetAttachments, next, "stale handoff attached the replaced initiator") |
| 862 | d.deferAttachmentTransitionCleanups(result) | 862 | d.deferAttachmentTransitionCleanups(result) | ||
| 863 | } | 863 | } | ||
| 864 | 864 | ||||
| @@ -1123,7 +1123,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) | |||||
| 1123 | bTransport := newDatagramTestTransport() | 1123 | bTransport := newDatagramTestTransport() | ||
| 1124 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | 1124 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | ||
| 1125 | b.initOverlays() | 1125 | b.initOverlays() | ||
| 1126 | - | second := &session{sessionCore: sessionCore{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})}} | 1126 | + | second := &session{sessionCore: sessionCore{id: "second", name: "second", attachments: map[*attachedClient]struct{}{b: {}}}} |
| 1127 | b.setSession(second) | 1127 | b.setSession(second) | ||
| 1128 | d.mu.Lock() | 1128 | d.mu.Lock() | ||
| 1129 | d.sessions[second.id] = second | 1129 | d.sessions[second.id] = second | ||
| @@ -1181,7 +1181,7 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) { | |||||
| 1181 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | 1181 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | ||
| 1182 | b.initOverlays() | 1182 | b.initOverlays() | ||
| 1183 | target := &session{sessionCore: sessionCore{id: "picker-delete-target", name: "picker-delete-target", | 1183 | target := &session{sessionCore: sessionCore{id: "picker-delete-target", name: "picker-delete-target", | ||
| 1184 | - | client: b, snatched: make(map[*attachedClient]struct{})}, ctx: source.ctx, cancel: func() {}, | 1184 | + | attachments: map[*attachedClient]struct{}{b: {}}}, ctx: source.ctx, cancel: func() {}, |
| 1185 | } | 1185 | } | ||
| 1186 | b.setSession(target) | 1186 | b.setSession(target) | ||
| 1187 | d.mu.Lock() | 1187 | d.mu.Lock() | ||
M internal/usecase/daemon/session_core.go +0 -2
| @@ -34,8 +34,6 @@ type sessionCore struct { | |||
| 34 | 34 | attachments map[*attachedClient]struct{} | |
| 35 | 35 | attachmentOrder map[*attachedClient]uint64 | |
| 36 | 36 | nextAttachmentID uint64 | |
| 37 | - | client *attachedClient | |
| 38 | - | snatched map[*attachedClient]struct{} | |
| 39 | 37 | createdAt int64 | |
| 40 | 38 | incarnation domain.IncarnationID | |
| 41 | 39 | mruAt atomic.Uint64 | |
| @@ -34,8 +34,6 @@ type sessionCore struct { | |||||
| 34 | attachments map[*attachedClient]struct{} | 34 | attachments map[*attachedClient]struct{} | ||
| 35 | attachmentOrder map[*attachedClient]uint64 | 35 | attachmentOrder map[*attachedClient]uint64 | ||
| 36 | nextAttachmentID uint64 | 36 | nextAttachmentID uint64 | ||
| 37 | - | client *attachedClient | |||
| 38 | - | snatched map[*attachedClient]struct{} | |||
| 39 | createdAt int64 | 37 | createdAt int64 | ||
| 40 | incarnation domain.IncarnationID | 38 | incarnation domain.IncarnationID | ||
| 41 | mruAt atomic.Uint64 | 39 | mruAt atomic.Uint64 | ||
M internal/usecase/daemon/session_core_test.go +14 -2
| @@ -9,6 +9,18 @@ import ( | |||
| 9 | 9 | "github.com/stretchr/testify/require" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | + | func clearAttachmentsForTest(sess *session) { | |
| 13 | + | for _, ac := range sess.snapshotAttachments() { | |
| 14 | + | sess.unregisterAttachment(ac) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func clearAttachmentsForTestLocked(sess *session) { | |
| 19 | + | for _, ac := range sess.snapshotAttachmentsLocked() { | |
| 20 | + | sess.unregisterAttachmentLocked(ac) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 12 | 24 | func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) { | |
| 13 | 25 | sess := &session{} | |
| 14 | 26 | sess.id = domain.SessionID("local") | |
| @@ -24,7 +36,7 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) { | |||
| 24 | 36 | ||
| 25 | 37 | core.mu.Lock() | |
| 26 | 38 | require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu") | |
| 27 | - | core.client = ac | |
| 39 | + | core.registerAttachmentLocked(ac) | |
| 28 | 40 | require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac)) | |
| 29 | 41 | sess.tabs = append(sess.tabs, &tab{name: "logs"}) | |
| 30 | 42 | require.Len(t, sess.tabs, 2) | |
| @@ -47,7 +59,7 @@ func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing. | |||
| 47 | 59 | sess.core().mu.Lock() | |
| 48 | 60 | sess.tabs = append(sess.tabs, &tab{name: "logs"}) | |
| 49 | 61 | sess.active = 1 | |
| 50 | - | sess.client = ac | |
| 62 | + | sess.registerAttachmentLocked(ac) | |
| 51 | 63 | ||
| 52 | 64 | preCall := make(chan struct{}) | |
| 53 | 65 | allowCall := make(chan struct{}) | |
| @@ -9,6 +9,18 @@ import ( | |||||
| 9 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | + | func clearAttachmentsForTest(sess *session) { | |||
| 13 | + | for _, ac := range sess.snapshotAttachments() { | |||
| 14 | + | sess.unregisterAttachment(ac) | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func clearAttachmentsForTestLocked(sess *session) { | |||
| 19 | + | for _, ac := range sess.snapshotAttachmentsLocked() { | |||
| 20 | + | sess.unregisterAttachmentLocked(ac) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 12 | func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) { | 24 | func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) { | ||
| 13 | sess := &session{} | 25 | sess := &session{} | ||
| 14 | sess.id = domain.SessionID("local") | 26 | sess.id = domain.SessionID("local") | ||
| @@ -24,7 +36,7 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) { | |||||
| 24 | 36 | ||||
| 25 | core.mu.Lock() | 37 | core.mu.Lock() | ||
| 26 | require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu") | 38 | require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu") | ||
| 27 | - | core.client = ac | 39 | + | core.registerAttachmentLocked(ac) |
| 28 | require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac)) | 40 | require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac)) | ||
| 29 | sess.tabs = append(sess.tabs, &tab{name: "logs"}) | 41 | sess.tabs = append(sess.tabs, &tab{name: "logs"}) | ||
| 30 | require.Len(t, sess.tabs, 2) | 42 | require.Len(t, sess.tabs, 2) | ||
| @@ -47,7 +59,7 @@ func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing. | |||||
| 47 | sess.core().mu.Lock() | 59 | sess.core().mu.Lock() | ||
| 48 | sess.tabs = append(sess.tabs, &tab{name: "logs"}) | 60 | sess.tabs = append(sess.tabs, &tab{name: "logs"}) | ||
| 49 | sess.active = 1 | 61 | sess.active = 1 | ||
| 50 | - | sess.client = ac | 62 | + | sess.registerAttachmentLocked(ac) |
| 51 | 63 | ||||
| 52 | preCall := make(chan struct{}) | 64 | preCall := make(chan struct{}) | ||
| 53 | allowCall := make(chan struct{}) | 65 | allowCall := make(chan struct{}) | ||
M internal/usecase/daemon/session_test.go +3 -50
| @@ -283,53 +283,6 @@ func TestEphemeralNumberingReuse(t *testing.T) { | |||
| 283 | 283 | ||
| 284 | 284 | // --- attach replace --------------------------------------------------------- | |
| 285 | 285 | ||
| 286 | - | func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) { | |
| 287 | - | p, releasePTY := newBlockingPTY(t) | |
| 288 | - | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 289 | - | ||
| 290 | - | // Client A creates and attaches to ephemeral "0". | |
| 291 | - | trA, sendsA, releaseA := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24})) | |
| 292 | - | var hg sync.WaitGroup | |
| 293 | - | hg.Go(func() { d.handleConn(trA) }) | |
| 294 | - | awaitFrame(t, sendsA, ports.MsgWelcome) | |
| 295 | - | awaitFrame(t, sendsA, ports.MsgOutput) | |
| 296 | - | sess := firstSession(d) | |
| 297 | - | require.NotNil(t, sess) | |
| 298 | - | sess.mu.Lock() | |
| 299 | - | acA := sess.client | |
| 300 | - | sess.mu.Unlock() | |
| 301 | - | require.NotNil(t, acA) | |
| 302 | - | // The transport observes Send before the render transaction releases sendMu. | |
| 303 | - | // Wait for that contract boundary before replacing A; otherwise the test can | |
| 304 | - | // intentionally classify the in-flight first paint as an interrupted render. | |
| 305 | - | acA.sendMu.Lock() | |
| 306 | - | require.Same(t, trA, acA.transportSnapshot().transport) | |
| 307 | - | acA.sendMu.Unlock() | |
| 308 | - | ||
| 309 | - | // Client B attaches to the same session, displacing A. | |
| 310 | - | trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24})) | |
| 311 | - | hg.Go(func() { d.handleConn(trB) }) | |
| 312 | - | awaitFrame(t, sendsB, ports.MsgWelcome) | |
| 313 | - | d.attachmentCleanupWg.Wait() | |
| 314 | - | ||
| 315 | - | // B is now the sole active client while A remains a live snatched member. | |
| 316 | - | sess.mu.Lock() | |
| 317 | - | require.NotNil(t, sess.client) | |
| 318 | - | require.NotSame(t, acA, sess.client) | |
| 319 | - | _, oldSnatched := sess.snatched[acA] | |
| 320 | - | sess.mu.Unlock() | |
| 321 | - | require.True(t, oldSnatched) | |
| 322 | - | require.Same(t, sess, acA.currentSession()) | |
| 323 | - | ||
| 324 | - | releaseA() | |
| 325 | - | releaseB() | |
| 326 | - | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| 327 | - | releasePTY() | |
| 328 | - | hg.Wait() | |
| 329 | - | d.sessWg.Wait() | |
| 330 | - | d.waitNotifies() | |
| 331 | - | } | |
| 332 | - | ||
| 333 | 286 | // --- detach semantics ------------------------------------------------------- | |
| 334 | 287 | ||
| 335 | 288 | func TestDetachKeepsEphemeralHeadless(t *testing.T) { | |
| @@ -351,7 +304,7 @@ func TestDetachKeepsEphemeralHeadless(t *testing.T) { | |||
| 351 | 304 | require.NotNil(t, sess) | |
| 352 | 305 | sess.mu.Lock() | |
| 353 | 306 | require.True(t, sess.ephemeral) | |
| 354 | - | require.Nil(t, sess.client, "ephemeral session is headless after detach") | |
| 307 | + | require.Empty(t, sess.snapshotAttachmentsLocked(), "ephemeral session is headless after detach") | |
| 355 | 308 | sess.mu.Unlock() | |
| 356 | 309 | ||
| 357 | 310 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| @@ -398,7 +351,7 @@ func TestDetachKeepsNamed(t *testing.T) { | |||
| 398 | 351 | require.Equal(t, 1, sessionCount(d), "named session survives detach") | |
| 399 | 352 | sess := firstSession(d) | |
| 400 | 353 | sess.mu.Lock() | |
| 401 | - | require.Nil(t, sess.client, "named session is headless after detach") | |
| 354 | + | require.Empty(t, sess.snapshotAttachmentsLocked(), "named session is headless after detach") | |
| 402 | 355 | sess.mu.Unlock() | |
| 403 | 356 | ||
| 404 | 357 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| @@ -1038,7 +991,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) { | |||
| 1038 | 991 | func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) { | |
| 1039 | 992 | d, sess, ac, _, releases := newManualTabSession(t, 2) | |
| 1040 | 993 | sess.mu.Lock() | |
| 1041 | - | sess.client = ac | |
| 994 | + | sess.registerAttachmentLocked(ac) | |
| 1042 | 995 | sess.mu.Unlock() | |
| 1043 | 996 | d.ptys = newBlockingOpenFactory(t, d) | |
| 1044 | 997 | defer releases[0]() | |
| @@ -283,53 +283,6 @@ func TestEphemeralNumberingReuse(t *testing.T) { | |||||
| 283 | 283 | ||||
| 284 | // --- attach replace --------------------------------------------------------- | 284 | // --- attach replace --------------------------------------------------------- | ||
| 285 | 285 | ||||
| 286 | - | func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) { | |||
| 287 | - | p, releasePTY := newBlockingPTY(t) | |||
| 288 | - | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 289 | - | ||||
| 290 | - | // Client A creates and attaches to ephemeral "0". | |||
| 291 | - | trA, sendsA, releaseA := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24})) | |||
| 292 | - | var hg sync.WaitGroup | |||
| 293 | - | hg.Go(func() { d.handleConn(trA) }) | |||
| 294 | - | awaitFrame(t, sendsA, ports.MsgWelcome) | |||
| 295 | - | awaitFrame(t, sendsA, ports.MsgOutput) | |||
| 296 | - | sess := firstSession(d) | |||
| 297 | - | require.NotNil(t, sess) | |||
| 298 | - | sess.mu.Lock() | |||
| 299 | - | acA := sess.client | |||
| 300 | - | sess.mu.Unlock() | |||
| 301 | - | require.NotNil(t, acA) | |||
| 302 | - | // The transport observes Send before the render transaction releases sendMu. | |||
| 303 | - | // Wait for that contract boundary before replacing A; otherwise the test can | |||
| 304 | - | // intentionally classify the in-flight first paint as an interrupted render. | |||
| 305 | - | acA.sendMu.Lock() | |||
| 306 | - | require.Same(t, trA, acA.transportSnapshot().transport) | |||
| 307 | - | acA.sendMu.Unlock() | |||
| 308 | - | ||||
| 309 | - | // Client B attaches to the same session, displacing A. | |||
| 310 | - | trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24})) | |||
| 311 | - | hg.Go(func() { d.handleConn(trB) }) | |||
| 312 | - | awaitFrame(t, sendsB, ports.MsgWelcome) | |||
| 313 | - | d.attachmentCleanupWg.Wait() | |||
| 314 | - | ||||
| 315 | - | // B is now the sole active client while A remains a live snatched member. | |||
| 316 | - | sess.mu.Lock() | |||
| 317 | - | require.NotNil(t, sess.client) | |||
| 318 | - | require.NotSame(t, acA, sess.client) | |||
| 319 | - | _, oldSnatched := sess.snatched[acA] | |||
| 320 | - | sess.mu.Unlock() | |||
| 321 | - | require.True(t, oldSnatched) | |||
| 322 | - | require.Same(t, sess, acA.currentSession()) | |||
| 323 | - | ||||
| 324 | - | releaseA() | |||
| 325 | - | releaseB() | |||
| 326 | - | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |||
| 327 | - | releasePTY() | |||
| 328 | - | hg.Wait() | |||
| 329 | - | d.sessWg.Wait() | |||
| 330 | - | d.waitNotifies() | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | // --- detach semantics ------------------------------------------------------- | 286 | // --- detach semantics ------------------------------------------------------- | ||
| 334 | 287 | ||||
| 335 | func TestDetachKeepsEphemeralHeadless(t *testing.T) { | 288 | func TestDetachKeepsEphemeralHeadless(t *testing.T) { | ||
| @@ -351,7 +304,7 @@ func TestDetachKeepsEphemeralHeadless(t *testing.T) { | |||||
| 351 | require.NotNil(t, sess) | 304 | require.NotNil(t, sess) | ||
| 352 | sess.mu.Lock() | 305 | sess.mu.Lock() | ||
| 353 | require.True(t, sess.ephemeral) | 306 | require.True(t, sess.ephemeral) | ||
| 354 | - | require.Nil(t, sess.client, "ephemeral session is headless after detach") | 307 | + | require.Empty(t, sess.snapshotAttachmentsLocked(), "ephemeral session is headless after detach") |
| 355 | sess.mu.Unlock() | 308 | sess.mu.Unlock() | ||
| 356 | 309 | ||||
| 357 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | 310 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | ||
| @@ -398,7 +351,7 @@ func TestDetachKeepsNamed(t *testing.T) { | |||||
| 398 | require.Equal(t, 1, sessionCount(d), "named session survives detach") | 351 | require.Equal(t, 1, sessionCount(d), "named session survives detach") | ||
| 399 | sess := firstSession(d) | 352 | sess := firstSession(d) | ||
| 400 | sess.mu.Lock() | 353 | sess.mu.Lock() | ||
| 401 | - | require.Nil(t, sess.client, "named session is headless after detach") | 354 | + | require.Empty(t, sess.snapshotAttachmentsLocked(), "named session is headless after detach") |
| 402 | sess.mu.Unlock() | 355 | sess.mu.Unlock() | ||
| 403 | 356 | ||||
| 404 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | 357 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | ||
| @@ -1038,7 +991,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) { | |||||
| 1038 | func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) { | 991 | func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) { | ||
| 1039 | d, sess, ac, _, releases := newManualTabSession(t, 2) | 992 | d, sess, ac, _, releases := newManualTabSession(t, 2) | ||
| 1040 | sess.mu.Lock() | 993 | sess.mu.Lock() | ||
| 1041 | - | sess.client = ac | 994 | + | sess.registerAttachmentLocked(ac) |
| 1042 | sess.mu.Unlock() | 995 | sess.mu.Unlock() | ||
| 1043 | d.ptys = newBlockingOpenFactory(t, d) | 996 | d.ptys = newBlockingOpenFactory(t, d) | ||
| 1044 | defer releases[0]() | 997 | defer releases[0]() | ||
M internal/usecase/daemon/session_view_test.go +1 -1
| @@ -22,7 +22,7 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) { | |||
| 22 | 22 | name: "alpha", | |
| 23 | 23 | createdAt: 42, | |
| 24 | 24 | ||
| 25 | - | client: &attachedClient{}}, active: 1, | |
| 25 | + | attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}}, active: 1, | |
| 26 | 26 | ||
| 27 | 27 | tabs: []*tab{ | |
| 28 | 28 | {stableID: "t1", name: "build"}, | |
| @@ -22,7 +22,7 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) { | |||||
| 22 | name: "alpha", | 22 | name: "alpha", | ||
| 23 | createdAt: 42, | 23 | createdAt: 42, | ||
| 24 | 24 | ||||
| 25 | - | client: &attachedClient{}}, active: 1, | 25 | + | attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}}, active: 1, |
| 26 | 26 | ||||
| 27 | tabs: []*tab{ | 27 | tabs: []*tab{ | ||
| 28 | {stableID: "t1", name: "build"}, | 28 | {stableID: "t1", name: "build"}, | ||