feat(daemon): transition attachments to remote views
- commit
- 7d7442327850868af46acdc1d84c63d0e185a983
- author
- brice · · 2026-08-06 17:15 UTC
- parent
- e9e6c54
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +203 -12
M internal/usecase/daemon/attachment_owner_test.go +45 -0
| @@ -68,6 +68,26 @@ func TestRemoteAttachmentTokenUsesExactRemoteOwnerMembership(t *testing.T) { | |||
| 68 | 68 | require.False(t, token.current(), "remote membership is part of exact token identity") | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 71 | + | func TestTransitionToRemoteViewPublishesOneAuthoritativeOwner(t *testing.T) { | |
| 72 | + | d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 73 | + | view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}} | |
| 74 | + | d.mu.Lock() | |
| 75 | + | require.NoError(t, d.registerRemoteViewLocked(view)) | |
| 76 | + | d.mu.Unlock() | |
| 77 | + | ||
| 78 | + | before := source.attachmentToken(ac, ac.transport()) | |
| 79 | + | require.True(t, before.current()) | |
| 80 | + | published, err := d.transitionToRemoteView(before, view) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | ||
| 83 | + | require.False(t, before.current()) | |
| 84 | + | require.True(t, published.current()) | |
| 85 | + | require.Same(t, view, ac.currentAttachmentOwner()) | |
| 86 | + | require.Empty(t, source.snapshotAttachments()) | |
| 87 | + | require.True(t, view.attachmentRegistered(ac)) | |
| 88 | + | require.Same(t, source, ac.previousOwner.Get()) | |
| 89 | + | } | |
| 90 | + | ||
| 71 | 91 | func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) { | |
| 72 | 92 | d := newTestDaemon(t, nil, stubClock{}) | |
| 73 | 93 | view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}} | |
| @@ -93,6 +113,31 @@ func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) { | |||
| 93 | 113 | require.False(t, token.current()) | |
| 94 | 114 | } | |
| 95 | 115 | ||
| 116 | + | func TestRemoteClientGoneParksExactOwnerBinding(t *testing.T) { | |
| 117 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 118 | + | view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}} | |
| 119 | + | d.mu.Lock() | |
| 120 | + | require.NoError(t, d.registerRemoteViewLocked(view)) | |
| 121 | + | d.mu.Unlock() | |
| 122 | + | ||
| 123 | + | transport := &closeTrackingTransport{} | |
| 124 | + | ac := &attachedClient{tr: transport, resumeCapable: true} | |
| 125 | + | ac.setAttachmentOwner(view) | |
| 126 | + | view.mu.Lock() | |
| 127 | + | require.True(t, view.registerAttachmentLocked(ac)) | |
| 128 | + | view.mu.Unlock() | |
| 129 | + | token := attachmentOwnerToken(view, ac, transport) | |
| 130 | + | ||
| 131 | + | require.True(t, d.clientGoneRemote(view, token, false)) | |
| 132 | + | require.Nil(t, ac.currentAttachmentOwner()) | |
| 133 | + | require.False(t, view.attachmentRegistered(ac)) | |
| 134 | + | d.mu.Lock() | |
| 135 | + | parked := d.parked[ac.resumeToken] | |
| 136 | + | d.mu.Unlock() | |
| 137 | + | require.NotNil(t, parked) | |
| 138 | + | require.Same(t, view, parked.owner) | |
| 139 | + | } | |
| 140 | + | ||
| 96 | 141 | func TestRemoteParkingRetainsStableOwnerBinding(t *testing.T) { | |
| 97 | 142 | d := newTestDaemon(t, nil, stubClock{}) | |
| 98 | 143 | view := &remoteView{ | |
| @@ -68,6 +68,26 @@ func TestRemoteAttachmentTokenUsesExactRemoteOwnerMembership(t *testing.T) { | |||||
| 68 | require.False(t, token.current(), "remote membership is part of exact token identity") | 68 | require.False(t, token.current(), "remote membership is part of exact token identity") | ||
| 69 | } | 69 | } | ||
| 70 | 70 | ||||
| 71 | + | func TestTransitionToRemoteViewPublishesOneAuthoritativeOwner(t *testing.T) { | |||
| 72 | + | d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 73 | + | view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}} | |||
| 74 | + | d.mu.Lock() | |||
| 75 | + | require.NoError(t, d.registerRemoteViewLocked(view)) | |||
| 76 | + | d.mu.Unlock() | |||
| 77 | + | ||||
| 78 | + | before := source.attachmentToken(ac, ac.transport()) | |||
| 79 | + | require.True(t, before.current()) | |||
| 80 | + | published, err := d.transitionToRemoteView(before, view) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | ||||
| 83 | + | require.False(t, before.current()) | |||
| 84 | + | require.True(t, published.current()) | |||
| 85 | + | require.Same(t, view, ac.currentAttachmentOwner()) | |||
| 86 | + | require.Empty(t, source.snapshotAttachments()) | |||
| 87 | + | require.True(t, view.attachmentRegistered(ac)) | |||
| 88 | + | require.Same(t, source, ac.previousOwner.Get()) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 71 | func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) { | 91 | func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) { | ||
| 72 | d := newTestDaemon(t, nil, stubClock{}) | 92 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 73 | view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}} | 93 | view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}} | ||
| @@ -93,6 +113,31 @@ func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) { | |||||
| 93 | require.False(t, token.current()) | 113 | require.False(t, token.current()) | ||
| 94 | } | 114 | } | ||
| 95 | 115 | ||||
| 116 | + | func TestRemoteClientGoneParksExactOwnerBinding(t *testing.T) { | |||
| 117 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 118 | + | view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}} | |||
| 119 | + | d.mu.Lock() | |||
| 120 | + | require.NoError(t, d.registerRemoteViewLocked(view)) | |||
| 121 | + | d.mu.Unlock() | |||
| 122 | + | ||||
| 123 | + | transport := &closeTrackingTransport{} | |||
| 124 | + | ac := &attachedClient{tr: transport, resumeCapable: true} | |||
| 125 | + | ac.setAttachmentOwner(view) | |||
| 126 | + | view.mu.Lock() | |||
| 127 | + | require.True(t, view.registerAttachmentLocked(ac)) | |||
| 128 | + | view.mu.Unlock() | |||
| 129 | + | token := attachmentOwnerToken(view, ac, transport) | |||
| 130 | + | ||||
| 131 | + | require.True(t, d.clientGoneRemote(view, token, false)) | |||
| 132 | + | require.Nil(t, ac.currentAttachmentOwner()) | |||
| 133 | + | require.False(t, view.attachmentRegistered(ac)) | |||
| 134 | + | d.mu.Lock() | |||
| 135 | + | parked := d.parked[ac.resumeToken] | |||
| 136 | + | d.mu.Unlock() | |||
| 137 | + | require.NotNil(t, parked) | |||
| 138 | + | require.Same(t, view, parked.owner) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 96 | func TestRemoteParkingRetainsStableOwnerBinding(t *testing.T) { | 141 | func TestRemoteParkingRetainsStableOwnerBinding(t *testing.T) { | ||
| 97 | d := newTestDaemon(t, nil, stubClock{}) | 142 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 98 | view := &remoteView{ | 143 | view := &remoteView{ | ||
M internal/usecase/daemon/client_frame_routing.go +16 -12
| @@ -13,24 +13,24 @@ const connectionSnapshotAttempts = 8 | |||
| 13 | 13 | // currentAttachmentConnection retries the lock-free session-to-role snapshot when a | |
| 14 | 14 | // handoff lands between those reads. The attachment gate still performs the final | |
| 15 | 15 | // mutation admission after this function returns. | |
| 16 | - | func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (*session, attachmentConnectionToken, bool) { | |
| 16 | + | func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentOwner, attachmentConnectionToken, bool) { | |
| 17 | 17 | for range connectionSnapshotAttempts { | |
| 18 | 18 | if !ac.currentTransportIs(tr) { | |
| 19 | 19 | break | |
| 20 | 20 | } | |
| 21 | - | sess := ac.currentAttachmentSession() | |
| 22 | - | if sess == nil { | |
| 21 | + | owner := ac.currentAttachmentOwner() | |
| 22 | + | if owner == nil { | |
| 23 | 23 | return nil, attachmentConnectionToken{}, false | |
| 24 | 24 | } | |
| 25 | - | if d.afterConnectionSessionSnapshot != nil { | |
| 25 | + | if sess := localSession(owner); sess != nil && d.afterConnectionSessionSnapshot != nil { | |
| 26 | 26 | d.afterConnectionSessionSnapshot(sess) | |
| 27 | 27 | } | |
| 28 | - | token := attachmentToken(sess, ac, tr) | |
| 29 | - | if token.ac == nil || ac.currentAttachmentSession() != sess { | |
| 28 | + | token := attachmentOwnerToken(owner, ac, tr) | |
| 29 | + | if token.ac == nil || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) { | |
| 30 | 30 | runtime.Gosched() | |
| 31 | 31 | continue | |
| 32 | 32 | } | |
| 33 | - | return sess, token, true | |
| 33 | + | return owner, token, true | |
| 34 | 34 | } | |
| 35 | 35 | return nil, attachmentConnectionToken{}, false | |
| 36 | 36 | } | |
| @@ -44,19 +44,23 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 44 | 44 | return | |
| 45 | 45 | } | |
| 46 | 46 | for { | |
| 47 | - | if !ac.currentTransportIs(tr) || ac.currentAttachmentSession() == nil { | |
| 47 | + | if !ac.currentTransportIs(tr) || ac.currentAttachmentOwner() == nil { | |
| 48 | 48 | return | |
| 49 | 49 | } | |
| 50 | 50 | f, err := tr.Recv() | |
| 51 | 51 | if err != nil { | |
| 52 | 52 | for range connectionSnapshotAttempts { | |
| 53 | - | sess, _, ok := d.currentAttachmentConnection(ac, tr) | |
| 53 | + | owner, token, ok := d.currentAttachmentConnection(ac, tr) | |
| 54 | 54 | if !ok { | |
| 55 | 55 | return | |
| 56 | 56 | } | |
| 57 | - | d.clientGone(sess, ac, tr, false) | |
| 58 | - | current := ac.currentAttachmentSession() | |
| 59 | - | if current == sess || !ac.currentTransportIs(tr) || current == nil { | |
| 57 | + | if sess := localSession(owner); sess != nil { | |
| 58 | + | d.clientGone(sess, ac, tr, false) | |
| 59 | + | } else if view, remote := owner.(*remoteView); remote { | |
| 60 | + | d.clientGoneRemote(view, token, false) | |
| 61 | + | } | |
| 62 | + | current := ac.currentAttachmentOwner() | |
| 63 | + | if sameAttachmentOwner(current, owner) || !ac.currentTransportIs(tr) || current == nil { | |
| 60 | 64 | return | |
| 61 | 65 | } | |
| 62 | 66 | } | |
| @@ -13,24 +13,24 @@ const connectionSnapshotAttempts = 8 | |||||
| 13 | // currentAttachmentConnection retries the lock-free session-to-role snapshot when a | 13 | // currentAttachmentConnection retries the lock-free session-to-role snapshot when a | ||
| 14 | // handoff lands between those reads. The attachment gate still performs the final | 14 | // handoff lands between those reads. The attachment gate still performs the final | ||
| 15 | // mutation admission after this function returns. | 15 | // mutation admission after this function returns. | ||
| 16 | - | func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (*session, attachmentConnectionToken, bool) { | 16 | + | func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentOwner, attachmentConnectionToken, bool) { |
| 17 | for range connectionSnapshotAttempts { | 17 | for range connectionSnapshotAttempts { | ||
| 18 | if !ac.currentTransportIs(tr) { | 18 | if !ac.currentTransportIs(tr) { | ||
| 19 | break | 19 | break | ||
| 20 | } | 20 | } | ||
| 21 | - | sess := ac.currentAttachmentSession() | 21 | + | owner := ac.currentAttachmentOwner() |
| 22 | - | if sess == nil { | 22 | + | if owner == nil { |
| 23 | return nil, attachmentConnectionToken{}, false | 23 | return nil, attachmentConnectionToken{}, false | ||
| 24 | } | 24 | } | ||
| 25 | - | if d.afterConnectionSessionSnapshot != nil { | 25 | + | if sess := localSession(owner); sess != nil && d.afterConnectionSessionSnapshot != nil { |
| 26 | d.afterConnectionSessionSnapshot(sess) | 26 | d.afterConnectionSessionSnapshot(sess) | ||
| 27 | } | 27 | } | ||
| 28 | - | token := attachmentToken(sess, ac, tr) | 28 | + | token := attachmentOwnerToken(owner, ac, tr) |
| 29 | - | if token.ac == nil || ac.currentAttachmentSession() != sess { | 29 | + | if token.ac == nil || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) { |
| 30 | runtime.Gosched() | 30 | runtime.Gosched() | ||
| 31 | continue | 31 | continue | ||
| 32 | } | 32 | } | ||
| 33 | - | return sess, token, true | 33 | + | return owner, token, true |
| 34 | } | 34 | } | ||
| 35 | return nil, attachmentConnectionToken{}, false | 35 | return nil, attachmentConnectionToken{}, false | ||
| 36 | } | 36 | } | ||
| @@ -44,19 +44,23 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 44 | return | 44 | return | ||
| 45 | } | 45 | } | ||
| 46 | for { | 46 | for { | ||
| 47 | - | if !ac.currentTransportIs(tr) || ac.currentAttachmentSession() == nil { | 47 | + | if !ac.currentTransportIs(tr) || ac.currentAttachmentOwner() == nil { |
| 48 | return | 48 | return | ||
| 49 | } | 49 | } | ||
| 50 | f, err := tr.Recv() | 50 | f, err := tr.Recv() | ||
| 51 | if err != nil { | 51 | if err != nil { | ||
| 52 | for range connectionSnapshotAttempts { | 52 | for range connectionSnapshotAttempts { | ||
| 53 | - | sess, _, ok := d.currentAttachmentConnection(ac, tr) | 53 | + | owner, token, ok := d.currentAttachmentConnection(ac, tr) |
| 54 | if !ok { | 54 | if !ok { | ||
| 55 | return | 55 | return | ||
| 56 | } | 56 | } | ||
| 57 | - | d.clientGone(sess, ac, tr, false) | 57 | + | if sess := localSession(owner); sess != nil { |
| 58 | - | current := ac.currentAttachmentSession() | 58 | + | d.clientGone(sess, ac, tr, false) |
| 59 | - | if current == sess || !ac.currentTransportIs(tr) || current == nil { | 59 | + | } else if view, remote := owner.(*remoteView); remote { |
| 60 | + | d.clientGoneRemote(view, token, false) | |||
| 61 | + | } | |||
| 62 | + | current := ac.currentAttachmentOwner() | |||
| 63 | + | if sameAttachmentOwner(current, owner) || !ac.currentTransportIs(tr) || current == nil { | |||
| 60 | return | 64 | return | ||
| 61 | } | 65 | } | ||
| 62 | } | 66 | } | ||
M internal/usecase/daemon/client_transport_errors.go +69 -0
| @@ -37,6 +37,9 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic | |||
| 37 | 37 | } | |
| 38 | 38 | token.effect.bindActionEnd(d, "detach") | |
| 39 | 39 | token.effect.End() | |
| 40 | + | if view, remote := token.owner.(*remoteView); remote { | |
| 41 | + | return d.clientGoneRemote(view, token, explicit) | |
| 42 | + | } | |
| 40 | 43 | sess := token.localSession() | |
| 41 | 44 | if sess == nil { | |
| 42 | 45 | return false | |
| @@ -53,6 +56,72 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic | |||
| 53 | 56 | return true | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 59 | + | // clientGoneRemote retires only the current local-client binding to a remote | |
| 60 | + | // view. The remote link itself remains a Phase 4 lifecycle concern; a failed | |
| 61 | + | // local transport parks the stable view owner without inventing a local PTY. | |
| 62 | + | func (d *Daemon) clientGoneRemote(view *remoteView, token attachmentConnectionToken, explicit bool) bool { | |
| 63 | + | if d == nil || view == nil || token.ac == nil || !sameAttachmentOwner(token.owner, view) { | |
| 64 | + | return false | |
| 65 | + | } | |
| 66 | + | ac := token.ac | |
| 67 | + | var parkingToken uint64 | |
| 68 | + | if !explicit { | |
| 69 | + | parkingToken = d.markParkingInFlightOwner(view, ac) | |
| 70 | + | } | |
| 71 | + | if !d.detachRemoteAttachmentCurrent(view, token) { | |
| 72 | + | d.clearParkingInFlightIfAbandonedOwner(view, ac, parkingToken) | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | d.unregisterPreview(ac) | |
| 76 | + | if !explicit && d.parkAttachmentOwner(view, ac) { | |
| 77 | + | _ = ac.closeCapturedTransport(token.transport.transport) | |
| 78 | + | return true | |
| 79 | + | } | |
| 80 | + | d.clearParkingInFlight(ac.resumeToken, ac) | |
| 81 | + | d.closePicker(ac) | |
| 82 | + | ac.clearPreviousSession() | |
| 83 | + | if explicit { | |
| 84 | + | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | |
| 85 | + | } | |
| 86 | + | _ = ac.closeCapturedTransport(token.transport.transport) | |
| 87 | + | return true | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // detachRemoteAttachmentCurrent invalidates exactly the frozen remote-view | |
| 91 | + | // membership. It performs no transport I/O while architecture locks are held. | |
| 92 | + | func (d *Daemon) detachRemoteAttachmentCurrent(view *remoteView, token attachmentConnectionToken) bool { | |
| 93 | + | if d == nil || view == nil || token.ac == nil || token.transport.transport == nil { | |
| 94 | + | return false | |
| 95 | + | } | |
| 96 | + | frozen := freezeAttachmentEffectGates(token.ac) | |
| 97 | + | defer frozen.unfreeze() | |
| 98 | + | if !frozen.acquired || !frozen.drained { | |
| 99 | + | return false | |
| 100 | + | } | |
| 101 | + | d.mu.Lock() | |
| 102 | + | if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) { | |
| 103 | + | d.mu.Unlock() | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | view.mu.Lock() | |
| 107 | + | current := sameAttachmentOwner(token.owner, view) && | |
| 108 | + | token.generation == token.ac.connectionGeneration.Load() && | |
| 109 | + | sameAttachmentOwner(token.ac.currentAttachmentOwner(), view) && | |
| 110 | + | token.ac.transportSnapshotCurrent(token.transport) | |
| 111 | + | if _, registered := view.attachments[token.ac]; !registered { | |
| 112 | + | current = false | |
| 113 | + | } | |
| 114 | + | if current { | |
| 115 | + | view.unregisterAttachmentLocked(token.ac) | |
| 116 | + | token.ac.connectionGeneration.Add(1) | |
| 117 | + | token.ac.setAttachmentOwner(nil) | |
| 118 | + | token.ac.invalidateFrozenAttachmentCapability() | |
| 119 | + | } | |
| 120 | + | view.mu.Unlock() | |
| 121 | + | d.mu.Unlock() | |
| 122 | + | return current | |
| 123 | + | } | |
| 124 | + | ||
| 56 | 125 | func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) { | |
| 57 | 126 | if sess == nil || ac == nil { | |
| 58 | 127 | return | |
| @@ -37,6 +37,9 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic | |||||
| 37 | } | 37 | } | ||
| 38 | token.effect.bindActionEnd(d, "detach") | 38 | token.effect.bindActionEnd(d, "detach") | ||
| 39 | token.effect.End() | 39 | token.effect.End() | ||
| 40 | + | if view, remote := token.owner.(*remoteView); remote { | |||
| 41 | + | return d.clientGoneRemote(view, token, explicit) | |||
| 42 | + | } | |||
| 40 | sess := token.localSession() | 43 | sess := token.localSession() | ||
| 41 | if sess == nil { | 44 | if sess == nil { | ||
| 42 | return false | 45 | return false | ||
| @@ -53,6 +56,72 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic | |||||
| 53 | return true | 56 | return true | ||
| 54 | } | 57 | } | ||
| 55 | 58 | ||||
| 59 | + | // clientGoneRemote retires only the current local-client binding to a remote | |||
| 60 | + | // view. The remote link itself remains a Phase 4 lifecycle concern; a failed | |||
| 61 | + | // local transport parks the stable view owner without inventing a local PTY. | |||
| 62 | + | func (d *Daemon) clientGoneRemote(view *remoteView, token attachmentConnectionToken, explicit bool) bool { | |||
| 63 | + | if d == nil || view == nil || token.ac == nil || !sameAttachmentOwner(token.owner, view) { | |||
| 64 | + | return false | |||
| 65 | + | } | |||
| 66 | + | ac := token.ac | |||
| 67 | + | var parkingToken uint64 | |||
| 68 | + | if !explicit { | |||
| 69 | + | parkingToken = d.markParkingInFlightOwner(view, ac) | |||
| 70 | + | } | |||
| 71 | + | if !d.detachRemoteAttachmentCurrent(view, token) { | |||
| 72 | + | d.clearParkingInFlightIfAbandonedOwner(view, ac, parkingToken) | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | d.unregisterPreview(ac) | |||
| 76 | + | if !explicit && d.parkAttachmentOwner(view, ac) { | |||
| 77 | + | _ = ac.closeCapturedTransport(token.transport.transport) | |||
| 78 | + | return true | |||
| 79 | + | } | |||
| 80 | + | d.clearParkingInFlight(ac.resumeToken, ac) | |||
| 81 | + | d.closePicker(ac) | |||
| 82 | + | ac.clearPreviousSession() | |||
| 83 | + | if explicit { | |||
| 84 | + | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | |||
| 85 | + | } | |||
| 86 | + | _ = ac.closeCapturedTransport(token.transport.transport) | |||
| 87 | + | return true | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // detachRemoteAttachmentCurrent invalidates exactly the frozen remote-view | |||
| 91 | + | // membership. It performs no transport I/O while architecture locks are held. | |||
| 92 | + | func (d *Daemon) detachRemoteAttachmentCurrent(view *remoteView, token attachmentConnectionToken) bool { | |||
| 93 | + | if d == nil || view == nil || token.ac == nil || token.transport.transport == nil { | |||
| 94 | + | return false | |||
| 95 | + | } | |||
| 96 | + | frozen := freezeAttachmentEffectGates(token.ac) | |||
| 97 | + | defer frozen.unfreeze() | |||
| 98 | + | if !frozen.acquired || !frozen.drained { | |||
| 99 | + | return false | |||
| 100 | + | } | |||
| 101 | + | d.mu.Lock() | |||
| 102 | + | if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) { | |||
| 103 | + | d.mu.Unlock() | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | view.mu.Lock() | |||
| 107 | + | current := sameAttachmentOwner(token.owner, view) && | |||
| 108 | + | token.generation == token.ac.connectionGeneration.Load() && | |||
| 109 | + | sameAttachmentOwner(token.ac.currentAttachmentOwner(), view) && | |||
| 110 | + | token.ac.transportSnapshotCurrent(token.transport) | |||
| 111 | + | if _, registered := view.attachments[token.ac]; !registered { | |||
| 112 | + | current = false | |||
| 113 | + | } | |||
| 114 | + | if current { | |||
| 115 | + | view.unregisterAttachmentLocked(token.ac) | |||
| 116 | + | token.ac.connectionGeneration.Add(1) | |||
| 117 | + | token.ac.setAttachmentOwner(nil) | |||
| 118 | + | token.ac.invalidateFrozenAttachmentCapability() | |||
| 119 | + | } | |||
| 120 | + | view.mu.Unlock() | |||
| 121 | + | d.mu.Unlock() | |||
| 122 | + | return current | |||
| 123 | + | } | |||
| 124 | + | ||||
| 56 | func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) { | 125 | func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) { | ||
| 57 | if sess == nil || ac == nil { | 126 | if sess == nil || ac == nil { | ||
| 58 | return | 127 | return | ||
A internal/usecase/daemon/remote_attachment_transition.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | // transitionToRemoteView publishes an attachment-owned remote surface without | |
| 4 | + | // changing the local session registry. Candidate construction and remote-link | |
| 5 | + | // I/O deliberately happen before this function; this function only performs | |
| 6 | + | // the frozen, exact local ownership hand-off. | |
| 7 | + | func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target *remoteView) (attachmentConnectionToken, error) { | |
| 8 | + | source := token.localSession() | |
| 9 | + | if d == nil || source == nil || token.ac == nil || target == nil { | |
| 10 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 11 | + | } | |
| 12 | + | if token.effect != nil { | |
| 13 | + | d.endActionAttachmentEffect(token.effect, "remote-view-transition") | |
| 14 | + | } | |
| 15 | + | frozen := freezeAttachmentEffectGates(token.ac) | |
| 16 | + | defer frozen.unfreeze() | |
| 17 | + | if !frozen.acquired || !frozen.drained { | |
| 18 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | d.mu.Lock() | |
| 22 | + | if d.closing || d.sessions[source.id] != source || !d.attachmentOwnerRegisteredByDaemonLocked(target) { | |
| 23 | + | d.mu.Unlock() | |
| 24 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 25 | + | } | |
| 26 | + | d.notices.routingMu.Lock() | |
| 27 | + | source.mu.Lock() | |
| 28 | + | target.mu.Lock() | |
| 29 | + | current := sameAttachmentOwner(token.owner, source) && | |
| 30 | + | token.generation == token.ac.connectionGeneration.Load() && | |
| 31 | + | sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) && | |
| 32 | + | token.ac.transportSnapshotCurrent(token.transport) && | |
| 33 | + | attachmentRegisteredLocked(source, token.ac) && | |
| 34 | + | !target.closed | |
| 35 | + | if !current { | |
| 36 | + | target.mu.Unlock() | |
| 37 | + | source.mu.Unlock() | |
| 38 | + | d.notices.routingMu.Unlock() | |
| 39 | + | d.mu.Unlock() | |
| 40 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 41 | + | } | |
| 42 | + | if !target.registerAttachmentLocked(token.ac) { | |
| 43 | + | target.mu.Unlock() | |
| 44 | + | source.mu.Unlock() | |
| 45 | + | d.notices.routingMu.Unlock() | |
| 46 | + | d.mu.Unlock() | |
| 47 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 48 | + | } | |
| 49 | + | unregisterAttachmentSessionLocked(source, token.ac) | |
| 50 | + | generation := token.ac.connectionGeneration.Add(1) | |
| 51 | + | token.ac.setAttachmentOwner(target) | |
| 52 | + | published := attachmentConnectionToken{ | |
| 53 | + | owner: target, | |
| 54 | + | ac: token.ac, | |
| 55 | + | generation: generation, | |
| 56 | + | transport: token.transport, | |
| 57 | + | rebase: true, | |
| 58 | + | } | |
| 59 | + | token.ac.publishFrozenAttachmentCapability(published) | |
| 60 | + | target.mu.Unlock() | |
| 61 | + | source.mu.Unlock() | |
| 62 | + | d.notices.routingMu.Unlock() | |
| 63 | + | d.mu.Unlock() | |
| 64 | + | ||
| 65 | + | // The old coordinator is no longer a lease owner for this attachment. Its | |
| 66 | + | // cleanup runs after publication and outside architecture locks. | |
| 67 | + | if coordinator := source.renderCoordinator(); coordinator != nil { | |
| 68 | + | coordinator.noteDetach(token.ac) | |
| 69 | + | } | |
| 70 | + | d.recalculateSessionGeometryAndInvalidateAsync(source, "remote_attachment_transition.go") | |
| 71 | + | token.ac.recordPreviousOwner(source) | |
| 72 | + | return published, nil | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | // transitionToRemoteView publishes an attachment-owned remote surface without | |||
| 4 | + | // changing the local session registry. Candidate construction and remote-link | |||
| 5 | + | // I/O deliberately happen before this function; this function only performs | |||
| 6 | + | // the frozen, exact local ownership hand-off. | |||
| 7 | + | func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target *remoteView) (attachmentConnectionToken, error) { | |||
| 8 | + | source := token.localSession() | |||
| 9 | + | if d == nil || source == nil || token.ac == nil || target == nil { | |||
| 10 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 11 | + | } | |||
| 12 | + | if token.effect != nil { | |||
| 13 | + | d.endActionAttachmentEffect(token.effect, "remote-view-transition") | |||
| 14 | + | } | |||
| 15 | + | frozen := freezeAttachmentEffectGates(token.ac) | |||
| 16 | + | defer frozen.unfreeze() | |||
| 17 | + | if !frozen.acquired || !frozen.drained { | |||
| 18 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | d.mu.Lock() | |||
| 22 | + | if d.closing || d.sessions[source.id] != source || !d.attachmentOwnerRegisteredByDaemonLocked(target) { | |||
| 23 | + | d.mu.Unlock() | |||
| 24 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 25 | + | } | |||
| 26 | + | d.notices.routingMu.Lock() | |||
| 27 | + | source.mu.Lock() | |||
| 28 | + | target.mu.Lock() | |||
| 29 | + | current := sameAttachmentOwner(token.owner, source) && | |||
| 30 | + | token.generation == token.ac.connectionGeneration.Load() && | |||
| 31 | + | sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) && | |||
| 32 | + | token.ac.transportSnapshotCurrent(token.transport) && | |||
| 33 | + | attachmentRegisteredLocked(source, token.ac) && | |||
| 34 | + | !target.closed | |||
| 35 | + | if !current { | |||
| 36 | + | target.mu.Unlock() | |||
| 37 | + | source.mu.Unlock() | |||
| 38 | + | d.notices.routingMu.Unlock() | |||
| 39 | + | d.mu.Unlock() | |||
| 40 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 41 | + | } | |||
| 42 | + | if !target.registerAttachmentLocked(token.ac) { | |||
| 43 | + | target.mu.Unlock() | |||
| 44 | + | source.mu.Unlock() | |||
| 45 | + | d.notices.routingMu.Unlock() | |||
| 46 | + | d.mu.Unlock() | |||
| 47 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 48 | + | } | |||
| 49 | + | unregisterAttachmentSessionLocked(source, token.ac) | |||
| 50 | + | generation := token.ac.connectionGeneration.Add(1) | |||
| 51 | + | token.ac.setAttachmentOwner(target) | |||
| 52 | + | published := attachmentConnectionToken{ | |||
| 53 | + | owner: target, | |||
| 54 | + | ac: token.ac, | |||
| 55 | + | generation: generation, | |||
| 56 | + | transport: token.transport, | |||
| 57 | + | rebase: true, | |||
| 58 | + | } | |||
| 59 | + | token.ac.publishFrozenAttachmentCapability(published) | |||
| 60 | + | target.mu.Unlock() | |||
| 61 | + | source.mu.Unlock() | |||
| 62 | + | d.notices.routingMu.Unlock() | |||
| 63 | + | d.mu.Unlock() | |||
| 64 | + | ||||
| 65 | + | // The old coordinator is no longer a lease owner for this attachment. Its | |||
| 66 | + | // cleanup runs after publication and outside architecture locks. | |||
| 67 | + | if coordinator := source.renderCoordinator(); coordinator != nil { | |||
| 68 | + | coordinator.noteDetach(token.ac) | |||
| 69 | + | } | |||
| 70 | + | d.recalculateSessionGeometryAndInvalidateAsync(source, "remote_attachment_transition.go") | |||
| 71 | + | token.ac.recordPreviousOwner(source) | |||
| 72 | + | return published, nil | |||
| 73 | + | } | |||