detached 52f10a1

feat(daemon): resume remote attachment owners

commit
52f10a1d6ff1563ee9503c8060a4f036fa7fbdc5
author
brice · · 2026-08-06 18:15 UTC
parent
ac571a7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +816 -124
M internal/usecase/daemon/attachment_effect_gate.go +10 -2
@@ -219,11 +219,19 @@ func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken)
219219 // after Welcome has completed so a replacement blocked behind that send can
220220 // publish before readiness or first paint.
221221 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
222- if ac == nil || sess == nil || tr == nil {
222+ return ac.beginCurrentAttachmentOwnerEffect(sess, tr)
223+}
224+
225+// beginCurrentAttachmentOwnerEffect waits out a transition that already froze
226+// ac, then admits the capability derived from one exact attachment owner.
227+// Handshakes use it after Welcome so a resumed remote view follows the same
228+// generation-fenced client lifecycle as a local session.
229+func (ac *attachedClient) beginCurrentAttachmentOwnerEffect(owner attachmentOwner, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
230+ if ac == nil || normalizeAttachmentOwner(owner) == nil || tr == nil {
223231 return attachmentConnectionToken{}, nil, false
224232 }
225233 for {
226- token := sess.attachmentToken(ac, tr)
234+ token := attachmentOwnerToken(owner, ac, tr)
227235 if token.ac == nil {
228236 return token, nil, false
229237 }
@@ -219,11 +219,19 @@ func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken)
219 // after Welcome has completed so a replacement blocked behind that send can219 // after Welcome has completed so a replacement blocked behind that send can
220 // publish before readiness or first paint.220 // publish before readiness or first paint.
221 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {221 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
222- if ac == nil || sess == nil || tr == nil {222+ return ac.beginCurrentAttachmentOwnerEffect(sess, tr)
223+}
224+
225+// beginCurrentAttachmentOwnerEffect waits out a transition that already froze
226+// ac, then admits the capability derived from one exact attachment owner.
227+// Handshakes use it after Welcome so a resumed remote view follows the same
228+// generation-fenced client lifecycle as a local session.
229+func (ac *attachedClient) beginCurrentAttachmentOwnerEffect(owner attachmentOwner, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
230+ if ac == nil || normalizeAttachmentOwner(owner) == nil || tr == nil {
223 return attachmentConnectionToken{}, nil, false231 return attachmentConnectionToken{}, nil, false
224 }232 }
225 for {233 for {
226- token := sess.attachmentToken(ac, tr)234+ token := attachmentOwnerToken(owner, ac, tr)
227 if token.ac == nil {235 if token.ac == nil {
228 return token, nil, false236 return token, nil, false
229 }237 }
A internal/usecase/daemon/attachment_handshake.go +113 -0
@@ -0,0 +1,113 @@
1+package daemon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+// completeAttachmentHandshake publishes one already-routed attachment. Its
10+// owner is deliberately generic so a resumed remote view receives the same
11+// local-client Welcome/output lifecycle without being manufactured as a local
12+// session.
13+func (d *Daemon) completeAttachmentHandshake(handshakeCtx context.Context, timedOut <-chan struct{}, stopHandshakeTransport, finishHandshake func(), tr ports.Transport, owner attachmentOwner, ac *attachedClient) {
14+ failAttachment := func() {
15+ d.failHandshakeAttachmentOwner(owner, ac, tr)
16+ }
17+ if ac == nil || normalizeAttachmentOwner(owner) == nil || handshakeCtx.Err() != nil {
18+ failAttachment()
19+ return
20+ }
21+
22+ expected := ac.transportSnapshot()
23+ welcomeToken := attachmentOwnerToken(owner, ac, tr)
24+ welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
25+ if expected.transport != tr || !admitted || welcomeToken.ac == nil {
26+ if admitted {
27+ welcomeTicket.End()
28+ }
29+ if !d.abortResumeClaim(ac) {
30+ failAttachment()
31+ }
32+ return
33+ }
34+ if handshakeCtx.Err() != nil {
35+ welcomeTicket.End()
36+ failAttachment()
37+ return
38+ }
39+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
40+ return ac.sendExpectedTransportForAttachment(expected, frameWelcomeForAttachment(owner, ac), welcomeTicket)
41+ }); err != nil {
42+ welcomeTicket.End()
43+ failAttachment()
44+ return
45+ }
46+ // Release Welcome's effect before discovering post-handshake authority so a
47+ // replacement blocked behind the send can publish its generation and lease.
48+ welcomeTicket.End()
49+
50+ postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentOwnerEffect(owner, tr)
51+ if !admitted || postWelcomeToken.ac == nil {
52+ if postWelcomeTicket != nil {
53+ postWelcomeTicket.End()
54+ }
55+ failAttachment()
56+ return
57+ }
58+ sess := localSession(owner)
59+ var lease *attachmentLease
60+ var rc *renderCoordinator
61+ if sess != nil {
62+ rc = sess.renderCoordinator()
63+ if rc != nil {
64+ lease = rc.attachmentLease(ac)
65+ }
66+ }
67+ if sess != nil && postWelcomeToken.ac.renderMode == ports.RenderModeProxiedContent {
68+ meta, metaErr := frameSessionMeta(sess, postWelcomeToken.ac, 1)
69+ if metaErr != nil {
70+ postWelcomeTicket.End()
71+ failAttachment()
72+ return
73+ }
74+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
75+ return postWelcomeToken.ac.sendExpectedTransportForAttachment(expected, meta, postWelcomeTicket)
76+ }); err != nil {
77+ postWelcomeTicket.End()
78+ failAttachment()
79+ return
80+ }
81+ postWelcomeToken.ac.sendMu.Lock()
82+ postWelcomeToken.ac.proxiedMetaRevision = 1
83+ postWelcomeToken.ac.sendMu.Unlock()
84+ }
85+ if lease != nil && (rc == nil || !rc.markAttachmentReady(lease)) {
86+ postWelcomeTicket.End()
87+ failAttachment()
88+ return
89+ }
90+ postWelcomeTicket.End()
91+
92+ paintToken := attachmentOwnerToken(owner, ac, tr)
93+ painted := make(chan bool, 1)
94+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
95+ painted <- d.firstPaintForTransition(paintToken)
96+ return nil
97+ }); err != nil {
98+ failAttachment()
99+ return
100+ }
101+ if !<-painted || handshakeCtx.Err() != nil {
102+ failAttachment()
103+ return
104+ }
105+ if !d.commitResumeClaim(ac) {
106+ failAttachment()
107+ return
108+ }
109+ stopHandshakeTransport()
110+ finishHandshake()
111+ d.runConnLoop(ac)
112+ _ = tr.Close()
113+}
@@ -0,0 +1,113 @@
1+package daemon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+// completeAttachmentHandshake publishes one already-routed attachment. Its
10+// owner is deliberately generic so a resumed remote view receives the same
11+// local-client Welcome/output lifecycle without being manufactured as a local
12+// session.
13+func (d *Daemon) completeAttachmentHandshake(handshakeCtx context.Context, timedOut <-chan struct{}, stopHandshakeTransport, finishHandshake func(), tr ports.Transport, owner attachmentOwner, ac *attachedClient) {
14+ failAttachment := func() {
15+ d.failHandshakeAttachmentOwner(owner, ac, tr)
16+ }
17+ if ac == nil || normalizeAttachmentOwner(owner) == nil || handshakeCtx.Err() != nil {
18+ failAttachment()
19+ return
20+ }
21+
22+ expected := ac.transportSnapshot()
23+ welcomeToken := attachmentOwnerToken(owner, ac, tr)
24+ welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
25+ if expected.transport != tr || !admitted || welcomeToken.ac == nil {
26+ if admitted {
27+ welcomeTicket.End()
28+ }
29+ if !d.abortResumeClaim(ac) {
30+ failAttachment()
31+ }
32+ return
33+ }
34+ if handshakeCtx.Err() != nil {
35+ welcomeTicket.End()
36+ failAttachment()
37+ return
38+ }
39+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
40+ return ac.sendExpectedTransportForAttachment(expected, frameWelcomeForAttachment(owner, ac), welcomeTicket)
41+ }); err != nil {
42+ welcomeTicket.End()
43+ failAttachment()
44+ return
45+ }
46+ // Release Welcome's effect before discovering post-handshake authority so a
47+ // replacement blocked behind the send can publish its generation and lease.
48+ welcomeTicket.End()
49+
50+ postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentOwnerEffect(owner, tr)
51+ if !admitted || postWelcomeToken.ac == nil {
52+ if postWelcomeTicket != nil {
53+ postWelcomeTicket.End()
54+ }
55+ failAttachment()
56+ return
57+ }
58+ sess := localSession(owner)
59+ var lease *attachmentLease
60+ var rc *renderCoordinator
61+ if sess != nil {
62+ rc = sess.renderCoordinator()
63+ if rc != nil {
64+ lease = rc.attachmentLease(ac)
65+ }
66+ }
67+ if sess != nil && postWelcomeToken.ac.renderMode == ports.RenderModeProxiedContent {
68+ meta, metaErr := frameSessionMeta(sess, postWelcomeToken.ac, 1)
69+ if metaErr != nil {
70+ postWelcomeTicket.End()
71+ failAttachment()
72+ return
73+ }
74+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
75+ return postWelcomeToken.ac.sendExpectedTransportForAttachment(expected, meta, postWelcomeTicket)
76+ }); err != nil {
77+ postWelcomeTicket.End()
78+ failAttachment()
79+ return
80+ }
81+ postWelcomeToken.ac.sendMu.Lock()
82+ postWelcomeToken.ac.proxiedMetaRevision = 1
83+ postWelcomeToken.ac.sendMu.Unlock()
84+ }
85+ if lease != nil && (rc == nil || !rc.markAttachmentReady(lease)) {
86+ postWelcomeTicket.End()
87+ failAttachment()
88+ return
89+ }
90+ postWelcomeTicket.End()
91+
92+ paintToken := attachmentOwnerToken(owner, ac, tr)
93+ painted := make(chan bool, 1)
94+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
95+ painted <- d.firstPaintForTransition(paintToken)
96+ return nil
97+ }); err != nil {
98+ failAttachment()
99+ return
100+ }
101+ if !<-painted || handshakeCtx.Err() != nil {
102+ failAttachment()
103+ return
104+ }
105+ if !d.commitResumeClaim(ac) {
106+ failAttachment()
107+ return
108+ }
109+ stopHandshakeTransport()
110+ finishHandshake()
111+ d.runConnLoop(ac)
112+ _ = tr.Close()
113+}
M internal/usecase/daemon/attachment_owner.go +28 -5
@@ -103,17 +103,40 @@ func (v *remoteView) attachmentRegistered(ac *attachedClient) bool {
103103 return registered
104104 }
105105
106-// close retires attachment membership without transport I/O. The daemon first
107-// marks shutdown under its registry lock, then calls this after releasing it;
108-// exact token validation observes the cleared membership immediately.
109-func (v *remoteView) close() {
106+// close retires attachment membership without transport I/O and returns the
107+// exact active attachments for retirement after the caller releases view.mu.
108+// The daemon first marks shutdown under its registry lock, then uses that
109+// snapshot to interrupt each blocked local-client transport.
110+func (v *remoteView) close() []*attachedClient {
110111 if v == nil {
111- return
112+ return nil
112113 }
113114 v.mu.Lock()
115+ attachments := make([]*attachedClient, 0, len(v.attachments))
116+ for ac := range v.attachments {
117+ attachments = append(attachments, ac)
118+ }
114119 v.closed = true
115120 clear(v.attachments)
116121 v.mu.Unlock()
122+ return attachments
123+}
124+
125+// retireShutdownRemoteAttachment closes one exact local-client transport after
126+// a remote view is unpublished. It never performs I/O while holding daemon or
127+// remote-view locks; a future remote link is separately retired in Phase 4.
128+func (d *Daemon) retireShutdownRemoteAttachment(view *remoteView, ac *attachedClient, reason uint8) {
129+ if d == nil || view == nil || ac == nil {
130+ return
131+ }
132+ if sameAttachmentOwner(ac.currentAttachmentOwner(), view) {
133+ ac.connectionGeneration.Add(1)
134+ ac.setAttachmentOwner(nil)
135+ }
136+ ac.clearPreviousSession()
137+ transport := ac.transport()
138+ d.boundedSend(ac, frameDetached(reason))
139+ _ = ac.closeCapturedTransport(ac.revokeTransport(transport))
117140 }
118141
119142 func attachmentOwnerRegistered(owner attachmentOwner, ac *attachedClient) bool {
@@ -103,17 +103,40 @@ func (v *remoteView) attachmentRegistered(ac *attachedClient) bool {
103 return registered103 return registered
104 }104 }
105 105
106-// close retires attachment membership without transport I/O. The daemon first106+// close retires attachment membership without transport I/O and returns the
107-// marks shutdown under its registry lock, then calls this after releasing it;107+// exact active attachments for retirement after the caller releases view.mu.
108-// exact token validation observes the cleared membership immediately.108+// The daemon first marks shutdown under its registry lock, then uses that
109-func (v *remoteView) close() {109+// snapshot to interrupt each blocked local-client transport.
110+func (v *remoteView) close() []*attachedClient {
110 if v == nil {111 if v == nil {
111- return112+ return nil
112 }113 }
113 v.mu.Lock()114 v.mu.Lock()
115+ attachments := make([]*attachedClient, 0, len(v.attachments))
116+ for ac := range v.attachments {
117+ attachments = append(attachments, ac)
118+ }
114 v.closed = true119 v.closed = true
115 clear(v.attachments)120 clear(v.attachments)
116 v.mu.Unlock()121 v.mu.Unlock()
122+ return attachments
123+}
124+
125+// retireShutdownRemoteAttachment closes one exact local-client transport after
126+// a remote view is unpublished. It never performs I/O while holding daemon or
127+// remote-view locks; a future remote link is separately retired in Phase 4.
128+func (d *Daemon) retireShutdownRemoteAttachment(view *remoteView, ac *attachedClient, reason uint8) {
129+ if d == nil || view == nil || ac == nil {
130+ return
131+ }
132+ if sameAttachmentOwner(ac.currentAttachmentOwner(), view) {
133+ ac.connectionGeneration.Add(1)
134+ ac.setAttachmentOwner(nil)
135+ }
136+ ac.clearPreviousSession()
137+ transport := ac.transport()
138+ d.boundedSend(ac, frameDetached(reason))
139+ _ = ac.closeCapturedTransport(ac.revokeTransport(transport))
117 }140 }
118 141
119 func attachmentOwnerRegistered(owner attachmentOwner, ac *attachedClient) bool {142 func attachmentOwnerRegistered(owner attachmentOwner, ac *attachedClient) bool {
M internal/usecase/daemon/attachment_owner_test.go +342 -0
@@ -1,6 +1,9 @@
11 package daemon
22
33 import (
4+ "errors"
5+ "io"
6+ "sync"
47 "testing"
58
69 "github.com/bnema/vev/internal/domain"
@@ -9,6 +12,39 @@ import (
912 "github.com/stretchr/testify/require"
1013 )
1114
15+type parkedRemoteResumeTransport struct {
16+ closeOnce sync.Once
17+ closed chan struct{}
18+ sends chan ports.Frame
19+}
20+
21+func newParkedRemoteResumeTransport() *parkedRemoteResumeTransport {
22+ return &parkedRemoteResumeTransport{closed: make(chan struct{}), sends: make(chan ports.Frame, 4)}
23+}
24+
25+func (t *parkedRemoteResumeTransport) Send(frame ports.Frame) error {
26+ t.sends <- frame
27+ return nil
28+}
29+
30+func (t *parkedRemoteResumeTransport) Recv() (ports.Frame, error) {
31+ <-t.closed
32+ return ports.Frame{}, io.EOF
33+}
34+
35+func (t *parkedRemoteResumeTransport) Close() error {
36+ t.closeOnce.Do(func() { close(t.closed) })
37+ return nil
38+}
39+
40+type failingRemoteTransport struct {
41+ closeTrackingTransport
42+}
43+
44+func (*failingRemoteTransport) Send(ports.Frame) error {
45+ return errors.New("send failed")
46+}
47+
1248 func TestAttachedClientOwnerBindingNarrowsLocalOnlyBehavior(t *testing.T) {
1349 local := &session{sessionCore: sessionCore{id: "local"}}
1450 remote := &remoteView{id: 7}
@@ -135,6 +171,8 @@ func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) {
135171 require.Empty(t, d.remoteViewsByKey)
136172 d.mu.Unlock()
137173 require.False(t, token.current())
174+ require.Nil(t, ac.currentAttachmentOwner())
175+ require.True(t, transport.Closed())
138176 }
139177
140178 func TestRemoteClientGoneParksExactOwnerBinding(t *testing.T) {
@@ -162,6 +200,310 @@ func TestRemoteClientGoneParksExactOwnerBinding(t *testing.T) {
162200 require.Same(t, view, parked.owner)
163201 }
164202
203+func TestRemoteClientResumeRebindsParkedAttachmentToExactView(t *testing.T) {
204+ d := newTestDaemon(t, nil, stubClock{})
205+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
206+ d.mu.Lock()
207+ require.NoError(t, d.registerRemoteViewLocked(view))
208+ d.mu.Unlock()
209+
210+ oldTransport := &closeTrackingTransport{}
211+ ac := &attachedClient{
212+ tr: oldTransport,
213+ output: newOutputStateStream(),
214+ size: domain.Size{Cols: 80, Rows: 24},
215+ resumeCapable: true,
216+ clientID: [16]byte{1},
217+ }
218+ ac.setAttachmentOwner(view)
219+ view.mu.Lock()
220+ require.True(t, view.registerAttachmentLocked(ac))
221+ view.mu.Unlock()
222+ oldToken := attachmentOwnerToken(view, ac, oldTransport)
223+ require.True(t, d.clientGoneRemote(view, oldToken, false))
224+ parkedToken := ac.resumeToken
225+
226+ newTransport := &closeTrackingTransport{}
227+ resumedView, resumed, handled, err := d.resumeParkedRemoteView(ports.Hello{
228+ Version: ports.ProtocolVersion,
229+ Intent: ports.IntentResume,
230+ ResumeToken: parkedToken,
231+ ClientID: ac.clientID,
232+ Size: domain.Size{Cols: 100, Rows: 30},
233+ }, newTransport)
234+ require.NoError(t, err)
235+ require.True(t, handled)
236+ require.Same(t, view, resumedView)
237+ require.Same(t, ac, resumed)
238+ require.Same(t, view, ac.currentAttachmentOwner())
239+ require.True(t, view.attachmentRegistered(ac))
240+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, ac.sizeSnapshot())
241+ require.NotEqual(t, parkedToken, ac.resumeToken)
242+ require.True(t, d.commitResumeClaim(ac))
243+ d.mu.Lock()
244+ require.NotContains(t, d.parked, parkedToken)
245+ require.Empty(t, d.sessions)
246+ d.mu.Unlock()
247+}
248+
249+func TestRemoteClientLiveResumeParksOldTransportAndRebindsExactView(t *testing.T) {
250+ d := newTestDaemon(t, nil, stubClock{})
251+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
252+ d.mu.Lock()
253+ require.NoError(t, d.registerRemoteViewLocked(view))
254+ d.mu.Unlock()
255+
256+ oldTransport := &closeTrackingTransport{}
257+ ac := &attachedClient{
258+ tr: oldTransport,
259+ output: newOutputStateStream(),
260+ size: domain.Size{Cols: 80, Rows: 24},
261+ resumeCapable: true,
262+ clientID: [16]byte{1},
263+ }
264+ d.mu.Lock()
265+ ac.resumeToken = d.nextResumeTokenLocked()
266+ d.mu.Unlock()
267+ ac.setAttachmentOwner(view)
268+ view.mu.Lock()
269+ require.True(t, view.registerAttachmentLocked(ac))
270+ view.mu.Unlock()
271+ oldToken := ac.resumeToken
272+
273+ newTransport := &closeTrackingTransport{}
274+ resumedView, resumed, handled, err := d.resumeLiveRemoteView(ports.Hello{
275+ Version: ports.ProtocolVersion,
276+ Intent: ports.IntentResume,
277+ ResumeToken: oldToken,
278+ ClientID: ac.clientID,
279+ Size: domain.Size{Cols: 100, Rows: 30},
280+ }, newTransport)
281+ require.NoError(t, err)
282+ require.True(t, handled)
283+ require.Same(t, view, resumedView)
284+ require.Same(t, ac, resumed)
285+ require.True(t, oldTransport.Closed())
286+ require.Same(t, view, ac.currentAttachmentOwner())
287+ require.True(t, d.commitResumeClaim(ac))
288+}
289+
290+func TestRemoteClientResumeWaitsForInFlightRemoteParking(t *testing.T) {
291+ d := newTestDaemon(t, nil, stubClock{})
292+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
293+ d.mu.Lock()
294+ require.NoError(t, d.registerRemoteViewLocked(view))
295+ d.mu.Unlock()
296+
297+ oldTransport := &closeTrackingTransport{}
298+ ac := &attachedClient{
299+ tr: oldTransport,
300+ output: newOutputStateStream(),
301+ size: domain.Size{Cols: 80, Rows: 24},
302+ resumeCapable: true,
303+ clientID: [16]byte{1},
304+ }
305+ ac.setAttachmentOwner(view)
306+ view.mu.Lock()
307+ require.True(t, view.registerAttachmentLocked(ac))
308+ view.mu.Unlock()
309+ resumeToken := ac.resumeToken
310+ if resumeToken == 0 {
311+ resumeToken = d.markParkingInFlightOwner(view, ac)
312+ d.clearParkingInFlight(resumeToken, ac)
313+ }
314+
315+ detachStarted := make(chan struct{})
316+ releaseDetach := make(chan struct{})
317+ d.beforeClientGoneDetach = func() {
318+ close(detachStarted)
319+ <-releaseDetach
320+ }
321+ waitArmed := make(chan struct{})
322+ d.afterParkingWaitArmed = func() { close(waitArmed) }
323+ gone := make(chan bool, 1)
324+ go func() {
325+ gone <- d.clientGoneRemote(view, attachmentOwnerToken(view, ac, oldTransport), false)
326+ }()
327+ awaitTestCompletion(t, detachStarted, "remote detach did not publish parking")
328+
329+ type result struct {
330+ view *remoteView
331+ ac *attachedClient
332+ handled bool
333+ err error
334+ }
335+ resumed := make(chan result, 1)
336+ go func() {
337+ resumedView, resumedAC, handled, err := d.resumeParkedRemoteView(ports.Hello{
338+ Version: ports.ProtocolVersion,
339+ Intent: ports.IntentResume,
340+ ResumeToken: resumeToken,
341+ ClientID: ac.clientID,
342+ Size: domain.Size{Cols: 80, Rows: 24},
343+ }, &closeTrackingTransport{})
344+ resumed <- result{view: resumedView, ac: resumedAC, handled: handled, err: err}
345+ }()
346+ awaitTestCompletion(t, waitArmed, "remote resume did not wait for in-flight parking")
347+ close(releaseDetach)
348+ require.True(t, awaitTestValue(t, gone, "remote detach did not finish"))
349+ got := awaitTestValue(t, resumed, "remote resume did not finish")
350+ require.NoError(t, got.err)
351+ require.True(t, got.handled)
352+ require.Same(t, view, got.view)
353+ require.Same(t, ac, got.ac)
354+ require.True(t, d.commitResumeClaim(ac))
355+}
356+
357+func TestRemoteClientResumeHandshakeRestoresLocalComposition(t *testing.T) {
358+ d := newTestDaemon(t, nil, stubClock{})
359+ view := &remoteView{
360+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
361+ screen: vt.NewScreen(80, 22),
362+ }
363+ view.screen.Write([]byte("remote content"))
364+ d.mu.Lock()
365+ require.NoError(t, d.registerRemoteViewLocked(view))
366+ d.mu.Unlock()
367+
368+ oldTransport := &closeTrackingTransport{}
369+ ac := &attachedClient{
370+ tr: oldTransport,
371+ output: newOutputStateStream(),
372+ size: domain.Size{Cols: 80, Rows: 24},
373+ resumeCapable: true,
374+ clientID: [16]byte{1},
375+ }
376+ ac.initOverlays()
377+ ac.setAttachmentOwner(view)
378+ view.mu.Lock()
379+ require.True(t, view.registerAttachmentLocked(ac))
380+ view.mu.Unlock()
381+ oldToken := attachmentOwnerToken(view, ac, oldTransport)
382+ require.True(t, d.clientGoneRemote(view, oldToken, false))
383+ parkedToken := ac.resumeToken
384+
385+ transport := newParkedRemoteResumeTransport()
386+ done := make(chan struct{})
387+ go func() {
388+ d.handleHello(transport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
389+ Version: ports.ProtocolVersion,
390+ Intent: ports.IntentResume,
391+ ResumeToken: parkedToken,
392+ ClientID: ac.clientID,
393+ Size: domain.Size{Cols: 100, Rows: 30},
394+ })})
395+ close(done)
396+ }()
397+
398+ welcomeFrame := awaitFrame(t, transport.sends, ports.MsgWelcome)
399+ welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload)
400+ require.NoError(t, err)
401+ require.Equal(t, "remote", welcome.SessionName)
402+ require.NotEqual(t, parkedToken, welcome.ResumeToken)
403+ outputFrame := awaitFrame(t, transport.sends, ports.MsgOutput)
404+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
405+ require.NoError(t, err)
406+ require.True(t, output.Full)
407+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, output.Size)
408+ require.Same(t, view, ac.currentAttachmentOwner())
409+ require.True(t, view.attachmentRegistered(ac))
410+
411+ require.NoError(t, transport.Close())
412+ awaitTestCompletion(t, done, "remote resume handshake did not finish")
413+ d.mu.Lock()
414+ parked := d.parked[welcome.ResumeToken]
415+ d.mu.Unlock()
416+ require.NotNil(t, parked)
417+ require.Same(t, view, parked.owner)
418+}
419+
420+func TestRemoteResumeHandshakeFailureRestoresParkedOwner(t *testing.T) {
421+ d := newTestDaemon(t, nil, stubClock{})
422+ view := &remoteView{
423+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
424+ screen: vt.NewScreen(80, 22),
425+ }
426+ view.screen.Write([]byte("retained content"))
427+ d.mu.Lock()
428+ require.NoError(t, d.registerRemoteViewLocked(view))
429+ d.mu.Unlock()
430+
431+ oldTransport := &closeTrackingTransport{}
432+ ac := &attachedClient{
433+ tr: oldTransport,
434+ output: newOutputStateStream(),
435+ size: domain.Size{Cols: 80, Rows: 24},
436+ resumeCapable: true,
437+ clientID: [16]byte{1},
438+ }
439+ ac.setAttachmentOwner(view)
440+ view.mu.Lock()
441+ require.True(t, view.registerAttachmentLocked(ac))
442+ view.mu.Unlock()
443+ require.True(t, d.clientGoneRemote(view, attachmentOwnerToken(view, ac, oldTransport), false))
444+ parkedToken := ac.resumeToken
445+
446+ failed := &resumeWelcomeFailureTransport{}
447+ d.handleHello(failed, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
448+ Version: ports.ProtocolVersion,
449+ Intent: ports.IntentResume,
450+ ResumeToken: parkedToken,
451+ ClientID: ac.clientID,
452+ Size: domain.Size{Cols: 80, Rows: 24},
453+ })})
454+
455+ require.Nil(t, ac.currentAttachmentOwner())
456+ require.False(t, view.attachmentRegistered(ac))
457+ view.mu.Lock()
458+ require.Equal(t, 80, view.screen.Frame.Width)
459+ require.Equal(t, 22, view.screen.Frame.Height)
460+ view.mu.Unlock()
461+ require.Equal(t, parkedToken, ac.resumeToken)
462+ d.mu.Lock()
463+ parked := d.parked[parkedToken]
464+ d.mu.Unlock()
465+ require.NotNil(t, parked)
466+ require.False(t, parked.claimed)
467+ require.Same(t, view, parked.owner)
468+}
469+
470+func TestRemotePaintSendFailureParksExactOwnerBinding(t *testing.T) {
471+ d := newTestDaemon(t, nil, stubClock{})
472+ view := &remoteView{
473+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
474+ screen: vt.NewScreen(80, 22),
475+ }
476+ view.screen.Write([]byte("remote content"))
477+ d.mu.Lock()
478+ require.NoError(t, d.registerRemoteViewLocked(view))
479+ d.mu.Unlock()
480+
481+ transport := &failingRemoteTransport{}
482+ ac := &attachedClient{
483+ tr: transport,
484+ output: newOutputStateStream(),
485+ size: domain.Size{Cols: 80, Rows: 24},
486+ resumeCapable: true,
487+ }
488+ ac.setAttachmentOwner(view)
489+ view.mu.Lock()
490+ require.True(t, view.registerAttachmentLocked(ac))
491+ view.mu.Unlock()
492+ token := attachmentOwnerToken(view, ac, transport)
493+ cleanupDone := make(chan struct{})
494+ d.afterAttachmentSendErrorCleanup = func() { close(cleanupDone) }
495+
496+ d.paintRemoteView(view, ac, true, token)
497+ awaitTestCompletion(t, cleanupDone, "remote send-error cleanup did not finish")
498+ require.Nil(t, ac.currentAttachmentOwner())
499+ require.False(t, view.attachmentRegistered(ac))
500+ d.mu.Lock()
501+ parked := d.parked[ac.resumeToken]
502+ d.mu.Unlock()
503+ require.NotNil(t, parked)
504+ require.Same(t, view, parked.owner)
505+}
506+
165507 func TestRemoteParkingRetainsStableOwnerBinding(t *testing.T) {
166508 d := newTestDaemon(t, nil, stubClock{})
167509 view := &remoteView{
@@ -1,6 +1,9 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "errors"
5+ "io"
6+ "sync"
4 "testing"7 "testing"
5 8
6 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
@@ -9,6 +12,39 @@ import (
9 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
10 )13 )
11 14
15+type parkedRemoteResumeTransport struct {
16+ closeOnce sync.Once
17+ closed chan struct{}
18+ sends chan ports.Frame
19+}
20+
21+func newParkedRemoteResumeTransport() *parkedRemoteResumeTransport {
22+ return &parkedRemoteResumeTransport{closed: make(chan struct{}), sends: make(chan ports.Frame, 4)}
23+}
24+
25+func (t *parkedRemoteResumeTransport) Send(frame ports.Frame) error {
26+ t.sends <- frame
27+ return nil
28+}
29+
30+func (t *parkedRemoteResumeTransport) Recv() (ports.Frame, error) {
31+ <-t.closed
32+ return ports.Frame{}, io.EOF
33+}
34+
35+func (t *parkedRemoteResumeTransport) Close() error {
36+ t.closeOnce.Do(func() { close(t.closed) })
37+ return nil
38+}
39+
40+type failingRemoteTransport struct {
41+ closeTrackingTransport
42+}
43+
44+func (*failingRemoteTransport) Send(ports.Frame) error {
45+ return errors.New("send failed")
46+}
47+
12 func TestAttachedClientOwnerBindingNarrowsLocalOnlyBehavior(t *testing.T) {48 func TestAttachedClientOwnerBindingNarrowsLocalOnlyBehavior(t *testing.T) {
13 local := &session{sessionCore: sessionCore{id: "local"}}49 local := &session{sessionCore: sessionCore{id: "local"}}
14 remote := &remoteView{id: 7}50 remote := &remoteView{id: 7}
@@ -135,6 +171,8 @@ func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) {
135 require.Empty(t, d.remoteViewsByKey)171 require.Empty(t, d.remoteViewsByKey)
136 d.mu.Unlock()172 d.mu.Unlock()
137 require.False(t, token.current())173 require.False(t, token.current())
174+ require.Nil(t, ac.currentAttachmentOwner())
175+ require.True(t, transport.Closed())
138 }176 }
139 177
140 func TestRemoteClientGoneParksExactOwnerBinding(t *testing.T) {178 func TestRemoteClientGoneParksExactOwnerBinding(t *testing.T) {
@@ -162,6 +200,310 @@ func TestRemoteClientGoneParksExactOwnerBinding(t *testing.T) {
162 require.Same(t, view, parked.owner)200 require.Same(t, view, parked.owner)
163 }201 }
164 202
203+func TestRemoteClientResumeRebindsParkedAttachmentToExactView(t *testing.T) {
204+ d := newTestDaemon(t, nil, stubClock{})
205+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
206+ d.mu.Lock()
207+ require.NoError(t, d.registerRemoteViewLocked(view))
208+ d.mu.Unlock()
209+
210+ oldTransport := &closeTrackingTransport{}
211+ ac := &attachedClient{
212+ tr: oldTransport,
213+ output: newOutputStateStream(),
214+ size: domain.Size{Cols: 80, Rows: 24},
215+ resumeCapable: true,
216+ clientID: [16]byte{1},
217+ }
218+ ac.setAttachmentOwner(view)
219+ view.mu.Lock()
220+ require.True(t, view.registerAttachmentLocked(ac))
221+ view.mu.Unlock()
222+ oldToken := attachmentOwnerToken(view, ac, oldTransport)
223+ require.True(t, d.clientGoneRemote(view, oldToken, false))
224+ parkedToken := ac.resumeToken
225+
226+ newTransport := &closeTrackingTransport{}
227+ resumedView, resumed, handled, err := d.resumeParkedRemoteView(ports.Hello{
228+ Version: ports.ProtocolVersion,
229+ Intent: ports.IntentResume,
230+ ResumeToken: parkedToken,
231+ ClientID: ac.clientID,
232+ Size: domain.Size{Cols: 100, Rows: 30},
233+ }, newTransport)
234+ require.NoError(t, err)
235+ require.True(t, handled)
236+ require.Same(t, view, resumedView)
237+ require.Same(t, ac, resumed)
238+ require.Same(t, view, ac.currentAttachmentOwner())
239+ require.True(t, view.attachmentRegistered(ac))
240+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, ac.sizeSnapshot())
241+ require.NotEqual(t, parkedToken, ac.resumeToken)
242+ require.True(t, d.commitResumeClaim(ac))
243+ d.mu.Lock()
244+ require.NotContains(t, d.parked, parkedToken)
245+ require.Empty(t, d.sessions)
246+ d.mu.Unlock()
247+}
248+
249+func TestRemoteClientLiveResumeParksOldTransportAndRebindsExactView(t *testing.T) {
250+ d := newTestDaemon(t, nil, stubClock{})
251+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
252+ d.mu.Lock()
253+ require.NoError(t, d.registerRemoteViewLocked(view))
254+ d.mu.Unlock()
255+
256+ oldTransport := &closeTrackingTransport{}
257+ ac := &attachedClient{
258+ tr: oldTransport,
259+ output: newOutputStateStream(),
260+ size: domain.Size{Cols: 80, Rows: 24},
261+ resumeCapable: true,
262+ clientID: [16]byte{1},
263+ }
264+ d.mu.Lock()
265+ ac.resumeToken = d.nextResumeTokenLocked()
266+ d.mu.Unlock()
267+ ac.setAttachmentOwner(view)
268+ view.mu.Lock()
269+ require.True(t, view.registerAttachmentLocked(ac))
270+ view.mu.Unlock()
271+ oldToken := ac.resumeToken
272+
273+ newTransport := &closeTrackingTransport{}
274+ resumedView, resumed, handled, err := d.resumeLiveRemoteView(ports.Hello{
275+ Version: ports.ProtocolVersion,
276+ Intent: ports.IntentResume,
277+ ResumeToken: oldToken,
278+ ClientID: ac.clientID,
279+ Size: domain.Size{Cols: 100, Rows: 30},
280+ }, newTransport)
281+ require.NoError(t, err)
282+ require.True(t, handled)
283+ require.Same(t, view, resumedView)
284+ require.Same(t, ac, resumed)
285+ require.True(t, oldTransport.Closed())
286+ require.Same(t, view, ac.currentAttachmentOwner())
287+ require.True(t, d.commitResumeClaim(ac))
288+}
289+
290+func TestRemoteClientResumeWaitsForInFlightRemoteParking(t *testing.T) {
291+ d := newTestDaemon(t, nil, stubClock{})
292+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
293+ d.mu.Lock()
294+ require.NoError(t, d.registerRemoteViewLocked(view))
295+ d.mu.Unlock()
296+
297+ oldTransport := &closeTrackingTransport{}
298+ ac := &attachedClient{
299+ tr: oldTransport,
300+ output: newOutputStateStream(),
301+ size: domain.Size{Cols: 80, Rows: 24},
302+ resumeCapable: true,
303+ clientID: [16]byte{1},
304+ }
305+ ac.setAttachmentOwner(view)
306+ view.mu.Lock()
307+ require.True(t, view.registerAttachmentLocked(ac))
308+ view.mu.Unlock()
309+ resumeToken := ac.resumeToken
310+ if resumeToken == 0 {
311+ resumeToken = d.markParkingInFlightOwner(view, ac)
312+ d.clearParkingInFlight(resumeToken, ac)
313+ }
314+
315+ detachStarted := make(chan struct{})
316+ releaseDetach := make(chan struct{})
317+ d.beforeClientGoneDetach = func() {
318+ close(detachStarted)
319+ <-releaseDetach
320+ }
321+ waitArmed := make(chan struct{})
322+ d.afterParkingWaitArmed = func() { close(waitArmed) }
323+ gone := make(chan bool, 1)
324+ go func() {
325+ gone <- d.clientGoneRemote(view, attachmentOwnerToken(view, ac, oldTransport), false)
326+ }()
327+ awaitTestCompletion(t, detachStarted, "remote detach did not publish parking")
328+
329+ type result struct {
330+ view *remoteView
331+ ac *attachedClient
332+ handled bool
333+ err error
334+ }
335+ resumed := make(chan result, 1)
336+ go func() {
337+ resumedView, resumedAC, handled, err := d.resumeParkedRemoteView(ports.Hello{
338+ Version: ports.ProtocolVersion,
339+ Intent: ports.IntentResume,
340+ ResumeToken: resumeToken,
341+ ClientID: ac.clientID,
342+ Size: domain.Size{Cols: 80, Rows: 24},
343+ }, &closeTrackingTransport{})
344+ resumed <- result{view: resumedView, ac: resumedAC, handled: handled, err: err}
345+ }()
346+ awaitTestCompletion(t, waitArmed, "remote resume did not wait for in-flight parking")
347+ close(releaseDetach)
348+ require.True(t, awaitTestValue(t, gone, "remote detach did not finish"))
349+ got := awaitTestValue(t, resumed, "remote resume did not finish")
350+ require.NoError(t, got.err)
351+ require.True(t, got.handled)
352+ require.Same(t, view, got.view)
353+ require.Same(t, ac, got.ac)
354+ require.True(t, d.commitResumeClaim(ac))
355+}
356+
357+func TestRemoteClientResumeHandshakeRestoresLocalComposition(t *testing.T) {
358+ d := newTestDaemon(t, nil, stubClock{})
359+ view := &remoteView{
360+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
361+ screen: vt.NewScreen(80, 22),
362+ }
363+ view.screen.Write([]byte("remote content"))
364+ d.mu.Lock()
365+ require.NoError(t, d.registerRemoteViewLocked(view))
366+ d.mu.Unlock()
367+
368+ oldTransport := &closeTrackingTransport{}
369+ ac := &attachedClient{
370+ tr: oldTransport,
371+ output: newOutputStateStream(),
372+ size: domain.Size{Cols: 80, Rows: 24},
373+ resumeCapable: true,
374+ clientID: [16]byte{1},
375+ }
376+ ac.initOverlays()
377+ ac.setAttachmentOwner(view)
378+ view.mu.Lock()
379+ require.True(t, view.registerAttachmentLocked(ac))
380+ view.mu.Unlock()
381+ oldToken := attachmentOwnerToken(view, ac, oldTransport)
382+ require.True(t, d.clientGoneRemote(view, oldToken, false))
383+ parkedToken := ac.resumeToken
384+
385+ transport := newParkedRemoteResumeTransport()
386+ done := make(chan struct{})
387+ go func() {
388+ d.handleHello(transport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
389+ Version: ports.ProtocolVersion,
390+ Intent: ports.IntentResume,
391+ ResumeToken: parkedToken,
392+ ClientID: ac.clientID,
393+ Size: domain.Size{Cols: 100, Rows: 30},
394+ })})
395+ close(done)
396+ }()
397+
398+ welcomeFrame := awaitFrame(t, transport.sends, ports.MsgWelcome)
399+ welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload)
400+ require.NoError(t, err)
401+ require.Equal(t, "remote", welcome.SessionName)
402+ require.NotEqual(t, parkedToken, welcome.ResumeToken)
403+ outputFrame := awaitFrame(t, transport.sends, ports.MsgOutput)
404+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
405+ require.NoError(t, err)
406+ require.True(t, output.Full)
407+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, output.Size)
408+ require.Same(t, view, ac.currentAttachmentOwner())
409+ require.True(t, view.attachmentRegistered(ac))
410+
411+ require.NoError(t, transport.Close())
412+ awaitTestCompletion(t, done, "remote resume handshake did not finish")
413+ d.mu.Lock()
414+ parked := d.parked[welcome.ResumeToken]
415+ d.mu.Unlock()
416+ require.NotNil(t, parked)
417+ require.Same(t, view, parked.owner)
418+}
419+
420+func TestRemoteResumeHandshakeFailureRestoresParkedOwner(t *testing.T) {
421+ d := newTestDaemon(t, nil, stubClock{})
422+ view := &remoteView{
423+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
424+ screen: vt.NewScreen(80, 22),
425+ }
426+ view.screen.Write([]byte("retained content"))
427+ d.mu.Lock()
428+ require.NoError(t, d.registerRemoteViewLocked(view))
429+ d.mu.Unlock()
430+
431+ oldTransport := &closeTrackingTransport{}
432+ ac := &attachedClient{
433+ tr: oldTransport,
434+ output: newOutputStateStream(),
435+ size: domain.Size{Cols: 80, Rows: 24},
436+ resumeCapable: true,
437+ clientID: [16]byte{1},
438+ }
439+ ac.setAttachmentOwner(view)
440+ view.mu.Lock()
441+ require.True(t, view.registerAttachmentLocked(ac))
442+ view.mu.Unlock()
443+ require.True(t, d.clientGoneRemote(view, attachmentOwnerToken(view, ac, oldTransport), false))
444+ parkedToken := ac.resumeToken
445+
446+ failed := &resumeWelcomeFailureTransport{}
447+ d.handleHello(failed, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
448+ Version: ports.ProtocolVersion,
449+ Intent: ports.IntentResume,
450+ ResumeToken: parkedToken,
451+ ClientID: ac.clientID,
452+ Size: domain.Size{Cols: 80, Rows: 24},
453+ })})
454+
455+ require.Nil(t, ac.currentAttachmentOwner())
456+ require.False(t, view.attachmentRegistered(ac))
457+ view.mu.Lock()
458+ require.Equal(t, 80, view.screen.Frame.Width)
459+ require.Equal(t, 22, view.screen.Frame.Height)
460+ view.mu.Unlock()
461+ require.Equal(t, parkedToken, ac.resumeToken)
462+ d.mu.Lock()
463+ parked := d.parked[parkedToken]
464+ d.mu.Unlock()
465+ require.NotNil(t, parked)
466+ require.False(t, parked.claimed)
467+ require.Same(t, view, parked.owner)
468+}
469+
470+func TestRemotePaintSendFailureParksExactOwnerBinding(t *testing.T) {
471+ d := newTestDaemon(t, nil, stubClock{})
472+ view := &remoteView{
473+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
474+ screen: vt.NewScreen(80, 22),
475+ }
476+ view.screen.Write([]byte("remote content"))
477+ d.mu.Lock()
478+ require.NoError(t, d.registerRemoteViewLocked(view))
479+ d.mu.Unlock()
480+
481+ transport := &failingRemoteTransport{}
482+ ac := &attachedClient{
483+ tr: transport,
484+ output: newOutputStateStream(),
485+ size: domain.Size{Cols: 80, Rows: 24},
486+ resumeCapable: true,
487+ }
488+ ac.setAttachmentOwner(view)
489+ view.mu.Lock()
490+ require.True(t, view.registerAttachmentLocked(ac))
491+ view.mu.Unlock()
492+ token := attachmentOwnerToken(view, ac, transport)
493+ cleanupDone := make(chan struct{})
494+ d.afterAttachmentSendErrorCleanup = func() { close(cleanupDone) }
495+
496+ d.paintRemoteView(view, ac, true, token)
497+ awaitTestCompletion(t, cleanupDone, "remote send-error cleanup did not finish")
498+ require.Nil(t, ac.currentAttachmentOwner())
499+ require.False(t, view.attachmentRegistered(ac))
500+ d.mu.Lock()
501+ parked := d.parked[ac.resumeToken]
502+ d.mu.Unlock()
503+ require.NotNil(t, parked)
504+ require.Same(t, view, parked.owner)
505+}
506+
165 func TestRemoteParkingRetainsStableOwnerBinding(t *testing.T) {507 func TestRemoteParkingRetainsStableOwnerBinding(t *testing.T) {
166 d := newTestDaemon(t, nil, stubClock{})508 d := newTestDaemon(t, nil, stubClock{})
167 view := &remoteView{509 view := &remoteView{
M internal/usecase/daemon/client_transport_errors.go +32 -3
@@ -68,13 +68,26 @@ func (d *Daemon) clientGoneRemote(view *remoteView, token attachmentConnectionTo
6868 if !explicit {
6969 parkingToken = d.markParkingInFlightOwner(view, ac)
7070 }
71+ if d.beforeClientGoneDetach != nil {
72+ d.beforeClientGoneDetach()
73+ }
7174 if !d.detachRemoteAttachmentCurrent(view, token) {
7275 d.clearParkingInFlightIfAbandonedOwner(view, ac, parkingToken)
7376 return false
7477 }
78+ return d.finishRemoteClientGone(view, ac, token.transport.transport, explicit)
79+}
80+
81+// finishRemoteClientGone completes remote local-client teardown only after the
82+// exact owner binding has been unpublished. It intentionally leaves remote-link
83+// lifecycle ownership to Phase 4.
84+func (d *Daemon) finishRemoteClientGone(view *remoteView, ac *attachedClient, transport ports.Transport, explicit bool) bool {
85+ if d == nil || view == nil || ac == nil {
86+ return false
87+ }
7588 d.unregisterPreview(ac)
7689 if !explicit && d.parkAttachmentOwner(view, ac) {
77- _ = ac.closeCapturedTransport(token.transport.transport)
90+ _ = ac.closeCapturedTransport(transport)
7891 return true
7992 }
8093 d.clearParkingInFlight(ac.resumeToken, ac)
@@ -83,17 +96,24 @@ func (d *Daemon) clientGoneRemote(view *remoteView, token attachmentConnectionTo
8396 if explicit {
8497 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
8598 }
86- _ = ac.closeCapturedTransport(token.transport.transport)
99+ _ = ac.closeCapturedTransport(transport)
87100 return true
88101 }
89102
90103 // detachRemoteAttachmentCurrent invalidates exactly the frozen remote-view
91104 // membership. It performs no transport I/O while architecture locks are held.
92105 func (d *Daemon) detachRemoteAttachmentCurrent(view *remoteView, token attachmentConnectionToken) bool {
106+ return d.detachRemoteAttachmentCurrentUntil(view, token, nil)
107+}
108+
109+// detachRemoteAttachmentCurrentUntil is the deadline-bounded counterpart used
110+// by asynchronous failed-send cleanup. A timed-out cleanup leaves ownership
111+// intact for a later terminal sweep rather than violating effect-gate ordering.
112+func (d *Daemon) detachRemoteAttachmentCurrentUntil(view *remoteView, token attachmentConnectionToken, done func() <-chan struct{}) bool {
93113 if d == nil || view == nil || token.ac == nil || token.transport.transport == nil {
94114 return false
95115 }
96- frozen := freezeAttachmentEffectGates(token.ac)
116+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
97117 defer frozen.unfreeze()
98118 if !frozen.acquired || !frozen.drained {
99119 return false
@@ -196,6 +216,15 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
196216 if failed == nil || failed != token.transport.transport {
197217 return
198218 }
219+ if view, remote := token.owner.(*remoteView); remote {
220+ parkingToken := d.markParkingInFlightOwner(view, token.ac)
221+ if d.detachRemoteAttachmentCurrentUntil(view, token, done) {
222+ d.finishRemoteClientGone(view, token.ac, failed, false)
223+ return
224+ }
225+ d.clearParkingInFlightIfAbandonedOwner(view, token.ac, parkingToken)
226+ return
227+ }
199228 sess := token.localSession()
200229 if sess == nil {
201230 return
@@ -68,13 +68,26 @@ func (d *Daemon) clientGoneRemote(view *remoteView, token attachmentConnectionTo
68 if !explicit {68 if !explicit {
69 parkingToken = d.markParkingInFlightOwner(view, ac)69 parkingToken = d.markParkingInFlightOwner(view, ac)
70 }70 }
71+ if d.beforeClientGoneDetach != nil {
72+ d.beforeClientGoneDetach()
73+ }
71 if !d.detachRemoteAttachmentCurrent(view, token) {74 if !d.detachRemoteAttachmentCurrent(view, token) {
72 d.clearParkingInFlightIfAbandonedOwner(view, ac, parkingToken)75 d.clearParkingInFlightIfAbandonedOwner(view, ac, parkingToken)
73 return false76 return false
74 }77 }
78+ return d.finishRemoteClientGone(view, ac, token.transport.transport, explicit)
79+}
80+
81+// finishRemoteClientGone completes remote local-client teardown only after the
82+// exact owner binding has been unpublished. It intentionally leaves remote-link
83+// lifecycle ownership to Phase 4.
84+func (d *Daemon) finishRemoteClientGone(view *remoteView, ac *attachedClient, transport ports.Transport, explicit bool) bool {
85+ if d == nil || view == nil || ac == nil {
86+ return false
87+ }
75 d.unregisterPreview(ac)88 d.unregisterPreview(ac)
76 if !explicit && d.parkAttachmentOwner(view, ac) {89 if !explicit && d.parkAttachmentOwner(view, ac) {
77- _ = ac.closeCapturedTransport(token.transport.transport)90+ _ = ac.closeCapturedTransport(transport)
78 return true91 return true
79 }92 }
80 d.clearParkingInFlight(ac.resumeToken, ac)93 d.clearParkingInFlight(ac.resumeToken, ac)
@@ -83,17 +96,24 @@ func (d *Daemon) clientGoneRemote(view *remoteView, token attachmentConnectionTo
83 if explicit {96 if explicit {
84 d.boundedSend(ac, frameDetached(ports.ReasonDetach))97 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
85 }98 }
86- _ = ac.closeCapturedTransport(token.transport.transport)99+ _ = ac.closeCapturedTransport(transport)
87 return true100 return true
88 }101 }
89 102
90 // detachRemoteAttachmentCurrent invalidates exactly the frozen remote-view103 // detachRemoteAttachmentCurrent invalidates exactly the frozen remote-view
91 // membership. It performs no transport I/O while architecture locks are held.104 // membership. It performs no transport I/O while architecture locks are held.
92 func (d *Daemon) detachRemoteAttachmentCurrent(view *remoteView, token attachmentConnectionToken) bool {105 func (d *Daemon) detachRemoteAttachmentCurrent(view *remoteView, token attachmentConnectionToken) bool {
106+ return d.detachRemoteAttachmentCurrentUntil(view, token, nil)
107+}
108+
109+// detachRemoteAttachmentCurrentUntil is the deadline-bounded counterpart used
110+// by asynchronous failed-send cleanup. A timed-out cleanup leaves ownership
111+// intact for a later terminal sweep rather than violating effect-gate ordering.
112+func (d *Daemon) detachRemoteAttachmentCurrentUntil(view *remoteView, token attachmentConnectionToken, done func() <-chan struct{}) bool {
93 if d == nil || view == nil || token.ac == nil || token.transport.transport == nil {113 if d == nil || view == nil || token.ac == nil || token.transport.transport == nil {
94 return false114 return false
95 }115 }
96- frozen := freezeAttachmentEffectGates(token.ac)116+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
97 defer frozen.unfreeze()117 defer frozen.unfreeze()
98 if !frozen.acquired || !frozen.drained {118 if !frozen.acquired || !frozen.drained {
99 return false119 return false
@@ -196,6 +216,15 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
196 if failed == nil || failed != token.transport.transport {216 if failed == nil || failed != token.transport.transport {
197 return217 return
198 }218 }
219+ if view, remote := token.owner.(*remoteView); remote {
220+ parkingToken := d.markParkingInFlightOwner(view, token.ac)
221+ if d.detachRemoteAttachmentCurrentUntil(view, token, done) {
222+ d.finishRemoteClientGone(view, token.ac, failed, false)
223+ return
224+ }
225+ d.clearParkingInFlightIfAbandonedOwner(view, token.ac, parkingToken)
226+ return
227+ }
199 sess := token.localSession()228 sess := token.localSession()
200 if sess == nil {229 if sess == nil {
201 return230 return
M internal/usecase/daemon/daemon.go +24 -103
@@ -813,10 +813,13 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
813813 snapshot := d.sessionsSnapshotLocked()
814814 empty := len(snapshot) == 0
815815 d.mu.Unlock()
816- // A remote view is not a daemon-liveness root. Retire its membership after
817- // releasing d.mu so a token cannot remain valid once shutdown begins.
816+ // A remote view is not a daemon-liveness root. Retire each exact local
817+ // attachment after releasing d.mu so a token cannot remain valid and a
818+ // blocked local-client Recv/Send cannot outlive daemon shutdown.
818819 for _, view := range remoteViews {
819- view.close()
820+ for _, ac := range view.close() {
821+ d.retireShutdownRemoteAttachment(view, ac, reason)
822+ }
820823 }
821824 d.finishParkedAttachmentRetirements(parkedRetirements)
822825 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
@@ -1154,8 +1157,24 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
11541157 return
11551158 }
11561159
1157- sess, ac, rerr := d.routeWithContext(handshakeCtx, h, tr)
1160+ view, ac, remoteResume, rerr := d.resumeParkedRemoteView(h, tr)
1161+ if !remoteResume {
1162+ view, ac, remoteResume, rerr = d.resumeLiveRemoteView(h, tr)
1163+ }
1164+ var owner attachmentOwner = view
1165+ if remoteResume && rerr == nil && h.RemoteTarget != nil && !remoteViewMatchesTarget(view, *h.RemoteTarget) {
1166+ d.abortResumeClaim(ac)
1167+ rerr = &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
1168+ }
1169+ if !remoteResume {
1170+ var sess *session
1171+ sess, ac, rerr = d.routeWithContext(handshakeCtx, h, tr)
1172+ owner = sess
1173+ }
11581174 if rerr != nil {
1175+ if errors.Is(rerr, errResumeTokenLifecycleRace) {
1176+ rerr = &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1177+ }
11591178 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
11601179 if pe, ok := errors.AsType[*protoErr](rerr); ok {
11611180 _ = sendHandshake(frameError(pe.code, pe.text))
@@ -1165,105 +1184,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
11651184 _ = tr.Close()
11661185 return
11671186 }
1168-
1169- failAttachment := func() {
1170- d.failHandshakeAttachment(sess, ac, tr)
1171- }
1172- if err := handshakeCtx.Err(); err != nil {
1173- failAttachment()
1174- return
1175- }
1176- rc := sess.renderCoordinator()
1177- lease := (*attachmentLease)(nil)
1178- if rc != nil {
1179- lease = rc.attachmentLease(ac)
1180- }
1181- expected := ac.transportSnapshot()
1182- welcomeToken := sess.attachmentToken(ac, tr)
1183- welcomeToken.lease = lease
1184- welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1185- if expected.transport != tr || !admitted || welcomeToken.ac == nil {
1186- if admitted {
1187- welcomeTicket.End()
1188- }
1189- if !d.abortResumeClaim(ac) {
1190- d.clientGone(sess, ac, tr, false)
1191- }
1192- return
1193- }
1194- if err := handshakeCtx.Err(); err != nil {
1195- welcomeTicket.End()
1196- failAttachment()
1197- return
1198- }
1199- if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1200- return ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, ac), welcomeTicket)
1201- }); err != nil {
1202- welcomeTicket.End()
1203- failAttachment()
1204- return
1205- }
1206- // Release Welcome's effect before discovering post-handshake authority so a
1207- // replacement blocked behind the send can publish its generation and lease.
1208- welcomeTicket.End()
1209- postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr)
1210- if !admitted {
1211- failAttachment()
1212- return
1213- }
1214- postWelcomeLease := postWelcomeToken.lease
1215- if postWelcomeToken.ac == nil {
1216- postWelcomeTicket.End()
1217- failAttachment()
1218- return
1219- }
1220- if postWelcomeToken.ac.renderMode == ports.RenderModeProxiedContent {
1221- meta, metaErr := frameSessionMeta(sess, postWelcomeToken.ac, 1)
1222- if metaErr != nil {
1223- postWelcomeTicket.End()
1224- failAttachment()
1225- return
1226- }
1227- if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1228- return postWelcomeToken.ac.sendExpectedTransportForAttachment(expected, meta, postWelcomeTicket)
1229- }); err != nil {
1230- postWelcomeTicket.End()
1231- failAttachment()
1232- return
1233- }
1234- postWelcomeToken.ac.sendMu.Lock()
1235- postWelcomeToken.ac.proxiedMetaRevision = 1
1236- postWelcomeToken.ac.sendMu.Unlock()
1237- }
1238- if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {
1239- postWelcomeTicket.End()
1240- // The attachment was detached while Welcome was in flight; never let
1241- // this stale handshake emit an Output frame.
1242- failAttachment()
1243- return
1244- }
1245- postWelcomeTicket.End()
1246- paintToken := sess.attachmentToken(ac, tr)
1247- painted := make(chan bool, 1)
1248- if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1249- painted <- d.firstPaintForTransition(paintToken)
1250- return nil
1251- }); err != nil {
1252- failAttachment()
1253- return
1254- }
1255- if !<-painted || handshakeCtx.Err() != nil {
1256- failAttachment()
1257- return
1258- }
1259- if !d.commitResumeClaim(ac) {
1260- failAttachment()
1261- return
1262- }
1263- stopHandshakeTransport()
1264- finishHandshake()
1265- d.runConnLoop(ac)
1266- _ = tr.Close()
1187+ d.completeAttachmentHandshake(handshakeCtx, timedOut, stopHandshakeTransport, finishHandshake, tr, owner, ac)
12671188 }
12681189
12691190 // protoErr is a session-level rejection carrying a wire ErrorMsg code.
@@ -813,10 +813,13 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
813 snapshot := d.sessionsSnapshotLocked()813 snapshot := d.sessionsSnapshotLocked()
814 empty := len(snapshot) == 0814 empty := len(snapshot) == 0
815 d.mu.Unlock()815 d.mu.Unlock()
816- // A remote view is not a daemon-liveness root. Retire its membership after816+ // A remote view is not a daemon-liveness root. Retire each exact local
817- // releasing d.mu so a token cannot remain valid once shutdown begins.817+ // attachment after releasing d.mu so a token cannot remain valid and a
818+ // blocked local-client Recv/Send cannot outlive daemon shutdown.
818 for _, view := range remoteViews {819 for _, view := range remoteViews {
819- view.close()820+ for _, ac := range view.close() {
821+ d.retireShutdownRemoteAttachment(view, ac, reason)
822+ }
820 }823 }
821 d.finishParkedAttachmentRetirements(parkedRetirements)824 d.finishParkedAttachmentRetirements(parkedRetirements)
822 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))825 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
@@ -1154,8 +1157,24 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1154 return1157 return
1155 }1158 }
1156 1159
1157- sess, ac, rerr := d.routeWithContext(handshakeCtx, h, tr)1160+ view, ac, remoteResume, rerr := d.resumeParkedRemoteView(h, tr)
1161+ if !remoteResume {
1162+ view, ac, remoteResume, rerr = d.resumeLiveRemoteView(h, tr)
1163+ }
1164+ var owner attachmentOwner = view
1165+ if remoteResume && rerr == nil && h.RemoteTarget != nil && !remoteViewMatchesTarget(view, *h.RemoteTarget) {
1166+ d.abortResumeClaim(ac)
1167+ rerr = &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
1168+ }
1169+ if !remoteResume {
1170+ var sess *session
1171+ sess, ac, rerr = d.routeWithContext(handshakeCtx, h, tr)
1172+ owner = sess
1173+ }
1158 if rerr != nil {1174 if rerr != nil {
1175+ if errors.Is(rerr, errResumeTokenLifecycleRace) {
1176+ rerr = &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1177+ }
1159 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)1178 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
1160 if pe, ok := errors.AsType[*protoErr](rerr); ok {1179 if pe, ok := errors.AsType[*protoErr](rerr); ok {
1161 _ = sendHandshake(frameError(pe.code, pe.text))1180 _ = sendHandshake(frameError(pe.code, pe.text))
@@ -1165,105 +1184,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1165 _ = tr.Close()1184 _ = tr.Close()
1166 return1185 return
1167 }1186 }
1168-1187+ d.completeAttachmentHandshake(handshakeCtx, timedOut, stopHandshakeTransport, finishHandshake, tr, owner, ac)
1169- failAttachment := func() {
1170- d.failHandshakeAttachment(sess, ac, tr)
1171- }
1172- if err := handshakeCtx.Err(); err != nil {
1173- failAttachment()
1174- return
1175- }
1176- rc := sess.renderCoordinator()
1177- lease := (*attachmentLease)(nil)
1178- if rc != nil {
1179- lease = rc.attachmentLease(ac)
1180- }
1181- expected := ac.transportSnapshot()
1182- welcomeToken := sess.attachmentToken(ac, tr)
1183- welcomeToken.lease = lease
1184- welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1185- if expected.transport != tr || !admitted || welcomeToken.ac == nil {
1186- if admitted {
1187- welcomeTicket.End()
1188- }
1189- if !d.abortResumeClaim(ac) {
1190- d.clientGone(sess, ac, tr, false)
1191- }
1192- return
1193- }
1194- if err := handshakeCtx.Err(); err != nil {
1195- welcomeTicket.End()
1196- failAttachment()
1197- return
1198- }
1199- if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1200- return ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, ac), welcomeTicket)
1201- }); err != nil {
1202- welcomeTicket.End()
1203- failAttachment()
1204- return
1205- }
1206- // Release Welcome's effect before discovering post-handshake authority so a
1207- // replacement blocked behind the send can publish its generation and lease.
1208- welcomeTicket.End()
1209- postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr)
1210- if !admitted {
1211- failAttachment()
1212- return
1213- }
1214- postWelcomeLease := postWelcomeToken.lease
1215- if postWelcomeToken.ac == nil {
1216- postWelcomeTicket.End()
1217- failAttachment()
1218- return
1219- }
1220- if postWelcomeToken.ac.renderMode == ports.RenderModeProxiedContent {
1221- meta, metaErr := frameSessionMeta(sess, postWelcomeToken.ac, 1)
1222- if metaErr != nil {
1223- postWelcomeTicket.End()
1224- failAttachment()
1225- return
1226- }
1227- if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1228- return postWelcomeToken.ac.sendExpectedTransportForAttachment(expected, meta, postWelcomeTicket)
1229- }); err != nil {
1230- postWelcomeTicket.End()
1231- failAttachment()
1232- return
1233- }
1234- postWelcomeToken.ac.sendMu.Lock()
1235- postWelcomeToken.ac.proxiedMetaRevision = 1
1236- postWelcomeToken.ac.sendMu.Unlock()
1237- }
1238- if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {
1239- postWelcomeTicket.End()
1240- // The attachment was detached while Welcome was in flight; never let
1241- // this stale handshake emit an Output frame.
1242- failAttachment()
1243- return
1244- }
1245- postWelcomeTicket.End()
1246- paintToken := sess.attachmentToken(ac, tr)
1247- painted := make(chan bool, 1)
1248- if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1249- painted <- d.firstPaintForTransition(paintToken)
1250- return nil
1251- }); err != nil {
1252- failAttachment()
1253- return
1254- }
1255- if !<-painted || handshakeCtx.Err() != nil {
1256- failAttachment()
1257- return
1258- }
1259- if !d.commitResumeClaim(ac) {
1260- failAttachment()
1261- return
1262- }
1263- stopHandshakeTransport()
1264- finishHandshake()
1265- d.runConnLoop(ac)
1266- _ = tr.Close()
1267 }1188 }
1268 1189
1269 // protoErr is a session-level rejection carrying a wire ErrorMsg code.1190 // protoErr is a session-level rejection carrying a wire ErrorMsg code.
M internal/usecase/daemon/handshake.go +13 -3
@@ -115,7 +115,13 @@ func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fal
115115 // by route. A fresh route-owned session is purged only after its attachment is
116116 // removed; existing sessions and their unrelated attachments are preserved.
117117 func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport) {
118- if sess == nil || ac == nil {
118+ d.failHandshakeAttachmentOwner(sess, ac, tr)
119+}
120+
121+// failHandshakeAttachmentOwner synchronously retires an admitted local-client
122+// handshake without assuming that its attachment owner owns a local session.
123+func (d *Daemon) failHandshakeAttachmentOwner(owner attachmentOwner, ac *attachedClient, tr ports.Transport) {
124+ if normalizeAttachmentOwner(owner) == nil || ac == nil {
119125 if tr != nil {
120126 _ = tr.Close()
121127 }
@@ -130,11 +136,15 @@ func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr p
130136 }
131137 return
132138 }
133- d.clientGone(sess, ac, tr, true)
139+ if view, remote := owner.(*remoteView); remote {
140+ d.clientGoneRemote(view, attachmentOwnerToken(view, ac, tr), true)
141+ } else if sess := localSession(owner); sess != nil {
142+ d.clientGone(sess, ac, tr, true)
143+ }
134144 if tr != nil {
135145 _ = tr.Close()
136146 }
137- if ac.routeCreatedSession {
147+ if sess := localSession(owner); sess != nil && ac.routeCreatedSession {
138148 sess.mu.Lock()
139149 empty := len(sess.attachments) == 0
140150 sess.mu.Unlock()
@@ -115,7 +115,13 @@ func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fal
115 // by route. A fresh route-owned session is purged only after its attachment is115 // by route. A fresh route-owned session is purged only after its attachment is
116 // removed; existing sessions and their unrelated attachments are preserved.116 // removed; existing sessions and their unrelated attachments are preserved.
117 func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport) {117 func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport) {
118- if sess == nil || ac == nil {118+ d.failHandshakeAttachmentOwner(sess, ac, tr)
119+}
120+
121+// failHandshakeAttachmentOwner synchronously retires an admitted local-client
122+// handshake without assuming that its attachment owner owns a local session.
123+func (d *Daemon) failHandshakeAttachmentOwner(owner attachmentOwner, ac *attachedClient, tr ports.Transport) {
124+ if normalizeAttachmentOwner(owner) == nil || ac == nil {
119 if tr != nil {125 if tr != nil {
120 _ = tr.Close()126 _ = tr.Close()
121 }127 }
@@ -130,11 +136,15 @@ func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr p
130 }136 }
131 return137 return
132 }138 }
133- d.clientGone(sess, ac, tr, true)139+ if view, remote := owner.(*remoteView); remote {
140+ d.clientGoneRemote(view, attachmentOwnerToken(view, ac, tr), true)
141+ } else if sess := localSession(owner); sess != nil {
142+ d.clientGone(sess, ac, tr, true)
143+ }
134 if tr != nil {144 if tr != nil {
135 _ = tr.Close()145 _ = tr.Close()
136 }146 }
137- if ac.routeCreatedSession {147+ if sess := localSession(owner); sess != nil && ac.routeCreatedSession {
138 sess.mu.Lock()148 sess.mu.Lock()
139 empty := len(sess.attachments) == 0149 empty := len(sess.attachments) == 0
140 sess.mu.Unlock()150 sess.mu.Unlock()
A internal/usecase/daemon/remote_resume.go +216 -0
@@ -0,0 +1,216 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
7+
8+// resumeParkedRemoteView claims and republishes a parked local-client binding
9+// for one exact remote view. It deliberately owns only the local-client
10+// connection lifecycle: remote-link transport and reconnect work remain
11+// separate Phase 4 responsibilities.
12+//
13+// handled is false when the resume credential does not identify a remote view,
14+// allowing the existing local-session resume route to handle it. Once a remote
15+// parked entry is observed, the function is authoritative for that credential
16+// and returns a fail-closed error on every validation failure.
17+func (d *Daemon) resumeParkedRemoteView(h ports.Hello, tr ports.Transport) (view *remoteView, ac *attachedClient, handled bool, err error) {
18+ if d == nil || h.ResumeToken == 0 || tr == nil || !h.Size.Valid() {
19+ return nil, nil, false, nil
20+ }
21+
22+ // A reconnect can race the old local transport's Recv failure between its
23+ // pre-detach parking publication and final parked entry. Once that marker
24+ // identifies a remote view, this path remains authoritative for the token
25+ // and waits for its exact publication rather than falling into local-session
26+ // resume logic.
27+ seenRemoteCredential := false
28+ var parked *parkedAttachment
29+ for {
30+ d.mu.Lock()
31+ parked = d.parked[h.ResumeToken]
32+ if parked != nil {
33+ view, handled = normalizeAttachmentOwner(parked.owner).(*remoteView)
34+ d.mu.Unlock()
35+ if !handled {
36+ return nil, nil, false, nil
37+ }
38+ break
39+ }
40+ pending := d.parking[h.ResumeToken]
41+ pendingView, pendingRemote := (*remoteView)(nil), false
42+ if pending != nil {
43+ pendingView, pendingRemote = normalizeAttachmentOwner(pending.owner).(*remoteView)
44+ }
45+ d.mu.Unlock()
46+ if !pendingRemote {
47+ if seenRemoteCredential {
48+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
49+ }
50+ return nil, nil, false, nil
51+ }
52+ if pendingView == nil || pending.ac == nil || pending.ac.clientID != h.ClientID {
53+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
54+ }
55+ seenRemoteCredential = true
56+ if !d.waitParkingInFlight(h) {
57+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
58+ }
59+ }
60+ ac = parked.ac
61+ if ac == nil {
62+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
63+ }
64+
65+ if d.beforeResumeParkedSendMu != nil {
66+ d.beforeResumeParkedSendMu()
67+ }
68+ ac.sendMu.Lock()
69+ d.mu.Lock()
70+ if d.closing {
71+ d.mu.Unlock()
72+ ac.sendMu.Unlock()
73+ return nil, nil, true, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
74+ }
75+ if d.parked[h.ResumeToken] != parked || parked.claimed || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
76+ d.mu.Unlock()
77+ ac.sendMu.Unlock()
78+ return nil, nil, true, errResumeTokenLifecycleRace
79+ }
80+ if ac.clientID != h.ClientID {
81+ d.mu.Unlock()
82+ ac.sendMu.Unlock()
83+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
84+ }
85+
86+ // Keep the previous credential claimed until Welcome commits. A failed
87+ // handshake restores this exact parked owner through abortResumeClaim.
88+ parked.claimed = true
89+ ac.resumeClaimToken = h.ResumeToken
90+ ac.rebaseOutput()
91+ if ac.output != nil {
92+ ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
93+ ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)
94+ }
95+ ac.replaceTransport(tr)
96+ ac.setSize(h.Size)
97+ ac.resumeToken = d.nextResumeTokenLocked()
98+ ac.parked = false
99+ d.mu.Unlock()
100+ ac.sendMu.Unlock()
101+
102+ if _, transitionErr := d.resumeRemoteAttachment(view, ac, ac.transportSnapshot()); transitionErr != nil {
103+ d.abortResumeClaim(ac)
104+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
105+ }
106+ // The successful first local composition resizes the private VT from the
107+ // new attachment window. Deferring it until after Welcome preserves a
108+ // parked view's content when this handshake fails.
109+ d.log.Info("client resumed", "session", attachmentOwnerName(view))
110+ return view, ac, true, nil
111+}
112+
113+// resumeRemoteAttachment republishes a parked attachment under the exact
114+// remote owner. The gate is frozen across membership and owner publication so
115+// no stale local-client effect can observe the temporary unowned state.
116+func (d *Daemon) resumeRemoteAttachment(view *remoteView, ac *attachedClient, transport transportSnapshot) (attachmentConnectionToken, error) {
117+ if d == nil || view == nil || ac == nil || transport.transport == nil {
118+ return attachmentConnectionToken{}, errAttachmentTransition
119+ }
120+ frozen := freezeAttachmentEffectGates(ac)
121+ defer frozen.unfreeze()
122+ if !frozen.acquired || !frozen.drained {
123+ return attachmentConnectionToken{}, errAttachmentTransition
124+ }
125+
126+ d.mu.Lock()
127+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
128+ d.mu.Unlock()
129+ return attachmentConnectionToken{}, errAttachmentTransition
130+ }
131+ view.mu.Lock()
132+ _, registered := view.attachments[ac]
133+ current := !view.closed &&
134+ ac.currentAttachmentOwner() == nil &&
135+ ac.transportSnapshotCurrent(transport) &&
136+ !registered
137+ if !current || !view.registerAttachmentLocked(ac) {
138+ view.mu.Unlock()
139+ d.mu.Unlock()
140+ return attachmentConnectionToken{}, errAttachmentTransition
141+ }
142+ generation := ac.connectionGeneration.Add(1)
143+ ac.setAttachmentOwner(view)
144+ published := attachmentConnectionToken{
145+ owner: view,
146+ ac: ac,
147+ generation: generation,
148+ transport: transport,
149+ }
150+ ac.publishFrozenAttachmentCapability(published)
151+ view.mu.Unlock()
152+ d.mu.Unlock()
153+ return published, nil
154+}
155+
156+// resumeLiveRemoteView handles the narrow reconnect race where the old local
157+// transport still owns a remote attachment when the same resume credential
158+// arrives. It parks that exact binding first, then reuses the parked resume
159+// publication so the old and replacement transports cannot both own the view.
160+func (d *Daemon) resumeLiveRemoteView(h ports.Hello, tr ports.Transport) (view *remoteView, ac *attachedClient, handled bool, err error) {
161+ if d == nil || h.ResumeToken == 0 || tr == nil || !h.Size.Valid() {
162+ return nil, nil, false, nil
163+ }
164+
165+ var clientMismatch bool
166+ d.mu.Lock()
167+ for _, candidateView := range d.remoteViews {
168+ candidateView.mu.Lock()
169+ for candidate := range candidateView.attachments {
170+ if !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken {
171+ continue
172+ }
173+ if candidate.clientID != h.ClientID {
174+ clientMismatch = true
175+ } else {
176+ view, ac = candidateView, candidate
177+ }
178+ break
179+ }
180+ candidateView.mu.Unlock()
181+ if view != nil || clientMismatch {
182+ break
183+ }
184+ }
185+ closing := d.closing
186+ d.mu.Unlock()
187+ if view == nil && !clientMismatch {
188+ return nil, nil, false, nil
189+ }
190+ if closing {
191+ return nil, nil, true, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
192+ }
193+ if clientMismatch {
194+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
195+ }
196+
197+ oldTransport := ac.transport()
198+ token := attachmentOwnerToken(view, ac, oldTransport)
199+ if token.ac != nil {
200+ _ = d.clientGoneRemote(view, token, false)
201+ }
202+ view, ac, handled, err = d.resumeParkedRemoteView(h, tr)
203+ if handled || err != nil {
204+ return view, ac, handled, err
205+ }
206+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
207+}
208+
209+func remoteViewMatchesTarget(view *remoteView, target domain.RemoteSessionTarget) bool {
210+ if view == nil || target.Validate() != nil {
211+ return false
212+ }
213+ return view.key.endpoint == target.Endpoint &&
214+ view.key.lifecycleID == target.LifecycleID &&
215+ view.key.sessionName == target.SessionName
216+}
@@ -0,0 +1,216 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
7+
8+// resumeParkedRemoteView claims and republishes a parked local-client binding
9+// for one exact remote view. It deliberately owns only the local-client
10+// connection lifecycle: remote-link transport and reconnect work remain
11+// separate Phase 4 responsibilities.
12+//
13+// handled is false when the resume credential does not identify a remote view,
14+// allowing the existing local-session resume route to handle it. Once a remote
15+// parked entry is observed, the function is authoritative for that credential
16+// and returns a fail-closed error on every validation failure.
17+func (d *Daemon) resumeParkedRemoteView(h ports.Hello, tr ports.Transport) (view *remoteView, ac *attachedClient, handled bool, err error) {
18+ if d == nil || h.ResumeToken == 0 || tr == nil || !h.Size.Valid() {
19+ return nil, nil, false, nil
20+ }
21+
22+ // A reconnect can race the old local transport's Recv failure between its
23+ // pre-detach parking publication and final parked entry. Once that marker
24+ // identifies a remote view, this path remains authoritative for the token
25+ // and waits for its exact publication rather than falling into local-session
26+ // resume logic.
27+ seenRemoteCredential := false
28+ var parked *parkedAttachment
29+ for {
30+ d.mu.Lock()
31+ parked = d.parked[h.ResumeToken]
32+ if parked != nil {
33+ view, handled = normalizeAttachmentOwner(parked.owner).(*remoteView)
34+ d.mu.Unlock()
35+ if !handled {
36+ return nil, nil, false, nil
37+ }
38+ break
39+ }
40+ pending := d.parking[h.ResumeToken]
41+ pendingView, pendingRemote := (*remoteView)(nil), false
42+ if pending != nil {
43+ pendingView, pendingRemote = normalizeAttachmentOwner(pending.owner).(*remoteView)
44+ }
45+ d.mu.Unlock()
46+ if !pendingRemote {
47+ if seenRemoteCredential {
48+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
49+ }
50+ return nil, nil, false, nil
51+ }
52+ if pendingView == nil || pending.ac == nil || pending.ac.clientID != h.ClientID {
53+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
54+ }
55+ seenRemoteCredential = true
56+ if !d.waitParkingInFlight(h) {
57+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
58+ }
59+ }
60+ ac = parked.ac
61+ if ac == nil {
62+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
63+ }
64+
65+ if d.beforeResumeParkedSendMu != nil {
66+ d.beforeResumeParkedSendMu()
67+ }
68+ ac.sendMu.Lock()
69+ d.mu.Lock()
70+ if d.closing {
71+ d.mu.Unlock()
72+ ac.sendMu.Unlock()
73+ return nil, nil, true, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
74+ }
75+ if d.parked[h.ResumeToken] != parked || parked.claimed || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
76+ d.mu.Unlock()
77+ ac.sendMu.Unlock()
78+ return nil, nil, true, errResumeTokenLifecycleRace
79+ }
80+ if ac.clientID != h.ClientID {
81+ d.mu.Unlock()
82+ ac.sendMu.Unlock()
83+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
84+ }
85+
86+ // Keep the previous credential claimed until Welcome commits. A failed
87+ // handshake restores this exact parked owner through abortResumeClaim.
88+ parked.claimed = true
89+ ac.resumeClaimToken = h.ResumeToken
90+ ac.rebaseOutput()
91+ if ac.output != nil {
92+ ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
93+ ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)
94+ }
95+ ac.replaceTransport(tr)
96+ ac.setSize(h.Size)
97+ ac.resumeToken = d.nextResumeTokenLocked()
98+ ac.parked = false
99+ d.mu.Unlock()
100+ ac.sendMu.Unlock()
101+
102+ if _, transitionErr := d.resumeRemoteAttachment(view, ac, ac.transportSnapshot()); transitionErr != nil {
103+ d.abortResumeClaim(ac)
104+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
105+ }
106+ // The successful first local composition resizes the private VT from the
107+ // new attachment window. Deferring it until after Welcome preserves a
108+ // parked view's content when this handshake fails.
109+ d.log.Info("client resumed", "session", attachmentOwnerName(view))
110+ return view, ac, true, nil
111+}
112+
113+// resumeRemoteAttachment republishes a parked attachment under the exact
114+// remote owner. The gate is frozen across membership and owner publication so
115+// no stale local-client effect can observe the temporary unowned state.
116+func (d *Daemon) resumeRemoteAttachment(view *remoteView, ac *attachedClient, transport transportSnapshot) (attachmentConnectionToken, error) {
117+ if d == nil || view == nil || ac == nil || transport.transport == nil {
118+ return attachmentConnectionToken{}, errAttachmentTransition
119+ }
120+ frozen := freezeAttachmentEffectGates(ac)
121+ defer frozen.unfreeze()
122+ if !frozen.acquired || !frozen.drained {
123+ return attachmentConnectionToken{}, errAttachmentTransition
124+ }
125+
126+ d.mu.Lock()
127+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
128+ d.mu.Unlock()
129+ return attachmentConnectionToken{}, errAttachmentTransition
130+ }
131+ view.mu.Lock()
132+ _, registered := view.attachments[ac]
133+ current := !view.closed &&
134+ ac.currentAttachmentOwner() == nil &&
135+ ac.transportSnapshotCurrent(transport) &&
136+ !registered
137+ if !current || !view.registerAttachmentLocked(ac) {
138+ view.mu.Unlock()
139+ d.mu.Unlock()
140+ return attachmentConnectionToken{}, errAttachmentTransition
141+ }
142+ generation := ac.connectionGeneration.Add(1)
143+ ac.setAttachmentOwner(view)
144+ published := attachmentConnectionToken{
145+ owner: view,
146+ ac: ac,
147+ generation: generation,
148+ transport: transport,
149+ }
150+ ac.publishFrozenAttachmentCapability(published)
151+ view.mu.Unlock()
152+ d.mu.Unlock()
153+ return published, nil
154+}
155+
156+// resumeLiveRemoteView handles the narrow reconnect race where the old local
157+// transport still owns a remote attachment when the same resume credential
158+// arrives. It parks that exact binding first, then reuses the parked resume
159+// publication so the old and replacement transports cannot both own the view.
160+func (d *Daemon) resumeLiveRemoteView(h ports.Hello, tr ports.Transport) (view *remoteView, ac *attachedClient, handled bool, err error) {
161+ if d == nil || h.ResumeToken == 0 || tr == nil || !h.Size.Valid() {
162+ return nil, nil, false, nil
163+ }
164+
165+ var clientMismatch bool
166+ d.mu.Lock()
167+ for _, candidateView := range d.remoteViews {
168+ candidateView.mu.Lock()
169+ for candidate := range candidateView.attachments {
170+ if !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken {
171+ continue
172+ }
173+ if candidate.clientID != h.ClientID {
174+ clientMismatch = true
175+ } else {
176+ view, ac = candidateView, candidate
177+ }
178+ break
179+ }
180+ candidateView.mu.Unlock()
181+ if view != nil || clientMismatch {
182+ break
183+ }
184+ }
185+ closing := d.closing
186+ d.mu.Unlock()
187+ if view == nil && !clientMismatch {
188+ return nil, nil, false, nil
189+ }
190+ if closing {
191+ return nil, nil, true, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
192+ }
193+ if clientMismatch {
194+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
195+ }
196+
197+ oldTransport := ac.transport()
198+ token := attachmentOwnerToken(view, ac, oldTransport)
199+ if token.ac != nil {
200+ _ = d.clientGoneRemote(view, token, false)
201+ }
202+ view, ac, handled, err = d.resumeParkedRemoteView(h, tr)
203+ if handled || err != nil {
204+ return view, ac, handled, err
205+ }
206+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
207+}
208+
209+func remoteViewMatchesTarget(view *remoteView, target domain.RemoteSessionTarget) bool {
210+ if view == nil || target.Validate() != nil {
211+ return false
212+ }
213+ return view.key.endpoint == target.Endpoint &&
214+ view.key.lifecycleID == target.LifecycleID &&
215+ view.key.sessionName == target.SessionName
216+}
M internal/usecase/daemon/resume.go +4 -0
@@ -357,6 +357,10 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
357357 sess.mu.Lock()
358358 sess.unregisterAttachmentLocked(ac)
359359 sess.mu.Unlock()
360+ } else if view, remote := normalizeAttachmentOwner(parked.owner).(*remoteView); remote {
361+ view.mu.Lock()
362+ view.unregisterAttachmentLocked(ac)
363+ view.mu.Unlock()
360364 }
361365 // Recreate the parked entry and its watcher instead of restoring a timer
362366 // that may already have fired while the claim held the old entry. This also
@@ -357,6 +357,10 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
357 sess.mu.Lock()357 sess.mu.Lock()
358 sess.unregisterAttachmentLocked(ac)358 sess.unregisterAttachmentLocked(ac)
359 sess.mu.Unlock()359 sess.mu.Unlock()
360+ } else if view, remote := normalizeAttachmentOwner(parked.owner).(*remoteView); remote {
361+ view.mu.Lock()
362+ view.unregisterAttachmentLocked(ac)
363+ view.mu.Unlock()
360 }364 }
361 // Recreate the parked entry and its watcher instead of restoring a timer365 // Recreate the parked entry and its watcher instead of restoring a timer
362 // that may already have fired while the claim held the old entry. This also366 // that may already have fired while the claim held the old entry. This also
M internal/usecase/daemon/wire.go +34 -8
@@ -2,20 +2,46 @@
22 package daemon
33
44 import (
5+ "strconv"
6+
57 "github.com/bnema/vev/internal/domain"
68 "github.com/bnema/vev/internal/ports"
79 )
810
911 func frameWelcome(s *session, ac *attachedClient) ports.Frame {
10- s.mu.Lock()
11- w := ports.Welcome{
12- SessionID: string(s.id),
13- SessionName: s.name,
14- Ephemeral: s.ephemeral,
15- RenderMode: ac.renderMode,
16- Capabilities: ports.CapabilityResume,
12+ return frameWelcomeForAttachment(s, ac)
13+}
14+
15+// frameWelcomeForAttachment emits the local-client handshake identity for one
16+// attachment owner. A remote view is a local daemon object rather than a
17+// synthetic session, so its opaque local view ID is used only for this wire
18+// identity while its remote session name remains presentation data.
19+func frameWelcomeForAttachment(owner attachmentOwner, ac *attachedClient) ports.Frame {
20+ if ac == nil {
21+ return ports.Frame{}
22+ }
23+ var w ports.Welcome
24+ switch owner := normalizeAttachmentOwner(owner).(type) {
25+ case *session:
26+ owner.mu.Lock()
27+ w = ports.Welcome{
28+ SessionID: string(owner.id),
29+ SessionName: owner.name,
30+ Ephemeral: owner.ephemeral,
31+ RenderMode: ac.renderMode,
32+ Capabilities: ports.CapabilityResume,
33+ }
34+ owner.mu.Unlock()
35+ case *remoteView:
36+ w = ports.Welcome{
37+ SessionID: "remote-view-" + strconv.FormatUint(uint64(owner.id), 10),
38+ SessionName: owner.key.sessionName,
39+ RenderMode: ac.renderMode,
40+ Capabilities: ports.CapabilityResume,
41+ }
42+ default:
43+ return ports.Frame{}
1744 }
18- s.mu.Unlock()
1945 w.ResumeToken = ac.resumeToken
2046 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
2147 }
@@ -2,20 +2,46 @@
2 package daemon2 package daemon
3 3
4 import (4 import (
5+ "strconv"
6+
5 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
7 )9 )
8 10
9 func frameWelcome(s *session, ac *attachedClient) ports.Frame {11 func frameWelcome(s *session, ac *attachedClient) ports.Frame {
10- s.mu.Lock()12+ return frameWelcomeForAttachment(s, ac)
11- w := ports.Welcome{13+}
12- SessionID: string(s.id),14+
13- SessionName: s.name,15+// frameWelcomeForAttachment emits the local-client handshake identity for one
14- Ephemeral: s.ephemeral,16+// attachment owner. A remote view is a local daemon object rather than a
15- RenderMode: ac.renderMode,17+// synthetic session, so its opaque local view ID is used only for this wire
16- Capabilities: ports.CapabilityResume,18+// identity while its remote session name remains presentation data.
19+func frameWelcomeForAttachment(owner attachmentOwner, ac *attachedClient) ports.Frame {
20+ if ac == nil {
21+ return ports.Frame{}
22+ }
23+ var w ports.Welcome
24+ switch owner := normalizeAttachmentOwner(owner).(type) {
25+ case *session:
26+ owner.mu.Lock()
27+ w = ports.Welcome{
28+ SessionID: string(owner.id),
29+ SessionName: owner.name,
30+ Ephemeral: owner.ephemeral,
31+ RenderMode: ac.renderMode,
32+ Capabilities: ports.CapabilityResume,
33+ }
34+ owner.mu.Unlock()
35+ case *remoteView:
36+ w = ports.Welcome{
37+ SessionID: "remote-view-" + strconv.FormatUint(uint64(owner.id), 10),
38+ SessionName: owner.key.sessionName,
39+ RenderMode: ac.renderMode,
40+ Capabilities: ports.CapabilityResume,
41+ }
42+ default:
43+ return ports.Frame{}
17 }44 }
18- s.mu.Unlock()
19 w.ResumeToken = ac.resumeToken45 w.ResumeToken = ac.resumeToken
20 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}46 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
21 }47 }

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