detached 707a1cd

test(daemon): verify snatched client lifecycle

commit
707a1cd91adf75dbf36c545e50ad0657818ad24f
author
brice · · 2026-07-28 01:12 UTC
parent
3de5569
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +1392 -1036
M README.md +2 -0
@@ -38,6 +38,8 @@ vev kill <name> kill a session (--all kills everything)
3838
3939 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.
4040
41+Each session has one active client. Attaching another client shows `Session snatched` on the prior client; press `r` to resume control, or `q`/Esc to quit that attachment. A remote snatched client that reconnects stays snatched and does not take control automatically.
42+
4143 ## Keys
4244
4345 No prefix key; everything is Alt.
@@ -38,6 +38,8 @@ vev kill <name> kill a session (--all kills everything)
38 38
39 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.39 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.
40 40
41+Each session has one active client. Attaching another client shows `Session snatched` on the prior client; press `r` to resume control, or `q`/Esc to quit that attachment. A remote snatched client that reconnects stays snatched and does not take control automatically.
42+
41 ## Keys43 ## Keys
42 44
43 No prefix key; everything is Alt.45 No prefix key; everything is Alt.
M internal/usecase/client/reconnect_toast_test.go +4 -2
@@ -1118,14 +1118,16 @@ func TestAttachAttemptReturnsWhileSenderSendIsBlockedAfterCancellation(t *testin
11181118 releaseSender()
11191119 }
11201120
1121-func TestDetachedResultReplacementAndCleanDetach(t *testing.T) {
1121+func TestDetachedResultLegacyReplacementCompatibilityAndCleanDetach(t *testing.T) {
1122+ // Current daemons keep healthy displaced clients connected. Continue decoding
1123+ // ReasonReplaced from older peers so mixed-version detach handling stays clear.
11221124 for _, tt := range []struct {
11231125 name string
11241126 reason uint8
11251127 wantText string
11261128 }{
11271129 {name: "clean detach", reason: ports.ReasonDetach},
1128- {name: "replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"},
1130+ {name: "legacy replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"},
11291131 } {
11301132 t.Run(tt.name, func(t *testing.T) {
11311133 err := detachedResult(tt.reason)
@@ -1118,14 +1118,16 @@ func TestAttachAttemptReturnsWhileSenderSendIsBlockedAfterCancellation(t *testin
1118 releaseSender()1118 releaseSender()
1119 }1119 }
1120 1120
1121-func TestDetachedResultReplacementAndCleanDetach(t *testing.T) {1121+func TestDetachedResultLegacyReplacementCompatibilityAndCleanDetach(t *testing.T) {
1122+ // Current daemons keep healthy displaced clients connected. Continue decoding
1123+ // ReasonReplaced from older peers so mixed-version detach handling stays clear.
1122 for _, tt := range []struct {1124 for _, tt := range []struct {
1123 name string1125 name string
1124 reason uint81126 reason uint8
1125 wantText string1127 wantText string
1126 }{1128 }{
1127 {name: "clean detach", reason: ports.ReasonDetach},1129 {name: "clean detach", reason: ports.ReasonDetach},
1128- {name: "replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"},1130+ {name: "legacy replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"},
1129 } {1131 } {
1130 t.Run(tt.name, func(t *testing.T) {1132 t.Run(tt.name, func(t *testing.T) {
1131 err := detachedResult(tt.reason)1133 err := detachedResult(tt.reason)
A internal/usecase/daemon/attachment_role.go +165 -0
@@ -0,0 +1,165 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+type attachmentRole uint8
6+
7+const (
8+ attachmentDetached attachmentRole = iota
9+ attachmentActive
10+ attachmentSnatched
11+)
12+
13+// attachmentRole derives the attachment's role from the session-owned
14+// registries. There is deliberately no second role field that could drift.
15+func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
16+ if s == nil || ac == nil {
17+ return attachmentDetached
18+ }
19+ s.mu.Lock()
20+ defer s.mu.Unlock()
21+ return s.attachmentRoleLocked(ac)
22+}
23+
24+// attachmentRoleLocked requires s.mu. Active membership wins if an invalid
25+// intermediate state contains the same attachment in both registries.
26+func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {
27+ if ac == nil {
28+ return attachmentDetached
29+ }
30+ if s.client == ac {
31+ return attachmentActive
32+ }
33+ if _, ok := s.snatched[ac]; ok {
34+ return attachmentSnatched
35+ }
36+ return attachmentDetached
37+}
38+
39+func (s *session) addSnatchedLocked(ac *attachedClient) {
40+ if ac == nil {
41+ return
42+ }
43+ if s.snatched == nil {
44+ s.snatched = make(map[*attachedClient]struct{})
45+ }
46+ s.snatched[ac] = struct{}{}
47+}
48+
49+type attachmentRoleToken struct {
50+ sess *session
51+ ac *attachedClient
52+ role attachmentRole
53+ generation uint64
54+ transport transportSnapshot
55+ lease *attachmentLease
56+ effect *roleEffectTicket
57+ // rebase marks a cross-session ownership move whose first paint must start a
58+ // dependency-free output chain. The rebase is deliberately deferred until
59+ // after transition publication releases all architecture locks.
60+ rebase bool
61+}
62+
63+func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
64+ if s == nil || ac == nil || tr == nil {
65+ return attachmentRoleToken{}
66+ }
67+ s.mu.Lock()
68+ transport := ac.transportSnapshot()
69+ if transport.transport != tr {
70+ s.mu.Unlock()
71+ return attachmentRoleToken{}
72+ }
73+ token := attachmentRoleToken{
74+ sess: s,
75+ ac: ac,
76+ role: s.attachmentRoleLocked(ac),
77+ generation: ac.roleGeneration.Load(),
78+ transport: transport,
79+ }
80+ s.mu.Unlock()
81+ if token.role == attachmentActive {
82+ if rc := s.renderCoordinator(); rc != nil {
83+ token.lease = rc.attachmentLease(ac)
84+ }
85+ }
86+ ac.bootstrapRoleCapability(token)
87+ return token
88+}
89+
90+func (t attachmentRoleToken) current() bool {
91+ return t.sess != nil && t.ac != nil &&
92+ t.ac.roleGeneration.Load() == t.generation &&
93+ t.ac.transportSnapshotCurrent(t.transport) &&
94+ t.sess.attachmentRole(t.ac) == t.role
95+}
96+
97+// activeCurrent admits post-transition effects only for the exact published
98+// role, transport incarnation, current-session link, and coordinator lease.
99+func (t attachmentRoleToken) activeCurrent() bool {
100+ if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess {
101+ return false
102+ }
103+ rc := t.sess.renderCoordinator()
104+ return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
105+}
106+
107+// activeEffect is the canonical admission check for a client-frame mutation.
108+// Tokens bind role generation, transport incarnation, session ownership, and
109+// coordinator lease, so handlers never rediscover authority from mutable state.
110+func (t attachmentRoleToken) activeEffect() bool {
111+ if t.effect != nil && !t.effect.ended.Load() {
112+ return true
113+ }
114+ return t.activeCurrent()
115+}
116+
117+func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) {
118+ if sess == nil || ac == nil || lease == nil {
119+ return nil, false
120+ }
121+ token := sess.attachmentToken(ac, ac.transport())
122+ token.lease = lease
123+ return ac.beginRoleEffect(token)
124+}
125+
126+func (t *attachmentRoleToken) endRoleEffect() {
127+ if t == nil || t.effect == nil {
128+ return
129+ }
130+ t.effect.End()
131+ t.effect = nil
132+}
133+
134+// activeEffectSessionLocked is the same admission check while t.sess.mu is
135+// already held at a session-owned mutation boundary.
136+func (t attachmentRoleToken) activeEffectSessionLocked() bool {
137+ if t.effect != nil && !t.effect.ended.Load() {
138+ return true
139+ }
140+ if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil ||
141+ t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) ||
142+ t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive {
143+ return false
144+ }
145+ rc := t.sess.renderCoordinator()
146+ return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
147+}
148+
149+func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error {
150+ if t.ac == nil {
151+ return errAttachmentTransition
152+ }
153+ t.ac.sendMu.Lock()
154+ defer t.ac.sendMu.Unlock()
155+ if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) ||
156+ !t.effect.beginTransportSend(t.transport) {
157+ return errAttachmentTransition
158+ }
159+ err := t.transport.transport.Send(frame)
160+ if err != nil {
161+ t.effect.reportTransportFailure(t.transport)
162+ }
163+ t.effect.endTransportSend()
164+ return err
165+}
@@ -0,0 +1,165 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+type attachmentRole uint8
6+
7+const (
8+ attachmentDetached attachmentRole = iota
9+ attachmentActive
10+ attachmentSnatched
11+)
12+
13+// attachmentRole derives the attachment's role from the session-owned
14+// registries. There is deliberately no second role field that could drift.
15+func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
16+ if s == nil || ac == nil {
17+ return attachmentDetached
18+ }
19+ s.mu.Lock()
20+ defer s.mu.Unlock()
21+ return s.attachmentRoleLocked(ac)
22+}
23+
24+// attachmentRoleLocked requires s.mu. Active membership wins if an invalid
25+// intermediate state contains the same attachment in both registries.
26+func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {
27+ if ac == nil {
28+ return attachmentDetached
29+ }
30+ if s.client == ac {
31+ return attachmentActive
32+ }
33+ if _, ok := s.snatched[ac]; ok {
34+ return attachmentSnatched
35+ }
36+ return attachmentDetached
37+}
38+
39+func (s *session) addSnatchedLocked(ac *attachedClient) {
40+ if ac == nil {
41+ return
42+ }
43+ if s.snatched == nil {
44+ s.snatched = make(map[*attachedClient]struct{})
45+ }
46+ s.snatched[ac] = struct{}{}
47+}
48+
49+type attachmentRoleToken struct {
50+ sess *session
51+ ac *attachedClient
52+ role attachmentRole
53+ generation uint64
54+ transport transportSnapshot
55+ lease *attachmentLease
56+ effect *roleEffectTicket
57+ // rebase marks a cross-session ownership move whose first paint must start a
58+ // dependency-free output chain. The rebase is deliberately deferred until
59+ // after transition publication releases all architecture locks.
60+ rebase bool
61+}
62+
63+func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
64+ if s == nil || ac == nil || tr == nil {
65+ return attachmentRoleToken{}
66+ }
67+ s.mu.Lock()
68+ transport := ac.transportSnapshot()
69+ if transport.transport != tr {
70+ s.mu.Unlock()
71+ return attachmentRoleToken{}
72+ }
73+ token := attachmentRoleToken{
74+ sess: s,
75+ ac: ac,
76+ role: s.attachmentRoleLocked(ac),
77+ generation: ac.roleGeneration.Load(),
78+ transport: transport,
79+ }
80+ s.mu.Unlock()
81+ if token.role == attachmentActive {
82+ if rc := s.renderCoordinator(); rc != nil {
83+ token.lease = rc.attachmentLease(ac)
84+ }
85+ }
86+ ac.bootstrapRoleCapability(token)
87+ return token
88+}
89+
90+func (t attachmentRoleToken) current() bool {
91+ return t.sess != nil && t.ac != nil &&
92+ t.ac.roleGeneration.Load() == t.generation &&
93+ t.ac.transportSnapshotCurrent(t.transport) &&
94+ t.sess.attachmentRole(t.ac) == t.role
95+}
96+
97+// activeCurrent admits post-transition effects only for the exact published
98+// role, transport incarnation, current-session link, and coordinator lease.
99+func (t attachmentRoleToken) activeCurrent() bool {
100+ if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess {
101+ return false
102+ }
103+ rc := t.sess.renderCoordinator()
104+ return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
105+}
106+
107+// activeEffect is the canonical admission check for a client-frame mutation.
108+// Tokens bind role generation, transport incarnation, session ownership, and
109+// coordinator lease, so handlers never rediscover authority from mutable state.
110+func (t attachmentRoleToken) activeEffect() bool {
111+ if t.effect != nil && !t.effect.ended.Load() {
112+ return true
113+ }
114+ return t.activeCurrent()
115+}
116+
117+func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) {
118+ if sess == nil || ac == nil || lease == nil {
119+ return nil, false
120+ }
121+ token := sess.attachmentToken(ac, ac.transport())
122+ token.lease = lease
123+ return ac.beginRoleEffect(token)
124+}
125+
126+func (t *attachmentRoleToken) endRoleEffect() {
127+ if t == nil || t.effect == nil {
128+ return
129+ }
130+ t.effect.End()
131+ t.effect = nil
132+}
133+
134+// activeEffectSessionLocked is the same admission check while t.sess.mu is
135+// already held at a session-owned mutation boundary.
136+func (t attachmentRoleToken) activeEffectSessionLocked() bool {
137+ if t.effect != nil && !t.effect.ended.Load() {
138+ return true
139+ }
140+ if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil ||
141+ t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) ||
142+ t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive {
143+ return false
144+ }
145+ rc := t.sess.renderCoordinator()
146+ return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
147+}
148+
149+func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error {
150+ if t.ac == nil {
151+ return errAttachmentTransition
152+ }
153+ t.ac.sendMu.Lock()
154+ defer t.ac.sendMu.Unlock()
155+ if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) ||
156+ !t.effect.beginTransportSend(t.transport) {
157+ return errAttachmentTransition
158+ }
159+ err := t.transport.transport.Send(frame)
160+ if err != nil {
161+ t.effect.reportTransportFailure(t.transport)
162+ }
163+ t.effect.endTransportSend()
164+ return err
165+}
A internal/usecase/daemon/attachment_transition.go +223 -0
@@ -0,0 +1,223 @@
1+package daemon
2+
3+import "errors"
4+
5+var errAttachmentTransition = errors.New("attachment transition is no longer valid")
6+
7+type attachmentTransitionRequest struct {
8+ source *session
9+ target *session
10+ next *attachedClient
11+ expectedRole attachmentRole
12+ targetRole attachmentRole
13+ expectedTransport transportSnapshot
14+ sourceToken *attachmentRoleToken
15+ action string
16+ activateTargetTab bool
17+ targetTabIndex int
18+ copySourceEnvironment bool
19+ // preserveRole commits navigation state without replacing attachment
20+ // ownership. It is used for same-session picker selections after the exact
21+ // initiating capability has been frozen and revalidated.
22+ preserveRole bool
23+ expectedSourceTab *tab
24+ createTargetLocked func() (*session, error)
25+ ready bool
26+ expectedTargetCurrent *attachedClient
27+ expectedTargetTransport transportSnapshot
28+ preflighted bool
29+ roleEffectsFrozen bool
30+ expectedTargetTransportInterrupted bool
31+ // activationBarrier is used when promoting an already connected snatched
32+ // attachment. Its role gate is deadline-drained before sendMu is acquired,
33+ // preserving the gate -> sendMu -> daemon/routing/session publication order.
34+ activationBarrier bool
35+}
36+
37+func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool {
38+ return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&
39+ token.transport.transport == req.expectedTransport.transport &&
40+ token.transport.incarnation == req.expectedTransport.incarnation
41+}
42+
43+// transitionSourceTokenCurrentLocked requires source.mu and, for an active
44+// source, sourceCoordinator.mu. This is the canonical handoff preflight: every
45+// client-originated navigation, delete, or create intent is bound to one exact
46+// role generation, transport incarnation, and coordinator lease.
47+func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
48+ if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||
49+ token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role ||
50+ req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {
51+ return false
52+ }
53+ if token.role != attachmentActive {
54+ return true
55+ }
56+ return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next &&
57+ sourceCoordinator.leaseCurrentLocked(token.lease, true)
58+}
59+
60+type attachmentTransitionResult struct {
61+ published attachmentRoleToken
62+ displaced attachmentRoleToken
63+ displacedInterrupted bool
64+ cleanups []renderLifecycleCleanup
65+}
66+
67+type attachmentTransitionParticipants struct {
68+ clients []*attachedClient
69+ interrupts []roleTransportInterrupt
70+}
71+
72+// snapshotAttachmentTransition discovers the exact participants while d.mu is
73+// held. It releases every architecture lock before any role effect is ended or
74+// any gate is frozen and drained.
75+func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) {
76+ d.mu.Lock()
77+ defer d.mu.Unlock()
78+
79+ source := req.source
80+ if source == nil {
81+ source = req.target
82+ }
83+ if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||
84+ d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target ||
85+ req.activationBarrier && (req.sourceToken == nil || source != req.target ||
86+ req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
87+ return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
88+ }
89+ if req.target != nil {
90+ req.target.mu.Lock()
91+ req.expectedTargetCurrent = req.target.client
92+ if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {
93+ req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
94+ }
95+ req.target.mu.Unlock()
96+ }
97+ if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
98+ return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
99+ }
100+ req.preflighted = true
101+ return req, attachmentTransitionParticipants{
102+ clients: []*attachedClient{req.next, req.expectedTargetCurrent},
103+ interrupts: []roleTransportInterrupt{{
104+ ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
105+ }},
106+ }, nil
107+}
108+
109+// freezeAttachmentTransition drains the snapshotted participants without any
110+// architecture lock held. The returned guard remains frozen through atomic
111+// publication and must be released by the caller.
112+func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenRoleEffectGates, bool, error) {
113+ if d.afterRoleEffectParticipantsSnapshotted != nil {
114+ d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients)
115+ }
116+ var drainDeadline *roleEffectDrainDeadline
117+ var drainDone func() <-chan struct{}
118+ if req.activationBarrier {
119+ drainDeadline = newRoleEffectDrainDeadline(d.clock)
120+ drainDone = drainDeadline.Done
121+ }
122+ frozen := freezeRoleEffectGatesInterruptingObservedUntil(participants.interrupts, drainDone, func(ac *attachedClient) {
123+ if d.afterRoleEffectGateFrozen != nil {
124+ d.afterRoleEffectGateFrozen(req.action, ac)
125+ }
126+ }, participants.clients...)
127+ if drainDeadline != nil {
128+ drainDeadline.stop()
129+ }
130+ if !frozen.acquired || !frozen.drained {
131+ if req.activationBarrier {
132+ _ = req.next.closeCapturedTransport(req.expectedTransport.transport)
133+ return frozen, false, errSendTimedOut
134+ }
135+ return frozen, false, errAttachmentTransition
136+ }
137+ interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)
138+ if d.afterRoleEffectsFrozen != nil {
139+ d.afterRoleEffectsFrozen()
140+ }
141+ return frozen, interrupted, nil
142+}
143+
144+// publishAttachmentTransition revalidates the frozen source, creates an
145+// optional target, and performs publication while d.mu remains held.
146+func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
147+ d.mu.Lock()
148+ defer d.mu.Unlock()
149+
150+ req.roleEffectsFrozen = true
151+ if req.target == nil {
152+ if !d.transitionSourcePreflightLocked(req) {
153+ return attachmentTransitionResult{}, errAttachmentTransition
154+ }
155+ var err error
156+ req.target, err = req.createTargetLocked()
157+ if err != nil {
158+ return attachmentTransitionResult{}, err
159+ }
160+ req.expectedTargetCurrent = nil
161+ }
162+ return d.transitionAttachmentLocked(req)
163+}
164+
165+// prepareActivatedAttachment rebases the first active paint while the caller
166+// still owns the activation barrier (and therefore sendMu).
167+func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) {
168+ ac := result.published.ac
169+ if ac.output != nil {
170+ ac.output.rebase()
171+ }
172+ ac.captureFrames = nil
173+ d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false)
174+ // The first paint still requests a reset, but the old panel dependency chain
175+ // has already been rebased atomically under the activation barrier.
176+ result.published.rebase = false
177+}
178+
179+func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
180+ req, participants, err := d.snapshotAttachmentTransition(req)
181+ if err != nil {
182+ return attachmentTransitionResult{}, err
183+ }
184+ if req.sourceToken != nil {
185+ d.endActionRoleEffect(req.sourceToken.effect, req.action)
186+ }
187+
188+ frozen, interrupted, err := d.freezeAttachmentTransition(req, participants)
189+ defer frozen.unfreeze()
190+ if err != nil {
191+ return attachmentTransitionResult{}, err
192+ }
193+ req.expectedTargetTransportInterrupted = interrupted
194+
195+ var releaseActivation func()
196+ if req.activationBarrier {
197+ releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken)
198+ if err != nil {
199+ return attachmentTransitionResult{}, err
200+ }
201+ defer func() {
202+ if releaseActivation != nil {
203+ releaseActivation()
204+ }
205+ }()
206+ }
207+
208+ result, err := d.publishAttachmentTransition(req)
209+ if err != nil {
210+ return result, err
211+ }
212+ if req.activationBarrier && result.published.role == attachmentActive {
213+ d.prepareActivatedAttachment(&result)
214+ }
215+ if releaseActivation != nil {
216+ releaseActivation()
217+ releaseActivation = nil
218+ }
219+ if result.published.role == attachmentActive {
220+ result.published.ac.clearSnatchedInput()
221+ }
222+ return result, nil
223+}
@@ -0,0 +1,223 @@
1+package daemon
2+
3+import "errors"
4+
5+var errAttachmentTransition = errors.New("attachment transition is no longer valid")
6+
7+type attachmentTransitionRequest struct {
8+ source *session
9+ target *session
10+ next *attachedClient
11+ expectedRole attachmentRole
12+ targetRole attachmentRole
13+ expectedTransport transportSnapshot
14+ sourceToken *attachmentRoleToken
15+ action string
16+ activateTargetTab bool
17+ targetTabIndex int
18+ copySourceEnvironment bool
19+ // preserveRole commits navigation state without replacing attachment
20+ // ownership. It is used for same-session picker selections after the exact
21+ // initiating capability has been frozen and revalidated.
22+ preserveRole bool
23+ expectedSourceTab *tab
24+ createTargetLocked func() (*session, error)
25+ ready bool
26+ expectedTargetCurrent *attachedClient
27+ expectedTargetTransport transportSnapshot
28+ preflighted bool
29+ roleEffectsFrozen bool
30+ expectedTargetTransportInterrupted bool
31+ // activationBarrier is used when promoting an already connected snatched
32+ // attachment. Its role gate is deadline-drained before sendMu is acquired,
33+ // preserving the gate -> sendMu -> daemon/routing/session publication order.
34+ activationBarrier bool
35+}
36+
37+func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool {
38+ return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&
39+ token.transport.transport == req.expectedTransport.transport &&
40+ token.transport.incarnation == req.expectedTransport.incarnation
41+}
42+
43+// transitionSourceTokenCurrentLocked requires source.mu and, for an active
44+// source, sourceCoordinator.mu. This is the canonical handoff preflight: every
45+// client-originated navigation, delete, or create intent is bound to one exact
46+// role generation, transport incarnation, and coordinator lease.
47+func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
48+ if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||
49+ token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role ||
50+ req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {
51+ return false
52+ }
53+ if token.role != attachmentActive {
54+ return true
55+ }
56+ return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next &&
57+ sourceCoordinator.leaseCurrentLocked(token.lease, true)
58+}
59+
60+type attachmentTransitionResult struct {
61+ published attachmentRoleToken
62+ displaced attachmentRoleToken
63+ displacedInterrupted bool
64+ cleanups []renderLifecycleCleanup
65+}
66+
67+type attachmentTransitionParticipants struct {
68+ clients []*attachedClient
69+ interrupts []roleTransportInterrupt
70+}
71+
72+// snapshotAttachmentTransition discovers the exact participants while d.mu is
73+// held. It releases every architecture lock before any role effect is ended or
74+// any gate is frozen and drained.
75+func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) {
76+ d.mu.Lock()
77+ defer d.mu.Unlock()
78+
79+ source := req.source
80+ if source == nil {
81+ source = req.target
82+ }
83+ if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||
84+ d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target ||
85+ req.activationBarrier && (req.sourceToken == nil || source != req.target ||
86+ req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
87+ return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
88+ }
89+ if req.target != nil {
90+ req.target.mu.Lock()
91+ req.expectedTargetCurrent = req.target.client
92+ if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {
93+ req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
94+ }
95+ req.target.mu.Unlock()
96+ }
97+ if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
98+ return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
99+ }
100+ req.preflighted = true
101+ return req, attachmentTransitionParticipants{
102+ clients: []*attachedClient{req.next, req.expectedTargetCurrent},
103+ interrupts: []roleTransportInterrupt{{
104+ ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
105+ }},
106+ }, nil
107+}
108+
109+// freezeAttachmentTransition drains the snapshotted participants without any
110+// architecture lock held. The returned guard remains frozen through atomic
111+// publication and must be released by the caller.
112+func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenRoleEffectGates, bool, error) {
113+ if d.afterRoleEffectParticipantsSnapshotted != nil {
114+ d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients)
115+ }
116+ var drainDeadline *roleEffectDrainDeadline
117+ var drainDone func() <-chan struct{}
118+ if req.activationBarrier {
119+ drainDeadline = newRoleEffectDrainDeadline(d.clock)
120+ drainDone = drainDeadline.Done
121+ }
122+ frozen := freezeRoleEffectGatesInterruptingObservedUntil(participants.interrupts, drainDone, func(ac *attachedClient) {
123+ if d.afterRoleEffectGateFrozen != nil {
124+ d.afterRoleEffectGateFrozen(req.action, ac)
125+ }
126+ }, participants.clients...)
127+ if drainDeadline != nil {
128+ drainDeadline.stop()
129+ }
130+ if !frozen.acquired || !frozen.drained {
131+ if req.activationBarrier {
132+ _ = req.next.closeCapturedTransport(req.expectedTransport.transport)
133+ return frozen, false, errSendTimedOut
134+ }
135+ return frozen, false, errAttachmentTransition
136+ }
137+ interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)
138+ if d.afterRoleEffectsFrozen != nil {
139+ d.afterRoleEffectsFrozen()
140+ }
141+ return frozen, interrupted, nil
142+}
143+
144+// publishAttachmentTransition revalidates the frozen source, creates an
145+// optional target, and performs publication while d.mu remains held.
146+func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
147+ d.mu.Lock()
148+ defer d.mu.Unlock()
149+
150+ req.roleEffectsFrozen = true
151+ if req.target == nil {
152+ if !d.transitionSourcePreflightLocked(req) {
153+ return attachmentTransitionResult{}, errAttachmentTransition
154+ }
155+ var err error
156+ req.target, err = req.createTargetLocked()
157+ if err != nil {
158+ return attachmentTransitionResult{}, err
159+ }
160+ req.expectedTargetCurrent = nil
161+ }
162+ return d.transitionAttachmentLocked(req)
163+}
164+
165+// prepareActivatedAttachment rebases the first active paint while the caller
166+// still owns the activation barrier (and therefore sendMu).
167+func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) {
168+ ac := result.published.ac
169+ if ac.output != nil {
170+ ac.output.rebase()
171+ }
172+ ac.captureFrames = nil
173+ d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false)
174+ // The first paint still requests a reset, but the old panel dependency chain
175+ // has already been rebased atomically under the activation barrier.
176+ result.published.rebase = false
177+}
178+
179+func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
180+ req, participants, err := d.snapshotAttachmentTransition(req)
181+ if err != nil {
182+ return attachmentTransitionResult{}, err
183+ }
184+ if req.sourceToken != nil {
185+ d.endActionRoleEffect(req.sourceToken.effect, req.action)
186+ }
187+
188+ frozen, interrupted, err := d.freezeAttachmentTransition(req, participants)
189+ defer frozen.unfreeze()
190+ if err != nil {
191+ return attachmentTransitionResult{}, err
192+ }
193+ req.expectedTargetTransportInterrupted = interrupted
194+
195+ var releaseActivation func()
196+ if req.activationBarrier {
197+ releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken)
198+ if err != nil {
199+ return attachmentTransitionResult{}, err
200+ }
201+ defer func() {
202+ if releaseActivation != nil {
203+ releaseActivation()
204+ }
205+ }()
206+ }
207+
208+ result, err := d.publishAttachmentTransition(req)
209+ if err != nil {
210+ return result, err
211+ }
212+ if req.activationBarrier && result.published.role == attachmentActive {
213+ d.prepareActivatedAttachment(&result)
214+ }
215+ if releaseActivation != nil {
216+ releaseActivation()
217+ releaseActivation = nil
218+ }
219+ if result.published.role == attachmentActive {
220+ result.published.ac.clearSnatchedInput()
221+ }
222+ return result, nil
223+}
A internal/usecase/daemon/attachment_transition_publish.go +324 -0
@@ -0,0 +1,324 @@
1+package daemon
2+
3+// transitionSourcePreflightLocked validates an initiating role while its gate
4+// is frozen and d.mu is held. No caller may create or delete a target before
5+// this succeeds.
6+func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {
7+ if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source {
8+ return false
9+ }
10+ d.notices.routingMu.Lock()
11+ defer d.notices.routingMu.Unlock()
12+ req.source.mu.Lock()
13+ defer req.source.mu.Unlock()
14+ coordinator := req.source.renderCoordinator()
15+ if coordinator == nil {
16+ return false
17+ }
18+ coordinator.mu.Lock()
19+ defer coordinator.mu.Unlock()
20+ if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {
21+ return false
22+ }
23+ return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)
24+}
25+
26+func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
27+ if token.sess == nil || token.ac == nil || token.role != attachmentActive {
28+ return false
29+ }
30+ d.mu.Lock()
31+ defer d.mu.Unlock()
32+ if d.closing || d.sessions[token.sess.id] != token.sess {
33+ return false
34+ }
35+ d.notices.routingMu.Lock()
36+ defer d.notices.routingMu.Unlock()
37+ token.sess.mu.Lock()
38+ defer token.sess.mu.Unlock()
39+ coordinator := token.sess.renderCoordinator()
40+ if coordinator == nil {
41+ return false
42+ }
43+ coordinator.mu.Lock()
44+ defer coordinator.mu.Unlock()
45+ req := attachmentTransitionRequest{
46+ source: token.sess, next: token.ac, expectedRole: token.role,
47+ expectedTransport: token.transport,
48+ }
49+ return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
50+}
51+
52+func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
53+ if expected == nil {
54+ return true
55+ }
56+ if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected {
57+ return false
58+ }
59+ expected.mu.Lock()
60+ defer expected.mu.Unlock()
61+ return expected.floating.state != floatingVisible
62+}
63+
64+// transitionAttachmentLocked is the publication half of transitionAttachment.
65+// Caller holds d.mu after the lock-free gate freeze/drain has completed.
66+func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
67+ d.notices.routingMu.Lock()
68+ defer d.notices.routingMu.Unlock()
69+ return d.transitionAttachmentRoutedLocked(req)
70+}
71+
72+// attachmentPublication owns the ordered session and coordinator locks and
73+// accumulates the values needed to construct postcommit capabilities.
74+type attachmentPublication struct {
75+ req attachmentTransitionRequest
76+ source *session
77+ old *attachedClient
78+ sourceCoordinator *renderCoordinator
79+ targetCoordinator *renderCoordinator
80+ unlockSessions func()
81+ unlockCoordinators func()
82+ displacedTransport transportSnapshot
83+ nextGeneration uint64
84+ displacedGeneration uint64
85+}
86+
87+// unlock releases coordinator locks before the ordered session locks. The
88+// caller still owns d.mu and notices.routingMu; no external I/O is permitted.
89+func (p *attachmentPublication) unlock() {
90+ p.unlockCoordinators()
91+ p.unlockSessions()
92+}
93+
94+// preflightAttachmentPublicationLocked revalidates source and target authority
95+// and acquires every lock needed by publication. Caller holds d.mu and
96+// notices.routingMu. On success, the returned guard owns ordered session and
97+// coordinator locks until unlock is called.
98+func (d *Daemon) preflightAttachmentPublicationLocked(req attachmentTransitionRequest) (*attachmentPublication, error) {
99+ source := req.source
100+ if source == nil {
101+ source = req.target
102+ }
103+ if !req.preflighted || !req.roleEffectsFrozen ||
104+ !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
105+ source == nil || req.target == nil || req.next == nil || d.closing ||
106+ d.sessions[source.id] != source || d.sessions[req.target.id] != req.target ||
107+ req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) {
108+ return nil, errAttachmentTransition
109+ }
110+
111+ // Install the target coordinator before taking both session locks. Lease
112+ // binding remains part of the atomic publication below.
113+ var targetCoordinator *renderCoordinator
114+ if req.targetRole == attachmentActive {
115+ targetCoordinator = d.ensureRenderCoordinator(req.target)
116+ }
117+ sourceCoordinator := source.renderCoordinator()
118+ unlockSessions := lockAttachmentSessions(source, req.target)
119+ if source.attachmentRoleLocked(req.next) != req.expectedRole ||
120+ !req.next.transportSnapshotCurrent(req.expectedTransport) {
121+ unlockSessions()
122+ return nil, errAttachmentTransition
123+ }
124+
125+ old := req.target.client
126+ if old != req.expectedTargetCurrent || source != req.target && old == req.next {
127+ unlockSessions()
128+ return nil, errAttachmentTransition
129+ }
130+ publication := &attachmentPublication{
131+ req: req, source: source, old: old,
132+ sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
133+ unlockSessions: unlockSessions,
134+ unlockCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),
135+ }
136+ if req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
137+ !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) {
138+ publication.unlock()
139+ return nil, errAttachmentTransition
140+ }
141+ if targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) {
142+ publication.unlock()
143+ return nil, errAttachmentTransition
144+ }
145+ if req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
146+ (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) {
147+ publication.unlock()
148+ return nil, errAttachmentTransition
149+ }
150+ if req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) {
151+ publication.unlock()
152+ return nil, errAttachmentTransition
153+ }
154+ return publication, nil
155+}
156+
157+// applyTargetStateLocked commits navigation metadata before role ownership, as
158+// part of the same locked publication. It reports role-preserving transitions.
159+func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
160+ req := publication.req
161+ if req.targetRole == attachmentActive {
162+ d.demoteParkedActiveForSessionLocked(req.target)
163+ }
164+ if req.copySourceEnvironment {
165+ req.target.terminal = publication.source.terminal
166+ req.target.env = copyEnvironment(publication.source.env)
167+ }
168+ if req.activateTargetTab {
169+ req.target.active = req.targetTabIndex
170+ }
171+ return req.preserveRole
172+}
173+
174+// publishAttachmentOwnershipLocked atomically changes session membership,
175+// generations, and currentSession while all role gates remain frozen.
176+func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
177+ req := publication.req
178+ if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next {
179+ publication.displacedTransport = publication.old.transportSnapshot()
180+ }
181+ if publication.source != req.target {
182+ if publication.source.client == req.next {
183+ publication.source.client = nil
184+ }
185+ delete(publication.source.snatched, req.next)
186+ }
187+ if req.targetRole == attachmentActive {
188+ delete(req.target.snatched, req.next)
189+ if publication.old != nil && publication.old != req.next {
190+ req.target.addSnatchedLocked(publication.old)
191+ }
192+ req.target.client = req.next
193+ } else {
194+ req.target.addSnatchedLocked(req.next)
195+ }
196+ publication.nextGeneration = req.next.roleGeneration.Add(1)
197+ if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive {
198+ publication.displacedGeneration = publication.old.roleGeneration.Add(1)
199+ }
200+ req.next.setSession(req.target)
201+}
202+
203+// buildAttachmentPostcommitPlanLocked binds coordinator lifecycle cleanup and
204+// constructs the exact published tokens after ownership has committed.
205+func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult {
206+ req := publication.req
207+ result := attachmentTransitionResult{}
208+ if publication.source != req.target && req.expectedRole == attachmentActive && publication.sourceCoordinator != nil {
209+ result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))
210+ }
211+ var lease *attachmentLease
212+ if publication.targetCoordinator != nil {
213+ var cleanup renderLifecycleCleanup
214+ cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready)
215+ result.cleanups = append(result.cleanups, cleanup)
216+ }
217+ result.published = attachmentRoleToken{
218+ sess: req.target,
219+ ac: req.next,
220+ role: req.targetRole,
221+ generation: publication.nextGeneration,
222+ transport: req.expectedTransport,
223+ lease: lease,
224+ rebase: publication.source != req.target && req.expectedRole == attachmentActive,
225+ }
226+ if publication.displacedGeneration != 0 {
227+ result.displaced = attachmentRoleToken{
228+ sess: req.target,
229+ ac: publication.old,
230+ role: attachmentSnatched,
231+ generation: publication.displacedGeneration,
232+ transport: publication.displacedTransport,
233+ }
234+ result.displacedInterrupted = req.expectedTargetTransportInterrupted
235+ }
236+ // Membership, currentSession, lease, generation, and both exact admission
237+ // capabilities become visible as one frozen publication. The gates remain
238+ // frozen until every architecture lock has been released.
239+ if req.roleEffectsFrozen {
240+ req.next.publishFrozenRoleCapability(result.published)
241+ if result.displaced.ac != nil {
242+ result.displaced.ac.publishFrozenRoleCapability(result.displaced)
243+ }
244+ }
245+ return result
246+}
247+
248+// transitionAttachmentRoutedLocked performs the centralized role publication.
249+// Caller holds d.mu and notices.routingMu. Every fallible lifecycle, role, and
250+// transport check occurs before session.client is changed. Coordinator
251+// finalization after that commit point cannot roll ownership back.
252+func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
253+ publication, err := d.preflightAttachmentPublicationLocked(req)
254+ if err != nil {
255+ return attachmentTransitionResult{}, err
256+ }
257+ defer publication.unlock()
258+
259+ if d.applyTargetStateLocked(publication) {
260+ return attachmentTransitionResult{published: *req.sourceToken}, nil
261+ }
262+ publishAttachmentOwnershipLocked(publication)
263+ return buildAttachmentPostcommitPlanLocked(publication), nil
264+}
265+
266+func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
267+ if role == attachmentActive || role == attachmentSnatched {
268+ return true
269+ }
270+ return expected && role == attachmentDetached
271+}
272+
273+// lockAttachmentSessions gives every two-session transition one stable order.
274+// Session IDs are immutable and unique while their lifecycles are registered.
275+func lockAttachmentSessions(a, b *session) func() {
276+ if a == b {
277+ a.mu.Lock()
278+ return a.mu.Unlock
279+ }
280+ first, second := a, b
281+ if first.id > second.id {
282+ first, second = second, first
283+ }
284+ first.mu.Lock()
285+ second.mu.Lock()
286+ return func() {
287+ second.mu.Unlock()
288+ first.mu.Unlock()
289+ }
290+}
291+
292+// lockAttachmentCoordinators follows the same immutable session-ID order as
293+// lockAttachmentSessions. Callers already hold the ordered session locks.
294+func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() {
295+ if aCoordinator == nil && bCoordinator == nil {
296+ return func() {}
297+ }
298+ if a == b || aCoordinator == bCoordinator {
299+ coordinator := aCoordinator
300+ if coordinator == nil {
301+ coordinator = bCoordinator
302+ }
303+ coordinator.mu.Lock()
304+ return coordinator.mu.Unlock
305+ }
306+ first, second := aCoordinator, bCoordinator
307+ if a.id > b.id {
308+ first, second = second, first
309+ }
310+ if first != nil {
311+ first.mu.Lock()
312+ }
313+ if second != nil {
314+ second.mu.Lock()
315+ }
316+ return func() {
317+ if second != nil {
318+ second.mu.Unlock()
319+ }
320+ if first != nil {
321+ first.mu.Unlock()
322+ }
323+ }
324+}
@@ -0,0 +1,324 @@
1+package daemon
2+
3+// transitionSourcePreflightLocked validates an initiating role while its gate
4+// is frozen and d.mu is held. No caller may create or delete a target before
5+// this succeeds.
6+func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {
7+ if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source {
8+ return false
9+ }
10+ d.notices.routingMu.Lock()
11+ defer d.notices.routingMu.Unlock()
12+ req.source.mu.Lock()
13+ defer req.source.mu.Unlock()
14+ coordinator := req.source.renderCoordinator()
15+ if coordinator == nil {
16+ return false
17+ }
18+ coordinator.mu.Lock()
19+ defer coordinator.mu.Unlock()
20+ if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {
21+ return false
22+ }
23+ return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)
24+}
25+
26+func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
27+ if token.sess == nil || token.ac == nil || token.role != attachmentActive {
28+ return false
29+ }
30+ d.mu.Lock()
31+ defer d.mu.Unlock()
32+ if d.closing || d.sessions[token.sess.id] != token.sess {
33+ return false
34+ }
35+ d.notices.routingMu.Lock()
36+ defer d.notices.routingMu.Unlock()
37+ token.sess.mu.Lock()
38+ defer token.sess.mu.Unlock()
39+ coordinator := token.sess.renderCoordinator()
40+ if coordinator == nil {
41+ return false
42+ }
43+ coordinator.mu.Lock()
44+ defer coordinator.mu.Unlock()
45+ req := attachmentTransitionRequest{
46+ source: token.sess, next: token.ac, expectedRole: token.role,
47+ expectedTransport: token.transport,
48+ }
49+ return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
50+}
51+
52+func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
53+ if expected == nil {
54+ return true
55+ }
56+ if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected {
57+ return false
58+ }
59+ expected.mu.Lock()
60+ defer expected.mu.Unlock()
61+ return expected.floating.state != floatingVisible
62+}
63+
64+// transitionAttachmentLocked is the publication half of transitionAttachment.
65+// Caller holds d.mu after the lock-free gate freeze/drain has completed.
66+func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
67+ d.notices.routingMu.Lock()
68+ defer d.notices.routingMu.Unlock()
69+ return d.transitionAttachmentRoutedLocked(req)
70+}
71+
72+// attachmentPublication owns the ordered session and coordinator locks and
73+// accumulates the values needed to construct postcommit capabilities.
74+type attachmentPublication struct {
75+ req attachmentTransitionRequest
76+ source *session
77+ old *attachedClient
78+ sourceCoordinator *renderCoordinator
79+ targetCoordinator *renderCoordinator
80+ unlockSessions func()
81+ unlockCoordinators func()
82+ displacedTransport transportSnapshot
83+ nextGeneration uint64
84+ displacedGeneration uint64
85+}
86+
87+// unlock releases coordinator locks before the ordered session locks. The
88+// caller still owns d.mu and notices.routingMu; no external I/O is permitted.
89+func (p *attachmentPublication) unlock() {
90+ p.unlockCoordinators()
91+ p.unlockSessions()
92+}
93+
94+// preflightAttachmentPublicationLocked revalidates source and target authority
95+// and acquires every lock needed by publication. Caller holds d.mu and
96+// notices.routingMu. On success, the returned guard owns ordered session and
97+// coordinator locks until unlock is called.
98+func (d *Daemon) preflightAttachmentPublicationLocked(req attachmentTransitionRequest) (*attachmentPublication, error) {
99+ source := req.source
100+ if source == nil {
101+ source = req.target
102+ }
103+ if !req.preflighted || !req.roleEffectsFrozen ||
104+ !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
105+ source == nil || req.target == nil || req.next == nil || d.closing ||
106+ d.sessions[source.id] != source || d.sessions[req.target.id] != req.target ||
107+ req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) {
108+ return nil, errAttachmentTransition
109+ }
110+
111+ // Install the target coordinator before taking both session locks. Lease
112+ // binding remains part of the atomic publication below.
113+ var targetCoordinator *renderCoordinator
114+ if req.targetRole == attachmentActive {
115+ targetCoordinator = d.ensureRenderCoordinator(req.target)
116+ }
117+ sourceCoordinator := source.renderCoordinator()
118+ unlockSessions := lockAttachmentSessions(source, req.target)
119+ if source.attachmentRoleLocked(req.next) != req.expectedRole ||
120+ !req.next.transportSnapshotCurrent(req.expectedTransport) {
121+ unlockSessions()
122+ return nil, errAttachmentTransition
123+ }
124+
125+ old := req.target.client
126+ if old != req.expectedTargetCurrent || source != req.target && old == req.next {
127+ unlockSessions()
128+ return nil, errAttachmentTransition
129+ }
130+ publication := &attachmentPublication{
131+ req: req, source: source, old: old,
132+ sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
133+ unlockSessions: unlockSessions,
134+ unlockCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),
135+ }
136+ if req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
137+ !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) {
138+ publication.unlock()
139+ return nil, errAttachmentTransition
140+ }
141+ if targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) {
142+ publication.unlock()
143+ return nil, errAttachmentTransition
144+ }
145+ if req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
146+ (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) {
147+ publication.unlock()
148+ return nil, errAttachmentTransition
149+ }
150+ if req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) {
151+ publication.unlock()
152+ return nil, errAttachmentTransition
153+ }
154+ return publication, nil
155+}
156+
157+// applyTargetStateLocked commits navigation metadata before role ownership, as
158+// part of the same locked publication. It reports role-preserving transitions.
159+func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
160+ req := publication.req
161+ if req.targetRole == attachmentActive {
162+ d.demoteParkedActiveForSessionLocked(req.target)
163+ }
164+ if req.copySourceEnvironment {
165+ req.target.terminal = publication.source.terminal
166+ req.target.env = copyEnvironment(publication.source.env)
167+ }
168+ if req.activateTargetTab {
169+ req.target.active = req.targetTabIndex
170+ }
171+ return req.preserveRole
172+}
173+
174+// publishAttachmentOwnershipLocked atomically changes session membership,
175+// generations, and currentSession while all role gates remain frozen.
176+func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
177+ req := publication.req
178+ if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next {
179+ publication.displacedTransport = publication.old.transportSnapshot()
180+ }
181+ if publication.source != req.target {
182+ if publication.source.client == req.next {
183+ publication.source.client = nil
184+ }
185+ delete(publication.source.snatched, req.next)
186+ }
187+ if req.targetRole == attachmentActive {
188+ delete(req.target.snatched, req.next)
189+ if publication.old != nil && publication.old != req.next {
190+ req.target.addSnatchedLocked(publication.old)
191+ }
192+ req.target.client = req.next
193+ } else {
194+ req.target.addSnatchedLocked(req.next)
195+ }
196+ publication.nextGeneration = req.next.roleGeneration.Add(1)
197+ if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive {
198+ publication.displacedGeneration = publication.old.roleGeneration.Add(1)
199+ }
200+ req.next.setSession(req.target)
201+}
202+
203+// buildAttachmentPostcommitPlanLocked binds coordinator lifecycle cleanup and
204+// constructs the exact published tokens after ownership has committed.
205+func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult {
206+ req := publication.req
207+ result := attachmentTransitionResult{}
208+ if publication.source != req.target && req.expectedRole == attachmentActive && publication.sourceCoordinator != nil {
209+ result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))
210+ }
211+ var lease *attachmentLease
212+ if publication.targetCoordinator != nil {
213+ var cleanup renderLifecycleCleanup
214+ cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready)
215+ result.cleanups = append(result.cleanups, cleanup)
216+ }
217+ result.published = attachmentRoleToken{
218+ sess: req.target,
219+ ac: req.next,
220+ role: req.targetRole,
221+ generation: publication.nextGeneration,
222+ transport: req.expectedTransport,
223+ lease: lease,
224+ rebase: publication.source != req.target && req.expectedRole == attachmentActive,
225+ }
226+ if publication.displacedGeneration != 0 {
227+ result.displaced = attachmentRoleToken{
228+ sess: req.target,
229+ ac: publication.old,
230+ role: attachmentSnatched,
231+ generation: publication.displacedGeneration,
232+ transport: publication.displacedTransport,
233+ }
234+ result.displacedInterrupted = req.expectedTargetTransportInterrupted
235+ }
236+ // Membership, currentSession, lease, generation, and both exact admission
237+ // capabilities become visible as one frozen publication. The gates remain
238+ // frozen until every architecture lock has been released.
239+ if req.roleEffectsFrozen {
240+ req.next.publishFrozenRoleCapability(result.published)
241+ if result.displaced.ac != nil {
242+ result.displaced.ac.publishFrozenRoleCapability(result.displaced)
243+ }
244+ }
245+ return result
246+}
247+
248+// transitionAttachmentRoutedLocked performs the centralized role publication.
249+// Caller holds d.mu and notices.routingMu. Every fallible lifecycle, role, and
250+// transport check occurs before session.client is changed. Coordinator
251+// finalization after that commit point cannot roll ownership back.
252+func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
253+ publication, err := d.preflightAttachmentPublicationLocked(req)
254+ if err != nil {
255+ return attachmentTransitionResult{}, err
256+ }
257+ defer publication.unlock()
258+
259+ if d.applyTargetStateLocked(publication) {
260+ return attachmentTransitionResult{published: *req.sourceToken}, nil
261+ }
262+ publishAttachmentOwnershipLocked(publication)
263+ return buildAttachmentPostcommitPlanLocked(publication), nil
264+}
265+
266+func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
267+ if role == attachmentActive || role == attachmentSnatched {
268+ return true
269+ }
270+ return expected && role == attachmentDetached
271+}
272+
273+// lockAttachmentSessions gives every two-session transition one stable order.
274+// Session IDs are immutable and unique while their lifecycles are registered.
275+func lockAttachmentSessions(a, b *session) func() {
276+ if a == b {
277+ a.mu.Lock()
278+ return a.mu.Unlock
279+ }
280+ first, second := a, b
281+ if first.id > second.id {
282+ first, second = second, first
283+ }
284+ first.mu.Lock()
285+ second.mu.Lock()
286+ return func() {
287+ second.mu.Unlock()
288+ first.mu.Unlock()
289+ }
290+}
291+
292+// lockAttachmentCoordinators follows the same immutable session-ID order as
293+// lockAttachmentSessions. Callers already hold the ordered session locks.
294+func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() {
295+ if aCoordinator == nil && bCoordinator == nil {
296+ return func() {}
297+ }
298+ if a == b || aCoordinator == bCoordinator {
299+ coordinator := aCoordinator
300+ if coordinator == nil {
301+ coordinator = bCoordinator
302+ }
303+ coordinator.mu.Lock()
304+ return coordinator.mu.Unlock
305+ }
306+ first, second := aCoordinator, bCoordinator
307+ if a.id > b.id {
308+ first, second = second, first
309+ }
310+ if first != nil {
311+ first.mu.Lock()
312+ }
313+ if second != nil {
314+ second.mu.Lock()
315+ }
316+ return func() {
317+ if second != nil {
318+ second.mu.Unlock()
319+ }
320+ if first != nil {
321+ first.mu.Unlock()
322+ }
323+ }
324+}
M internal/usecase/daemon/daemon.go +2 -3
@@ -682,12 +682,11 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
682682 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
683683 d.mu.Lock()
684684 d.closing = true
685- for token, parked := range d.parked {
686- d.removeParkedLocked(token, parked)
687- }
685+ parkedRetirements := d.purgeAllParkedLocked()
688686 snapshot := d.sessionsSnapshotLocked()
689687 empty := len(snapshot) == 0
690688 d.mu.Unlock()
689+ finishParkedAttachmentRetirements(parkedRetirements)
691690 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
692691 if empty {
693692 d.doneOnce.Do(func() { close(d.done) })
@@ -682,12 +682,11 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
682 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {682 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
683 d.mu.Lock()683 d.mu.Lock()
684 d.closing = true684 d.closing = true
685- for token, parked := range d.parked {685+ parkedRetirements := d.purgeAllParkedLocked()
686- d.removeParkedLocked(token, parked)
687- }
688 snapshot := d.sessionsSnapshotLocked()686 snapshot := d.sessionsSnapshotLocked()
689 empty := len(snapshot) == 0687 empty := len(snapshot) == 0
690 d.mu.Unlock()688 d.mu.Unlock()
689+ finishParkedAttachmentRetirements(parkedRetirements)
691 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))690 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
692 if empty {691 if empty {
693 d.doneOnce.Do(func() { close(d.done) })692 d.doneOnce.Do(func() { close(d.done) })
M internal/usecase/daemon/resume.go +46 -4
@@ -133,13 +133,55 @@ func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) {
133133 }
134134 }
135135
136-// purgeParkedForSessionLocked invalidates every parked token for sess. Caller
137-// holds d.mu. It is reserved for terminal session kill and daemon shutdown.
138-func (d *Daemon) purgeParkedForSessionLocked(sess *session) {
136+type parkedAttachmentRetirement struct {
137+ parked *parkedAttachment
138+ transport transportSnapshot
139+}
140+
141+func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
142+ delete(d.parked, token)
143+ parked.ac.clearPreviousSession()
144+ parked.ac.resumeToken = 0
145+ parked.ac.parked = false
146+ parked.ac.roleGeneration.Add(1)
147+ parked.ac.setSession(nil)
148+ return parkedAttachmentRetirement{
149+ parked: parked,
150+ transport: parked.ac.transportSnapshot(),
151+ }
152+}
153+
154+// purgeParkedForSessionLocked invalidates every parked token for sess and
155+// returns the external resources that must be retired after releasing d.mu.
156+// It is reserved for terminal session kill and daemon shutdown.
157+func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
158+ var retirements []parkedAttachmentRetirement
139159 for token, parked := range d.parked {
140160 if parked.sess == sess {
141- d.removeParkedLocked(token, parked)
161+ retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
162+ }
163+ }
164+ return retirements
165+}
166+
167+func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement {
168+ retirements := make([]parkedAttachmentRetirement, 0, len(d.parked))
169+ for token, parked := range d.parked {
170+ retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
171+ }
172+ return retirements
173+}
174+
175+// finishParkedAttachmentRetirements stops timers and closes transports without
176+// holding daemon, session, coordinator, or pane locks.
177+func finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) {
178+ for _, retirement := range retirements {
179+ if retirement.parked.timer != nil {
180+ retirement.parked.timer.Stop()
142181 }
182+ retirement.parked.closeDone()
183+ ac := retirement.parked.ac
184+ _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))
143185 }
144186 }
145187
@@ -133,13 +133,55 @@ func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) {
133 }133 }
134 }134 }
135 135
136-// purgeParkedForSessionLocked invalidates every parked token for sess. Caller136+type parkedAttachmentRetirement struct {
137-// holds d.mu. It is reserved for terminal session kill and daemon shutdown.137+ parked *parkedAttachment
138-func (d *Daemon) purgeParkedForSessionLocked(sess *session) {138+ transport transportSnapshot
139+}
140+
141+func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
142+ delete(d.parked, token)
143+ parked.ac.clearPreviousSession()
144+ parked.ac.resumeToken = 0
145+ parked.ac.parked = false
146+ parked.ac.roleGeneration.Add(1)
147+ parked.ac.setSession(nil)
148+ return parkedAttachmentRetirement{
149+ parked: parked,
150+ transport: parked.ac.transportSnapshot(),
151+ }
152+}
153+
154+// purgeParkedForSessionLocked invalidates every parked token for sess and
155+// returns the external resources that must be retired after releasing d.mu.
156+// It is reserved for terminal session kill and daemon shutdown.
157+func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
158+ var retirements []parkedAttachmentRetirement
139 for token, parked := range d.parked {159 for token, parked := range d.parked {
140 if parked.sess == sess {160 if parked.sess == sess {
141- d.removeParkedLocked(token, parked)161+ retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
162+ }
163+ }
164+ return retirements
165+}
166+
167+func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement {
168+ retirements := make([]parkedAttachmentRetirement, 0, len(d.parked))
169+ for token, parked := range d.parked {
170+ retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
171+ }
172+ return retirements
173+}
174+
175+// finishParkedAttachmentRetirements stops timers and closes transports without
176+// holding daemon, session, coordinator, or pane locks.
177+func finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) {
178+ for _, retirement := range retirements {
179+ if retirement.parked.timer != nil {
180+ retirement.parked.timer.Stop()
142 }181 }
182+ retirement.parked.closeDone()
183+ ac := retirement.parked.ac
184+ _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))
143 }185 }
144 }186 }
145 187
M internal/usecase/daemon/resume_test.go +7 -5
@@ -520,18 +520,19 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
520520 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
521521 for _, tc := range []struct {
522522 name string
523- discard func(*Daemon, uint64, *parkedAttachment)
523+ discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement
524524 }{
525525 {
526526 name: "expiry",
527- discard: func(d *Daemon, token uint64, parked *parkedAttachment) {
527+ discard: func(d *Daemon, token uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
528528 d.removeParkedLocked(token, parked)
529+ return nil
529530 },
530531 },
531532 {
532533 name: "session purge",
533- discard: func(d *Daemon, _ uint64, parked *parkedAttachment) {
534- d.purgeParkedForSessionLocked(parked.sess)
534+ discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
535+ return d.purgeParkedForSessionLocked(parked.sess)
535536 },
536537 },
537538 } {
@@ -549,8 +550,9 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
549550 d.mu.Lock()
550551 parked := d.parked[ac.resumeToken]
551552 require.NotNil(t, parked)
552- tc.discard(d, ac.resumeToken, parked)
553+ retirements := tc.discard(d, ac.resumeToken, parked)
553554 d.mu.Unlock()
555+ finishParkedAttachmentRetirements(retirements)
554556
555557 require.Nil(t, ac.previousSession.Get())
556558 })
@@ -520,18 +520,19 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
520 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {520 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
521 for _, tc := range []struct {521 for _, tc := range []struct {
522 name string522 name string
523- discard func(*Daemon, uint64, *parkedAttachment)523+ discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement
524 }{524 }{
525 {525 {
526 name: "expiry",526 name: "expiry",
527- discard: func(d *Daemon, token uint64, parked *parkedAttachment) {527+ discard: func(d *Daemon, token uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
528 d.removeParkedLocked(token, parked)528 d.removeParkedLocked(token, parked)
529+ return nil
529 },530 },
530 },531 },
531 {532 {
532 name: "session purge",533 name: "session purge",
533- discard: func(d *Daemon, _ uint64, parked *parkedAttachment) {534+ discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
534- d.purgeParkedForSessionLocked(parked.sess)535+ return d.purgeParkedForSessionLocked(parked.sess)
535 },536 },
536 },537 },
537 } {538 } {
@@ -549,8 +550,9 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
549 d.mu.Lock()550 d.mu.Lock()
550 parked := d.parked[ac.resumeToken]551 parked := d.parked[ac.resumeToken]
551 require.NotNil(t, parked)552 require.NotNil(t, parked)
552- tc.discard(d, ac.resumeToken, parked)553+ retirements := tc.discard(d, ac.resumeToken, parked)
553 d.mu.Unlock()554 d.mu.Unlock()
555+ finishParkedAttachmentRetirements(retirements)
554 556
555 require.Nil(t, ac.previousSession.Get())557 require.Nil(t, ac.previousSession.Get())
556 })558 })
A internal/usecase/daemon/role_effect_freeze.go +263 -0
@@ -0,0 +1,263 @@
1+package daemon
2+
3+import (
4+ "sort"
5+ "sync"
6+ "sync/atomic"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+type roleEffectDrainDeadline struct {
12+ clock ports.Clock
13+ done chan struct{}
14+ stopCh chan struct{}
15+ finished chan struct{}
16+ startOnce sync.Once
17+ stopOnce sync.Once
18+ started atomic.Bool
19+}
20+
21+func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline {
22+ return &roleEffectDrainDeadline{
23+ clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}),
24+ }
25+}
26+
27+func (d *roleEffectDrainDeadline) stop() {
28+ if d == nil {
29+ return
30+ }
31+ d.stopOnce.Do(func() {
32+ // Claim an unstarted deadline without allocating a timer. Done is never
33+ // called after the owning freeze/drain operation returns.
34+ d.startOnce.Do(func() {})
35+ if !d.started.Load() {
36+ return
37+ }
38+ close(d.stopCh)
39+ <-d.finished
40+ })
41+}
42+
43+func (d *roleEffectDrainDeadline) Done() <-chan struct{} {
44+ if d == nil {
45+ return nil
46+ }
47+ d.startOnce.Do(func() {
48+ d.started.Store(true)
49+ timer := d.clock.NewTimer(detachNotifyTimeout)
50+ go func() {
51+ defer close(d.finished)
52+ select {
53+ case <-timer.C():
54+ close(d.done)
55+ case <-d.stopCh:
56+ timer.Stop()
57+ }
58+ }()
59+ })
60+ return d.done
61+}
62+
63+type frozenRoleEffectGates struct {
64+ clients []*attachedClient
65+ interruptions map[*attachedClient]transportSnapshot
66+ acquired bool
67+ drained bool
68+}
69+
70+func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {
71+ interrupted, ok := f.interruptions[ac]
72+ return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation
73+}
74+
75+type roleTransportInterrupt struct {
76+ ac *attachedClient
77+ transport transportSnapshot
78+}
79+
80+// freezeRoleEffectGates establishes transition priority in immutable attachment
81+// order, then drains earlier admissions. It must be called without daemon,
82+// routing, session, coordinator, send, overlay, router, or pane locks held.
83+func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {
84+ return freezeRoleEffectGatesInterrupting(nil, clients...)
85+}
86+
87+// freezeRoleEffectGatesInterrupting may close an exact captured transport only
88+// while an earlier admitted effect is inside that transport's Send. Closing and
89+// all drain waits happen without the gate mutex or an architecture lock held.
90+func freezeRoleEffectGatesInterrupting(interrupts []roleTransportInterrupt, clients ...*attachedClient) frozenRoleEffectGates {
91+ return freezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...)
92+}
93+
94+func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
95+ return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...)
96+}
97+
98+// freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound
99+// for ordered acquisition and drain. Acquisition is all-or-nothing: if the
100+// bound expires behind another owner, only gates acquired by this call are
101+// rolled back, in reverse order. Once the complete set is owned, a terminal
102+// teardown may proceed when done closes during drain; late tickets then retire
103+// against an invalidated capability without pinning the session owner
104+// indefinitely.
105+func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
106+ ordered := orderedRoleEffectClients(clients)
107+ frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen)
108+ if !acquired {
109+ return frozenRoleEffectGates{}
110+ }
111+
112+ interruptByClient := roleTransportInterruptsByClient(interrupts)
113+ frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient))
114+ interruptFrozenRoleTransports(ordered, interruptByClient)
115+ frozen.acquired = true
116+ frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, done)
117+ return frozen
118+}
119+
120+// orderedRoleEffectClients snapshots unique participants in immutable lock
121+// order. Assigning a previously unused order does not freeze or wait on a gate.
122+func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient {
123+ unique := make(map[*attachedClient]struct{}, len(clients))
124+ ordered := make([]*attachedClient, 0, len(clients))
125+ for _, ac := range clients {
126+ if ac == nil {
127+ continue
128+ }
129+ if _, exists := unique[ac]; exists {
130+ continue
131+ }
132+ unique[ac] = struct{}{}
133+ ac.roleEffects.immutableOrder()
134+ ordered = append(ordered, ac)
135+ }
136+ sort.Slice(ordered, func(i, j int) bool {
137+ return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load()
138+ })
139+ return ordered
140+}
141+
142+// waitForRoleEffectChangeLocked requires g.mu and returns with g.mu held. A
143+// true result means the overall deadline won and the caller must stop waiting.
144+func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{}) bool {
145+ if done == nil {
146+ g.cond.Wait()
147+ return false
148+ }
149+ changed := g.changed
150+ doneCh := done()
151+ g.mu.Unlock()
152+ select {
153+ case <-changed:
154+ case <-doneCh:
155+ }
156+ g.mu.Lock()
157+ select {
158+ case <-doneCh:
159+ return true
160+ default:
161+ return false
162+ }
163+}
164+
165+// acquireRoleEffectGates establishes exclusive transition ownership in order.
166+// It must run without architecture locks. Deadline rollback releases only this
167+// call's partial acquisition and does so in reverse order.
168+func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient)) (frozenRoleEffectGates, bool) {
169+ frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))}
170+ for _, ac := range ordered {
171+ g := &ac.roleEffects
172+ g.mu.Lock()
173+ g.initLocked()
174+ for g.phase == roleEffectsFrozen {
175+ if waitForRoleEffectChangeLocked(g, done) {
176+ g.mu.Unlock()
177+ frozen.unfreeze()
178+ return frozenRoleEffectGates{}, false
179+ }
180+ }
181+ g.phase = roleEffectsFrozen
182+ g.mu.Unlock()
183+ frozen.clients = append(frozen.clients, ac)
184+ if afterFrozen != nil {
185+ afterFrozen(ac)
186+ }
187+ }
188+ return frozen, true
189+}
190+
191+func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*attachedClient]transportSnapshot {
192+ byClient := make(map[*attachedClient]transportSnapshot, len(interrupts))
193+ for _, interrupt := range interrupts {
194+ if interrupt.ac != nil && interrupt.transport.transport != nil {
195+ byClient[interrupt.ac] = interrupt.transport
196+ }
197+ }
198+ return byClient
199+}
200+
201+// interruptFrozenRoleTransports snapshots every exact in-flight link while all
202+// gates are frozen, then closes the complete set before any drain wait. One
203+// uncooperative Send cannot prevent another participant from being interrupted.
204+func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) {
205+ toClose := make([]roleTransportInterrupt, 0, len(interruptByClient))
206+ for _, ac := range ordered {
207+ expected, mayInterrupt := interruptByClient[ac]
208+ if !mayInterrupt {
209+ continue
210+ }
211+ g := &ac.roleEffects
212+ g.mu.Lock()
213+ if gateHasTransportEffectLocked(g, expected) {
214+ toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected})
215+ }
216+ g.mu.Unlock()
217+ }
218+ for _, interrupt := range toClose {
219+ _ = interrupt.ac.closeCapturedTransport(interrupt.transport.transport)
220+ }
221+}
222+
223+// drainFrozenRoleEffects waits for admissions made before the freeze. A closed
224+// deadline permits terminal teardown to continue with the capability frozen;
225+// late tickets can then retire without pinning the owner indefinitely.
226+func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool {
227+ for _, ac := range ordered {
228+ g := &ac.roleEffects
229+ g.mu.Lock()
230+ for g.inFlight != 0 {
231+ if waitForRoleEffectChangeLocked(g, done) {
232+ g.mu.Unlock()
233+ return false
234+ }
235+ }
236+ if expected, mayInterrupt := interruptByClient[ac]; mayInterrupt &&
237+ g.failedTransport.transport == expected.transport && g.failedTransport.incarnation == expected.incarnation {
238+ interruptions[ac] = expected
239+ }
240+ g.mu.Unlock()
241+ }
242+ return true
243+}
244+
245+func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool {
246+ for _, active := range g.transportEffects {
247+ if active.transport == expected.transport && active.incarnation == expected.incarnation {
248+ return true
249+ }
250+ }
251+ return false
252+}
253+
254+func (f frozenRoleEffectGates) unfreeze() {
255+ for i := len(f.clients) - 1; i >= 0; i-- {
256+ g := &f.clients[i].roleEffects
257+ g.mu.Lock()
258+ g.initLocked()
259+ g.phase = roleEffectsStable
260+ g.signalLocked()
261+ g.mu.Unlock()
262+ }
263+}
@@ -0,0 +1,263 @@
1+package daemon
2+
3+import (
4+ "sort"
5+ "sync"
6+ "sync/atomic"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+type roleEffectDrainDeadline struct {
12+ clock ports.Clock
13+ done chan struct{}
14+ stopCh chan struct{}
15+ finished chan struct{}
16+ startOnce sync.Once
17+ stopOnce sync.Once
18+ started atomic.Bool
19+}
20+
21+func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline {
22+ return &roleEffectDrainDeadline{
23+ clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}),
24+ }
25+}
26+
27+func (d *roleEffectDrainDeadline) stop() {
28+ if d == nil {
29+ return
30+ }
31+ d.stopOnce.Do(func() {
32+ // Claim an unstarted deadline without allocating a timer. Done is never
33+ // called after the owning freeze/drain operation returns.
34+ d.startOnce.Do(func() {})
35+ if !d.started.Load() {
36+ return
37+ }
38+ close(d.stopCh)
39+ <-d.finished
40+ })
41+}
42+
43+func (d *roleEffectDrainDeadline) Done() <-chan struct{} {
44+ if d == nil {
45+ return nil
46+ }
47+ d.startOnce.Do(func() {
48+ d.started.Store(true)
49+ timer := d.clock.NewTimer(detachNotifyTimeout)
50+ go func() {
51+ defer close(d.finished)
52+ select {
53+ case <-timer.C():
54+ close(d.done)
55+ case <-d.stopCh:
56+ timer.Stop()
57+ }
58+ }()
59+ })
60+ return d.done
61+}
62+
63+type frozenRoleEffectGates struct {
64+ clients []*attachedClient
65+ interruptions map[*attachedClient]transportSnapshot
66+ acquired bool
67+ drained bool
68+}
69+
70+func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {
71+ interrupted, ok := f.interruptions[ac]
72+ return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation
73+}
74+
75+type roleTransportInterrupt struct {
76+ ac *attachedClient
77+ transport transportSnapshot
78+}
79+
80+// freezeRoleEffectGates establishes transition priority in immutable attachment
81+// order, then drains earlier admissions. It must be called without daemon,
82+// routing, session, coordinator, send, overlay, router, or pane locks held.
83+func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {
84+ return freezeRoleEffectGatesInterrupting(nil, clients...)
85+}
86+
87+// freezeRoleEffectGatesInterrupting may close an exact captured transport only
88+// while an earlier admitted effect is inside that transport's Send. Closing and
89+// all drain waits happen without the gate mutex or an architecture lock held.
90+func freezeRoleEffectGatesInterrupting(interrupts []roleTransportInterrupt, clients ...*attachedClient) frozenRoleEffectGates {
91+ return freezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...)
92+}
93+
94+func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
95+ return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...)
96+}
97+
98+// freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound
99+// for ordered acquisition and drain. Acquisition is all-or-nothing: if the
100+// bound expires behind another owner, only gates acquired by this call are
101+// rolled back, in reverse order. Once the complete set is owned, a terminal
102+// teardown may proceed when done closes during drain; late tickets then retire
103+// against an invalidated capability without pinning the session owner
104+// indefinitely.
105+func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
106+ ordered := orderedRoleEffectClients(clients)
107+ frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen)
108+ if !acquired {
109+ return frozenRoleEffectGates{}
110+ }
111+
112+ interruptByClient := roleTransportInterruptsByClient(interrupts)
113+ frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient))
114+ interruptFrozenRoleTransports(ordered, interruptByClient)
115+ frozen.acquired = true
116+ frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, done)
117+ return frozen
118+}
119+
120+// orderedRoleEffectClients snapshots unique participants in immutable lock
121+// order. Assigning a previously unused order does not freeze or wait on a gate.
122+func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient {
123+ unique := make(map[*attachedClient]struct{}, len(clients))
124+ ordered := make([]*attachedClient, 0, len(clients))
125+ for _, ac := range clients {
126+ if ac == nil {
127+ continue
128+ }
129+ if _, exists := unique[ac]; exists {
130+ continue
131+ }
132+ unique[ac] = struct{}{}
133+ ac.roleEffects.immutableOrder()
134+ ordered = append(ordered, ac)
135+ }
136+ sort.Slice(ordered, func(i, j int) bool {
137+ return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load()
138+ })
139+ return ordered
140+}
141+
142+// waitForRoleEffectChangeLocked requires g.mu and returns with g.mu held. A
143+// true result means the overall deadline won and the caller must stop waiting.
144+func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{}) bool {
145+ if done == nil {
146+ g.cond.Wait()
147+ return false
148+ }
149+ changed := g.changed
150+ doneCh := done()
151+ g.mu.Unlock()
152+ select {
153+ case <-changed:
154+ case <-doneCh:
155+ }
156+ g.mu.Lock()
157+ select {
158+ case <-doneCh:
159+ return true
160+ default:
161+ return false
162+ }
163+}
164+
165+// acquireRoleEffectGates establishes exclusive transition ownership in order.
166+// It must run without architecture locks. Deadline rollback releases only this
167+// call's partial acquisition and does so in reverse order.
168+func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient)) (frozenRoleEffectGates, bool) {
169+ frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))}
170+ for _, ac := range ordered {
171+ g := &ac.roleEffects
172+ g.mu.Lock()
173+ g.initLocked()
174+ for g.phase == roleEffectsFrozen {
175+ if waitForRoleEffectChangeLocked(g, done) {
176+ g.mu.Unlock()
177+ frozen.unfreeze()
178+ return frozenRoleEffectGates{}, false
179+ }
180+ }
181+ g.phase = roleEffectsFrozen
182+ g.mu.Unlock()
183+ frozen.clients = append(frozen.clients, ac)
184+ if afterFrozen != nil {
185+ afterFrozen(ac)
186+ }
187+ }
188+ return frozen, true
189+}
190+
191+func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*attachedClient]transportSnapshot {
192+ byClient := make(map[*attachedClient]transportSnapshot, len(interrupts))
193+ for _, interrupt := range interrupts {
194+ if interrupt.ac != nil && interrupt.transport.transport != nil {
195+ byClient[interrupt.ac] = interrupt.transport
196+ }
197+ }
198+ return byClient
199+}
200+
201+// interruptFrozenRoleTransports snapshots every exact in-flight link while all
202+// gates are frozen, then closes the complete set before any drain wait. One
203+// uncooperative Send cannot prevent another participant from being interrupted.
204+func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) {
205+ toClose := make([]roleTransportInterrupt, 0, len(interruptByClient))
206+ for _, ac := range ordered {
207+ expected, mayInterrupt := interruptByClient[ac]
208+ if !mayInterrupt {
209+ continue
210+ }
211+ g := &ac.roleEffects
212+ g.mu.Lock()
213+ if gateHasTransportEffectLocked(g, expected) {
214+ toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected})
215+ }
216+ g.mu.Unlock()
217+ }
218+ for _, interrupt := range toClose {
219+ _ = interrupt.ac.closeCapturedTransport(interrupt.transport.transport)
220+ }
221+}
222+
223+// drainFrozenRoleEffects waits for admissions made before the freeze. A closed
224+// deadline permits terminal teardown to continue with the capability frozen;
225+// late tickets can then retire without pinning the owner indefinitely.
226+func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool {
227+ for _, ac := range ordered {
228+ g := &ac.roleEffects
229+ g.mu.Lock()
230+ for g.inFlight != 0 {
231+ if waitForRoleEffectChangeLocked(g, done) {
232+ g.mu.Unlock()
233+ return false
234+ }
235+ }
236+ if expected, mayInterrupt := interruptByClient[ac]; mayInterrupt &&
237+ g.failedTransport.transport == expected.transport && g.failedTransport.incarnation == expected.incarnation {
238+ interruptions[ac] = expected
239+ }
240+ g.mu.Unlock()
241+ }
242+ return true
243+}
244+
245+func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool {
246+ for _, active := range g.transportEffects {
247+ if active.transport == expected.transport && active.incarnation == expected.incarnation {
248+ return true
249+ }
250+ }
251+ return false
252+}
253+
254+func (f frozenRoleEffectGates) unfreeze() {
255+ for i := len(f.clients) - 1; i >= 0; i-- {
256+ g := &f.clients[i].roleEffects
257+ g.mu.Lock()
258+ g.initLocked()
259+ g.phase = roleEffectsStable
260+ g.signalLocked()
261+ g.mu.Unlock()
262+ }
263+}
M internal/usecase/daemon/role_effect_gate.go +0 -227
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "sort"
54 "sync"
65 "sync/atomic"
76
@@ -56,58 +55,6 @@ type roleEffectGate struct {
5655
5756 var nextRoleEffectGateOrder atomic.Uint64
5857
59-type roleEffectDrainDeadline struct {
60- clock ports.Clock
61- done chan struct{}
62- stopCh chan struct{}
63- finished chan struct{}
64- startOnce sync.Once
65- stopOnce sync.Once
66- started atomic.Bool
67-}
68-
69-func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline {
70- return &roleEffectDrainDeadline{
71- clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}),
72- }
73-}
74-
75-func (d *roleEffectDrainDeadline) stop() {
76- if d == nil {
77- return
78- }
79- d.stopOnce.Do(func() {
80- // Claim an unstarted deadline without allocating a timer. Done is never
81- // called after the owning freeze/drain operation returns.
82- d.startOnce.Do(func() {})
83- if !d.started.Load() {
84- return
85- }
86- close(d.stopCh)
87- <-d.finished
88- })
89-}
90-
91-func (d *roleEffectDrainDeadline) Done() <-chan struct{} {
92- if d == nil {
93- return nil
94- }
95- d.startOnce.Do(func() {
96- d.started.Store(true)
97- timer := d.clock.NewTimer(detachNotifyTimeout)
98- go func() {
99- defer close(d.finished)
100- select {
101- case <-timer.C():
102- close(d.done)
103- case <-d.stopCh:
104- timer.Stop()
105- }
106- }()
107- })
108- return d.done
109-}
110-
11158 func (g *roleEffectGate) initLocked() {
11259 if g.cond == nil {
11360 g.cond = sync.NewCond(&g.mu)
@@ -349,177 +296,3 @@ func (ac *attachedClient) invalidateFrozenRoleCapability() {
349296 g.failedTransport = transportSnapshot{}
350297 g.mu.Unlock()
351298 }
352-
353-type frozenRoleEffectGates struct {
354- clients []*attachedClient
355- interruptions map[*attachedClient]transportSnapshot
356- acquired bool
357- drained bool
358-}
359-
360-func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {
361- interrupted, ok := f.interruptions[ac]
362- return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation
363-}
364-
365-type roleTransportInterrupt struct {
366- ac *attachedClient
367- transport transportSnapshot
368-}
369-
370-// freezeRoleEffectGates establishes transition priority in immutable attachment
371-// order, then drains earlier admissions. It must be called without daemon,
372-// routing, session, coordinator, send, overlay, router, or pane locks held.
373-func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {
374- return freezeRoleEffectGatesInterrupting(nil, clients...)
375-}
376-
377-// freezeRoleEffectGatesInterrupting may close an exact captured transport only
378-// while an earlier admitted effect is inside that transport's Send. Closing and
379-// all drain waits happen without the gate mutex or an architecture lock held.
380-func freezeRoleEffectGatesInterrupting(interrupts []roleTransportInterrupt, clients ...*attachedClient) frozenRoleEffectGates {
381- return freezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...)
382-}
383-
384-func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
385- return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...)
386-}
387-
388-// freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound
389-// for ordered acquisition and drain. Acquisition is all-or-nothing: if the
390-// bound expires behind another owner, only gates acquired by this call are
391-// rolled back, in reverse order. Once the complete set is owned, a terminal
392-// teardown may proceed when done closes during drain; late tickets then retire
393-// against an invalidated capability without pinning the session owner
394-// indefinitely.
395-func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
396- unique := make(map[*attachedClient]struct{}, len(clients))
397- ordered := make([]*attachedClient, 0, len(clients))
398- for _, ac := range clients {
399- if ac == nil {
400- continue
401- }
402- if _, exists := unique[ac]; exists {
403- continue
404- }
405- unique[ac] = struct{}{}
406- ac.roleEffects.immutableOrder()
407- ordered = append(ordered, ac)
408- }
409- sort.Slice(ordered, func(i, j int) bool {
410- return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load()
411- })
412-
413- frozenClients := make([]*attachedClient, 0, len(ordered))
414- for _, ac := range ordered {
415- g := &ac.roleEffects
416- g.mu.Lock()
417- g.initLocked()
418- for g.phase == roleEffectsFrozen {
419- if done == nil {
420- g.cond.Wait()
421- continue
422- }
423- changed := g.changed
424- doneCh := done()
425- g.mu.Unlock()
426- select {
427- case <-changed:
428- case <-doneCh:
429- }
430- g.mu.Lock()
431- select {
432- case <-doneCh:
433- g.mu.Unlock()
434- partial := frozenRoleEffectGates{clients: frozenClients}
435- partial.unfreeze()
436- return frozenRoleEffectGates{}
437- default:
438- }
439- }
440- g.phase = roleEffectsFrozen
441- g.mu.Unlock()
442- frozenClients = append(frozenClients, ac)
443- if afterFrozen != nil {
444- afterFrozen(ac)
445- }
446- }
447- interruptByClient := make(map[*attachedClient]transportSnapshot, len(interrupts))
448- for _, interrupt := range interrupts {
449- if interrupt.ac != nil && interrupt.transport.transport != nil {
450- interruptByClient[interrupt.ac] = interrupt.transport
451- }
452- }
453- // Snapshot every exact in-flight link while all participant gates are frozen,
454- // then close the complete set before waiting on any one ticket. One
455- // uncooperative Send therefore cannot prevent another participant's Send
456- // from being interrupted.
457- interruptions := make(map[*attachedClient]transportSnapshot, len(interruptByClient))
458- toClose := make([]roleTransportInterrupt, 0, len(interruptByClient))
459- for _, ac := range ordered {
460- expected, mayInterrupt := interruptByClient[ac]
461- if !mayInterrupt {
462- continue
463- }
464- g := &ac.roleEffects
465- g.mu.Lock()
466- if gateHasTransportEffectLocked(g, expected) {
467- toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected})
468- }
469- g.mu.Unlock()
470- }
471- for _, interrupt := range toClose {
472- _ = interrupt.ac.closeCapturedTransport(interrupt.transport.transport)
473- }
474-
475- for _, ac := range ordered {
476- g := &ac.roleEffects
477- g.mu.Lock()
478- for g.inFlight != 0 {
479- if done == nil {
480- g.cond.Wait()
481- continue
482- }
483- changed := g.changed
484- doneCh := done()
485- g.mu.Unlock()
486- select {
487- case <-changed:
488- case <-doneCh:
489- }
490- g.mu.Lock()
491- select {
492- case <-doneCh:
493- g.mu.Unlock()
494- return frozenRoleEffectGates{clients: frozenClients, interruptions: interruptions, acquired: true}
495- default:
496- }
497- }
498- if expected, mayInterrupt := interruptByClient[ac]; mayInterrupt &&
499- g.failedTransport.transport == expected.transport && g.failedTransport.incarnation == expected.incarnation {
500- interruptions[ac] = expected
501- }
502- g.mu.Unlock()
503- }
504- return frozenRoleEffectGates{clients: frozenClients, interruptions: interruptions, acquired: true, drained: true}
505-}
506-
507-func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool {
508- for _, active := range g.transportEffects {
509- if active.transport == expected.transport && active.incarnation == expected.incarnation {
510- return true
511- }
512- }
513- return false
514-}
515-
516-func (f frozenRoleEffectGates) unfreeze() {
517- for i := len(f.clients) - 1; i >= 0; i-- {
518- g := &f.clients[i].roleEffects
519- g.mu.Lock()
520- g.initLocked()
521- g.phase = roleEffectsStable
522- g.signalLocked()
523- g.mu.Unlock()
524- }
525-}
@@ -1,7 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "sort"
5 "sync"4 "sync"
6 "sync/atomic"5 "sync/atomic"
7 6
@@ -56,58 +55,6 @@ type roleEffectGate struct {
56 55
57 var nextRoleEffectGateOrder atomic.Uint6456 var nextRoleEffectGateOrder atomic.Uint64
58 57
59-type roleEffectDrainDeadline struct {
60- clock ports.Clock
61- done chan struct{}
62- stopCh chan struct{}
63- finished chan struct{}
64- startOnce sync.Once
65- stopOnce sync.Once
66- started atomic.Bool
67-}
68-
69-func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline {
70- return &roleEffectDrainDeadline{
71- clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}),
72- }
73-}
74-
75-func (d *roleEffectDrainDeadline) stop() {
76- if d == nil {
77- return
78- }
79- d.stopOnce.Do(func() {
80- // Claim an unstarted deadline without allocating a timer. Done is never
81- // called after the owning freeze/drain operation returns.
82- d.startOnce.Do(func() {})
83- if !d.started.Load() {
84- return
85- }
86- close(d.stopCh)
87- <-d.finished
88- })
89-}
90-
91-func (d *roleEffectDrainDeadline) Done() <-chan struct{} {
92- if d == nil {
93- return nil
94- }
95- d.startOnce.Do(func() {
96- d.started.Store(true)
97- timer := d.clock.NewTimer(detachNotifyTimeout)
98- go func() {
99- defer close(d.finished)
100- select {
101- case <-timer.C():
102- close(d.done)
103- case <-d.stopCh:
104- timer.Stop()
105- }
106- }()
107- })
108- return d.done
109-}
110-
111 func (g *roleEffectGate) initLocked() {58 func (g *roleEffectGate) initLocked() {
112 if g.cond == nil {59 if g.cond == nil {
113 g.cond = sync.NewCond(&g.mu)60 g.cond = sync.NewCond(&g.mu)
@@ -349,177 +296,3 @@ func (ac *attachedClient) invalidateFrozenRoleCapability() {
349 g.failedTransport = transportSnapshot{}296 g.failedTransport = transportSnapshot{}
350 g.mu.Unlock()297 g.mu.Unlock()
351 }298 }
352-
353-type frozenRoleEffectGates struct {
354- clients []*attachedClient
355- interruptions map[*attachedClient]transportSnapshot
356- acquired bool
357- drained bool
358-}
359-
360-func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {
361- interrupted, ok := f.interruptions[ac]
362- return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation
363-}
364-
365-type roleTransportInterrupt struct {
366- ac *attachedClient
367- transport transportSnapshot
368-}
369-
370-// freezeRoleEffectGates establishes transition priority in immutable attachment
371-// order, then drains earlier admissions. It must be called without daemon,
372-// routing, session, coordinator, send, overlay, router, or pane locks held.
373-func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {
374- return freezeRoleEffectGatesInterrupting(nil, clients...)
375-}
376-
377-// freezeRoleEffectGatesInterrupting may close an exact captured transport only
378-// while an earlier admitted effect is inside that transport's Send. Closing and
379-// all drain waits happen without the gate mutex or an architecture lock held.
380-func freezeRoleEffectGatesInterrupting(interrupts []roleTransportInterrupt, clients ...*attachedClient) frozenRoleEffectGates {
381- return freezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...)
382-}
383-
384-func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
385- return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...)
386-}
387-
388-// freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound
389-// for ordered acquisition and drain. Acquisition is all-or-nothing: if the
390-// bound expires behind another owner, only gates acquired by this call are
391-// rolled back, in reverse order. Once the complete set is owned, a terminal
392-// teardown may proceed when done closes during drain; late tickets then retire
393-// against an invalidated capability without pinning the session owner
394-// indefinitely.
395-func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
396- unique := make(map[*attachedClient]struct{}, len(clients))
397- ordered := make([]*attachedClient, 0, len(clients))
398- for _, ac := range clients {
399- if ac == nil {
400- continue
401- }
402- if _, exists := unique[ac]; exists {
403- continue
404- }
405- unique[ac] = struct{}{}
406- ac.roleEffects.immutableOrder()
407- ordered = append(ordered, ac)
408- }
409- sort.Slice(ordered, func(i, j int) bool {
410- return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load()
411- })
412-
413- frozenClients := make([]*attachedClient, 0, len(ordered))
414- for _, ac := range ordered {
415- g := &ac.roleEffects
416- g.mu.Lock()
417- g.initLocked()
418- for g.phase == roleEffectsFrozen {
419- if done == nil {
420- g.cond.Wait()
421- continue
422- }
423- changed := g.changed
424- doneCh := done()
425- g.mu.Unlock()
426- select {
427- case <-changed:
428- case <-doneCh:
429- }
430- g.mu.Lock()
431- select {
432- case <-doneCh:
433- g.mu.Unlock()
434- partial := frozenRoleEffectGates{clients: frozenClients}
435- partial.unfreeze()
436- return frozenRoleEffectGates{}
437- default:
438- }
439- }
440- g.phase = roleEffectsFrozen
441- g.mu.Unlock()
442- frozenClients = append(frozenClients, ac)
443- if afterFrozen != nil {
444- afterFrozen(ac)
445- }
446- }
447- interruptByClient := make(map[*attachedClient]transportSnapshot, len(interrupts))
448- for _, interrupt := range interrupts {
449- if interrupt.ac != nil && interrupt.transport.transport != nil {
450- interruptByClient[interrupt.ac] = interrupt.transport
451- }
452- }
453- // Snapshot every exact in-flight link while all participant gates are frozen,
454- // then close the complete set before waiting on any one ticket. One
455- // uncooperative Send therefore cannot prevent another participant's Send
456- // from being interrupted.
457- interruptions := make(map[*attachedClient]transportSnapshot, len(interruptByClient))
458- toClose := make([]roleTransportInterrupt, 0, len(interruptByClient))
459- for _, ac := range ordered {
460- expected, mayInterrupt := interruptByClient[ac]
461- if !mayInterrupt {
462- continue
463- }
464- g := &ac.roleEffects
465- g.mu.Lock()
466- if gateHasTransportEffectLocked(g, expected) {
467- toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected})
468- }
469- g.mu.Unlock()
470- }
471- for _, interrupt := range toClose {
472- _ = interrupt.ac.closeCapturedTransport(interrupt.transport.transport)
473- }
474-
475- for _, ac := range ordered {
476- g := &ac.roleEffects
477- g.mu.Lock()
478- for g.inFlight != 0 {
479- if done == nil {
480- g.cond.Wait()
481- continue
482- }
483- changed := g.changed
484- doneCh := done()
485- g.mu.Unlock()
486- select {
487- case <-changed:
488- case <-doneCh:
489- }
490- g.mu.Lock()
491- select {
492- case <-doneCh:
493- g.mu.Unlock()
494- return frozenRoleEffectGates{clients: frozenClients, interruptions: interruptions, acquired: true}
495- default:
496- }
497- }
498- if expected, mayInterrupt := interruptByClient[ac]; mayInterrupt &&
499- g.failedTransport.transport == expected.transport && g.failedTransport.incarnation == expected.incarnation {
500- interruptions[ac] = expected
501- }
502- g.mu.Unlock()
503- }
504- return frozenRoleEffectGates{clients: frozenClients, interruptions: interruptions, acquired: true, drained: true}
505-}
506-
507-func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool {
508- for _, active := range g.transportEffects {
509- if active.transport == expected.transport && active.incarnation == expected.incarnation {
510- return true
511- }
512- }
513- return false
514-}
515-
516-func (f frozenRoleEffectGates) unfreeze() {
517- for i := len(f.clients) - 1; i >= 0; i-- {
518- g := &f.clients[i].roleEffects
519- g.mu.Lock()
520- g.initLocked()
521- g.phase = roleEffectsStable
522- g.signalLocked()
523- g.mu.Unlock()
524- }
525-}
M internal/usecase/daemon/session.go +2 -1
@@ -1436,7 +1436,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
14361436 // while the complete immutable gate set remains frozen.
14371437 delete(d.sessions, sess.id)
14381438 d.clearBarScriptsForSession(sess.id)
1439- d.purgeParkedForSessionLocked(sess)
1439+ parkedRetirements := d.purgeParkedForSessionLocked(sess)
14401440 detachedActive := sess.client
14411441 attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))
14421442 capture := func(attached *attachedClient) {
@@ -1474,6 +1474,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
14741474 d.closing = true
14751475 }
14761476 d.mu.Unlock()
1477+ finishParkedAttachmentRetirements(parkedRetirements)
14771478 frozen.unfreeze()
14781479 frozen = frozenRoleEffectGates{}
14791480 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
@@ -1436,7 +1436,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1436 // while the complete immutable gate set remains frozen.1436 // while the complete immutable gate set remains frozen.
1437 delete(d.sessions, sess.id)1437 delete(d.sessions, sess.id)
1438 d.clearBarScriptsForSession(sess.id)1438 d.clearBarScriptsForSession(sess.id)
1439- d.purgeParkedForSessionLocked(sess)1439+ parkedRetirements := d.purgeParkedForSessionLocked(sess)
1440 detachedActive := sess.client1440 detachedActive := sess.client
1441 attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))1441 attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))
1442 capture := func(attached *attachedClient) {1442 capture := func(attached *attachedClient) {
@@ -1474,6 +1474,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1474 d.closing = true1474 d.closing = true
1475 }1475 }
1476 d.mu.Unlock()1476 d.mu.Unlock()
1477+ finishParkedAttachmentRetirements(parkedRetirements)
1477 frozen.unfreeze()1478 frozen.unfreeze()
1478 frozen = frozenRoleEffectGates{}1479 frozen = frozenRoleEffectGates{}
1479 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)1480 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
A internal/usecase/daemon/snatched_cleanup.go +171 -0
@@ -0,0 +1,171 @@
1+package daemon
2+
3+// removeSnatchedAttachment removes exactly one role generation and transport
4+// from session routing. It never touches the active attachment or closes a
5+// transport, allowing callers that already interrupted a blocked send to avoid
6+// closing the same link twice.
7+func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool {
8+ return d.unrouteSnatchedAttachment(token, true)
9+}
10+
11+func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool {
12+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
13+ return false
14+ }
15+ frozen := freezeRoleEffectGates(token.ac)
16+ defer frozen.unfreeze()
17+ d.notices.routingMu.Lock()
18+ token.sess.mu.Lock()
19+ current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
20+ token.ac.roleGeneration.Load() == token.generation &&
21+ token.ac.transportSnapshotCurrent(token.transport)
22+ if current {
23+ delete(token.sess.snatched, token.ac)
24+ token.ac.roleGeneration.Add(1)
25+ token.ac.invalidateFrozenRoleCapability()
26+ }
27+ token.sess.mu.Unlock()
28+ d.notices.routingMu.Unlock()
29+ if !current {
30+ return false
31+ }
32+
33+ token.ac.clearSnatchedInput()
34+ d.unregisterPreview(token.ac)
35+ if terminal {
36+ token.ac.clearPreviousSession()
37+ }
38+ token.ac.setSession(nil)
39+ token.ac.clearCaptureFrames()
40+ return true
41+}
42+
43+// parkSnatchedAttachment removes only the exact lost snatched incarnation and
44+// retains its resume identity for a role-preserving reconnect.
45+func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {
46+ if token.ac == nil || !token.ac.resumeCapable || !d.unrouteSnatchedAttachment(token, false) {
47+ return false
48+ }
49+ if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
50+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
51+ return true
52+ }
53+ token.ac.clearPreviousSession()
54+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
55+ return false
56+}
57+
58+func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool {
59+ if d.parkSnatchedAttachment(token) {
60+ return true
61+ }
62+ return d.dropSnatchedAttachment(token)
63+}
64+
65+// cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced
66+// transport that was closed to drain an admitted send. It waits on the gate's
67+// condition rather than ordinary effect admission, then removes only the exact
68+// published snatched incarnation. Attachment-local cleanup runs without routing
69+// or session locks while the terminal capability remains frozen.
70+func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool {
71+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
72+ return false
73+ }
74+ frozen := freezeRoleEffectGates(token.ac)
75+ defer frozen.unfreeze()
76+
77+ d.notices.routingMu.Lock()
78+ token.sess.mu.Lock()
79+ current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
80+ token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
81+ token.ac.transportSnapshotCurrent(token.transport)
82+ token.sess.mu.Unlock()
83+ d.notices.routingMu.Unlock()
84+ if !current {
85+ return false
86+ }
87+
88+ // The frozen gate prevents a promotion from publishing between overlay
89+ // cleanup and terminal registry publication.
90+ d.clearForSnatch(token)
91+
92+ d.notices.routingMu.Lock()
93+ token.sess.mu.Lock()
94+ current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
95+ token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
96+ token.ac.transportSnapshotCurrent(token.transport)
97+ if current {
98+ delete(token.sess.snatched, token.ac)
99+ token.ac.roleGeneration.Add(1)
100+ token.ac.invalidateFrozenRoleCapability()
101+ }
102+ token.sess.mu.Unlock()
103+ d.notices.routingMu.Unlock()
104+ if !current {
105+ return false
106+ }
107+
108+ token.ac.setSession(nil)
109+ if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
110+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
111+ token.ac.clearSnatchedInput()
112+ d.unregisterPreview(token.ac)
113+ return true
114+ }
115+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
116+ token.ac.clearSnatchedInput()
117+ d.unregisterPreview(token.ac)
118+ token.ac.clearPreviousSession()
119+ token.ac.clearCaptureFrames()
120+ return true
121+}
122+
123+// dropSnatchedAttachment removes and closes only the captured snatched link.
124+func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool {
125+ if !d.removeSnatchedAttachment(token) {
126+ return false
127+ }
128+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
129+ return true
130+}
131+
132+// deferAttachmentTransitionCleanups retires coordinator timers and accounting
133+// after ownership publication. Cleanup never gates the attaching handshake. A
134+// displaced render that already owns sendMu is interrupted by closing only its
135+// captured transport; an idle healthy snatched transport remains connected and
136+// receives a dependency-free reset panel.
137+func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {
138+ if token := result.displaced; token.ac != nil && token.transport.transport != nil {
139+ blockedRender := result.displacedInterrupted
140+ if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {
141+ blockedRender = !token.ac.sendMu.TryLock()
142+ if !blockedRender {
143+ token.ac.sendMu.Unlock()
144+ }
145+ }
146+ d.attachmentCleanupWg.Go(func() {
147+ if d.afterDisplacedCleanupStarted != nil {
148+ d.afterDisplacedCleanupStarted()
149+ }
150+ if blockedRender {
151+ // This path owns terminal cleanup independently of ordinary role
152+ // admission: a stale send-error detach may transiently freeze the gate.
153+ _ = token.ac.closeCapturedTransport(token.transport.transport)
154+ d.cleanupInterruptedSnatchedAttachment(token)
155+ return
156+ }
157+ ticket, admitted := token.ac.beginRoleEffect(token)
158+ if !admitted {
159+ return
160+ }
161+ defer ticket.End()
162+ if err := d.sendInitialSnatchedPanel(token, ticket); err != nil {
163+ ticket.End()
164+ d.parkOrDropSnatchedAttachment(token)
165+ }
166+ })
167+ }
168+ for _, cleanup := range result.cleanups {
169+ d.attachmentCleanupWg.Go(cleanup.finish)
170+ }
171+}
@@ -0,0 +1,171 @@
1+package daemon
2+
3+// removeSnatchedAttachment removes exactly one role generation and transport
4+// from session routing. It never touches the active attachment or closes a
5+// transport, allowing callers that already interrupted a blocked send to avoid
6+// closing the same link twice.
7+func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool {
8+ return d.unrouteSnatchedAttachment(token, true)
9+}
10+
11+func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool {
12+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
13+ return false
14+ }
15+ frozen := freezeRoleEffectGates(token.ac)
16+ defer frozen.unfreeze()
17+ d.notices.routingMu.Lock()
18+ token.sess.mu.Lock()
19+ current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
20+ token.ac.roleGeneration.Load() == token.generation &&
21+ token.ac.transportSnapshotCurrent(token.transport)
22+ if current {
23+ delete(token.sess.snatched, token.ac)
24+ token.ac.roleGeneration.Add(1)
25+ token.ac.invalidateFrozenRoleCapability()
26+ }
27+ token.sess.mu.Unlock()
28+ d.notices.routingMu.Unlock()
29+ if !current {
30+ return false
31+ }
32+
33+ token.ac.clearSnatchedInput()
34+ d.unregisterPreview(token.ac)
35+ if terminal {
36+ token.ac.clearPreviousSession()
37+ }
38+ token.ac.setSession(nil)
39+ token.ac.clearCaptureFrames()
40+ return true
41+}
42+
43+// parkSnatchedAttachment removes only the exact lost snatched incarnation and
44+// retains its resume identity for a role-preserving reconnect.
45+func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {
46+ if token.ac == nil || !token.ac.resumeCapable || !d.unrouteSnatchedAttachment(token, false) {
47+ return false
48+ }
49+ if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
50+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
51+ return true
52+ }
53+ token.ac.clearPreviousSession()
54+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
55+ return false
56+}
57+
58+func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool {
59+ if d.parkSnatchedAttachment(token) {
60+ return true
61+ }
62+ return d.dropSnatchedAttachment(token)
63+}
64+
65+// cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced
66+// transport that was closed to drain an admitted send. It waits on the gate's
67+// condition rather than ordinary effect admission, then removes only the exact
68+// published snatched incarnation. Attachment-local cleanup runs without routing
69+// or session locks while the terminal capability remains frozen.
70+func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool {
71+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
72+ return false
73+ }
74+ frozen := freezeRoleEffectGates(token.ac)
75+ defer frozen.unfreeze()
76+
77+ d.notices.routingMu.Lock()
78+ token.sess.mu.Lock()
79+ current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
80+ token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
81+ token.ac.transportSnapshotCurrent(token.transport)
82+ token.sess.mu.Unlock()
83+ d.notices.routingMu.Unlock()
84+ if !current {
85+ return false
86+ }
87+
88+ // The frozen gate prevents a promotion from publishing between overlay
89+ // cleanup and terminal registry publication.
90+ d.clearForSnatch(token)
91+
92+ d.notices.routingMu.Lock()
93+ token.sess.mu.Lock()
94+ current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
95+ token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
96+ token.ac.transportSnapshotCurrent(token.transport)
97+ if current {
98+ delete(token.sess.snatched, token.ac)
99+ token.ac.roleGeneration.Add(1)
100+ token.ac.invalidateFrozenRoleCapability()
101+ }
102+ token.sess.mu.Unlock()
103+ d.notices.routingMu.Unlock()
104+ if !current {
105+ return false
106+ }
107+
108+ token.ac.setSession(nil)
109+ if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
110+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
111+ token.ac.clearSnatchedInput()
112+ d.unregisterPreview(token.ac)
113+ return true
114+ }
115+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
116+ token.ac.clearSnatchedInput()
117+ d.unregisterPreview(token.ac)
118+ token.ac.clearPreviousSession()
119+ token.ac.clearCaptureFrames()
120+ return true
121+}
122+
123+// dropSnatchedAttachment removes and closes only the captured snatched link.
124+func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool {
125+ if !d.removeSnatchedAttachment(token) {
126+ return false
127+ }
128+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
129+ return true
130+}
131+
132+// deferAttachmentTransitionCleanups retires coordinator timers and accounting
133+// after ownership publication. Cleanup never gates the attaching handshake. A
134+// displaced render that already owns sendMu is interrupted by closing only its
135+// captured transport; an idle healthy snatched transport remains connected and
136+// receives a dependency-free reset panel.
137+func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {
138+ if token := result.displaced; token.ac != nil && token.transport.transport != nil {
139+ blockedRender := result.displacedInterrupted
140+ if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {
141+ blockedRender = !token.ac.sendMu.TryLock()
142+ if !blockedRender {
143+ token.ac.sendMu.Unlock()
144+ }
145+ }
146+ d.attachmentCleanupWg.Go(func() {
147+ if d.afterDisplacedCleanupStarted != nil {
148+ d.afterDisplacedCleanupStarted()
149+ }
150+ if blockedRender {
151+ // This path owns terminal cleanup independently of ordinary role
152+ // admission: a stale send-error detach may transiently freeze the gate.
153+ _ = token.ac.closeCapturedTransport(token.transport.transport)
154+ d.cleanupInterruptedSnatchedAttachment(token)
155+ return
156+ }
157+ ticket, admitted := token.ac.beginRoleEffect(token)
158+ if !admitted {
159+ return
160+ }
161+ defer ticket.End()
162+ if err := d.sendInitialSnatchedPanel(token, ticket); err != nil {
163+ ticket.End()
164+ d.parkOrDropSnatchedAttachment(token)
165+ }
166+ })
167+ }
168+ for _, cleanup := range result.cleanups {
169+ d.attachmentCleanupWg.Go(cleanup.finish)
170+ }
171+}
M internal/usecase/daemon/snatched_client.go +0 -792
@@ -6,14 +6,6 @@ import (
66 "github.com/bnema/vev/internal/ports"
77 )
88
9-type attachmentRole uint8
10-
11-const (
12- attachmentDetached attachmentRole = iota
13- attachmentActive
14- attachmentSnatched
15-)
16-
179 type initialSnatchedPanelAttempt struct {
1810 done chan struct{}
1911 err error
@@ -66,560 +58,6 @@ func (d *Daemon) sendInitialSnatchedPanel(token attachmentRoleToken, ticket *rol
6658 return err
6759 }
6860
69-// attachmentRole derives the attachment's role from the session-owned
70-// registries. There is deliberately no second role field that could drift.
71-func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
72- if s == nil || ac == nil {
73- return attachmentDetached
74- }
75- s.mu.Lock()
76- defer s.mu.Unlock()
77- return s.attachmentRoleLocked(ac)
78-}
79-
80-// attachmentRoleLocked requires s.mu. Active membership wins if an invalid
81-// intermediate state contains the same attachment in both registries.
82-func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {
83- if ac == nil {
84- return attachmentDetached
85- }
86- if s.client == ac {
87- return attachmentActive
88- }
89- if _, ok := s.snatched[ac]; ok {
90- return attachmentSnatched
91- }
92- return attachmentDetached
93-}
94-
95-func (s *session) addSnatchedLocked(ac *attachedClient) {
96- if ac == nil {
97- return
98- }
99- if s.snatched == nil {
100- s.snatched = make(map[*attachedClient]struct{})
101- }
102- s.snatched[ac] = struct{}{}
103-}
104-
105-var errAttachmentTransition = errors.New("attachment transition is no longer valid")
106-
107-type attachmentTransitionRequest struct {
108- source *session
109- target *session
110- next *attachedClient
111- expectedRole attachmentRole
112- targetRole attachmentRole
113- expectedTransport transportSnapshot
114- sourceToken *attachmentRoleToken
115- action string
116- activateTargetTab bool
117- targetTabIndex int
118- copySourceEnvironment bool
119- // preserveRole commits navigation state without replacing attachment
120- // ownership. It is used for same-session picker selections after the exact
121- // initiating capability has been frozen and revalidated.
122- preserveRole bool
123- expectedSourceTab *tab
124- createTargetLocked func() (*session, error)
125- ready bool
126- expectedTargetCurrent *attachedClient
127- expectedTargetTransport transportSnapshot
128- preflighted bool
129- roleEffectsFrozen bool
130- expectedTargetTransportInterrupted bool
131- // activationBarrier is used when promoting an already connected snatched
132- // attachment. Its role gate is deadline-drained before sendMu is acquired,
133- // preserving the gate -> sendMu -> daemon/routing/session publication order.
134- activationBarrier bool
135-}
136-
137-func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool {
138- return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&
139- token.transport.transport == req.expectedTransport.transport &&
140- token.transport.incarnation == req.expectedTransport.incarnation
141-}
142-
143-// transitionSourceTokenCurrentLocked requires source.mu and, for an active
144-// source, sourceCoordinator.mu. This is the canonical handoff preflight: every
145-// client-originated navigation, delete, or create intent is bound to one exact
146-// role generation, transport incarnation, and coordinator lease.
147-func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
148- if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||
149- token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role ||
150- req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {
151- return false
152- }
153- if token.role != attachmentActive {
154- return true
155- }
156- return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next &&
157- sourceCoordinator.leaseCurrentLocked(token.lease, true)
158-}
159-
160-type attachmentRoleToken struct {
161- sess *session
162- ac *attachedClient
163- role attachmentRole
164- generation uint64
165- transport transportSnapshot
166- lease *attachmentLease
167- effect *roleEffectTicket
168- // rebase marks a cross-session ownership move whose first paint must start a
169- // dependency-free output chain. The rebase is deliberately deferred until
170- // after transition publication releases all architecture locks.
171- rebase bool
172-}
173-
174-func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
175- if s == nil || ac == nil || tr == nil {
176- return attachmentRoleToken{}
177- }
178- s.mu.Lock()
179- transport := ac.transportSnapshot()
180- if transport.transport != tr {
181- s.mu.Unlock()
182- return attachmentRoleToken{}
183- }
184- token := attachmentRoleToken{
185- sess: s,
186- ac: ac,
187- role: s.attachmentRoleLocked(ac),
188- generation: ac.roleGeneration.Load(),
189- transport: transport,
190- }
191- s.mu.Unlock()
192- if token.role == attachmentActive {
193- if rc := s.renderCoordinator(); rc != nil {
194- token.lease = rc.attachmentLease(ac)
195- }
196- }
197- ac.bootstrapRoleCapability(token)
198- return token
199-}
200-
201-func (t attachmentRoleToken) current() bool {
202- return t.sess != nil && t.ac != nil &&
203- t.ac.roleGeneration.Load() == t.generation &&
204- t.ac.transportSnapshotCurrent(t.transport) &&
205- t.sess.attachmentRole(t.ac) == t.role
206-}
207-
208-// activeCurrent admits post-transition effects only for the exact published
209-// role, transport incarnation, current-session link, and coordinator lease.
210-func (t attachmentRoleToken) activeCurrent() bool {
211- if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess {
212- return false
213- }
214- rc := t.sess.renderCoordinator()
215- return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
216-}
217-
218-// activeEffect is the canonical admission check for a client-frame mutation.
219-// Tokens bind role generation, transport incarnation, session ownership, and
220-// coordinator lease, so handlers never rediscover authority from mutable state.
221-func (t attachmentRoleToken) activeEffect() bool {
222- if t.effect != nil && !t.effect.ended.Load() {
223- return true
224- }
225- return t.activeCurrent()
226-}
227-
228-func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) {
229- if sess == nil || ac == nil || lease == nil {
230- return nil, false
231- }
232- token := sess.attachmentToken(ac, ac.transport())
233- token.lease = lease
234- return ac.beginRoleEffect(token)
235-}
236-
237-func (t *attachmentRoleToken) endRoleEffect() {
238- if t == nil || t.effect == nil {
239- return
240- }
241- t.effect.End()
242- t.effect = nil
243-}
244-
245-// activeEffectSessionLocked is the same admission check while t.sess.mu is
246-// already held at a session-owned mutation boundary.
247-func (t attachmentRoleToken) activeEffectSessionLocked() bool {
248- if t.effect != nil && !t.effect.ended.Load() {
249- return true
250- }
251- if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil ||
252- t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) ||
253- t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive {
254- return false
255- }
256- rc := t.sess.renderCoordinator()
257- return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
258-}
259-
260-func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error {
261- if t.ac == nil {
262- return errAttachmentTransition
263- }
264- t.ac.sendMu.Lock()
265- defer t.ac.sendMu.Unlock()
266- if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) ||
267- !t.effect.beginTransportSend(t.transport) {
268- return errAttachmentTransition
269- }
270- err := t.transport.transport.Send(frame)
271- if err != nil {
272- t.effect.reportTransportFailure(t.transport)
273- }
274- t.effect.endTransportSend()
275- return err
276-}
277-
278-type attachmentTransitionResult struct {
279- published attachmentRoleToken
280- displaced attachmentRoleToken
281- displacedInterrupted bool
282- cleanups []renderLifecycleCleanup
283-}
284-
285-func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
286- // Preflight identifies every gate the publication can affect. No gate is
287- // frozen while an architecture lock is held.
288- d.mu.Lock()
289- source := req.source
290- if source == nil {
291- source = req.target
292- }
293- if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||
294- d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target ||
295- req.activationBarrier && (req.sourceToken == nil || source != req.target ||
296- req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
297- d.mu.Unlock()
298- return attachmentTransitionResult{}, errAttachmentTransition
299- }
300- if req.target != nil {
301- req.target.mu.Lock()
302- req.expectedTargetCurrent = req.target.client
303- if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {
304- req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
305- }
306- req.target.mu.Unlock()
307- }
308- if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
309- d.mu.Unlock()
310- return attachmentTransitionResult{}, errAttachmentTransition
311- }
312- req.preflighted = true
313- d.mu.Unlock()
314-
315- if req.sourceToken != nil {
316- d.endActionRoleEffect(req.sourceToken.effect, req.action)
317- }
318-
319- participants := []*attachedClient{req.next, req.expectedTargetCurrent}
320- if d.afterRoleEffectParticipantsSnapshotted != nil {
321- d.afterRoleEffectParticipantsSnapshotted(req.action, participants)
322- }
323- interrupts := []roleTransportInterrupt{{
324- ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
325- }}
326- var drainDeadline *roleEffectDrainDeadline
327- var drainDone func() <-chan struct{}
328- if req.activationBarrier {
329- drainDeadline = newRoleEffectDrainDeadline(d.clock)
330- drainDone = drainDeadline.Done
331- }
332- frozen := freezeRoleEffectGatesInterruptingObservedUntil(interrupts, drainDone, func(ac *attachedClient) {
333- if d.afterRoleEffectGateFrozen != nil {
334- d.afterRoleEffectGateFrozen(req.action, ac)
335- }
336- }, participants...)
337- if drainDeadline != nil {
338- drainDeadline.stop()
339- }
340- defer frozen.unfreeze()
341- if !frozen.acquired || !frozen.drained {
342- if req.activationBarrier {
343- _ = req.next.closeCapturedTransport(req.expectedTransport.transport)
344- return attachmentTransitionResult{}, errSendTimedOut
345- }
346- return attachmentTransitionResult{}, errAttachmentTransition
347- }
348- req.expectedTargetTransportInterrupted = frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)
349- if d.afterRoleEffectsFrozen != nil {
350- d.afterRoleEffectsFrozen()
351- }
352-
353- var releaseActivation func()
354- if req.activationBarrier {
355- var err error
356- releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken)
357- if err != nil {
358- return attachmentTransitionResult{}, err
359- }
360- defer func() {
361- if releaseActivation != nil {
362- releaseActivation()
363- }
364- }()
365- }
366-
367- d.mu.Lock()
368- req.roleEffectsFrozen = true
369- if req.target == nil {
370- if !d.transitionSourcePreflightLocked(req) {
371- d.mu.Unlock()
372- return attachmentTransitionResult{}, errAttachmentTransition
373- }
374- var err error
375- req.target, err = req.createTargetLocked()
376- if err != nil {
377- d.mu.Unlock()
378- return attachmentTransitionResult{}, err
379- }
380- req.expectedTargetCurrent = nil
381- }
382- result, err := d.transitionAttachmentLocked(req)
383- d.mu.Unlock()
384- if err == nil && req.activationBarrier && result.published.role == attachmentActive {
385- ac := result.published.ac
386- if ac.output != nil {
387- ac.output.rebase()
388- }
389- ac.captureFrames = nil
390- d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false)
391- // The first paint still requests a reset, but the old panel dependency
392- // chain has already been rebased atomically under the activation barrier.
393- result.published.rebase = false
394- }
395- if releaseActivation != nil {
396- releaseActivation()
397- releaseActivation = nil
398- }
399- if err == nil && result.published.role == attachmentActive {
400- result.published.ac.clearSnatchedInput()
401- }
402- return result, err
403-}
404-
405-// transitionSourcePreflightLocked validates an initiating role while its gate
406-// is frozen and d.mu is held. No caller may create or delete a target before
407-// this succeeds.
408-func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {
409- if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source {
410- return false
411- }
412- d.notices.routingMu.Lock()
413- defer d.notices.routingMu.Unlock()
414- req.source.mu.Lock()
415- defer req.source.mu.Unlock()
416- coordinator := req.source.renderCoordinator()
417- if coordinator == nil {
418- return false
419- }
420- coordinator.mu.Lock()
421- defer coordinator.mu.Unlock()
422- if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {
423- return false
424- }
425- return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)
426-}
427-
428-func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
429- if token.sess == nil || token.ac == nil || token.role != attachmentActive {
430- return false
431- }
432- d.mu.Lock()
433- defer d.mu.Unlock()
434- if d.closing || d.sessions[token.sess.id] != token.sess {
435- return false
436- }
437- d.notices.routingMu.Lock()
438- defer d.notices.routingMu.Unlock()
439- token.sess.mu.Lock()
440- defer token.sess.mu.Unlock()
441- coordinator := token.sess.renderCoordinator()
442- if coordinator == nil {
443- return false
444- }
445- coordinator.mu.Lock()
446- defer coordinator.mu.Unlock()
447- req := attachmentTransitionRequest{
448- source: token.sess, next: token.ac, expectedRole: token.role,
449- expectedTransport: token.transport,
450- }
451- return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
452-}
453-
454-func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
455- if expected == nil {
456- return true
457- }
458- if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected {
459- return false
460- }
461- expected.mu.Lock()
462- defer expected.mu.Unlock()
463- return expected.floating.state != floatingVisible
464-}
465-
466-// transitionAttachmentLocked is the publication half of transitionAttachment.
467-// Caller holds d.mu after the lock-free gate freeze/drain has completed.
468-func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
469- d.notices.routingMu.Lock()
470- defer d.notices.routingMu.Unlock()
471- return d.transitionAttachmentRoutedLocked(req)
472-}
473-
474-// transitionAttachmentRoutedLocked performs the centralized role publication.
475-// Caller holds d.mu and notices.routingMu. Every fallible lifecycle, role, and
476-// transport check occurs before session.client is changed. Coordinator
477-// finalization after that commit point cannot roll ownership back.
478-func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
479- source := req.source
480- if source == nil {
481- source = req.target
482- }
483- if !req.preflighted || !req.roleEffectsFrozen ||
484- !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
485- source == nil || req.target == nil || req.next == nil || d.closing ||
486- d.sessions[source.id] != source || d.sessions[req.target.id] != req.target ||
487- req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) {
488- return attachmentTransitionResult{}, errAttachmentTransition
489- }
490- // Install the target coordinator object before taking both session locks;
491- // lease binding itself remains part of the atomic publication below.
492- var targetCoordinator *renderCoordinator
493- if req.targetRole == attachmentActive {
494- targetCoordinator = d.ensureRenderCoordinator(req.target)
495- }
496- sourceCoordinator := source.renderCoordinator()
497-
498- unlockSessions := lockAttachmentSessions(source, req.target)
499- if source.attachmentRoleLocked(req.next) != req.expectedRole ||
500- !req.next.transportSnapshotCurrent(req.expectedTransport) {
501- unlockSessions()
502- return attachmentTransitionResult{}, errAttachmentTransition
503- }
504-
505- old := req.target.client
506- if req.preflighted && old != req.expectedTargetCurrent {
507- unlockSessions()
508- return attachmentTransitionResult{}, errAttachmentTransition
509- }
510- if source != req.target && old == req.next {
511- unlockSessions()
512- return attachmentTransitionResult{}, errAttachmentTransition
513- }
514- unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator)
515- if req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
516- !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) {
517- unlockCoordinators()
518- unlockSessions()
519- return attachmentTransitionResult{}, errAttachmentTransition
520- }
521- if targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) {
522- unlockCoordinators()
523- unlockSessions()
524- return attachmentTransitionResult{}, errAttachmentTransition
525- }
526- if req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
527- (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) {
528- unlockCoordinators()
529- unlockSessions()
530- return attachmentTransitionResult{}, errAttachmentTransition
531- }
532- if req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) {
533- unlockCoordinators()
534- unlockSessions()
535- return attachmentTransitionResult{}, errAttachmentTransition
536- }
537-
538- if req.targetRole == attachmentActive {
539- d.demoteParkedActiveForSessionLocked(req.target)
540- }
541- if req.copySourceEnvironment {
542- req.target.terminal = source.terminal
543- req.target.env = copyEnvironment(source.env)
544- }
545- if req.activateTargetTab {
546- req.target.active = req.targetTabIndex
547- }
548- if req.preserveRole {
549- unlockCoordinators()
550- unlockSessions()
551- return attachmentTransitionResult{published: *req.sourceToken}, nil
552- }
553-
554- var displacedTransport transportSnapshot
555- if req.targetRole == attachmentActive && old != nil && old != req.next {
556- displacedTransport = old.transportSnapshot()
557- }
558- if source != req.target {
559- if source.client == req.next {
560- source.client = nil
561- }
562- delete(source.snatched, req.next)
563- }
564- if req.targetRole == attachmentActive {
565- delete(req.target.snatched, req.next)
566- if old != nil && old != req.next {
567- req.target.addSnatchedLocked(old)
568- }
569- req.target.client = req.next
570- } else {
571- req.target.addSnatchedLocked(req.next)
572- }
573- nextGeneration := req.next.roleGeneration.Add(1)
574- var displacedGeneration uint64
575- if old != nil && old != req.next && req.targetRole == attachmentActive {
576- displacedGeneration = old.roleGeneration.Add(1)
577- }
578- req.next.setSession(req.target)
579-
580- result := attachmentTransitionResult{}
581- if source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil {
582- result.cleanups = append(result.cleanups, sourceCoordinator.beginDetachLocked(req.next))
583- }
584- var lease *attachmentLease
585- if targetCoordinator != nil {
586- var cleanup renderLifecycleCleanup
587- cleanup, lease = targetCoordinator.beginReplaceLocked(old, req.next, req.ready)
588- result.cleanups = append(result.cleanups, cleanup)
589- }
590- result.published = attachmentRoleToken{
591- sess: req.target,
592- ac: req.next,
593- role: req.targetRole,
594- generation: nextGeneration,
595- transport: req.expectedTransport,
596- lease: lease,
597- rebase: source != req.target && req.expectedRole == attachmentActive,
598- }
599- if displacedGeneration != 0 {
600- result.displaced = attachmentRoleToken{
601- sess: req.target,
602- ac: old,
603- role: attachmentSnatched,
604- generation: displacedGeneration,
605- transport: displacedTransport,
606- }
607- result.displacedInterrupted = req.expectedTargetTransportInterrupted
608- }
609- // Membership, currentSession, lease, generation, and both exact admission
610- // capabilities become visible as one frozen publication. The gates remain
611- // frozen until every architecture lock below has been released.
612- if req.roleEffectsFrozen {
613- req.next.publishFrozenRoleCapability(result.published)
614- if result.displaced.ac != nil {
615- result.displaced.ac.publishFrozenRoleCapability(result.displaced)
616- }
617- }
618- unlockCoordinators()
619- unlockSessions()
620- return result, nil
621-}
622-
62361 func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool {
62462 ac := token.ac
62563 ac.sendMu.Lock()
@@ -667,233 +105,3 @@ func (d *Daemon) boundedSendSnatchedControl(token attachmentRoleToken, frame por
667105 }
668106 return err
669107 }
670-
671-// removeSnatchedAttachment removes exactly one role generation and transport
672-// from session routing. It never touches the active attachment or closes a
673-// transport, allowing callers that already interrupted a blocked send to avoid
674-// closing the same link twice.
675-func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool {
676- return d.unrouteSnatchedAttachment(token, true)
677-}
678-
679-func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool {
680- if token.sess == nil || token.ac == nil || token.transport.transport == nil {
681- return false
682- }
683- frozen := freezeRoleEffectGates(token.ac)
684- defer frozen.unfreeze()
685- d.notices.routingMu.Lock()
686- token.sess.mu.Lock()
687- current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
688- token.ac.roleGeneration.Load() == token.generation &&
689- token.ac.transportSnapshotCurrent(token.transport)
690- if current {
691- delete(token.sess.snatched, token.ac)
692- token.ac.roleGeneration.Add(1)
693- token.ac.invalidateFrozenRoleCapability()
694- }
695- token.sess.mu.Unlock()
696- d.notices.routingMu.Unlock()
697- if !current {
698- return false
699- }
700-
701- token.ac.clearSnatchedInput()
702- d.unregisterPreview(token.ac)
703- if terminal {
704- token.ac.clearPreviousSession()
705- }
706- token.ac.setSession(nil)
707- token.ac.clearCaptureFrames()
708- return true
709-}
710-
711-// parkSnatchedAttachment removes only the exact lost snatched incarnation and
712-// retains its resume identity for a role-preserving reconnect.
713-func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {
714- if token.ac == nil || !token.ac.resumeCapable || !d.unrouteSnatchedAttachment(token, false) {
715- return false
716- }
717- if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
718- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
719- return true
720- }
721- token.ac.clearPreviousSession()
722- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
723- return false
724-}
725-
726-func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool {
727- if d.parkSnatchedAttachment(token) {
728- return true
729- }
730- return d.dropSnatchedAttachment(token)
731-}
732-
733-// cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced
734-// transport that was closed to drain an admitted send. It waits on the gate's
735-// condition rather than ordinary effect admission, then removes only the exact
736-// published snatched incarnation. Attachment-local cleanup runs without routing
737-// or session locks while the terminal capability remains frozen.
738-func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool {
739- if token.sess == nil || token.ac == nil || token.transport.transport == nil {
740- return false
741- }
742- frozen := freezeRoleEffectGates(token.ac)
743- defer frozen.unfreeze()
744-
745- d.notices.routingMu.Lock()
746- token.sess.mu.Lock()
747- current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
748- token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
749- token.ac.transportSnapshotCurrent(token.transport)
750- token.sess.mu.Unlock()
751- d.notices.routingMu.Unlock()
752- if !current {
753- return false
754- }
755-
756- // The frozen gate prevents a promotion from publishing between overlay
757- // cleanup and terminal registry publication.
758- d.clearForSnatch(token)
759-
760- d.notices.routingMu.Lock()
761- token.sess.mu.Lock()
762- current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
763- token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
764- token.ac.transportSnapshotCurrent(token.transport)
765- if current {
766- delete(token.sess.snatched, token.ac)
767- token.ac.roleGeneration.Add(1)
768- token.ac.invalidateFrozenRoleCapability()
769- }
770- token.sess.mu.Unlock()
771- d.notices.routingMu.Unlock()
772- if !current {
773- return false
774- }
775-
776- token.ac.setSession(nil)
777- if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
778- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
779- token.ac.clearSnatchedInput()
780- d.unregisterPreview(token.ac)
781- return true
782- }
783- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
784- token.ac.clearSnatchedInput()
785- d.unregisterPreview(token.ac)
786- token.ac.clearPreviousSession()
787- token.ac.clearCaptureFrames()
788- return true
789-}
790-
791-// dropSnatchedAttachment removes and closes only the captured snatched link.
792-func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool {
793- if !d.removeSnatchedAttachment(token) {
794- return false
795- }
796- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
797- return true
798-}
799-
800-func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
801- if role == attachmentActive || role == attachmentSnatched {
802- return true
803- }
804- return expected && role == attachmentDetached
805-}
806-
807-// lockAttachmentSessions gives every two-session transition one stable order.
808-// Session IDs are immutable and unique while their lifecycles are registered.
809-func lockAttachmentSessions(a, b *session) func() {
810- if a == b {
811- a.mu.Lock()
812- return a.mu.Unlock
813- }
814- first, second := a, b
815- if first.id > second.id {
816- first, second = second, first
817- }
818- first.mu.Lock()
819- second.mu.Lock()
820- return func() {
821- second.mu.Unlock()
822- first.mu.Unlock()
823- }
824-}
825-
826-// lockAttachmentCoordinators follows the same immutable session-ID order as
827-// lockAttachmentSessions. Callers already hold the ordered session locks.
828-func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() {
829- if aCoordinator == nil && bCoordinator == nil {
830- return func() {}
831- }
832- if a == b || aCoordinator == bCoordinator {
833- coordinator := aCoordinator
834- if coordinator == nil {
835- coordinator = bCoordinator
836- }
837- coordinator.mu.Lock()
838- return coordinator.mu.Unlock
839- }
840- first, second := aCoordinator, bCoordinator
841- if a.id > b.id {
842- first, second = second, first
843- }
844- if first != nil {
845- first.mu.Lock()
846- }
847- if second != nil {
848- second.mu.Lock()
849- }
850- return func() {
851- if second != nil {
852- second.mu.Unlock()
853- }
854- if first != nil {
855- first.mu.Unlock()
856- }
857- }
858-}
859-
860-// deferAttachmentTransitionCleanups retires coordinator timers and accounting
861-// after ownership publication. Cleanup never gates the attaching handshake. A
862-// displaced render that already owns sendMu is interrupted by closing only its
863-// captured transport; an idle healthy snatched transport remains connected and
864-// receives a dependency-free reset panel.
865-func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {
866- if token := result.displaced; token.ac != nil && token.transport.transport != nil {
867- blockedRender := result.displacedInterrupted
868- if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {
869- blockedRender = !token.ac.sendMu.TryLock()
870- if !blockedRender {
871- token.ac.sendMu.Unlock()
872- }
873- }
874- d.attachmentCleanupWg.Go(func() {
875- if d.afterDisplacedCleanupStarted != nil {
876- d.afterDisplacedCleanupStarted()
877- }
878- if blockedRender {
879- // This path owns terminal cleanup independently of ordinary role
880- // admission: a stale send-error detach may transiently freeze the gate.
881- _ = token.ac.closeCapturedTransport(token.transport.transport)
882- d.cleanupInterruptedSnatchedAttachment(token)
883- return
884- }
885- ticket, admitted := token.ac.beginRoleEffect(token)
886- if !admitted {
887- return
888- }
889- defer ticket.End()
890- if err := d.sendInitialSnatchedPanel(token, ticket); err != nil {
891- ticket.End()
892- d.parkOrDropSnatchedAttachment(token)
893- }
894- })
895- }
896- for _, cleanup := range result.cleanups {
897- d.attachmentCleanupWg.Go(cleanup.finish)
898- }
899-}
@@ -6,14 +6,6 @@ import (
6 "github.com/bnema/vev/internal/ports"6 "github.com/bnema/vev/internal/ports"
7 )7 )
8 8
9-type attachmentRole uint8
10-
11-const (
12- attachmentDetached attachmentRole = iota
13- attachmentActive
14- attachmentSnatched
15-)
16-
17 type initialSnatchedPanelAttempt struct {9 type initialSnatchedPanelAttempt struct {
18 done chan struct{}10 done chan struct{}
19 err error11 err error
@@ -66,560 +58,6 @@ func (d *Daemon) sendInitialSnatchedPanel(token attachmentRoleToken, ticket *rol
66 return err58 return err
67 }59 }
68 60
69-// attachmentRole derives the attachment's role from the session-owned
70-// registries. There is deliberately no second role field that could drift.
71-func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
72- if s == nil || ac == nil {
73- return attachmentDetached
74- }
75- s.mu.Lock()
76- defer s.mu.Unlock()
77- return s.attachmentRoleLocked(ac)
78-}
79-
80-// attachmentRoleLocked requires s.mu. Active membership wins if an invalid
81-// intermediate state contains the same attachment in both registries.
82-func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {
83- if ac == nil {
84- return attachmentDetached
85- }
86- if s.client == ac {
87- return attachmentActive
88- }
89- if _, ok := s.snatched[ac]; ok {
90- return attachmentSnatched
91- }
92- return attachmentDetached
93-}
94-
95-func (s *session) addSnatchedLocked(ac *attachedClient) {
96- if ac == nil {
97- return
98- }
99- if s.snatched == nil {
100- s.snatched = make(map[*attachedClient]struct{})
101- }
102- s.snatched[ac] = struct{}{}
103-}
104-
105-var errAttachmentTransition = errors.New("attachment transition is no longer valid")
106-
107-type attachmentTransitionRequest struct {
108- source *session
109- target *session
110- next *attachedClient
111- expectedRole attachmentRole
112- targetRole attachmentRole
113- expectedTransport transportSnapshot
114- sourceToken *attachmentRoleToken
115- action string
116- activateTargetTab bool
117- targetTabIndex int
118- copySourceEnvironment bool
119- // preserveRole commits navigation state without replacing attachment
120- // ownership. It is used for same-session picker selections after the exact
121- // initiating capability has been frozen and revalidated.
122- preserveRole bool
123- expectedSourceTab *tab
124- createTargetLocked func() (*session, error)
125- ready bool
126- expectedTargetCurrent *attachedClient
127- expectedTargetTransport transportSnapshot
128- preflighted bool
129- roleEffectsFrozen bool
130- expectedTargetTransportInterrupted bool
131- // activationBarrier is used when promoting an already connected snatched
132- // attachment. Its role gate is deadline-drained before sendMu is acquired,
133- // preserving the gate -> sendMu -> daemon/routing/session publication order.
134- activationBarrier bool
135-}
136-
137-func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool {
138- return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&
139- token.transport.transport == req.expectedTransport.transport &&
140- token.transport.incarnation == req.expectedTransport.incarnation
141-}
142-
143-// transitionSourceTokenCurrentLocked requires source.mu and, for an active
144-// source, sourceCoordinator.mu. This is the canonical handoff preflight: every
145-// client-originated navigation, delete, or create intent is bound to one exact
146-// role generation, transport incarnation, and coordinator lease.
147-func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
148- if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||
149- token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role ||
150- req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {
151- return false
152- }
153- if token.role != attachmentActive {
154- return true
155- }
156- return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next &&
157- sourceCoordinator.leaseCurrentLocked(token.lease, true)
158-}
159-
160-type attachmentRoleToken struct {
161- sess *session
162- ac *attachedClient
163- role attachmentRole
164- generation uint64
165- transport transportSnapshot
166- lease *attachmentLease
167- effect *roleEffectTicket
168- // rebase marks a cross-session ownership move whose first paint must start a
169- // dependency-free output chain. The rebase is deliberately deferred until
170- // after transition publication releases all architecture locks.
171- rebase bool
172-}
173-
174-func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
175- if s == nil || ac == nil || tr == nil {
176- return attachmentRoleToken{}
177- }
178- s.mu.Lock()
179- transport := ac.transportSnapshot()
180- if transport.transport != tr {
181- s.mu.Unlock()
182- return attachmentRoleToken{}
183- }
184- token := attachmentRoleToken{
185- sess: s,
186- ac: ac,
187- role: s.attachmentRoleLocked(ac),
188- generation: ac.roleGeneration.Load(),
189- transport: transport,
190- }
191- s.mu.Unlock()
192- if token.role == attachmentActive {
193- if rc := s.renderCoordinator(); rc != nil {
194- token.lease = rc.attachmentLease(ac)
195- }
196- }
197- ac.bootstrapRoleCapability(token)
198- return token
199-}
200-
201-func (t attachmentRoleToken) current() bool {
202- return t.sess != nil && t.ac != nil &&
203- t.ac.roleGeneration.Load() == t.generation &&
204- t.ac.transportSnapshotCurrent(t.transport) &&
205- t.sess.attachmentRole(t.ac) == t.role
206-}
207-
208-// activeCurrent admits post-transition effects only for the exact published
209-// role, transport incarnation, current-session link, and coordinator lease.
210-func (t attachmentRoleToken) activeCurrent() bool {
211- if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess {
212- return false
213- }
214- rc := t.sess.renderCoordinator()
215- return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
216-}
217-
218-// activeEffect is the canonical admission check for a client-frame mutation.
219-// Tokens bind role generation, transport incarnation, session ownership, and
220-// coordinator lease, so handlers never rediscover authority from mutable state.
221-func (t attachmentRoleToken) activeEffect() bool {
222- if t.effect != nil && !t.effect.ended.Load() {
223- return true
224- }
225- return t.activeCurrent()
226-}
227-
228-func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) {
229- if sess == nil || ac == nil || lease == nil {
230- return nil, false
231- }
232- token := sess.attachmentToken(ac, ac.transport())
233- token.lease = lease
234- return ac.beginRoleEffect(token)
235-}
236-
237-func (t *attachmentRoleToken) endRoleEffect() {
238- if t == nil || t.effect == nil {
239- return
240- }
241- t.effect.End()
242- t.effect = nil
243-}
244-
245-// activeEffectSessionLocked is the same admission check while t.sess.mu is
246-// already held at a session-owned mutation boundary.
247-func (t attachmentRoleToken) activeEffectSessionLocked() bool {
248- if t.effect != nil && !t.effect.ended.Load() {
249- return true
250- }
251- if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil ||
252- t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) ||
253- t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive {
254- return false
255- }
256- rc := t.sess.renderCoordinator()
257- return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
258-}
259-
260-func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error {
261- if t.ac == nil {
262- return errAttachmentTransition
263- }
264- t.ac.sendMu.Lock()
265- defer t.ac.sendMu.Unlock()
266- if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) ||
267- !t.effect.beginTransportSend(t.transport) {
268- return errAttachmentTransition
269- }
270- err := t.transport.transport.Send(frame)
271- if err != nil {
272- t.effect.reportTransportFailure(t.transport)
273- }
274- t.effect.endTransportSend()
275- return err
276-}
277-
278-type attachmentTransitionResult struct {
279- published attachmentRoleToken
280- displaced attachmentRoleToken
281- displacedInterrupted bool
282- cleanups []renderLifecycleCleanup
283-}
284-
285-func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
286- // Preflight identifies every gate the publication can affect. No gate is
287- // frozen while an architecture lock is held.
288- d.mu.Lock()
289- source := req.source
290- if source == nil {
291- source = req.target
292- }
293- if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||
294- d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target ||
295- req.activationBarrier && (req.sourceToken == nil || source != req.target ||
296- req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
297- d.mu.Unlock()
298- return attachmentTransitionResult{}, errAttachmentTransition
299- }
300- if req.target != nil {
301- req.target.mu.Lock()
302- req.expectedTargetCurrent = req.target.client
303- if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {
304- req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
305- }
306- req.target.mu.Unlock()
307- }
308- if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
309- d.mu.Unlock()
310- return attachmentTransitionResult{}, errAttachmentTransition
311- }
312- req.preflighted = true
313- d.mu.Unlock()
314-
315- if req.sourceToken != nil {
316- d.endActionRoleEffect(req.sourceToken.effect, req.action)
317- }
318-
319- participants := []*attachedClient{req.next, req.expectedTargetCurrent}
320- if d.afterRoleEffectParticipantsSnapshotted != nil {
321- d.afterRoleEffectParticipantsSnapshotted(req.action, participants)
322- }
323- interrupts := []roleTransportInterrupt{{
324- ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
325- }}
326- var drainDeadline *roleEffectDrainDeadline
327- var drainDone func() <-chan struct{}
328- if req.activationBarrier {
329- drainDeadline = newRoleEffectDrainDeadline(d.clock)
330- drainDone = drainDeadline.Done
331- }
332- frozen := freezeRoleEffectGatesInterruptingObservedUntil(interrupts, drainDone, func(ac *attachedClient) {
333- if d.afterRoleEffectGateFrozen != nil {
334- d.afterRoleEffectGateFrozen(req.action, ac)
335- }
336- }, participants...)
337- if drainDeadline != nil {
338- drainDeadline.stop()
339- }
340- defer frozen.unfreeze()
341- if !frozen.acquired || !frozen.drained {
342- if req.activationBarrier {
343- _ = req.next.closeCapturedTransport(req.expectedTransport.transport)
344- return attachmentTransitionResult{}, errSendTimedOut
345- }
346- return attachmentTransitionResult{}, errAttachmentTransition
347- }
348- req.expectedTargetTransportInterrupted = frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)
349- if d.afterRoleEffectsFrozen != nil {
350- d.afterRoleEffectsFrozen()
351- }
352-
353- var releaseActivation func()
354- if req.activationBarrier {
355- var err error
356- releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken)
357- if err != nil {
358- return attachmentTransitionResult{}, err
359- }
360- defer func() {
361- if releaseActivation != nil {
362- releaseActivation()
363- }
364- }()
365- }
366-
367- d.mu.Lock()
368- req.roleEffectsFrozen = true
369- if req.target == nil {
370- if !d.transitionSourcePreflightLocked(req) {
371- d.mu.Unlock()
372- return attachmentTransitionResult{}, errAttachmentTransition
373- }
374- var err error
375- req.target, err = req.createTargetLocked()
376- if err != nil {
377- d.mu.Unlock()
378- return attachmentTransitionResult{}, err
379- }
380- req.expectedTargetCurrent = nil
381- }
382- result, err := d.transitionAttachmentLocked(req)
383- d.mu.Unlock()
384- if err == nil && req.activationBarrier && result.published.role == attachmentActive {
385- ac := result.published.ac
386- if ac.output != nil {
387- ac.output.rebase()
388- }
389- ac.captureFrames = nil
390- d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false)
391- // The first paint still requests a reset, but the old panel dependency
392- // chain has already been rebased atomically under the activation barrier.
393- result.published.rebase = false
394- }
395- if releaseActivation != nil {
396- releaseActivation()
397- releaseActivation = nil
398- }
399- if err == nil && result.published.role == attachmentActive {
400- result.published.ac.clearSnatchedInput()
401- }
402- return result, err
403-}
404-
405-// transitionSourcePreflightLocked validates an initiating role while its gate
406-// is frozen and d.mu is held. No caller may create or delete a target before
407-// this succeeds.
408-func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {
409- if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source {
410- return false
411- }
412- d.notices.routingMu.Lock()
413- defer d.notices.routingMu.Unlock()
414- req.source.mu.Lock()
415- defer req.source.mu.Unlock()
416- coordinator := req.source.renderCoordinator()
417- if coordinator == nil {
418- return false
419- }
420- coordinator.mu.Lock()
421- defer coordinator.mu.Unlock()
422- if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {
423- return false
424- }
425- return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)
426-}
427-
428-func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
429- if token.sess == nil || token.ac == nil || token.role != attachmentActive {
430- return false
431- }
432- d.mu.Lock()
433- defer d.mu.Unlock()
434- if d.closing || d.sessions[token.sess.id] != token.sess {
435- return false
436- }
437- d.notices.routingMu.Lock()
438- defer d.notices.routingMu.Unlock()
439- token.sess.mu.Lock()
440- defer token.sess.mu.Unlock()
441- coordinator := token.sess.renderCoordinator()
442- if coordinator == nil {
443- return false
444- }
445- coordinator.mu.Lock()
446- defer coordinator.mu.Unlock()
447- req := attachmentTransitionRequest{
448- source: token.sess, next: token.ac, expectedRole: token.role,
449- expectedTransport: token.transport,
450- }
451- return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
452-}
453-
454-func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
455- if expected == nil {
456- return true
457- }
458- if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected {
459- return false
460- }
461- expected.mu.Lock()
462- defer expected.mu.Unlock()
463- return expected.floating.state != floatingVisible
464-}
465-
466-// transitionAttachmentLocked is the publication half of transitionAttachment.
467-// Caller holds d.mu after the lock-free gate freeze/drain has completed.
468-func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
469- d.notices.routingMu.Lock()
470- defer d.notices.routingMu.Unlock()
471- return d.transitionAttachmentRoutedLocked(req)
472-}
473-
474-// transitionAttachmentRoutedLocked performs the centralized role publication.
475-// Caller holds d.mu and notices.routingMu. Every fallible lifecycle, role, and
476-// transport check occurs before session.client is changed. Coordinator
477-// finalization after that commit point cannot roll ownership back.
478-func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
479- source := req.source
480- if source == nil {
481- source = req.target
482- }
483- if !req.preflighted || !req.roleEffectsFrozen ||
484- !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
485- source == nil || req.target == nil || req.next == nil || d.closing ||
486- d.sessions[source.id] != source || d.sessions[req.target.id] != req.target ||
487- req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) {
488- return attachmentTransitionResult{}, errAttachmentTransition
489- }
490- // Install the target coordinator object before taking both session locks;
491- // lease binding itself remains part of the atomic publication below.
492- var targetCoordinator *renderCoordinator
493- if req.targetRole == attachmentActive {
494- targetCoordinator = d.ensureRenderCoordinator(req.target)
495- }
496- sourceCoordinator := source.renderCoordinator()
497-
498- unlockSessions := lockAttachmentSessions(source, req.target)
499- if source.attachmentRoleLocked(req.next) != req.expectedRole ||
500- !req.next.transportSnapshotCurrent(req.expectedTransport) {
501- unlockSessions()
502- return attachmentTransitionResult{}, errAttachmentTransition
503- }
504-
505- old := req.target.client
506- if req.preflighted && old != req.expectedTargetCurrent {
507- unlockSessions()
508- return attachmentTransitionResult{}, errAttachmentTransition
509- }
510- if source != req.target && old == req.next {
511- unlockSessions()
512- return attachmentTransitionResult{}, errAttachmentTransition
513- }
514- unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator)
515- if req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
516- !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) {
517- unlockCoordinators()
518- unlockSessions()
519- return attachmentTransitionResult{}, errAttachmentTransition
520- }
521- if targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) {
522- unlockCoordinators()
523- unlockSessions()
524- return attachmentTransitionResult{}, errAttachmentTransition
525- }
526- if req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
527- (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) {
528- unlockCoordinators()
529- unlockSessions()
530- return attachmentTransitionResult{}, errAttachmentTransition
531- }
532- if req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) {
533- unlockCoordinators()
534- unlockSessions()
535- return attachmentTransitionResult{}, errAttachmentTransition
536- }
537-
538- if req.targetRole == attachmentActive {
539- d.demoteParkedActiveForSessionLocked(req.target)
540- }
541- if req.copySourceEnvironment {
542- req.target.terminal = source.terminal
543- req.target.env = copyEnvironment(source.env)
544- }
545- if req.activateTargetTab {
546- req.target.active = req.targetTabIndex
547- }
548- if req.preserveRole {
549- unlockCoordinators()
550- unlockSessions()
551- return attachmentTransitionResult{published: *req.sourceToken}, nil
552- }
553-
554- var displacedTransport transportSnapshot
555- if req.targetRole == attachmentActive && old != nil && old != req.next {
556- displacedTransport = old.transportSnapshot()
557- }
558- if source != req.target {
559- if source.client == req.next {
560- source.client = nil
561- }
562- delete(source.snatched, req.next)
563- }
564- if req.targetRole == attachmentActive {
565- delete(req.target.snatched, req.next)
566- if old != nil && old != req.next {
567- req.target.addSnatchedLocked(old)
568- }
569- req.target.client = req.next
570- } else {
571- req.target.addSnatchedLocked(req.next)
572- }
573- nextGeneration := req.next.roleGeneration.Add(1)
574- var displacedGeneration uint64
575- if old != nil && old != req.next && req.targetRole == attachmentActive {
576- displacedGeneration = old.roleGeneration.Add(1)
577- }
578- req.next.setSession(req.target)
579-
580- result := attachmentTransitionResult{}
581- if source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil {
582- result.cleanups = append(result.cleanups, sourceCoordinator.beginDetachLocked(req.next))
583- }
584- var lease *attachmentLease
585- if targetCoordinator != nil {
586- var cleanup renderLifecycleCleanup
587- cleanup, lease = targetCoordinator.beginReplaceLocked(old, req.next, req.ready)
588- result.cleanups = append(result.cleanups, cleanup)
589- }
590- result.published = attachmentRoleToken{
591- sess: req.target,
592- ac: req.next,
593- role: req.targetRole,
594- generation: nextGeneration,
595- transport: req.expectedTransport,
596- lease: lease,
597- rebase: source != req.target && req.expectedRole == attachmentActive,
598- }
599- if displacedGeneration != 0 {
600- result.displaced = attachmentRoleToken{
601- sess: req.target,
602- ac: old,
603- role: attachmentSnatched,
604- generation: displacedGeneration,
605- transport: displacedTransport,
606- }
607- result.displacedInterrupted = req.expectedTargetTransportInterrupted
608- }
609- // Membership, currentSession, lease, generation, and both exact admission
610- // capabilities become visible as one frozen publication. The gates remain
611- // frozen until every architecture lock below has been released.
612- if req.roleEffectsFrozen {
613- req.next.publishFrozenRoleCapability(result.published)
614- if result.displaced.ac != nil {
615- result.displaced.ac.publishFrozenRoleCapability(result.displaced)
616- }
617- }
618- unlockCoordinators()
619- unlockSessions()
620- return result, nil
621-}
622-
623 func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool {61 func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool {
624 ac := token.ac62 ac := token.ac
625 ac.sendMu.Lock()63 ac.sendMu.Lock()
@@ -667,233 +105,3 @@ func (d *Daemon) boundedSendSnatchedControl(token attachmentRoleToken, frame por
667 }105 }
668 return err106 return err
669 }107 }
670-
671-// removeSnatchedAttachment removes exactly one role generation and transport
672-// from session routing. It never touches the active attachment or closes a
673-// transport, allowing callers that already interrupted a blocked send to avoid
674-// closing the same link twice.
675-func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool {
676- return d.unrouteSnatchedAttachment(token, true)
677-}
678-
679-func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool {
680- if token.sess == nil || token.ac == nil || token.transport.transport == nil {
681- return false
682- }
683- frozen := freezeRoleEffectGates(token.ac)
684- defer frozen.unfreeze()
685- d.notices.routingMu.Lock()
686- token.sess.mu.Lock()
687- current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
688- token.ac.roleGeneration.Load() == token.generation &&
689- token.ac.transportSnapshotCurrent(token.transport)
690- if current {
691- delete(token.sess.snatched, token.ac)
692- token.ac.roleGeneration.Add(1)
693- token.ac.invalidateFrozenRoleCapability()
694- }
695- token.sess.mu.Unlock()
696- d.notices.routingMu.Unlock()
697- if !current {
698- return false
699- }
700-
701- token.ac.clearSnatchedInput()
702- d.unregisterPreview(token.ac)
703- if terminal {
704- token.ac.clearPreviousSession()
705- }
706- token.ac.setSession(nil)
707- token.ac.clearCaptureFrames()
708- return true
709-}
710-
711-// parkSnatchedAttachment removes only the exact lost snatched incarnation and
712-// retains its resume identity for a role-preserving reconnect.
713-func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {
714- if token.ac == nil || !token.ac.resumeCapable || !d.unrouteSnatchedAttachment(token, false) {
715- return false
716- }
717- if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
718- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
719- return true
720- }
721- token.ac.clearPreviousSession()
722- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
723- return false
724-}
725-
726-func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool {
727- if d.parkSnatchedAttachment(token) {
728- return true
729- }
730- return d.dropSnatchedAttachment(token)
731-}
732-
733-// cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced
734-// transport that was closed to drain an admitted send. It waits on the gate's
735-// condition rather than ordinary effect admission, then removes only the exact
736-// published snatched incarnation. Attachment-local cleanup runs without routing
737-// or session locks while the terminal capability remains frozen.
738-func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool {
739- if token.sess == nil || token.ac == nil || token.transport.transport == nil {
740- return false
741- }
742- frozen := freezeRoleEffectGates(token.ac)
743- defer frozen.unfreeze()
744-
745- d.notices.routingMu.Lock()
746- token.sess.mu.Lock()
747- current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
748- token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
749- token.ac.transportSnapshotCurrent(token.transport)
750- token.sess.mu.Unlock()
751- d.notices.routingMu.Unlock()
752- if !current {
753- return false
754- }
755-
756- // The frozen gate prevents a promotion from publishing between overlay
757- // cleanup and terminal registry publication.
758- d.clearForSnatch(token)
759-
760- d.notices.routingMu.Lock()
761- token.sess.mu.Lock()
762- current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
763- token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
764- token.ac.transportSnapshotCurrent(token.transport)
765- if current {
766- delete(token.sess.snatched, token.ac)
767- token.ac.roleGeneration.Add(1)
768- token.ac.invalidateFrozenRoleCapability()
769- }
770- token.sess.mu.Unlock()
771- d.notices.routingMu.Unlock()
772- if !current {
773- return false
774- }
775-
776- token.ac.setSession(nil)
777- if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
778- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
779- token.ac.clearSnatchedInput()
780- d.unregisterPreview(token.ac)
781- return true
782- }
783- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
784- token.ac.clearSnatchedInput()
785- d.unregisterPreview(token.ac)
786- token.ac.clearPreviousSession()
787- token.ac.clearCaptureFrames()
788- return true
789-}
790-
791-// dropSnatchedAttachment removes and closes only the captured snatched link.
792-func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool {
793- if !d.removeSnatchedAttachment(token) {
794- return false
795- }
796- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
797- return true
798-}
799-
800-func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
801- if role == attachmentActive || role == attachmentSnatched {
802- return true
803- }
804- return expected && role == attachmentDetached
805-}
806-
807-// lockAttachmentSessions gives every two-session transition one stable order.
808-// Session IDs are immutable and unique while their lifecycles are registered.
809-func lockAttachmentSessions(a, b *session) func() {
810- if a == b {
811- a.mu.Lock()
812- return a.mu.Unlock
813- }
814- first, second := a, b
815- if first.id > second.id {
816- first, second = second, first
817- }
818- first.mu.Lock()
819- second.mu.Lock()
820- return func() {
821- second.mu.Unlock()
822- first.mu.Unlock()
823- }
824-}
825-
826-// lockAttachmentCoordinators follows the same immutable session-ID order as
827-// lockAttachmentSessions. Callers already hold the ordered session locks.
828-func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() {
829- if aCoordinator == nil && bCoordinator == nil {
830- return func() {}
831- }
832- if a == b || aCoordinator == bCoordinator {
833- coordinator := aCoordinator
834- if coordinator == nil {
835- coordinator = bCoordinator
836- }
837- coordinator.mu.Lock()
838- return coordinator.mu.Unlock
839- }
840- first, second := aCoordinator, bCoordinator
841- if a.id > b.id {
842- first, second = second, first
843- }
844- if first != nil {
845- first.mu.Lock()
846- }
847- if second != nil {
848- second.mu.Lock()
849- }
850- return func() {
851- if second != nil {
852- second.mu.Unlock()
853- }
854- if first != nil {
855- first.mu.Unlock()
856- }
857- }
858-}
859-
860-// deferAttachmentTransitionCleanups retires coordinator timers and accounting
861-// after ownership publication. Cleanup never gates the attaching handshake. A
862-// displaced render that already owns sendMu is interrupted by closing only its
863-// captured transport; an idle healthy snatched transport remains connected and
864-// receives a dependency-free reset panel.
865-func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {
866- if token := result.displaced; token.ac != nil && token.transport.transport != nil {
867- blockedRender := result.displacedInterrupted
868- if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {
869- blockedRender = !token.ac.sendMu.TryLock()
870- if !blockedRender {
871- token.ac.sendMu.Unlock()
872- }
873- }
874- d.attachmentCleanupWg.Go(func() {
875- if d.afterDisplacedCleanupStarted != nil {
876- d.afterDisplacedCleanupStarted()
877- }
878- if blockedRender {
879- // This path owns terminal cleanup independently of ordinary role
880- // admission: a stale send-error detach may transiently freeze the gate.
881- _ = token.ac.closeCapturedTransport(token.transport.transport)
882- d.cleanupInterruptedSnatchedAttachment(token)
883- return
884- }
885- ticket, admitted := token.ac.beginRoleEffect(token)
886- if !admitted {
887- return
888- }
889- defer ticket.End()
890- if err := d.sendInitialSnatchedPanel(token, ticket); err != nil {
891- ticket.End()
892- d.parkOrDropSnatchedAttachment(token)
893- }
894- })
895- }
896- for _, cleanup := range result.cleanups {
897- d.attachmentCleanupWg.Go(cleanup.finish)
898- }
899-}
M internal/usecase/daemon/snatched_client_test.go +183 -2
@@ -248,7 +248,7 @@ func TestActiveFrameRevalidatesExactRoleBeforeEveryEffect(t *testing.T) {
248248 }
249249 }
250250
251-func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t *testing.T) {
251+func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) {
252252 pty := &transactionalResizePTY{}
253253 d, sess, first, _ := newManualSessionWithPTYs(t, pty)
254254 firstTransport := newDatagramTestTransport()
@@ -334,6 +334,8 @@ func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t *
334334 message, err := ports.UnmarshalDetached(detached.Payload)
335335 require.NoError(t, err)
336336 require.Equal(t, ports.ReasonSessionKilled, message.Reason)
337+ _, recvErr := transport.Recv()
338+ require.ErrorIs(t, recvErr, io.EOF)
337339 }
338340 require.Same(t, otherClient, other.client)
339341 require.Same(t, other, otherClient.currentSession())
@@ -348,6 +350,146 @@ func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t *
348350 }
349351 }
350352
353+func TestKillSessionRetiresParkedSnatchedAttachment(t *testing.T) {
354+ pty, release := newBlockingPTY(t)
355+ defer release()
356+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
357+
358+ parkedTransport := &closeTrackingTransport{}
359+ sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport)
360+ require.NoError(t, err)
361+ parkedToken := parkedClient.resumeToken
362+ d.clientGone(sess, parkedClient, parkedTransport, false)
363+
364+ activeTransport := &closeTrackingTransport{}
365+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
366+ require.NoError(t, err)
367+ d.mu.Lock()
368+ parked := d.parked[parkedToken]
369+ d.mu.Unlock()
370+ require.NotNil(t, parked)
371+ require.Equal(t, attachmentSnatched, parked.role)
372+ parkedGeneration := parkedClient.roleGeneration.Load()
373+
374+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
375+ d.waitNotifies()
376+
377+ require.Nil(t, parkedClient.transport(), "terminal teardown must revoke the parked link ownership")
378+ require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration, "terminal teardown must invalidate the parked role generation")
379+ require.Zero(t, parkedClient.resumeToken)
380+ require.False(t, parkedClient.parked)
381+ select {
382+ case <-parked.done:
383+ default:
384+ t.Fatal("terminal teardown left the parked expiry goroutine live")
385+ }
386+ require.True(t, parkedTransport.Closed())
387+ require.Nil(t, parkedClient.currentSession())
388+ require.Nil(t, active.currentSession())
389+ require.True(t, activeTransport.Closed())
390+}
391+
392+func TestDaemonShutdownClosesSnatchedClients(t *testing.T) {
393+ pty, release := newBlockingPTY(t)
394+ defer release()
395+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
396+
397+ parkedTransport := &closeTrackingTransport{}
398+ sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport)
399+ require.NoError(t, err)
400+ parkedToken := parkedClient.resumeToken
401+ d.clientGone(sess, parkedClient, parkedTransport, false)
402+
403+ waitingTransport := &closeTrackingTransport{}
404+ _, waiting, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), waitingTransport)
405+ require.NoError(t, err)
406+ activeTransport := &closeTrackingTransport{}
407+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
408+ require.NoError(t, err)
409+ d.attachmentCleanupWg.Wait()
410+
411+ d.mu.Lock()
412+ parked := d.parked[parkedToken]
413+ d.mu.Unlock()
414+ require.NotNil(t, parked)
415+ require.Equal(t, attachmentSnatched, parked.role)
416+ parkedGeneration := parkedClient.roleGeneration.Load()
417+ require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
418+ require.Equal(t, attachmentActive, sess.attachmentRole(active))
419+
420+ require.False(t, d.shutdownAll(ports.ReasonServerShutdown))
421+ d.waitNotifies()
422+ d.attachmentCleanupWg.Wait()
423+
424+ require.Nil(t, parkedClient.transport(), "shutdown must revoke parked attachment link ownership")
425+ require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration)
426+ select {
427+ case <-parked.done:
428+ default:
429+ t.Fatal("shutdown left the parked expiry goroutine live")
430+ }
431+ for _, client := range []*attachedClient{parkedClient, waiting, active} {
432+ require.Nil(t, client.currentSession())
433+ }
434+ for _, transport := range []*closeTrackingTransport{parkedTransport, waitingTransport, activeTransport} {
435+ require.True(t, transport.Closed())
436+ }
437+ for _, transport := range []*closeTrackingTransport{waitingTransport, activeTransport} {
438+ frames := transport.Sends()
439+ require.NotEmpty(t, frames)
440+ detached, err := ports.UnmarshalDetached(frames[len(frames)-1].Payload)
441+ require.NoError(t, err)
442+ require.Equal(t, ports.ReasonServerShutdown, detached.Reason)
443+ }
444+}
445+
446+func TestSnatchedQuitDoesNotResetActivePaneThemeOrSize(t *testing.T) {
447+ pty := &transactionalResizePTY{}
448+ d, sess, waiting, _ := newManualSessionWithPTYs(t, pty)
449+ waitingTransport := &closeTrackingTransport{}
450+ waiting.replaceTransport(waitingTransport)
451+
452+ activeTransport := &closeTrackingTransport{}
453+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
454+ active.initOverlays()
455+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
456+ target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
457+ expectedTransport: active.transportSnapshot(), ready: true,
458+ })
459+ require.NoError(t, err)
460+ for _, cleanup := range transition.cleanups {
461+ cleanup.finish()
462+ }
463+
464+ activeTheme := ports.Theme{
465+ HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56},
466+ HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21},
467+ TrueColor: true,
468+ }
469+ d.applyTheme(sess, active, activeTheme)
470+ activeSize := domain.Size{Cols: 101, Rows: 37}
471+ d.resize(sess, active, activeSize)
472+ require.Equal(t, activeSize, active.size)
473+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
474+ requestedBeforeQuit := pty.requested()
475+
476+ token := sess.attachmentToken(waiting, waitingTransport)
477+ require.True(t, d.handleSnatchedClientFrame(token, ports.Frame{
478+ Type: ports.MsgInput,
479+ Payload: ports.MarshalInput(ports.Input{
480+ Data: []byte{'q'},
481+ }),
482+ }))
483+
484+ require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
485+ require.Same(t, active, sess.client)
486+ require.False(t, activeTransport.Closed())
487+ require.True(t, waitingTransport.Closed())
488+ require.Equal(t, activeSize, active.size)
489+ require.Equal(t, requestedBeforeQuit, pty.requested(), "snatched quit must not resize active panes")
490+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
491+}
492+
351493 func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) {
352494 writes := make(chan []byte, 1)
353495 pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
@@ -607,7 +749,7 @@ func (t *failingSnatchedTransport) Closed() bool {
607749 return t.closed
608750 }
609751
610-func TestSnatchedPanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {
752+func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) {
611753 d := newTestDaemon(t, nil, stubClock{})
612754 oldTransport := &failingSnatchedTransport{}
613755 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
@@ -637,6 +779,45 @@ func TestSnatchedPanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {
637779 require.False(t, activeTransport.Closed())
638780 }
639781
782+func TestSnatchedPanelFailurePreservesActivePaneThemeAndSize(t *testing.T) {
783+ pty := &transactionalResizePTY{}
784+ d, sess, waiting, _ := newManualSessionWithPTYs(t, pty)
785+ failed := &failingSnatchedTransport{}
786+ waiting.replaceTransport(failed)
787+
788+ activeTransport := &closeTrackingTransport{}
789+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
790+ active.initOverlays()
791+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
792+ target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
793+ expectedTransport: active.transportSnapshot(), ready: true,
794+ })
795+ require.NoError(t, err)
796+
797+ activeTheme := ports.Theme{
798+ HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56},
799+ HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21},
800+ TrueColor: true,
801+ }
802+ d.applyTheme(sess, active, activeTheme)
803+ activeSize := domain.Size{Cols: 103, Rows: 39}
804+ d.resize(sess, active, activeSize)
805+ require.Equal(t, activeSize, active.size)
806+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
807+ requestedBeforeFailure := pty.requested()
808+
809+ d.deferAttachmentTransitionCleanups(transition)
810+ d.attachmentCleanupWg.Wait()
811+
812+ require.True(t, failed.Closed())
813+ require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
814+ require.Same(t, active, sess.client)
815+ require.False(t, activeTransport.Closed())
816+ require.Equal(t, activeSize, active.size)
817+ require.Equal(t, requestedBeforeFailure, pty.requested(), "snatched panel failure must not resize active panes")
818+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
819+}
820+
640821 func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {
641822 d := newTestDaemon(t, nil, stubClock{})
642823 oldTransport := &failingSnatchedTransport{}
@@ -248,7 +248,7 @@ func TestActiveFrameRevalidatesExactRoleBeforeEveryEffect(t *testing.T) {
248 }248 }
249 }249 }
250 250
251-func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t *testing.T) {251+func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) {
252 pty := &transactionalResizePTY{}252 pty := &transactionalResizePTY{}
253 d, sess, first, _ := newManualSessionWithPTYs(t, pty)253 d, sess, first, _ := newManualSessionWithPTYs(t, pty)
254 firstTransport := newDatagramTestTransport()254 firstTransport := newDatagramTestTransport()
@@ -334,6 +334,8 @@ func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t *
334 message, err := ports.UnmarshalDetached(detached.Payload)334 message, err := ports.UnmarshalDetached(detached.Payload)
335 require.NoError(t, err)335 require.NoError(t, err)
336 require.Equal(t, ports.ReasonSessionKilled, message.Reason)336 require.Equal(t, ports.ReasonSessionKilled, message.Reason)
337+ _, recvErr := transport.Recv()
338+ require.ErrorIs(t, recvErr, io.EOF)
337 }339 }
338 require.Same(t, otherClient, other.client)340 require.Same(t, otherClient, other.client)
339 require.Same(t, other, otherClient.currentSession())341 require.Same(t, other, otherClient.currentSession())
@@ -348,6 +350,146 @@ func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t *
348 }350 }
349 }351 }
350 352
353+func TestKillSessionRetiresParkedSnatchedAttachment(t *testing.T) {
354+ pty, release := newBlockingPTY(t)
355+ defer release()
356+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
357+
358+ parkedTransport := &closeTrackingTransport{}
359+ sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport)
360+ require.NoError(t, err)
361+ parkedToken := parkedClient.resumeToken
362+ d.clientGone(sess, parkedClient, parkedTransport, false)
363+
364+ activeTransport := &closeTrackingTransport{}
365+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
366+ require.NoError(t, err)
367+ d.mu.Lock()
368+ parked := d.parked[parkedToken]
369+ d.mu.Unlock()
370+ require.NotNil(t, parked)
371+ require.Equal(t, attachmentSnatched, parked.role)
372+ parkedGeneration := parkedClient.roleGeneration.Load()
373+
374+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
375+ d.waitNotifies()
376+
377+ require.Nil(t, parkedClient.transport(), "terminal teardown must revoke the parked link ownership")
378+ require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration, "terminal teardown must invalidate the parked role generation")
379+ require.Zero(t, parkedClient.resumeToken)
380+ require.False(t, parkedClient.parked)
381+ select {
382+ case <-parked.done:
383+ default:
384+ t.Fatal("terminal teardown left the parked expiry goroutine live")
385+ }
386+ require.True(t, parkedTransport.Closed())
387+ require.Nil(t, parkedClient.currentSession())
388+ require.Nil(t, active.currentSession())
389+ require.True(t, activeTransport.Closed())
390+}
391+
392+func TestDaemonShutdownClosesSnatchedClients(t *testing.T) {
393+ pty, release := newBlockingPTY(t)
394+ defer release()
395+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
396+
397+ parkedTransport := &closeTrackingTransport{}
398+ sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport)
399+ require.NoError(t, err)
400+ parkedToken := parkedClient.resumeToken
401+ d.clientGone(sess, parkedClient, parkedTransport, false)
402+
403+ waitingTransport := &closeTrackingTransport{}
404+ _, waiting, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), waitingTransport)
405+ require.NoError(t, err)
406+ activeTransport := &closeTrackingTransport{}
407+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
408+ require.NoError(t, err)
409+ d.attachmentCleanupWg.Wait()
410+
411+ d.mu.Lock()
412+ parked := d.parked[parkedToken]
413+ d.mu.Unlock()
414+ require.NotNil(t, parked)
415+ require.Equal(t, attachmentSnatched, parked.role)
416+ parkedGeneration := parkedClient.roleGeneration.Load()
417+ require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
418+ require.Equal(t, attachmentActive, sess.attachmentRole(active))
419+
420+ require.False(t, d.shutdownAll(ports.ReasonServerShutdown))
421+ d.waitNotifies()
422+ d.attachmentCleanupWg.Wait()
423+
424+ require.Nil(t, parkedClient.transport(), "shutdown must revoke parked attachment link ownership")
425+ require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration)
426+ select {
427+ case <-parked.done:
428+ default:
429+ t.Fatal("shutdown left the parked expiry goroutine live")
430+ }
431+ for _, client := range []*attachedClient{parkedClient, waiting, active} {
432+ require.Nil(t, client.currentSession())
433+ }
434+ for _, transport := range []*closeTrackingTransport{parkedTransport, waitingTransport, activeTransport} {
435+ require.True(t, transport.Closed())
436+ }
437+ for _, transport := range []*closeTrackingTransport{waitingTransport, activeTransport} {
438+ frames := transport.Sends()
439+ require.NotEmpty(t, frames)
440+ detached, err := ports.UnmarshalDetached(frames[len(frames)-1].Payload)
441+ require.NoError(t, err)
442+ require.Equal(t, ports.ReasonServerShutdown, detached.Reason)
443+ }
444+}
445+
446+func TestSnatchedQuitDoesNotResetActivePaneThemeOrSize(t *testing.T) {
447+ pty := &transactionalResizePTY{}
448+ d, sess, waiting, _ := newManualSessionWithPTYs(t, pty)
449+ waitingTransport := &closeTrackingTransport{}
450+ waiting.replaceTransport(waitingTransport)
451+
452+ activeTransport := &closeTrackingTransport{}
453+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
454+ active.initOverlays()
455+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
456+ target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
457+ expectedTransport: active.transportSnapshot(), ready: true,
458+ })
459+ require.NoError(t, err)
460+ for _, cleanup := range transition.cleanups {
461+ cleanup.finish()
462+ }
463+
464+ activeTheme := ports.Theme{
465+ HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56},
466+ HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21},
467+ TrueColor: true,
468+ }
469+ d.applyTheme(sess, active, activeTheme)
470+ activeSize := domain.Size{Cols: 101, Rows: 37}
471+ d.resize(sess, active, activeSize)
472+ require.Equal(t, activeSize, active.size)
473+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
474+ requestedBeforeQuit := pty.requested()
475+
476+ token := sess.attachmentToken(waiting, waitingTransport)
477+ require.True(t, d.handleSnatchedClientFrame(token, ports.Frame{
478+ Type: ports.MsgInput,
479+ Payload: ports.MarshalInput(ports.Input{
480+ Data: []byte{'q'},
481+ }),
482+ }))
483+
484+ require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
485+ require.Same(t, active, sess.client)
486+ require.False(t, activeTransport.Closed())
487+ require.True(t, waitingTransport.Closed())
488+ require.Equal(t, activeSize, active.size)
489+ require.Equal(t, requestedBeforeQuit, pty.requested(), "snatched quit must not resize active panes")
490+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
491+}
492+
351 func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) {493 func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) {
352 writes := make(chan []byte, 1)494 writes := make(chan []byte, 1)
353 pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}495 pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
@@ -607,7 +749,7 @@ func (t *failingSnatchedTransport) Closed() bool {
607 return t.closed749 return t.closed
608 }750 }
609 751
610-func TestSnatchedPanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {752+func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) {
611 d := newTestDaemon(t, nil, stubClock{})753 d := newTestDaemon(t, nil, stubClock{})
612 oldTransport := &failingSnatchedTransport{}754 oldTransport := &failingSnatchedTransport{}
613 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}755 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
@@ -637,6 +779,45 @@ func TestSnatchedPanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {
637 require.False(t, activeTransport.Closed())779 require.False(t, activeTransport.Closed())
638 }780 }
639 781
782+func TestSnatchedPanelFailurePreservesActivePaneThemeAndSize(t *testing.T) {
783+ pty := &transactionalResizePTY{}
784+ d, sess, waiting, _ := newManualSessionWithPTYs(t, pty)
785+ failed := &failingSnatchedTransport{}
786+ waiting.replaceTransport(failed)
787+
788+ activeTransport := &closeTrackingTransport{}
789+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
790+ active.initOverlays()
791+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
792+ target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
793+ expectedTransport: active.transportSnapshot(), ready: true,
794+ })
795+ require.NoError(t, err)
796+
797+ activeTheme := ports.Theme{
798+ HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56},
799+ HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21},
800+ TrueColor: true,
801+ }
802+ d.applyTheme(sess, active, activeTheme)
803+ activeSize := domain.Size{Cols: 103, Rows: 39}
804+ d.resize(sess, active, activeSize)
805+ require.Equal(t, activeSize, active.size)
806+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
807+ requestedBeforeFailure := pty.requested()
808+
809+ d.deferAttachmentTransitionCleanups(transition)
810+ d.attachmentCleanupWg.Wait()
811+
812+ require.True(t, failed.Closed())
813+ require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
814+ require.Same(t, active, sess.client)
815+ require.False(t, activeTransport.Closed())
816+ require.Equal(t, activeSize, active.size)
817+ require.Equal(t, requestedBeforeFailure, pty.requested(), "snatched panel failure must not resize active panes")
818+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
819+}
820+
640 func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {821 func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {
641 d := newTestDaemon(t, nil, stubClock{})822 d := newTestDaemon(t, nil, stubClock{})
642 oldTransport := &failingSnatchedTransport{}823 oldTransport := &failingSnatchedTransport{}

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