detached 409ae78

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
4141 sess.mu.Unlock()
4242 require.False(t, firstAttached)
4343 require.True(t, secondAttached)
44- require.Same(t, second, sess.client)
44+ require.Contains(t, sess.snapshotAttachments(), second)
4545 require.False(t, secondTransport.Closed())
4646 }
4747
@@ -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
4646 return attachmentDetached
4747 }
4848
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-
5849 func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
5950 return attachmentSessionRole(s, ac)
6051 }
@@ -63,8 +54,6 @@ func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {
6354 return attachmentSessionRoleLocked(s, ac)
6455 }
6556
66-func (s *session) addSnatchedLocked(ac *attachedClient) { addSnatchedLocked(s, ac) }
67-
6857 type attachmentRoleToken struct {
6958 sess attachmentSession
7059 ac *attachedClient
@@ -46,15 +46,6 @@ func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) at
46 return attachmentDetached46 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 attachmentSession58 sess attachmentSession
70 ac *attachedClient59 ac *attachedClient
M internal/usecase/daemon/attachment_transition_prelocked_test.go +4 -112
@@ -2,120 +2,12 @@ package daemon
22
33 import (
44 "testing"
5- "time"
65
76 "github.com/stretchr/testify/require"
87
98 "github.com/bnema/vev/internal/domain"
109 )
1110
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-
11911 func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure(t *testing.T) {
12012 t.Parallel()
12113
@@ -124,10 +16,10 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
12416 target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}, active: 0}
12517 next := &attachedClient{tr: &closeTrackingTransport{}}
12618 next.setSession(source)
127- source.addSnatchedLocked(next)
19+ source.registerAttachmentLocked(next)
12820 old := &attachedClient{tr: &closeTrackingTransport{}}
12921 old.setSession(target)
130- target.client = old
22+ target.registerAttachment(old)
13123 d.sessions[source.id] = source
13224 d.sessions[target.id] = target
13325
@@ -140,7 +32,7 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
14032 source: source,
14133 target: target,
14234 next: next,
143- expectedRole: attachmentSnatched,
35+ expectedRole: attachmentActive,
14436 targetRole: attachmentActive,
14537 expectedTransport: next.transportSnapshot(),
14638 expectedTargetCurrent: old,
@@ -160,7 +52,7 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
16052
16153 require.ErrorIs(t, err, errAttachmentTransition)
16254 require.Nil(t, publication)
163- require.Equal(t, attachmentSnatched, source.attachmentRole(next))
55+ require.Equal(t, attachmentActive, source.attachmentRole(next))
16456 require.Equal(t, attachmentDetached, target.attachmentRole(next))
16557 require.Equal(t, attachmentActive, target.attachmentRole(old))
16658 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 = old22+ target.registerAttachment(old)
131 d.sessions[source.id] = source23 d.sessions[source.id] = source
132 d.sessions[target.id] = target24 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) {
176176 sctxW, cancelW := context.WithCancel(d.serveCtx)
177177 t.Cleanup(cancelW)
178178 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}}
180180 acW.setSession(sessW)
181181 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)
182182 d.sessions[sessW.id] = sessW
@@ -446,7 +446,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
446446 sctxB, cancelB := context.WithCancel(d.serveCtx)
447447 t.Cleanup(cancelB)
448448 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}}
450450 acB.setSession(sessB)
451451 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
452452 d.sessions[sessB.id] = sessB
@@ -465,7 +465,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
465465 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
466466 d, sess, ac, sends, releases := newManualTabSession(t, 3)
467467 sess.mu.Lock()
468- sess.client = ac
468+ sess.registerAttachmentLocked(ac)
469469 sess.mu.Unlock()
470470 d.ptys = newBlockingOpenFactory(t, d)
471471 defer releases[0]()
@@ -512,8 +512,8 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
512512 require.NoError(t, d.jumpAttention(sess1, ac))
513513
514514 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())
517517 require.Equal(t, 1, activeTabIndex(sess2))
518518 _ = mustOutputData(t, sends)
519519 }
@@ -556,7 +556,7 @@ func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) {
556556 d.sessions[sess2.id] = sess2
557557
558558 sess1.mu.Lock()
559- sess1.client = nil
559+ clearAttachmentsForTestLocked(sess1)
560560 sess1.mu.Unlock()
561561
562562 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] = sessW182 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] = sessB452 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 = ac468+ 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] = sess2556 d.sessions[sess2.id] = sess2
557 557
558 sess1.mu.Lock()558 sess1.mu.Lock()
559- sess1.client = nil559+ 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)
6464 r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
6565 d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
6666 sess := newBarRefreshTestSession()
67- sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}}
67+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 120, Rows: 24}})
6868
6969 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
7070 waitBarRefreshIdle(t, d)
@@ -91,7 +91,7 @@ func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
9191 sess := newBarRefreshTestSession()
9292 sess.name = "work"
9393 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}})
9595 sess.active = 1
9696 sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38}
9797 active := sess.tabs[1].focusedPane()
@@ -131,7 +131,7 @@ func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
131131 d.barScripts.cfg.topRight = ""
132132 d.barScripts.cfg.bottomRight = ""
133133 sess := newBarRefreshTestSession()
134- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
134+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
135135 d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
136136
137137 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
@@ -146,7 +146,7 @@ func TestDefaultBarConfigDoesNotInvokeRunner(t *testing.T) {
146146 d := newBarRefreshTestDaemon(r, time.Second)
147147 d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar)
148148 sess := newBarRefreshTestSession()
149- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
149+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
150150
151151 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
152152 require.Empty(t, r.calls)
@@ -156,7 +156,7 @@ func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
156156 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
157157 d := newBarRefreshTestDaemon(r, 5*time.Second)
158158 sess := newBarRefreshTestSession()
159- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
159+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
160160
161161 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
162162 waitBarRefreshIdle(t, d)
@@ -173,7 +173,7 @@ func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
173173 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
174174 d := newBarRefreshTestDaemon(r, 5*time.Second)
175175 sess := newBarRefreshTestSession()
176- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
176+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
177177
178178 require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
179179 waitBarRefreshIdle(t, d)
@@ -191,7 +191,7 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
191191 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
192192 d := newBarRefreshTestDaemon(r, 5*time.Second)
193193 sess := newBarRefreshTestSession()
194- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
194+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
195195
196196 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
197197 waitBarRefreshIdle(t, d)
@@ -219,7 +219,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
219219 r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}
220220 d := newBarRefreshTestDaemon(r, 60*time.Second)
221221 sess := newBarRefreshTestSession()
222- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
222+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
223223 d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}
224224
225225 // Verifies refreshBarScriptsAllSessions iterates every live session and
@@ -252,7 +252,7 @@ func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
252252 d := newBarRefreshTestDaemon(r, time.Second)
253253 d.log = slog.New(slog.NewTextHandler(&logs, nil))
254254 sess := newBarRefreshTestSession()
255- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
255+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
256256
257257 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
258258 waitBarRefreshIdle(t, d)
@@ -287,7 +287,7 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
287287 r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})
288288 d := newBarRefreshTestDaemon(r, time.Second)
289289 sess := newBarRefreshTestSession()
290- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
290+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
291291
292292 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
293293 select {
@@ -337,7 +337,7 @@ func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) {
337337 d.log = logger
338338 d.baseEnv = []string{"PATH=/usr/bin"}
339339 sess := newBarRefreshTestSession()
340- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
340+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
341341
342342 readBuf := func() string {
343343 mu.Lock()
@@ -420,11 +420,11 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
420420 a := newBarRefreshTestSession()
421421 a.id = "a"
422422 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}})
424424 b := newBarRefreshTestSession()
425425 b.id = "b"
426426 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}})
428428
429429 require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
430430 waitBarRefreshIdle(t, d)
@@ -645,7 +645,7 @@ func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) {
645645 d.baseEnv = tc.baseEnv
646646 sess := newBarRefreshTestSession()
647647 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}})
649649
650650 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
651651 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 = 195 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 and225 // 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 = logger337 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.baseEnv645 d.baseEnv = tc.baseEnv
646 sess := newBarRefreshTestSession()646 sess := newBarRefreshTestSession()
647 sess.env = tc.sessEnv647 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) {
231231 win := newTestTabWithContext(p, sctx, cancel)
232232 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
233233 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}
235235 publishTiledPaneOwners(sess, win)
236236 ac.setSession(sess)
237237 d.sessions[sess.id] = sess
@@ -265,7 +265,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
265265 win := newTestTabWithContext(p, sctx, cancel)
266266 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
267267 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}
269269 publishTiledPaneOwners(sess, win)
270270 ac.setSession(sess)
271271 d.sessions[sess.id] = sess
@@ -290,7 +290,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
290290 win := newTestTabWithContext(p, sctx, cancel)
291291 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
292292 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}
294294 publishTiledPaneOwners(sess, win)
295295 ac.setSession(sess)
296296 d.sessions[sess.id] = sess
@@ -363,7 +363,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
363363 d.clipboardWorker(sess)
364364
365365 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")
367367 }
368368
369369 type movingClipboardErrorTransport struct {
@@ -432,7 +432,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
432432 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
433433
434434 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)
436436 }
437437
438438 func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
@@ -461,7 +461,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
461461 close(oldTransport.release)
462462 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
463463
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")
465465 select {
466466 case <-oldTransport.closed:
467467 t.Fatal("a send error from the pane's retired owner closed its client transport")
@@ -495,7 +495,7 @@ func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) {
495495 d.clipboardWorker(sess)
496496 require.Zero(t, oldTransport.sendCount(), "stale queued work reached its captured failing transport")
497497 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")
499499 require.False(t, newTransport.Closed())
500500 }
501501
@@ -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] = sess237 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] = sess271 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] = sess296 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) {
9393 ac := &attachedClient{}
9494 ac.setSession(h.session)
9595 h.session.mu.Lock()
96- h.session.client = ac
96+ h.session.registerAttachmentLocked(ac)
9797 h.session.mu.Unlock()
9898 invalidations := make(chan renderInvalidation, 1)
9999 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -403,7 +403,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
403403 require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary")
404404 sess.snapshotMu.Unlock()
405405 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")
407407 sess.mu.Unlock()
408408 })
409409 }
@@ -613,7 +613,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
613613 d.mu.Unlock()
614614 require.NotNil(t, created)
615615 created.mu.Lock()
616- require.Nil(t, created.client)
616+ require.Empty(t, created.snapshotAttachmentsLocked())
617617 require.Equal(t, "/tmp/work", created.cwd)
618618 require.Equal(t, []string{"INHERITED=yes"}, created.env)
619619 require.True(t, created.terminal.TrueColor)
@@ -622,7 +622,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
622622 tb.mu.Lock()
623623 require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size)
624624 tb.mu.Unlock()
625- require.Nil(t, source.client)
625+ require.Empty(t, source.snapshotAttachments())
626626
627627 taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
628628 require.False(t, taken.OK)
@@ -674,7 +674,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
674674 d := newTestDaemon(t, nil, stubClock{})
675675 work := addControlSession(d, "work", "t_work", "p_work")
676676 work.ephemeral = false
677- work.client = &attachedClient{}
677+ work.registerAttachment(&attachedClient{})
678678 work.mruAt.Store(2)
679679 tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
680680 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 = ac96+ 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 = false676 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) {
9898 tb.panes["pane-2"] = closing
9999 ac := &attachedClient{output: newOutputStateStream()}
100100 ac.initOverlays()
101- sess.client = ac
101+ sess.registerAttachment(ac)
102102 ac.setSession(sess)
103103 closing.mu.Lock()
104104 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"] = closing98 tb.panes["pane-2"] = closing
99 ac := &attachedClient{output: newOutputStateStream()}99 ac := &attachedClient{output: newOutputStateStream()}
100 ac.initOverlays()100 ac.initOverlays()
101- sess.client = ac101+ 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
4848 ac := &attachedClient{output: newOutputStateStream()}
4949 failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)}
5050 ac.replaceTransport(failed)
51- sess := &session{sessionCore: sessionCore{name: "work", client: ac}}
51+ sess := &session{sessionCore: sessionCore{name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}}
5252 ac.setSession(sess)
5353
5454 used, err := d.boundedSendOutputErrTransport(ac, []byte("copy"))
@@ -60,7 +60,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin
6060 require.NoError(t, decodeErr)
6161 require.Equal(t, []byte("copy"), out.Data)
6262 d.detachOnSendError(sess, ac, used)
63- require.Same(t, ac, sess.client)
63+ require.Contains(t, sess.snapshotAttachments(), ac)
6464 require.False(t, replacement.Closed())
6565 }
6666
@@ -458,7 +458,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
458458
459459 sess := firstSession(d)
460460 require.NotNil(t, sess)
461- ac := sess.client
461+ ac := sess.snapshotAttachments()[0]
462462 require.NotNil(t, ac)
463463 d.handleInput(sess, ac, []byte("\x1b "))
464464 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.client461+ 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
413413 }
414414 tabs = append(tabs, tb)
415415 }
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}
417417 for _, tb := range tabs {
418418 publishTiledPaneOwners(sess, tb)
419419 }
@@ -537,7 +537,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
537537 require.Equal(t, ports.MsgWelcome, welcome.Type)
538538 sess := firstSession(d)
539539 sess.mu.Lock()
540- ac := sess.client
540+ ac := sess.snapshotAttachmentsLocked()[0]
541541 sess.mu.Unlock()
542542 require.NotNil(t, ac, "route must publish the attachment before Welcome returns")
543543
@@ -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.client540+ 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) {
130130 tb.mu.Unlock()
131131 require.NotNil(t, floating)
132132 sess.mu.Lock()
133- sess.client = &attachedClient{}
133+ sess.registerAttachmentLocked(&attachedClient{})
134134 sess.mu.Unlock()
135135 require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility")
136136 sess.mu.Lock()
137- sess.client = nil
137+ clearAttachmentsForTestLocked(sess)
138138 sess.mu.Unlock()
139139 select {
140140 case <-pty.readerStarted:
@@ -228,7 +228,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing.
228228 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
229229 ac.initOverlays()
230230 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}
232232 ac.setSession(sess)
233233
234234 // 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 = nil137+ 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 without234 // 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) {
595595 installTestFloating(tb, p, true)
596596 ac := &attachedClient{}
597597 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}}
599599 base := barState{}
600600 cfg := domain.FloatingConfig{Width: 100, Height: 100}
601601
@@ -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) {
406406 sourceClient.initOverlays()
407407 destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
408408 destinationClient.initOverlays()
409- source.client = sourceClient
410- destination.client = destinationClient
409+ source.registerAttachment(sourceClient)
410+ destination.registerAttachment(destinationClient)
411411
412412 floating.onExit()
413413 d.sessWg.Wait()
@@ -770,7 +770,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) {
770770 tr, _ := newCapturingTransport(t)
771771 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
772772 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()}
774774 ac.setSession(sess)
775775
776776 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 = sourceClient409+ source.registerAttachment(sourceClient)
410- destination.client = destinationClient410+ 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) {
5353 ac.setSession(h.session)
5454 ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
5555 h.session.mu.Lock()
56- h.session.client = ac
56+ h.session.registerAttachmentLocked(ac)
5757 h.session.mu.Unlock()
5858 invalidations := make(chan renderInvalidation, 1)
5959 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -82,7 +82,7 @@ func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) {
8282 ac.setSession(h.session)
8383 ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
8484 h.session.mu.Lock()
85- h.session.client = ac
85+ h.session.registerAttachmentLocked(ac)
8686 h.session.mu.Unlock()
8787 invalidations := make(chan renderInvalidation, 1)
8888 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -232,7 +232,7 @@ func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) {
232232 rc.attach(ac)
233233 } else {
234234 sess.mu.Lock()
235- sess.client = nil
235+ clearAttachmentsForTestLocked(sess)
236236 sess.mu.Unlock()
237237 }
238238
@@ -1015,7 +1015,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
10151015 // A displaced attachment makes the previously valid handoff stale by the
10161016 // time switchToTarget commits it.
10171017 current.mu.Lock()
1018- current.client = nil
1018+ clearAttachmentsForTestLocked(current)
10191019 current.mu.Unlock()
10201020
10211021 d.backSession(current, ac)
@@ -1224,7 +1224,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) {
12241224 d.handleInput(sess, ac, []byte("\x1b "))
12251225 d.handleInput(sess, ac, []byte("DET\r"))
12261226
1227- require.Nil(t, sess.client)
1227+ require.Empty(t, sess.snapshotAttachments())
12281228 f := awaitFrame(t, sends, ports.MsgDetached)
12291229 det, err := ports.UnmarshalDetached(f.Payload)
12301230 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 = ac56+ 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 = ac85+ 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 = nil235+ 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 the1015 // 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 = nil1018+ 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) {
163163 awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment")
164164
165165 source.mu.Lock()
166- source.client = replacement
166+ source.registerAttachmentLocked(replacement)
167167 source.mu.Unlock()
168168 close(releaseSnapshot)
169169 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 = replacement166+ 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) {
177177 }
178178 d, source, client, _ := newManualSessionWithPTYs(t, ptys...)
179179 if tt.finalSource {
180- source.client = nil
180+ clearAttachmentsForTest(source)
181181 client.setSession(nil)
182182 }
183183 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 = nil180+ 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) {
109109 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
110110 defer releaseAll(releases)
111111 source.mu.Lock()
112- source.client = nil
112+ clearAttachmentsForTestLocked(source)
113113 source.mu.Unlock()
114114 sourceTab := source.tabs[0]
115115 moved := sourceTab.focusedPane()
@@ -130,7 +130,7 @@ func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) {
130130 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
131131 defer releaseAll(releases)
132132 source.mu.Lock()
133- source.client = nil
133+ clearAttachmentsForTestLocked(source)
134134 source.mu.Unlock()
135135 sourceTab := source.tabs[0]
136136 moved := sourceTab.focusedPane()
@@ -157,7 +157,7 @@ func TestMovePickerEnterCommitsMoveTab(t *testing.T) {
157157 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
158158 defer releaseAll(releases)
159159 source.mu.Lock()
160- source.client = nil
160+ clearAttachmentsForTestLocked(source)
161161 source.mu.Unlock()
162162 movedTab := source.tabs[0]
163163 movedTab.stableID = "moved-tab"
@@ -265,7 +265,7 @@ func TestMovePickerSourceClientReplacementInvalidatesAction(t *testing.T) {
265265 replacement.initOverlays()
266266 replacement.setSession(source)
267267 source.mu.Lock()
268- source.client = replacement
268+ source.registerAttachmentLocked(replacement)
269269 source.mu.Unlock()
270270
271271 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 = nil112+ 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 = nil133+ 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 = nil160+ 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 = replacement268+ 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
116116 }))
117117
118118 require.Same(t, moved, destination.tabs[destination.active])
119- require.Same(t, client, destination.client)
119+ require.Contains(t, destination.snapshotAttachments(), client)
120120 require.Same(t, destination, client.currentSession())
121121 moved.mu.Lock()
122122 require.Equal(t, floatingVisible, moved.floating.state)
@@ -159,7 +159,7 @@ func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) {
159159
160160 require.Nil(t, source.tabs)
161161 require.Same(t, destination, client.currentSession())
162- require.Same(t, client, destination.client)
162+ require.Contains(t, destination.snapshotAttachments(), client)
163163 require.Equal(t, attachmentActive, destination.attachmentRole(client))
164164 require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs)
165165 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) {
4949 defer release2()
5050 d, source, _, _ := newManualSessionWithPTYs(t, p1)
5151 source.mu.Lock()
52- source.client = nil
52+ clearAttachmentsForTestLocked(source)
5353 source.mu.Unlock()
5454 sourceTab := source.tabs[0]
5555 sourceTab.stableID = "source-tab"
@@ -115,7 +115,7 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
115115 require.NoError(t, err)
116116 require.Nil(t, source.tabs)
117117 require.Same(t, destination, client.currentSession())
118- require.Same(t, client, destination.client)
118+ require.Contains(t, destination.snapshotAttachments(), client)
119119 require.Equal(t, attachmentActive, destination.attachmentRole(client))
120120 require.Same(t, destinationTab, destination.tabs[destination.active])
121121 require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint")
@@ -129,7 +129,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing
129129 require.NoError(t, err)
130130 require.NoError(t, clip.Close())
131131 source.mu.Lock()
132- source.client = nil
132+ clearAttachmentsForTestLocked(source)
133133 source.clipFiles = []string{clip.Name()}
134134 source.mu.Unlock()
135135 active.setSession(nil)
@@ -179,7 +179,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
179179 movedPTY := newQuietPTY()
180180 d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
181181 source.mu.Lock()
182- source.client = nil
182+ clearAttachmentsForTestLocked(source)
183183 source.mu.Unlock()
184184 moved := source.tabs[0].focusedPane()
185185 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 = nil52+ 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 = nil132+ 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 = nil182+ 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
149149 }
150150 d.mu.Unlock()
151151 alpha.mu.Lock()
152- attached := alpha.client
152+ attached := alpha.snapshotAttachmentsLocked()
153153 alpha.mu.Unlock()
154154 require.True(t, stopped, "vertical overflow leaves the stopped session stopped")
155155 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")
157157 }
158158
159159 func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
@@ -172,10 +172,10 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
172172
173173 require.Same(t, alpha, ac.currentSession())
174174 alpha.mu.Lock()
175- require.Same(t, ac, alpha.client)
175+ require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
176176 alpha.mu.Unlock()
177177 charlie.mu.Lock()
178- require.Nil(t, charlie.client)
178+ require.Empty(t, charlie.snapshotAttachmentsLocked())
179179 charlie.mu.Unlock()
180180 }
181181
@@ -196,10 +196,10 @@ func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
196196 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
197197 require.Same(t, alpha, ac.currentSession())
198198 alpha.mu.Lock()
199- require.Same(t, ac, alpha.client)
199+ require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
200200 alpha.mu.Unlock()
201201 charlie.mu.Lock()
202- require.Nil(t, charlie.client)
202+ require.Empty(t, charlie.snapshotAttachmentsLocked())
203203 charlie.mu.Unlock()
204204 }
205205
@@ -218,10 +218,10 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
218218 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor)
219219 require.Same(t, alpha, ac.currentSession())
220220 alpha.mu.Lock()
221- require.Same(t, ac, alpha.client)
221+ require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
222222 alpha.mu.Unlock()
223223 charlie.mu.Lock()
224- require.Nil(t, charlie.client)
224+ require.Empty(t, charlie.snapshotAttachmentsLocked())
225225 charlie.mu.Unlock()
226226 }
227227
@@ -251,16 +251,16 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
251251 require.True(t, ok)
252252 replacement := &attachedClient{}
253253 alpha.mu.Lock()
254- alpha.client = replacement
254+ alpha.registerAttachmentLocked(replacement)
255255 alpha.mu.Unlock()
256256
257257 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
258258 require.Same(t, alpha, ac.currentSession())
259259 alpha.mu.Lock()
260- require.Same(t, replacement, alpha.client)
260+ require.Contains(t, alpha.snapshotAttachmentsLocked(), replacement)
261261 alpha.mu.Unlock()
262262 charlie.mu.Lock()
263- require.Nil(t, charlie.client)
263+ require.Empty(t, charlie.snapshotAttachmentsLocked())
264264 charlie.mu.Unlock()
265265 }
266266
@@ -314,7 +314,7 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
314314 continue
315315 }
316316 candidate.mu.Lock()
317- if candidate.client == ac {
317+ if attachmentRegistered(candidate, ac) {
318318 owners = append(owners, candidate)
319319 }
320320 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.client152+ 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 = replacement254+ 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 continue314 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) {
132132
133133 require.Same(t, source, old.currentSession())
134134 source.mu.Lock()
135- require.Same(t, old, source.client)
135+ require.Contains(t, source.snapshotAttachmentsLocked(), old)
136136 source.mu.Unlock()
137137 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")
139139 require.Zero(t, target.active, "stale handoff changed target tab focus")
140140 target.mu.Unlock()
141141 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
7373
7474 require.Same(t, source, ac.currentSession())
7575 target.mu.Lock()
76- require.Nil(t, target.client)
76+ require.Empty(t, target.snapshotAttachmentsLocked())
7777 require.Zero(t, target.active)
7878 target.mu.Unlock()
7979 })
@@ -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) {
106106 newToasts, _ := visibleToasts(current)
107107 require.Empty(t, newToasts)
108108 sess.mu.Lock()
109- require.Same(t, current, sess.client)
109+ require.Contains(t, sess.snapshotAttachmentsLocked(), current)
110110 sess.mu.Unlock()
111111 }
112112
@@ -379,7 +379,7 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach
379379 for _, pane := range tb.panes {
380380 pane.ctx, pane.cancel = wctx, wcancel
381381 }
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}}
383383 ac.setSession(sess)
384384 d.sessions[sess.id] = sess
385385 t.Cleanup(cancel)
@@ -521,7 +521,7 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
521521
522522 other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
523523 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() {}}
525525 other.setSession(sess2)
526526 d.mu.Lock()
527527 d.sessions[sess2.id] = sess2
@@ -606,7 +606,7 @@ func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) {
606606 func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
607607 d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock())
608608 sessA.mu.Lock()
609- sessA.client = nil
609+ clearAttachmentsForTestLocked(sessA)
610610 sessA.mu.Unlock()
611611
612612 clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
@@ -628,7 +628,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
628628 require.Empty(t, oldToasts, "a detached session must not paint a toast")
629629
630630 sessB.mu.Lock()
631- sessB.client = clientB
631+ sessB.registerAttachmentLocked(clientB)
632632 sessB.mu.Unlock()
633633 d.drainPendingForFirstPaint(sessB, clientB)
634634 bToasts, _ := visibleToasts(clientB)
@@ -638,7 +638,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
638638 require.Equal(t, "global announcement", bToasts[0].Message)
639639
640640 sessA.mu.Lock()
641- sessA.client = oldA
641+ sessA.registerAttachmentLocked(oldA)
642642 sessA.mu.Unlock()
643643 d.drainPendingForFirstPaint(sessA, oldA)
644644 aToasts, _ := visibleToasts(oldA)
@@ -652,7 +652,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
652652 secondA.initOverlays()
653653 secondA.setSession(sessA)
654654 sessA.mu.Lock()
655- sessA.client = secondA
655+ sessA.registerAttachmentLocked(secondA)
656656 sessA.mu.Unlock()
657657 d.drainPendingForFirstPaint(sessA, secondA)
658658 secondToasts, _ := visibleToasts(secondA)
@@ -664,7 +664,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
664664
665665 second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
666666 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() {}}
668668 second.setSession(sess2)
669669 d.mu.Lock()
670670 d.sessions[sess2.id] = sess2
@@ -682,7 +682,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
682682 func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {
683683 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
684684 sess.mu.Lock()
685- sess.client = nil
685+ clearAttachmentsForTestLocked(sess)
686686 sess.mu.Unlock()
687687
688688 routeObserved := make(chan struct{})
@@ -701,7 +701,7 @@ func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {
701701 // Publish the attachment and start firstPaint while global routing still
702702 // owns its gate. firstPaint must wait for the queue, then drain it.
703703 sess.mu.Lock()
704- sess.client = ac
704+ sess.registerAttachmentLocked(ac)
705705 sess.mu.Unlock()
706706 paintDone := make(chan struct{})
707707 go func() {
@@ -744,7 +744,7 @@ func TestNotifyGlobalSerializesDetachWithDelivery(t *testing.T) {
744744 toasts, _ := visibleToasts(ac)
745745 require.Len(t, toasts, 1)
746746 sess.mu.Lock()
747- require.Nil(t, sess.client)
747+ require.Empty(t, sess.snapshotAttachmentsLocked())
748748 sess.mu.Unlock()
749749 }
750750
@@ -782,14 +782,14 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
782782 newToasts, _ := visibleToasts(current)
783783 require.Empty(t, newToasts)
784784 sess.mu.Lock()
785- require.Same(t, current, sess.client)
785+ require.Contains(t, sess.snapshotAttachmentsLocked(), current)
786786 sess.mu.Unlock()
787787 }
788788
789789 func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) {
790790 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
791791 sess.mu.Lock()
792- sess.client = nil
792+ clearAttachmentsForTestLocked(sess)
793793 sess.mu.Unlock()
794794
795795 d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil)
@@ -797,7 +797,7 @@ func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.
797797 d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)
798798
799799 sess.mu.Lock()
800- sess.client = ac
800+ sess.registerAttachmentLocked(ac)
801801 sess.mu.Unlock()
802802 d.drainPendingForFirstPaint(sess, ac)
803803
@@ -813,7 +813,7 @@ func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.
813813 func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
814814 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
815815 sess.mu.Lock()
816- sess.client = nil
816+ clearAttachmentsForTestLocked(sess)
817817 sess.mu.Unlock()
818818
819819 d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil)
@@ -824,7 +824,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
824824
825825 // Re-attach and let firstPaint drain the pending queue.
826826 sess.mu.Lock()
827- sess.client = ac
827+ sess.registerAttachmentLocked(ac)
828828 sess.mu.Unlock()
829829 d.firstPaint(sess, ac, domain.Size{})
830830
@@ -841,7 +841,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
841841 func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
842842 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
843843 sess.mu.Lock()
844- sess.client = nil
844+ clearAttachmentsForTestLocked(sess)
845845 sess.mu.Unlock()
846846
847847 cause := errors.New("open store.db: permission denied")
@@ -854,7 +854,7 @@ func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
854854
855855 // Re-attach and let firstPaint drain the pending queue.
856856 sess.mu.Lock()
857- sess.client = ac
857+ sess.registerAttachmentLocked(ac)
858858 sess.mu.Unlock()
859859 d.firstPaint(sess, ac, domain.Size{})
860860
@@ -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, wcancel380 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] = sess384 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] = sess2527 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 = nil609+ 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 = clientB631+ 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 = oldA641+ 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 = secondA655+ 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] = sess2670 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 = nil685+ 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 still701 // 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 = ac704+ 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 = nil792+ 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 = ac800+ 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 = nil816+ 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 = ac827+ 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 = nil844+ 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 = ac857+ 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) {
9494 p2, release2 := newBlockingPTY(t)
9595 d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2)
9696 sess.mu.Lock()
97- sess.client = ac
97+ sess.registerAttachmentLocked(ac)
9898 sess.mu.Unlock()
9999 d.ptys = newBlockingOpenFactory(t, d)
100100 defer release1()
@@ -317,7 +317,7 @@ func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *t
317317 d.handleInput(current, ac, []byte("recent\r"))
318318
319319 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")
321321 require.False(t, ac.overlays.paletteActive())
322322 require.Empty(t, d.paletteRecent, "session selections are not command recency")
323323 awaitFrame(t, sends, ports.MsgOutput)
@@ -340,7 +340,7 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te
340340
341341 require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment")
342342 current.mu.Lock()
343- require.Same(t, ac, current.client)
343+ require.Contains(t, current.snapshotAttachmentsLocked(), ac)
344344 current.mu.Unlock()
345345 require.True(t, ac.overlays.paletteActive())
346346 ac.overlays.paletteMu.Lock()
@@ -517,7 +517,7 @@ func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) {
517517 // no-op BSK command succeeds, but its post-execution beginRoleEffect is
518518 // deterministically denied.
519519 sess.mu.Lock()
520- sess.client = nil
520+ clearAttachmentsForTestLocked(sess)
521521 sess.mu.Unlock()
522522
523523 d.handlePaletteInput(ac, []byte("\r"))
@@ -685,13 +685,13 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
685685 require.NoError(t, err)
686686 require.False(t, ac.overlays.promptActive())
687687 require.Equal(t, 2, sessionCount(d))
688- require.Nil(t, sess.client)
688+ require.Empty(t, sess.snapshotAttachments())
689689 newSess := ac.currentSession()
690690 require.NotNil(t, newSess)
691691 require.NotSame(t, sess, newSess)
692692 require.Equal(t, "scratch", newSess.name)
693693 require.False(t, newSess.ephemeral)
694- require.Same(t, ac, newSess.client)
694+ require.Contains(t, newSess.snapshotAttachments(), ac)
695695 require.Equal(t, attachmentActive, newSess.attachmentRole(ac))
696696 require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")
697697 require.Contains(t, string(finalOutput.Data), "scratch")
@@ -701,7 +701,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
701701 func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
702702 d, sess, ac, sends, releases := newManualTabSession(t, 2)
703703 sess.mu.Lock()
704- sess.client = ac
704+ sess.registerAttachmentLocked(ac)
705705 sess.mu.Unlock()
706706 defer releases[0]()
707707 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 = ac97+ 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 is517 // 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 = nil520+ 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 = ac704+ 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) {
7979 source.snapEligible.Store(true)
8080
8181 destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
82+ destinationAttachment := &attachedClient{}
8283 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{}}
8987 destination.snapEligible.Store(true)
90- destination.client.initOverlays()
91- destination.client.setSession(destination)
88+ destinationAttachment.initOverlays()
89+ destinationAttachment.setSession(destination)
9290 d.sessions[destination.id] = destination
9391
9492 sourceInvalidations := make(chan renderInvalidation, 2)
9593 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)
9796 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)
9998 destinationCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { destinationInvalidations <- invalidation }
10099
101100 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] = destination90 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) {
185185 ac := &attachedClient{}
186186 ac.setSession(h.session)
187187 h.session.mu.Lock()
188- h.session.client = ac
188+ h.session.registerAttachmentLocked(ac)
189189 h.session.mu.Unlock()
190190 invalidations := make(chan renderInvalidation, 2)
191191 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -270,7 +270,7 @@ func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) {
270270 ac := &attachedClient{}
271271 ac.setSession(h.session)
272272 h.session.mu.Lock()
273- h.session.client = ac
273+ h.session.registerAttachmentLocked(ac)
274274 h.session.mu.Unlock()
275275 invalidations := make(chan renderInvalidation, 2)
276276 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -406,7 +406,7 @@ func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
406406 ac := &attachedClient{}
407407 ac.setSession(h.session)
408408 h.session.mu.Lock()
409- h.session.client = ac
409+ h.session.registerAttachmentLocked(ac)
410410 h.session.mu.Unlock()
411411 invalidations := make(chan renderInvalidation, 2)
412412 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 = ac188+ 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 = ac273+ 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 = ac409+ 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 {
2727 size := domain.Size{Cols: 80, Rows: 23}
2828 sourceTab := newTab(newQuietPTY(), size)
2929 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}
3232 d := &Daemon{sessions: map[domain.SessionID]attachmentSession{source.id: source, destination.id: destination}}
3333 p := sourceTab.focusedPane()
3434 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) {
200200 d, sess, ac, sends, releases := newManualTabSession(t, 2)
201201 defer releaseAll(releases)
202202 sess.mu.Lock()
203- sess.client = ac
203+ sess.registerAttachmentLocked(ac)
204204 secondTabID := domain.TabStableID(sess.tabs[1].stableID)
205205 sess.mu.Unlock()
206206 sess.mruAt.Store(1)
@@ -601,7 +601,7 @@ func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) {
601601 defer release1()
602602 defer release2()
603603 d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2)
604- current.client = &attachedClient{}
604+ current.registerAttachment(&attachedClient{})
605605
606606 require.Error(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1}))
607607 require.Equal(t, 0, current.active)
@@ -829,7 +829,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
829829 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
830830 d, sess, ac, sends, releases := newManualTabSession(t, 2)
831831 sess.mu.Lock()
832- sess.client = ac
832+ sess.registerAttachmentLocked(ac)
833833 sess.mu.Unlock()
834834 d.ptys = newBlockingOpenFactory(t, d)
835835 defer func() {
@@ -962,8 +962,8 @@ func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) {
962962 sctx2, cancel2 := context.WithCancel(d.serveCtx)
963963 defer cancel1()
964964 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)}}
967967 ac1.setSession(sess1)
968968 ac2.setSession(sess2)
969969 ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil)
@@ -978,8 +978,8 @@ func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) {
978978 d.handleInput(sess1, ac1, []byte("\r"))
979979
980980 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())
983983 require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")
984984
985985 d.attachmentCleanupWg.Wait()
@@ -1016,8 +1016,8 @@ func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
10161016 sctx2, cancel2 := context.WithCancel(d.serveCtx)
10171017 defer cancel1()
10181018 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)}}
10211021 ac1.setSession(sess1)
10221022 ac2.setSession(sess2)
10231023 d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2
@@ -1051,8 +1051,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
10511051 sctx2, cancel2 := context.WithCancel(d.serveCtx)
10521052 defer cancel1()
10531053 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)}}
10561056 ac1.setSession(sess1)
10571057 ac2.setSession(sess2)
10581058 d.sessions[sess1.id] = sess1
@@ -1102,7 +1102,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
11021102 sctx2, cancel2 := context.WithCancel(d.serveCtx)
11031103 defer cancel1()
11041104 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)}}
11061106 sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
11071107 ac.setSession(sess1)
11081108 d.sessions[sess1.id] = sess1
@@ -1182,8 +1182,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
11821182 sctx2, cancel2 := context.WithCancel(d.serveCtx)
11831183 defer cancel1()
11841184 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"}}
11871187 ac1.setSession(sess1)
11881188 ac2.setSession(sess2)
11891189 d.sessions[sess1.id] = sess1
@@ -1700,7 +1700,7 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T)
17001700 target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
17011701 d.sessions[target.id] = target
17021702 current.mu.Lock()
1703- current.client = ac
1703+ current.registerAttachmentLocked(ac)
17041704 current.mu.Unlock()
17051705 require.NotNil(t, d.attachCoordinator(current, nil, ac, true))
17061706
@@ -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 = ac203+ 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 = ac832+ 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, sess21023 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] = sess11058 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] = sess11108 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] = sess11189 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] = target1701 d.sessions[target.id] = target
1702 current.mu.Lock()1702 current.mu.Lock()
1703- current.client = ac1703+ 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) {
838838 client := newProxyTestTransport()
839839 ac := &attachedClient{}
840840 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: {}}}}
843843 ac.setSession(first)
844844
845845 const unstableSnapshots = 100
@@ -866,14 +866,14 @@ func TestConnectionLoopStopsWhenReceiveErrorCleanupMakesNoProgress(t *testing.T)
866866 client := newProxyTestTransport()
867867 ac := &attachedClient{}
868868 ac.replaceTransport(client)
869- local := &session{sessionCore: sessionCore{client: ac}}
869+ local := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
870870 ac.setSession(local)
871871
872872 cleanupAttempts := 0
873873 d.beforeClientGoneDetach = func() {
874874 cleanupAttempts++
875875 local.mu.Lock()
876- local.client = &attachedClient{}
876+ local.registerAttachmentLocked(&attachedClient{})
877877 local.mu.Unlock()
878878 }
879879 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 = 100845 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 := 0872 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 := 0879 snapshots := 0
M internal/usecase/daemon/proxy_lifecycle_test.go +7 -5
@@ -255,7 +255,7 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
255255
256256 ac := &attachedClient{}
257257 proxy.sessionCore.mu.Lock()
258- proxy.client = ac
258+ proxy.sessionCore.registerAttachmentLocked(ac)
259259 proxy.sessionCore.mu.Unlock()
260260 d.cancelProxyWarmForAttachment(proxy, ac)
261261 awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete")
@@ -266,7 +266,9 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
266266 d.mu.Unlock()
267267
268268 proxy.sessionCore.mu.Lock()
269- proxy.client = nil
269+ for _, attachment := range proxy.sessionCore.snapshotAttachmentsLocked() {
270+ proxy.sessionCore.unregisterAttachmentLocked(attachment)
271+ }
270272 proxy.sessionCore.mu.Unlock()
271273 d.armProxyWarm(proxy)
272274 secondTimer := awaitTestValue(t, clock.timers, "second proxy timer was not registered")
@@ -471,7 +473,7 @@ func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) {
471473 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true}
472474 old.initOverlays()
473475 old.setSession(sess)
474- sess.client = old
476+ sess.registerAttachment(old)
475477 d.mu.Lock()
476478 require.True(t, d.registerSessionLocked(sess))
477479 d.mu.Unlock()
@@ -487,7 +489,7 @@ func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) {
487489 })
488490 require.NoError(t, err)
489491 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)
491493 require.Nil(t, old.currentAttachmentSession())
492494
493495 d.deferAttachmentTransitionCleanups(result)
@@ -1322,7 +1324,7 @@ func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) {
13221324
13231325 func setProxyLifecycleClient(p *proxySession, ac *attachedClient) {
13241326 p.sessionCore.mu.Lock()
1325- p.client = ac
1327+ p.sessionCore.registerAttachmentLocked(ac)
13261328 p.sessionCore.mu.Unlock()
13271329 }
13281330
@@ -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 = ac258+ 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 = nil269+ 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 = old476+ 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 = ac1327+ 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) {
9191 ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
9292 ac.setSession(proxy)
9393 proxy.sessionCore.mu.Lock()
94- proxy.client = ac
94+ proxy.sessionCore.registerAttachmentLocked(ac)
9595 proxy.sessionCore.mu.Unlock()
9696
9797 d.paint(proxy, ac, true, nil)
@@ -296,7 +296,7 @@ func newAttachedProxyFixture(t *testing.T, d *Daemon, clientTr ports.Transport,
296296 ac := &attachedClient{tr: clientTr, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
297297 ac.setSession(proxy)
298298 proxy.sessionCore.mu.Lock()
299- proxy.client = ac
299+ proxy.sessionCore.registerAttachmentLocked(ac)
300300 proxy.sessionCore.mu.Unlock()
301301 return proxy, ac
302302 }
@@ -394,9 +394,9 @@ func TestProxySendErrorDetachesProxyAttachment(t *testing.T) {
394394 d.paint(proxy, ac, true, nil)
395395
396396 proxy.sessionCore.mu.Lock()
397- client := proxy.client
397+ attachments := proxy.sessionCore.snapshotAttachmentsLocked()
398398 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")
400400 require.Nil(t, ac.currentAttachmentSession(), "the client must no longer point at the proxy")
401401 }
402402
@@ -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 = ac94+ 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 = ac299+ proxy.sessionCore.registerAttachmentLocked(ac)
300 proxy.sessionCore.mu.Unlock()300 proxy.sessionCore.mu.Unlock()
301 return proxy, ac301 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.client397+ 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) (
539539 ac.initOverlays()
540540 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
541541 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}
543543 publishTiledPaneOwners(sess, tb)
544544 ac.setSession(sess)
545545 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] = sess545 d.sessions[id] = sess
M internal/usecase/daemon/render_coordinator_test.go +3 -3
@@ -1306,7 +1306,7 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal
13061306 clock: clock.clock,
13071307 wake: func(w renderWake) {
13081308 // 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].
13101310 d.paint(sess, w.lease.attachment, w.reset, w.lease)
13111311 },
13121312 ackReady: func() bool { return true },
@@ -1316,10 +1316,10 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal
13161316 rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
13171317 timer := awaitCoordinatorScheduledTimer(t, clock)
13181318
1319- // Model attachClient's publication window exactly: sess.client is new,
1319+ // Model attachClient's publication window exactly: sess.snapshotAttachments()[0] is new,
13201320 // but coordinator replacement has not yet invalidated the old deadline.
13211321 sess.mu.Lock()
1322- sess.client = replacement
1322+ sess.registerAttachmentLocked(replacement)
13231323 sess.mu.Unlock()
13241324 timer.ch <- time.Time{}
13251325 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 use1308 // 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 = replacement1322+ 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
9595 fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
9696 restore: func(sess *session, ac *attachedClient, healthy ports.Transport) {
9797 sess.mu.Lock()
98- sess.client = ac
98+ sess.registerAttachmentLocked(ac)
9999 sess.mu.Unlock()
100100 ac.setSession(sess)
101101 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 = ac98+ 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) {
5757 // its original healthy transport, then retry the pending state.
5858 if failure.name == "send" {
5959 sess.mu.Lock()
60- sess.client = ac
60+ sess.registerAttachmentLocked(ac)
6161 sess.mu.Unlock()
6262 ac.setSession(sess)
6363 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 = ac60+ 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) {
127127 pty, release := newBlockingPTY(t)
128128 t.Cleanup(release)
129129 d, sess, _, sends := newManualSessionWithPTYs(t, pty)
130- ac := sess.client
130+ ac := sess.snapshotAttachments()[0]
131131 ac.sendMu.Lock()
132132 ac.output.next = ac.output.maxOutstanding
133133 ac.output.acked = 0
@@ -240,7 +240,7 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
240240 require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow")
241241
242242 sess.mu.Lock()
243- sess.client = ac
243+ sess.registerAttachmentLocked(ac)
244244 sess.mu.Unlock()
245245 ac.setSession(sess)
246246 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.client130+ ac := sess.snapshotAttachments()[0]
131 ac.sendMu.Lock()131 ac.sendMu.Lock()
132 ac.output.next = ac.output.maxOutstanding132 ac.output.next = ac.output.maxOutstanding
133 ac.output.acked = 0133 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 = ac243+ 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
491491 viewer.overlays.pickerMu.Lock()
492492 viewer.overlays.pickerPreview = target.tabs[0]
493493 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: {}}}}
495495 previews := make(chan renderWake, 2)
496496 rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w })
497497
@@ -502,7 +502,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
502502 drainCoordinatorTimers(clock) // detach must clear the pending output work.
503503
504504 target.mu.Lock()
505- target.client = nil
505+ clearAttachmentsForTestLocked(target)
506506 target.mu.Unlock()
507507 rc.noteDetach(targetClient)
508508 steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
@@ -540,7 +540,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
540540 replacement := &attachedClient{output: newOutputStateStream()}
541541 rc.noteReplace(oldClient, replacement, true)
542542 target.mu.Lock()
543- target.client = replacement
543+ target.registerAttachmentLocked(replacement)
544544 target.mu.Unlock()
545545 // The replacement's initial full paint remains gated by the batch. Its
546546 // reset must survive completion and coalesce with the completion wake.
@@ -575,7 +575,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
575575 wakes := make(chan renderWake, 1)
576576 rc.opts.wake = func(w renderWake) { wakes <- w }
577577 target.mu.Lock()
578- target.client = nil
578+ clearAttachmentsForTestLocked(target)
579579 target.mu.Unlock()
580580 rc.noteDetach(client)
581581
@@ -628,7 +628,7 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
628628 name string
629629 setup func(*Daemon, *session, *tab, *pane)
630630 }{
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) }},
632632 {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) {
633633 other := newTab(nil, domain.Size{Cols: 80, Rows: 23})
634634 sess.tabs = append([]*tab{other}, sess.tabs...)
@@ -667,13 +667,13 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
667667 require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition")
668668 p.screen.ClearDamage()
669669
670- sess.client = nil
670+ clearAttachmentsForTest(sess)
671671 viewer := &attachedClient{}
672672 viewer.initOverlays()
673673 viewer.overlays.pickerMu.Lock()
674674 viewer.overlays.pickerPreview = tb
675675 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: {}}}}
677677 p.screen.Write([]byte("preview"))
678678 _ = d.paneRenderable(sess, tb, p)
679679 require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")
@@ -802,7 +802,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
802802 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
803803 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
804804 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}}
806806 ac.setSession(sess)
807807
808808 // Client rows are one more than the equivalent case in a single-bar
@@ -1062,7 +1062,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
10621062 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
10631063 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
10641064 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}}
10661066 ac.setSession(sess)
10671067
10681068 d.resize(sess, ac, newSize)
@@ -1091,7 +1091,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
10911091 sctx, cancel := context.WithCancel(context.Background())
10921092 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
10931093 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}
10951095 ac.setSession(sess)
10961096 d.sessions[sess.id] = sess
10971097
@@ -1103,7 +1103,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
11031103
11041104 require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")
11051105 sess.mu.Lock()
1106- require.Nil(t, sess.client)
1106+ require.Empty(t, sess.snapshotAttachmentsLocked())
11071107 sess.mu.Unlock()
11081108
11091109 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -1135,7 +1135,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
11351135 win := newTestTabWithContext(p, sctx, cancel)
11361136 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
11371137 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}
11391139 ac.setSession(sess)
11401140 d.sessions[sess.id] = sess
11411141 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 = nil505+ 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 = replacement543+ target.registerAttachmentLocked(replacement)
544 target.mu.Unlock()544 target.mu.Unlock()
545 // The replacement's initial full paint remains gated by the batch. Its545 // 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 = nil578+ 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 string628 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 = nil670+ 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 = tb674 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-bar808 // 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] = sess1096 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] = sess1140 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) {
110110
111111 d.clientGone(sess, ac, ac.transport(), false)
112112 require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")
113- require.Nil(t, sess.client)
113+ require.Empty(t, sess.snapshotAttachments())
114114 d.mu.Lock()
115115 _, parked := d.parked[token]
116116 d.mu.Unlock()
@@ -214,7 +214,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
214214 welcome := <-tr.sends
215215 require.Equal(t, ports.MsgWelcome, welcome.Type)
216216 sess.mu.Lock()
217- resumed := sess.client
217+ resumed := sess.snapshotAttachmentsLocked()[0]
218218 sess.mu.Unlock()
219219 require.Same(t, ac, resumed)
220220
@@ -253,7 +253,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
253253
254254 d.clientGone(sess, ac, ac.transport(), false)
255255 require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive")
256- require.Nil(t, sess.client)
256+ require.Empty(t, sess.snapshotAttachments())
257257 d.mu.Lock()
258258 _, parked := d.parked[token]
259259 d.mu.Unlock()
@@ -265,7 +265,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
265265 require.Same(t, sess, resumedSess)
266266 require.Same(t, ac, resumedAC)
267267 require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")
268- require.Same(t, resumedAC, sess.client)
268+ require.Contains(t, sess.snapshotAttachments(), resumedAC)
269269 }
270270
271271 // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race
@@ -283,7 +283,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
283283 token := ac.resumeToken
284284 require.NotZero(t, token)
285285 d.clientGone(sess, ac, oldTr, false)
286- require.Nil(t, sess.client)
286+ require.Empty(t, sess.snapshotAttachments())
287287 d.mu.Lock()
288288 parked := d.parked[token]
289289 sessionsBefore := len(d.sessions)
@@ -329,7 +329,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
329329 require.ErrorAs(t, got.err, &pe)
330330 require.Equal(t, ports.ErrNoSuchSession, pe.code)
331331 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")
333333 require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake")
334334 d.mu.Lock()
335335 _, stillParked := d.parked[token]
@@ -354,7 +354,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
354354 require.NoError(t, err)
355355 token := ac.resumeToken
356356 require.NotZero(t, token)
357- require.Same(t, ac, sess.client)
357+ require.Contains(t, sess.snapshotAttachments(), ac)
358358 d.mu.Lock()
359359 _, parkedAtStart := d.parked[token]
360360 sessionsBefore := len(d.sessions)
@@ -403,7 +403,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
403403 require.ErrorAs(t, got.err, &pe)
404404 require.Equal(t, ports.ErrNoSuchSession, pe.code)
405405 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")
407407 require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake")
408408 d.mu.Lock()
409409 _, stillParked := d.parked[token]
@@ -427,7 +427,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
427427 require.NoError(t, err)
428428 token := ac.resumeToken
429429 require.NotZero(t, token)
430- require.Same(t, ac, sess.client)
430+ require.Contains(t, sess.snapshotAttachments(), ac)
431431
432432 reachedGap := make(chan struct{})
433433 releaseGap := make(chan struct{})
@@ -445,7 +445,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
445445 }()
446446 awaitTestCompletion(t, reachedGap, "teardown did not pause after detach before park")
447447
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")
449449 d.mu.Lock()
450450 parkingInGap := d.parking[token]
451451 _, parkedInGap := d.parked[token]
@@ -493,9 +493,10 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
493493 require.NoError(t, got.err, "same-client token must recover across detach-before-park")
494494 require.Same(t, sess, got.sess)
495495 require.Same(t, ac, got.ac)
496- require.Same(t, ac, sess.client)
496+ require.Contains(t, sess.snapshotAttachments(), ac)
497497 require.Same(t, newTr, ac.transport())
498498 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")
499500 require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport")
500501 require.False(t, newTr.Closed(), "rebound transport must survive")
501502 d.mu.Lock()
@@ -506,7 +507,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
506507 require.False(t, stillParking, "parking marker must be consumed after park/resume")
507508
508509 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")
510511 require.Same(t, newTr, ac.transport())
511512 require.False(t, newTr.Closed())
512513 }
@@ -525,7 +526,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
525526 require.NoError(t, err)
526527 token := ac.resumeToken
527528 require.NotZero(t, token)
528- require.Same(t, ac, sess.client)
529+ require.Contains(t, sess.snapshotAttachments(), ac)
529530
530531 reachedGap := make(chan struct{})
531532 releaseGap := make(chan struct{})
@@ -549,7 +550,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
549550 }()
550551 awaitTestCompletion(t, reachedGap, "first live resume did not pause after detach before park")
551552
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")
553554 d.mu.Lock()
554555 parkingInGap := d.parking[token]
555556 _, parkedInGap := d.parked[token]
@@ -595,7 +596,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
595596
596597 require.Same(t, sess, winner.sess)
597598 require.Same(t, ac, winner.ac)
598- require.Same(t, ac, sess.client)
599+ require.Contains(t, sess.snapshotAttachments(), ac)
599600 require.Same(t, winnerTr, ac.transport())
600601 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
601602 require.True(t, oldTr.Closed(), "winning resume must retire the old transport")
@@ -615,7 +616,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
615616 require.False(t, stillParking, "parking marker must be consumed after park/resume")
616617
617618 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")
619620 require.Same(t, winnerTr, ac.transport())
620621 require.False(t, winnerTr.Closed())
621622 }
@@ -661,7 +662,7 @@ func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
661662 awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned marker")
662663
663664 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")
665666
666667 waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after explicit detach")
667668 require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared")
@@ -720,7 +721,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
720721 win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) {
721722 t.Helper()
722723 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")
724725 },
725726 },
726727 {
@@ -746,7 +747,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
746747 require.NoError(t, err)
747748 token := ac.resumeToken
748749 require.NotZero(t, token)
749- require.Same(t, ac, sess.client)
750+ require.Contains(t, sess.snapshotAttachments(), ac)
750751
751752 validated := make(chan struct{})
752753 releaseMark := make(chan struct{})
@@ -803,7 +804,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
803804 require.NotZero(t, token)
804805
805806 require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot()))
806- require.Nil(t, sess.client)
807+ require.Empty(t, sess.snapshotAttachments())
807808 require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker")
808809
809810 d.mu.Lock()
@@ -862,7 +863,7 @@ func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *tes
862863 releaseOnce.Do(func() { close(releaseDetach) })
863864 awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound")
864865
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")
866867 require.Same(t, newTr, ac.transport())
867868 require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup")
868869 require.True(t, oldTr.Closed(), "live resume retires the captured old transport")
@@ -893,7 +894,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) {
893894 var pe *protoErr
894895 require.ErrorAs(t, err, &pe)
895896 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")
897898 require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport")
898899 require.True(t, oldTr.Closed(), "failed live park must close the captured old transport")
899900 d.mu.Lock()
@@ -919,7 +920,7 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
919920 require.Same(t, sess, resumedSess)
920921 require.Same(t, ac, resumedAC)
921922 require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token")
922- require.Same(t, resumedAC, sess.client)
923+ require.Contains(t, sess.snapshotAttachments(), resumedAC)
923924 }
924925
925926 func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
@@ -1026,8 +1027,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
10261027 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
10271028 require.NoError(t, err)
10281029 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)
10311031
10321032 sends := newTr.Sends()
10331033 require.Len(t, sends, 1)
@@ -1074,7 +1074,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
10741074 require.True(t, sess.detachIfCurrent(ac))
10751075 require.True(t, d.parkAttachment(sess, ac))
10761076
1077- // Headless close cannot find the parked attachment through sess.client.
1077+ // Headless close cannot find the parked attachment through sess.snapshotAttachments()[0].
10781078 // Its capture must already have been released before the attachment parked.
10791079 require.NoError(t, d.closePane(sess, tb, closed.id, nil, false))
10801080 ac.sendMu.Lock()
@@ -1270,7 +1270,7 @@ func TestEphemeralParkExpiryKeepsSession(t *testing.T) {
12701270
12711271 require.Equal(t, 1, sessionCount(d), "token expiry does not kill ephemeral session")
12721272 sess.mu.Lock()
1273- require.Nil(t, sess.client)
1273+ require.Empty(t, sess.snapshotAttachmentsLocked())
12741274 sess.mu.Unlock()
12751275 }
12761276
@@ -1377,7 +1377,7 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) {
13771377
13781378 d.clientGone(sess, ac, oldTr, false)
13791379
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")
13811381 require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler")
13821382 require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop")
13831383 }
@@ -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.sends214 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.client217+ 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 race271 // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race
@@ -283,7 +283,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
283 token := ac.resumeToken283 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.resumeToken355 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.resumeToken428 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.resumeToken527 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.resumeToken748 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 *protoErr894 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
161161 later.initOverlays()
162162 later.setSession(sess)
163163 sess.mu.Lock()
164- sess.addSnatchedLocked(blocked)
165- sess.addSnatchedLocked(later)
164+ sess.registerAttachmentLocked(blocked)
165+ sess.registerAttachmentLocked(later)
166166 sess.mu.Unlock()
167167
168168 rc := d.attachCoordinator(sess, nil, first, true)
@@ -226,9 +226,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
226226 require.Same(t, sess, d.sessions[sess.id], "partial gate acquisition removed the session registry owner")
227227 d.mu.Unlock()
228228 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)
232232 sess.mu.Unlock()
233233 for _, ac := range []*attachedClient{first, blocked, later} {
234234 require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership")
@@ -287,7 +287,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
287287 unrelatedTransport := &closeTrackingTransport{}
288288 unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size}
289289 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() {}}
291291 unrelatedClient.setSession(unrelated)
292292 d.mu.Lock()
293293 d.sessions[unrelated.id] = unrelated
@@ -318,7 +318,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
318318 require.True(t, snatchedTransport.Closed())
319319 require.True(t, activeTransport.Closed())
320320 require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted")
321- require.Same(t, unrelatedClient, unrelated.client)
321+ require.Contains(t, unrelated.snapshotAttachments(), unrelatedClient)
322322 require.Same(t, unrelated, unrelatedClient.currentSession())
323323 requireRoleGateRetired(t, snatched)
324324 requireRoleGateRetired(t, active)
@@ -348,7 +348,7 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
348348 require.Same(t, sess, d.sessions[sess.id], "transport incarnation change should abort stale teardown publication")
349349 d.mu.Unlock()
350350 sess.mu.Lock()
351- require.Same(t, ac, sess.client)
351+ require.Contains(t, sess.snapshotAttachmentsLocked(), ac)
352352 sess.mu.Unlock()
353353 requireRoleGateRetired(t, ac)
354354 }
@@ -482,7 +482,7 @@ func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing
482482 awaitTestCompletion(t, cleanupTracked, "tracked render failure cleanup did not retire")
483483
484484 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")
486486 sess.mu.Unlock()
487487 require.Equal(t, resumedGeneration, original.roleGeneration.Load())
488488 require.True(t, resumed.published.activeCurrent())
@@ -518,7 +518,7 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing
518518 awaitTestCompletion(t, waitGroupDone(&d.attachmentCleanupWg), "bounded render cleanup leaked its tracked goroutine")
519519
520520 sess.mu.Lock()
521- require.Same(t, ac, sess.client)
521+ require.Contains(t, sess.snapshotAttachmentsLocked(), ac)
522522 sess.mu.Unlock()
523523 require.False(t, transport.Closed())
524524 }
@@ -567,13 +567,13 @@ func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t
567567 case <-time.After(time.Second):
568568 t.Fatal("closing the exact old transport did not unblock its render")
569569 }
570- require.Same(t, next, sess.client)
570+ require.Contains(t, sess.snapshotAttachments(), next)
571571 require.False(t, newTransport.Closed(), "replacement transport was affected by old-link interruption")
572572
573573 d.deferAttachmentTransitionCleanups(result)
574574 d.attachmentCleanupWg.Wait()
575575 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)
577577 require.False(t, newTransport.Closed())
578578 }
579579
@@ -856,9 +856,9 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
856856
857857 require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus")
858858 target.mu.Lock()
859- targetClient := target.client
859+ targetAttachments := target.snapshotAttachmentsLocked()
860860 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")
862862 d.deferAttachmentTransitionCleanups(result)
863863 }
864864
@@ -1123,7 +1123,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T)
11231123 bTransport := newDatagramTestTransport()
11241124 b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
11251125 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: {}}}}
11271127 b.setSession(second)
11281128 d.mu.Lock()
11291129 d.sessions[second.id] = second
@@ -1181,7 +1181,7 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
11811181 b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
11821182 b.initOverlays()
11831183 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() {},
11851185 }
11861186 b.setSession(target)
11871187 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] = unrelated293 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.client859+ 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] = second1129 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 {
3434 attachments map[*attachedClient]struct{}
3535 attachmentOrder map[*attachedClient]uint64
3636 nextAttachmentID uint64
37- client *attachedClient
38- snatched map[*attachedClient]struct{}
3937 createdAt int64
4038 incarnation domain.IncarnationID
4139 mruAt atomic.Uint64
@@ -34,8 +34,6 @@ type sessionCore struct {
34 attachments map[*attachedClient]struct{}34 attachments map[*attachedClient]struct{}
35 attachmentOrder map[*attachedClient]uint6435 attachmentOrder map[*attachedClient]uint64
36 nextAttachmentID uint6436 nextAttachmentID uint64
37- client *attachedClient
38- snatched map[*attachedClient]struct{}
39 createdAt int6437 createdAt int64
40 incarnation domain.IncarnationID38 incarnation domain.IncarnationID
41 mruAt atomic.Uint6439 mruAt atomic.Uint64
M internal/usecase/daemon/session_core_test.go +14 -2
@@ -9,6 +9,18 @@ import (
99 "github.com/stretchr/testify/require"
1010 )
1111
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+
1224 func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
1325 sess := &session{}
1426 sess.id = domain.SessionID("local")
@@ -24,7 +36,7 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
2436
2537 core.mu.Lock()
2638 require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu")
27- core.client = ac
39+ core.registerAttachmentLocked(ac)
2840 require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))
2941 sess.tabs = append(sess.tabs, &tab{name: "logs"})
3042 require.Len(t, sess.tabs, 2)
@@ -47,7 +59,7 @@ func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.
4759 sess.core().mu.Lock()
4860 sess.tabs = append(sess.tabs, &tab{name: "logs"})
4961 sess.active = 1
50- sess.client = ac
62+ sess.registerAttachmentLocked(ac)
5163
5264 preCall := make(chan struct{})
5365 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 = ac39+ 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 = 161 sess.active = 1
50- sess.client = ac62+ 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) {
283283
284284 // --- attach replace ---------------------------------------------------------
285285
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-
333286 // --- detach semantics -------------------------------------------------------
334287
335288 func TestDetachKeepsEphemeralHeadless(t *testing.T) {
@@ -351,7 +304,7 @@ func TestDetachKeepsEphemeralHeadless(t *testing.T) {
351304 require.NotNil(t, sess)
352305 sess.mu.Lock()
353306 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")
355308 sess.mu.Unlock()
356309
357310 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -398,7 +351,7 @@ func TestDetachKeepsNamed(t *testing.T) {
398351 require.Equal(t, 1, sessionCount(d), "named session survives detach")
399352 sess := firstSession(d)
400353 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")
402355 sess.mu.Unlock()
403356
404357 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -1038,7 +991,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
1038991 func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) {
1039992 d, sess, ac, _, releases := newManualTabSession(t, 2)
1040993 sess.mu.Lock()
1041- sess.client = ac
994+ sess.registerAttachmentLocked(ac)
1042995 sess.mu.Unlock()
1043996 d.ptys = newBlockingOpenFactory(t, d)
1044997 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 = ac994+ 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) {
2222 name: "alpha",
2323 createdAt: 42,
2424
25- client: &attachedClient{}}, active: 1,
25+ attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}}, active: 1,
2626
2727 tabs: []*tab{
2828 {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"},

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close