refactor(daemon): park independent attachments
- commit
- 8f5598a0c09e51ac35eb7b4f8a201a39ba86a8e5
- author
- brice · · 2026-08-02 23:16 UTC
- committer
- brice · · 2026-08-03 20:53 UTC
- parent
- d318448
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
28 files · +488 -5439
A internal/usecase/daemon/attachment_parking_test.go +268 -0
| @@ -0,0 +1,268 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type resumeWelcomeFailureTransport struct { | |
| 13 | + | closeTrackingTransport | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (*resumeWelcomeFailureTransport) Send(ports.Frame) error { return errWelcomeSendFailed } | |
| 17 | + | ||
| 18 | + | var errWelcomeSendFailed = errors.New("welcome send failed") | |
| 19 | + | ||
| 20 | + | func TestAttachmentLossParksOnlyThatAttachment(t *testing.T) { | |
| 21 | + | pty, release := newBlockingPTY(t) | |
| 22 | + | defer release() | |
| 23 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 24 | + | ||
| 25 | + | firstTransport := &closeTrackingTransport{} | |
| 26 | + | sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | secondTransport := &closeTrackingTransport{} | |
| 29 | + | _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport) | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | ||
| 32 | + | d.clientGone(sess, first, firstTransport, false) | |
| 33 | + | ||
| 34 | + | d.mu.Lock() | |
| 35 | + | parked := d.parked[first.resumeToken] | |
| 36 | + | d.mu.Unlock() | |
| 37 | + | require.NotNil(t, parked) | |
| 38 | + | sess.mu.Lock() | |
| 39 | + | _, firstAttached := sess.attachments[first] | |
| 40 | + | _, secondAttached := sess.attachments[second] | |
| 41 | + | sess.mu.Unlock() | |
| 42 | + | require.False(t, firstAttached) | |
| 43 | + | require.True(t, secondAttached) | |
| 44 | + | require.Same(t, second, sess.client) | |
| 45 | + | require.False(t, secondTransport.Closed()) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestTwoAttachmentsParkIndependently(t *testing.T) { | |
| 49 | + | pty, release := newBlockingPTY(t) | |
| 50 | + | defer release() | |
| 51 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 52 | + | firstTransport := &closeTrackingTransport{} | |
| 53 | + | sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | secondTransport := &closeTrackingTransport{} | |
| 56 | + | _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | firstToken, secondToken := first.resumeToken, second.resumeToken | |
| 59 | + | ||
| 60 | + | d.clientGone(sess, first, firstTransport, false) | |
| 61 | + | d.clientGone(sess, second, secondTransport, false) | |
| 62 | + | d.mu.Lock() | |
| 63 | + | firstParked, secondParked := d.parked[firstToken], d.parked[secondToken] | |
| 64 | + | d.mu.Unlock() | |
| 65 | + | require.NotNil(t, firstParked) | |
| 66 | + | require.NotNil(t, secondParked) | |
| 67 | + | require.NotSame(t, firstParked, secondParked) | |
| 68 | + | sess.mu.Lock() | |
| 69 | + | require.Empty(t, sess.attachments) | |
| 70 | + | sess.mu.Unlock() | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestParkExpiryRemovesOnlyOneAttachment(t *testing.T) { | |
| 74 | + | pty, release := newBlockingPTY(t) | |
| 75 | + | defer release() | |
| 76 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 77 | + | firstTransport := &closeTrackingTransport{} | |
| 78 | + | sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | secondTransport := &closeTrackingTransport{} | |
| 81 | + | _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | d.clientGone(sess, first, firstTransport, false) | |
| 84 | + | d.clientGone(sess, second, secondTransport, false) | |
| 85 | + | firstToken, secondToken := first.resumeToken, second.resumeToken | |
| 86 | + | d.mu.Lock() | |
| 87 | + | firstParked := d.parked[firstToken] | |
| 88 | + | d.mu.Unlock() | |
| 89 | + | d.expireParked(firstToken, firstParked) | |
| 90 | + | d.mu.Lock() | |
| 91 | + | _, firstRetained := d.parked[firstToken] | |
| 92 | + | _, secondRetained := d.parked[secondToken] | |
| 93 | + | d.mu.Unlock() | |
| 94 | + | require.False(t, firstRetained) | |
| 95 | + | require.True(t, secondRetained) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) { | |
| 99 | + | pty, release := newBlockingPTY(t) | |
| 100 | + | defer release() | |
| 101 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 102 | + | oldTransport := &closeTrackingTransport{} | |
| 103 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | oldToken := ac.resumeToken | |
| 106 | + | oldGeneration := ac.roleGeneration.Load() | |
| 107 | + | d.clientGone(sess, ac, oldTransport, false) | |
| 108 | + | ||
| 109 | + | newTransport := &closeTrackingTransport{} | |
| 110 | + | resumed, same, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", oldToken), newTransport, defaultSize) | |
| 111 | + | require.NoError(t, err) | |
| 112 | + | require.True(t, ok) | |
| 113 | + | require.Same(t, sess, resumed) | |
| 114 | + | require.Same(t, ac, same) | |
| 115 | + | require.NotEqual(t, oldToken, ac.resumeToken) | |
| 116 | + | require.Greater(t, ac.roleGeneration.Load(), oldGeneration) | |
| 117 | + | require.True(t, d.commitResumeClaim(ac)) | |
| 118 | + | d.mu.Lock() | |
| 119 | + | _, oldRetained := d.parked[oldToken] | |
| 120 | + | d.mu.Unlock() | |
| 121 | + | require.False(t, oldRetained) | |
| 122 | + | ||
| 123 | + | before := ac.resumeToken | |
| 124 | + | d.clientGone(sess, ac, oldTransport, false) | |
| 125 | + | require.Equal(t, before, ac.resumeToken) | |
| 126 | + | d.mu.Lock() | |
| 127 | + | _, staleParked := d.parked[oldToken] | |
| 128 | + | d.mu.Unlock() | |
| 129 | + | require.False(t, staleParked) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) { | |
| 133 | + | pty, release := newBlockingPTY(t) | |
| 134 | + | defer release() | |
| 135 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 136 | + | oldTransport := &closeTrackingTransport{} | |
| 137 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | oldToken := ac.resumeToken | |
| 140 | + | d.clientGone(sess, ac, oldTransport, false) | |
| 141 | + | ||
| 142 | + | resumedTransport := &closeTrackingTransport{} | |
| 143 | + | d.handleHello(resumedTransport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", oldToken))}) | |
| 144 | + | newToken := ac.resumeToken | |
| 145 | + | require.NotEqual(t, oldToken, newToken) | |
| 146 | + | d.mu.Lock() | |
| 147 | + | _, oldRetained := d.parked[oldToken] | |
| 148 | + | newParked := d.parked[newToken] | |
| 149 | + | d.mu.Unlock() | |
| 150 | + | require.False(t, oldRetained) | |
| 151 | + | require.NotNil(t, newParked) | |
| 152 | + | require.NotEmpty(t, resumedTransport.Sends()) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) { | |
| 156 | + | pty, release := newBlockingPTY(t) | |
| 157 | + | defer release() | |
| 158 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 159 | + | oldTransport := &closeTrackingTransport{} | |
| 160 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 161 | + | require.NoError(t, err) | |
| 162 | + | token := ac.resumeToken | |
| 163 | + | d.clientGone(sess, ac, oldTransport, false) | |
| 164 | + | ||
| 165 | + | failed := &resumeWelcomeFailureTransport{} | |
| 166 | + | d.handleHello(failed, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", token))}) | |
| 167 | + | require.Nil(t, ac.transport()) | |
| 168 | + | d.mu.Lock() | |
| 169 | + | parked := d.parked[token] | |
| 170 | + | d.mu.Unlock() | |
| 171 | + | require.NotNil(t, parked) | |
| 172 | + | require.False(t, parked.claimed) | |
| 173 | + | require.Equal(t, token, ac.resumeToken) | |
| 174 | + | ||
| 175 | + | _, resumed, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize) | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | require.True(t, ok) | |
| 178 | + | require.Same(t, ac, resumed) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestConcurrentResumeHasExactlyOneWinner(t *testing.T) { | |
| 182 | + | pty, release := newBlockingPTY(t) | |
| 183 | + | defer release() | |
| 184 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 185 | + | oldTransport := &closeTrackingTransport{} | |
| 186 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 187 | + | require.NoError(t, err) | |
| 188 | + | token := ac.resumeToken | |
| 189 | + | d.clientGone(sess, ac, oldTransport, false) | |
| 190 | + | ||
| 191 | + | type result struct { | |
| 192 | + | ok bool | |
| 193 | + | err error | |
| 194 | + | } | |
| 195 | + | results := make(chan result, 2) | |
| 196 | + | for range 2 { | |
| 197 | + | go func() { | |
| 198 | + | _, _, ok, resumeErr := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize) | |
| 199 | + | results <- result{ok: ok, err: resumeErr} | |
| 200 | + | }() | |
| 201 | + | } | |
| 202 | + | var winners int | |
| 203 | + | for range 2 { | |
| 204 | + | got := <-results | |
| 205 | + | if got.ok { | |
| 206 | + | winners++ | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | require.Equal(t, 1, winners) | |
| 210 | + | require.NotEqual(t, token, ac.resumeToken) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func TestKillSessionClosesEveryAttachmentAndParkedCredential(t *testing.T) { | |
| 214 | + | pty, release := newBlockingPTY(t) | |
| 215 | + | defer release() | |
| 216 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 217 | + | firstTransport := &closeTrackingTransport{} | |
| 218 | + | sess, _, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport) | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | secondTransport := &closeTrackingTransport{} | |
| 221 | + | _, _, err = d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport) | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | parkedTransport := &closeTrackingTransport{} | |
| 224 | + | _, parkedAttachment, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), parkedTransport) | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | d.clientGone(sess, parkedAttachment, parkedTransport, false) | |
| 227 | + | parkedToken := parkedAttachment.resumeToken | |
| 228 | + | ||
| 229 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 230 | + | d.waitNotifies() | |
| 231 | + | require.True(t, firstTransport.Closed()) | |
| 232 | + | require.True(t, secondTransport.Closed()) | |
| 233 | + | require.True(t, parkedTransport.Closed()) | |
| 234 | + | d.mu.Lock() | |
| 235 | + | _, retained := d.parked[parkedToken] | |
| 236 | + | d.mu.Unlock() | |
| 237 | + | require.False(t, retained) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestResumeCredentialIsInvalidAfterDaemonRestart(t *testing.T) { | |
| 241 | + | pty, release := newBlockingPTY(t) | |
| 242 | + | defer release() | |
| 243 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 244 | + | transport := &closeTrackingTransport{} | |
| 245 | + | _, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport) | |
| 246 | + | require.NoError(t, err) | |
| 247 | + | token := ac.resumeToken | |
| 248 | + | restarted := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 249 | + | _, _, err = restarted.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}) | |
| 250 | + | var protocolErr *protoErr | |
| 251 | + | require.ErrorAs(t, err, &protocolErr) | |
| 252 | + | require.Equal(t, ports.ErrNoSuchSession, protocolErr.code) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestHandleHelloAllowsMultipleAttachments(t *testing.T) { | |
| 256 | + | pty, release := newBlockingPTY(t) | |
| 257 | + | defer release() | |
| 258 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 259 | + | firstTransport := &closeTrackingTransport{} | |
| 260 | + | sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport) | |
| 261 | + | require.NoError(t, err) | |
| 262 | + | secondTransport := &closeTrackingTransport{} | |
| 263 | + | _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport) | |
| 264 | + | require.NoError(t, err) | |
| 265 | + | require.Equal(t, attachmentActive, sess.attachmentRole(first)) | |
| 266 | + | require.Equal(t, attachmentActive, sess.attachmentRole(second)) | |
| 267 | + | require.NotSame(t, first, second) | |
| 268 | + | } | |
M internal/usecase/daemon/attachment_role.go +15 -3
| @@ -39,7 +39,10 @@ func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) at | |||
| 39 | 39 | return attachmentDetached | |
| 40 | 40 | } | |
| 41 | 41 | core := entry.core() | |
| 42 | - | if core.client == ac { | |
| 42 | + | // Membership is the lifecycle authority. Keep the primary pointer as a | |
| 43 | + | // narrow construction-time fallback for older headless fixtures; attached | |
| 44 | + | // routes always publish into the collection. | |
| 45 | + | if _, ok := core.attachments[ac]; ok || core.client == ac { | |
| 43 | 46 | return attachmentActive | |
| 44 | 47 | } | |
| 45 | 48 | if _, ok := core.snatched[ac]; ok { | |
| @@ -156,9 +159,15 @@ func (t attachmentRoleToken) current() bool { | |||
| 156 | 159 | // activeCurrent admits post-transition effects only for the exact published | |
| 157 | 160 | // role, transport incarnation, current-session link, and coordinator lease. | |
| 158 | 161 | func (t attachmentRoleToken) activeCurrent() bool { | |
| 159 | - | if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentAttachmentSession() != t.sess { | |
| 162 | + | if t.role != attachmentActive || !t.current() || t.ac.currentAttachmentSession() != t.sess { | |
| 160 | 163 | return false | |
| 161 | 164 | } | |
| 165 | + | // Membership grants each attachment its own protocol capability. The | |
| 166 | + | // session coordinator may still have a primary render lease while a second | |
| 167 | + | // attachment is joining; that attachment must not be treated as displaced. | |
| 168 | + | if t.lease == nil { | |
| 169 | + | return true | |
| 170 | + | } | |
| 162 | 171 | rc := t.sess.core().coordinator.Load() | |
| 163 | 172 | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 164 | 173 | } | |
| @@ -196,11 +205,14 @@ func (t attachmentRoleToken) activeEffectSessionLocked() bool { | |||
| 196 | 205 | if t.effect != nil && !t.effect.ended.Load() { | |
| 197 | 206 | return true | |
| 198 | 207 | } | |
| 199 | - | if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil || | |
| 208 | + | if t.role != attachmentActive || t.sess == nil || t.ac == nil || | |
| 200 | 209 | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 201 | 210 | t.ac.currentAttachmentSession() != t.sess || attachmentSessionRoleLocked(t.sess, t.ac) != attachmentActive { | |
| 202 | 211 | return false | |
| 203 | 212 | } | |
| 213 | + | if t.lease == nil { | |
| 214 | + | return true | |
| 215 | + | } | |
| 204 | 216 | rc := t.sess.core().coordinator.Load() | |
| 205 | 217 | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 206 | 218 | } | |
M internal/usecase/daemon/attachment_transition.go +18 -63
| @@ -48,10 +48,6 @@ type attachmentTransitionRequest struct { | |||
| 48 | 48 | preflighted bool | |
| 49 | 49 | roleEffectsFrozen bool | |
| 50 | 50 | expectedTargetTransportInterrupted bool | |
| 51 | - | // activationBarrier is used when promoting an already connected snatched | |
| 52 | - | // attachment. Its role gate is deadline-drained before sendMu is acquired, | |
| 53 | - | // preserving the gate -> sendMu -> daemon/routing/session publication order. | |
| 54 | - | activationBarrier bool | |
| 55 | 51 | } | |
| 56 | 52 | ||
| 57 | 53 | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool { | |
| @@ -110,9 +106,7 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) ( | |||
| 110 | 106 | targetCore = req.target.core() | |
| 111 | 107 | } | |
| 112 | 108 | if sourceCore == nil || req.target != nil && targetCore == nil || req.next == nil || d.closing || | |
| 113 | - | d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target || | |
| 114 | - | req.activationBarrier && (req.sourceToken == nil || source != req.target || | |
| 115 | - | req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) { | |
| 109 | + | d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target { | |
| 116 | 110 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| 117 | 111 | } | |
| 118 | 112 | if req.target != nil { | |
| @@ -127,12 +121,17 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) ( | |||
| 127 | 121 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| 128 | 122 | } | |
| 129 | 123 | req.preflighted = true | |
| 130 | - | return req, attachmentTransitionParticipants{ | |
| 131 | - | clients: []*attachedClient{req.next, req.expectedTargetCurrent}, | |
| 132 | - | interrupts: []roleTransportInterrupt{{ | |
| 124 | + | participants := attachmentTransitionParticipants{clients: []*attachedClient{req.next}} | |
| 125 | + | // A new attachment joins a session; it does not displace or interrupt an | |
| 126 | + | // existing attachment. Handoff callers provide sourceToken and retain the | |
| 127 | + | // legacy target participant set for their cross-session publication. | |
| 128 | + | if req.source != nil || req.action != "" { | |
| 129 | + | participants.clients = append(participants.clients, req.expectedTargetCurrent) | |
| 130 | + | participants.interrupts = append(participants.interrupts, roleTransportInterrupt{ | |
| 133 | 131 | ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport, | |
| 134 | - | }}, | |
| 135 | - | }, nil | |
| 132 | + | }) | |
| 133 | + | } | |
| 134 | + | return req, participants, nil | |
| 136 | 135 | } | |
| 137 | 136 | ||
| 138 | 137 | // freezeAttachmentTransition drains the snapshotted participants without any | |
| @@ -142,25 +141,12 @@ func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, par | |||
| 142 | 141 | if d.afterRoleEffectParticipantsSnapshotted != nil { | |
| 143 | 142 | d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients) | |
| 144 | 143 | } | |
| 145 | - | var drainDeadline *roleEffectDrainDeadline | |
| 146 | - | var drainDone func() <-chan struct{} | |
| 147 | - | if req.activationBarrier { | |
| 148 | - | drainDeadline = newRoleEffectDrainDeadline(d.clock) | |
| 149 | - | drainDone = drainDeadline.Done | |
| 150 | - | } | |
| 151 | - | frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) { | |
| 144 | + | frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, afterFrozen: func(ac *attachedClient) { | |
| 152 | 145 | if d.afterRoleEffectGateFrozen != nil { | |
| 153 | 146 | d.afterRoleEffectGateFrozen(req.action, ac) | |
| 154 | 147 | } | |
| 155 | 148 | }}, participants.clients...) | |
| 156 | - | if drainDeadline != nil { | |
| 157 | - | drainDeadline.stop() | |
| 158 | - | } | |
| 159 | 149 | if !frozen.acquired || !frozen.drained { | |
| 160 | - | if req.activationBarrier { | |
| 161 | - | _ = req.next.closeCapturedTransport(req.expectedTransport.transport) | |
| 162 | - | return frozen, false, errSendTimedOut | |
| 163 | - | } | |
| 164 | 150 | return frozen, false, errAttachmentTransition | |
| 165 | 151 | } | |
| 166 | 152 | interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport) | |
| @@ -191,20 +177,6 @@ func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (a | |||
| 191 | 177 | return d.transitionAttachmentLocked(req) | |
| 192 | 178 | } | |
| 193 | 179 | ||
| 194 | - | // prepareActivatedAttachment rebases the first active paint while the caller | |
| 195 | - | // still owns the activation barrier (and therefore sendMu). | |
| 196 | - | func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) { | |
| 197 | - | ac := result.published.ac | |
| 198 | - | ac.rebaseOutput() | |
| 199 | - | ac.captureFrames = nil | |
| 200 | - | if sess, ok := localSession(result.published.sess); ok { | |
| 201 | - | d.applyHostThemeSendLocked(sess, ac, ac.getClientTheme(), false) | |
| 202 | - | } | |
| 203 | - | // The first paint still requests a reset, but the old panel dependency chain | |
| 204 | - | // has already been rebased atomically under the activation barrier. | |
| 205 | - | result.published.rebase = false | |
| 206 | - | } | |
| 207 | - | ||
| 208 | 180 | func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 209 | 181 | req, participants, err := d.snapshotAttachmentTransition(req) | |
| 210 | 182 | if err != nil { | |
| @@ -221,36 +193,19 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||
| 221 | 193 | } | |
| 222 | 194 | req.expectedTargetTransportInterrupted = interrupted | |
| 223 | 195 | ||
| 224 | - | var releaseActivation func() | |
| 225 | - | if req.activationBarrier { | |
| 226 | - | releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken) | |
| 227 | - | if err != nil { | |
| 228 | - | return attachmentTransitionResult{}, err | |
| 229 | - | } | |
| 230 | - | defer func() { | |
| 231 | - | if releaseActivation != nil { | |
| 232 | - | releaseActivation() | |
| 233 | - | } | |
| 234 | - | }() | |
| 235 | - | } | |
| 236 | - | ||
| 237 | 196 | result, err := d.publishAttachmentTransition(req) | |
| 238 | 197 | if err != nil { | |
| 239 | 198 | return result, err | |
| 240 | 199 | } | |
| 241 | - | if req.activationBarrier && result.published.role == attachmentActive { | |
| 242 | - | d.prepareActivatedAttachment(&result) | |
| 243 | - | } | |
| 244 | - | if releaseActivation != nil { | |
| 245 | - | releaseActivation() | |
| 246 | - | releaseActivation = nil | |
| 247 | - | } | |
| 248 | - | if result.published.role == attachmentActive { | |
| 249 | - | result.published.ac.clearSnatchedInput() | |
| 250 | - | } | |
| 251 | 200 | // Warm-proxy timers are external clock operations. Apply them only after | |
| 252 | 201 | // frozen ownership publication and every architecture lock have completed; | |
| 253 | 202 | // the lifecycle helper revalidates exact registry pointers and clients. | |
| 254 | 203 | d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveRole) | |
| 255 | 204 | return result, nil | |
| 256 | 205 | } | |
| 206 | + | ||
| 207 | + | func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) { | |
| 208 | + | for _, cleanup := range result.cleanups { | |
| 209 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 210 | + | } | |
| 211 | + | } | |
M internal/usecase/daemon/attachment_transition_publish.go +18 -35
| @@ -141,7 +141,14 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 141 | 141 | ||
| 142 | 142 | var targetCoordinator *renderCoordinator | |
| 143 | 143 | if req.targetRole == attachmentActive { | |
| 144 | - | targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target) | |
| 144 | + | // The coordinator remains session-owned while attachments join the | |
| 145 | + | // session. Only the first attachment claims its primary render lease; | |
| 146 | + | // later attachments must not replace or invalidate it. | |
| 147 | + | if targetCore.client == nil { | |
| 148 | + | targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target) | |
| 149 | + | } else { | |
| 150 | + | targetCoordinator = targetCore.coordinator.Load() | |
| 151 | + | } | |
| 145 | 152 | } | |
| 146 | 153 | sourceCoordinator := sourceCore.coordinator.Load() | |
| 147 | 154 | if attachmentSessionRoleLocked(source, req.next) != req.expectedRole || | |
| @@ -160,7 +167,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 160 | 167 | } | |
| 161 | 168 | invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | |
| 162 | 169 | !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab)) | |
| 163 | - | invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) | |
| 170 | + | invalid = invalid || targetCoordinator != nil && old == nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) | |
| 164 | 171 | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | |
| 165 | 172 | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | |
| 166 | 173 | invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) | |
| @@ -176,11 +183,6 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 176 | 183 | // part of the same locked publication. It reports role-preserving transitions. | |
| 177 | 184 | func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool { | |
| 178 | 185 | req := publication.req | |
| 179 | - | if req.targetRole == attachmentActive { | |
| 180 | - | if target, ok := localSession(req.target); ok { | |
| 181 | - | d.demoteParkedActiveForSessionLocked(target) | |
| 182 | - | } | |
| 183 | - | } | |
| 184 | 186 | if req.copySourceEnvironment { | |
| 185 | 187 | source, sourceOK := localSession(publication.source) | |
| 186 | 188 | target, targetOK := localSession(req.target) | |
| @@ -202,43 +204,24 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||
| 202 | 204 | registerAttachmentSessionLocked(publication.source, req.next) | |
| 203 | 205 | registerAttachmentSessionLocked(req.target, req.next) | |
| 204 | 206 | if publication.old != nil && publication.old != req.next { | |
| 207 | + | // Existing attachments remain registered and retain their transport and | |
| 208 | + | // generation. A new attach is a collection insertion, never a | |
| 209 | + | // replacement handoff. | |
| 205 | 210 | registerAttachmentSessionLocked(req.target, publication.old) | |
| 206 | 211 | } | |
| 207 | - | if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next { | |
| 208 | - | publication.displacedTransport = publication.old.transportSnapshot() | |
| 209 | - | // A proxied daemon attachment has no local snatched UI and must never | |
| 210 | - | // linger in that role. Replacing it is terminal so its owning local proxy | |
| 211 | - | // receives ReasonReplaced and cannot kick the direct user back later. | |
| 212 | - | publication.terminalDisplaced = publication.old.proxied | |
| 213 | - | } | |
| 214 | - | if publication.source != req.target { | |
| 215 | - | if publication.source.core().client == req.next { | |
| 216 | - | publication.source.core().client = nil | |
| 217 | - | } | |
| 218 | - | delete(publication.source.core().snatched, req.next) | |
| 212 | + | if publication.source != req.target && publication.source.core().client == req.next { | |
| 213 | + | publication.source.core().client = nil | |
| 219 | 214 | } | |
| 215 | + | delete(publication.source.core().snatched, req.next) | |
| 220 | 216 | if req.targetRole == attachmentActive { | |
| 221 | 217 | delete(req.target.core().snatched, req.next) | |
| 222 | - | if publication.old != nil && publication.old != req.next { | |
| 223 | - | if publication.terminalDisplaced { | |
| 224 | - | delete(req.target.core().snatched, publication.old) | |
| 225 | - | req.target.core().unregisterAttachmentLocked(publication.old) | |
| 226 | - | } else { | |
| 227 | - | addSnatchedLocked(req.target, publication.old) | |
| 228 | - | } | |
| 218 | + | if req.target.core().client == nil { | |
| 219 | + | req.target.core().client = req.next | |
| 229 | 220 | } | |
| 230 | - | req.target.core().client = req.next | |
| 231 | 221 | } else { | |
| 232 | 222 | addSnatchedLocked(req.target, req.next) | |
| 233 | 223 | } | |
| 234 | 224 | publication.nextGeneration = req.next.roleGeneration.Add(1) | |
| 235 | - | if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive { | |
| 236 | - | publication.displacedGeneration = publication.old.roleGeneration.Add(1) | |
| 237 | - | if publication.terminalDisplaced { | |
| 238 | - | publication.old.setSession(nil) | |
| 239 | - | publication.old.invalidateFrozenRoleCapability() | |
| 240 | - | } | |
| 241 | - | } | |
| 242 | 225 | req.next.setSession(req.target) | |
| 243 | 226 | } | |
| 244 | 227 | ||
| @@ -251,7 +234,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att | |||
| 251 | 234 | result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next)) | |
| 252 | 235 | } | |
| 253 | 236 | var lease *attachmentLease | |
| 254 | - | if publication.targetCoordinator != nil { | |
| 237 | + | if publication.targetCoordinator != nil && publication.old == nil { | |
| 255 | 238 | var cleanup renderLifecycleCleanup | |
| 256 | 239 | cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready) | |
| 257 | 240 | result.cleanups = append(result.cleanups, cleanup) | |
M internal/usecase/daemon/client.go +9 -19
| @@ -50,7 +50,11 @@ type attachedClient struct { | |||
| 50 | 50 | roleEffects roleEffectGate | |
| 51 | 51 | resumeCapable bool | |
| 52 | 52 | resumeToken uint64 | |
| 53 | - | parked bool | |
| 53 | + | // resumeClaimToken is non-zero only between a parked resume claim and its | |
| 54 | + | // successful Welcome. It lets a failed pre-claim handshake restore the old | |
| 55 | + | // credential instead of consuming it. | |
| 56 | + | resumeClaimToken uint64 | |
| 57 | + | parked bool | |
| 54 | 58 | // proxied is negotiated once by Hello and remains immutable for this | |
| 55 | 59 | // attachment, including across transport resume. | |
| 56 | 60 | proxied bool | |
| @@ -75,14 +79,8 @@ type attachedClient struct { | |||
| 75 | 79 | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | |
| 76 | 80 | // snapshots cannot leak when an attachment switches tabs or sessions. | |
| 77 | 81 | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | |
| 78 | - | // initialSnatchedMu elects exactly one reset-panel sender per role generation. | |
| 79 | - | // A Welcome handshake and displaced-client cleanup may both discover the | |
| 80 | - | // same post-transition snatched role; the loser waits for the elected send. | |
| 81 | - | initialSnatchedMu sync.Mutex | |
| 82 | - | initialSnatchedGeneration uint64 | |
| 83 | - | initialSnatchedAttempt *initialSnatchedPanelAttempt | |
| 84 | - | size domain.Size | |
| 85 | - | keys *keys.Router | |
| 82 | + | size domain.Size | |
| 83 | + | keys *keys.Router | |
| 86 | 84 | // view is attachment-local navigation state. It is never inferred from a | |
| 87 | 85 | // session-wide active tab, so multiple attachments can observe different | |
| 88 | 86 | // tabs and panes without changing shared session ownership. | |
| @@ -98,15 +96,8 @@ type attachedClient struct { | |||
| 98 | 96 | // previousSession is guarded independently. It is retained through temporary | |
| 99 | 97 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 100 | 98 | previousSession Guarded[attachmentSession] | |
| 101 | - | // snatchedInputMu serializes the restricted input parser with its delayed | |
| 102 | - | // standalone-ESC callback. It is independent of routing and transport locks; | |
| 103 | - | // role activation and terminal close clear it only after releasing those locks. | |
| 104 | - | snatchedInputMu sync.Mutex | |
| 105 | - | snatchedInputPending []byte | |
| 106 | - | snatchedInputDrain bool | |
| 107 | - | snatchedInputESC pendingByteTimer | |
| 108 | - | linkMu sync.Mutex | |
| 109 | - | sendMu sync.Mutex | |
| 99 | + | linkMu sync.Mutex | |
| 100 | + | sendMu sync.Mutex | |
| 110 | 101 | } | |
| 111 | 102 | ||
| 112 | 103 | type cursorOut struct { | |
| @@ -261,7 +252,6 @@ func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport { | |||
| 261 | 252 | ac.tr = nil | |
| 262 | 253 | ac.transportIncarnation++ | |
| 263 | 254 | ac.linkMu.Unlock() | |
| 264 | - | ac.clearSnatchedInput() | |
| 265 | 255 | return tr | |
| 266 | 256 | } | |
| 267 | 257 | ||
M internal/usecase/daemon/client_frame_routing.go +7 -91
| @@ -36,9 +36,8 @@ func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) ( | |||
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | // runConnLoop is the per-connection input router: it pumps client messages | |
| 39 | - | // until detach, EOF, or a transport error. Role is resolved after every Recv so | |
| 40 | - | // a transport displaced while blocked in Recv immediately adopts its restricted | |
| 41 | - | // snatched routing rather than executing one stale active frame. | |
| 39 | + | // until detach, EOF, or a transport error. The attachment generation is | |
| 40 | + | // resolved after every Recv so a stale transport cannot execute a new frame. | |
| 42 | 41 | func (d *Daemon) runConnLoop(ac *attachedClient) { | |
| 43 | 42 | tr := ac.transport() | |
| 44 | 43 | if tr == nil { | |
| @@ -51,13 +50,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 51 | 50 | f, err := tr.Recv() | |
| 52 | 51 | if err != nil { | |
| 53 | 52 | for range connectionRoleAttempts { | |
| 54 | - | sess, token, ok := d.currentConnectionRole(ac, tr) | |
| 53 | + | sess, _, ok := d.currentConnectionRole(ac, tr) | |
| 55 | 54 | if !ok { | |
| 56 | 55 | return | |
| 57 | 56 | } | |
| 58 | - | if token.role == attachmentSnatched { | |
| 59 | - | d.parkOrDropSnatchedAttachment(token) | |
| 60 | - | } else if proxy, ok := sess.(*proxySession); ok { | |
| 57 | + | if proxy, ok := sess.(*proxySession); ok { | |
| 61 | 58 | d.detachProxyOnSendError(proxy, ac, tr) | |
| 62 | 59 | } else if local, ok := localSession(sess); ok { | |
| 63 | 60 | d.clientGone(local, ac, tr, false) | |
| @@ -75,21 +72,13 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 75 | 72 | if !ok { | |
| 76 | 73 | return | |
| 77 | 74 | } | |
| 78 | - | switch token.role { | |
| 79 | - | case attachmentActive: | |
| 80 | - | if d.handleActiveClientFrame(token, f) { | |
| 81 | - | return | |
| 82 | - | } | |
| 83 | - | case attachmentSnatched: | |
| 84 | - | if d.handleSnatchedClientFrame(token, f) { | |
| 85 | - | return | |
| 86 | - | } | |
| 75 | + | if token.role == attachmentActive && d.handleActiveClientFrame(token, f) { | |
| 76 | + | return | |
| 87 | 77 | } | |
| 88 | 78 | } | |
| 89 | 79 | } | |
| 90 | 80 | ||
| 91 | - | // handleActiveClientFrame owns the ordinary attached-client protocol. Keeping | |
| 92 | - | // it separate makes the restricted snatched protocol an explicit allow-list. | |
| 81 | + | // handleActiveClientFrame owns the attached-client protocol. | |
| 93 | 82 | func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool { | |
| 94 | 83 | if !token.activeEffect() { | |
| 95 | 84 | return false | |
| @@ -287,76 +276,3 @@ func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool { | |||
| 287 | 276 | } | |
| 288 | 277 | return true | |
| 289 | 278 | } | |
| 290 | - | ||
| 291 | - | // handleSnatchedClientFrame is intentionally an allow-list. Strict resume and | |
| 292 | - | // quit actions are introduced separately; until then ordinary input is ignored. | |
| 293 | - | func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Frame) bool { | |
| 294 | - | if !token.current() { | |
| 295 | - | return false | |
| 296 | - | } | |
| 297 | - | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 298 | - | if !admitted { | |
| 299 | - | return false | |
| 300 | - | } | |
| 301 | - | token.effect = ticket | |
| 302 | - | defer token.endRoleEffect() | |
| 303 | - | if d.afterRoleEffectAdmitted != nil { | |
| 304 | - | d.afterRoleEffectAdmitted(token) | |
| 305 | - | } | |
| 306 | - | switch f.Type { | |
| 307 | - | case ports.MsgInput: | |
| 308 | - | if in, err := ports.UnmarshalInput(f.Payload); err == nil && d.handleSnatchedInput(token, in) { | |
| 309 | - | return true | |
| 310 | - | } | |
| 311 | - | case ports.MsgResize: | |
| 312 | - | if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() { | |
| 313 | - | ac := token.ac | |
| 314 | - | ac.sendMu.Lock() | |
| 315 | - | if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) { | |
| 316 | - | ac.sendMu.Unlock() | |
| 317 | - | return false | |
| 318 | - | } | |
| 319 | - | ac.size = resize.Size | |
| 320 | - | ac.sendMu.Unlock() | |
| 321 | - | if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil { | |
| 322 | - | token.endRoleEffect() | |
| 323 | - | d.parkOrDropSnatchedAttachment(token) | |
| 324 | - | return true | |
| 325 | - | } | |
| 326 | - | } | |
| 327 | - | case ports.MsgTheme: | |
| 328 | - | if theme, err := ports.UnmarshalTheme(f.Payload); err == nil { | |
| 329 | - | ac := token.ac | |
| 330 | - | ac.sendMu.Lock() | |
| 331 | - | if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) { | |
| 332 | - | ac.sendMu.Unlock() | |
| 333 | - | return false | |
| 334 | - | } | |
| 335 | - | clientTheme := themeFromMessage(theme) | |
| 336 | - | ac.setClientTheme(clientTheme) | |
| 337 | - | ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme)) | |
| 338 | - | ac.sendMu.Unlock() | |
| 339 | - | if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil { | |
| 340 | - | token.endRoleEffect() | |
| 341 | - | d.parkOrDropSnatchedAttachment(token) | |
| 342 | - | return true | |
| 343 | - | } | |
| 344 | - | } | |
| 345 | - | case ports.MsgAck: | |
| 346 | - | if ack, err := ports.UnmarshalAck(f.Payload); err == nil { | |
| 347 | - | token.ac.ackOutputState(ack.AckedStateNum) | |
| 348 | - | } | |
| 349 | - | case ports.MsgPing: | |
| 350 | - | if err := d.sendSnatchedControl(token, framePong()); err != nil { | |
| 351 | - | token.endRoleEffect() | |
| 352 | - | d.parkOrDropSnatchedAttachment(token) | |
| 353 | - | return true | |
| 354 | - | } | |
| 355 | - | case ports.MsgDetach: | |
| 356 | - | _ = d.sendSnatchedControl(token, frameDetached(ports.ReasonDetach)) | |
| 357 | - | token.endRoleEffect() | |
| 358 | - | d.dropSnatchedAttachment(token) | |
| 359 | - | return true | |
| 360 | - | } | |
| 361 | - | return false | |
| 362 | - | } | |
M internal/usecase/daemon/client_transport_errors.go +6 -0
| @@ -64,6 +64,12 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port | |||
| 64 | 64 | } | |
| 65 | 65 | if rc := sess.renderCoordinator(); rc != nil { | |
| 66 | 66 | rc.noteDetach(ac) | |
| 67 | + | sess.mu.Lock() | |
| 68 | + | primary := sess.client | |
| 69 | + | sess.mu.Unlock() | |
| 70 | + | if primary != nil { | |
| 71 | + | d.attachCoordinator(sess, ac, primary, true) | |
| 72 | + | } | |
| 67 | 73 | } | |
| 68 | 74 | sess.mu.Lock() | |
| 69 | 75 | ephemeral := sess.ephemeral | |
M internal/usecase/daemon/daemon.go +15 -43
| @@ -144,15 +144,6 @@ type Daemon struct { | |||
| 144 | 144 | // after publishResizeCommit acquires attachment sendMu and before it acquires | |
| 145 | 145 | // owner fences. Tests must not perform external work from this callback. | |
| 146 | 146 | afterResizeCommitSendLocked func() | |
| 147 | - | // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs | |
| 148 | - | // after one overlay-family mutex is released and before the next is taken. | |
| 149 | - | afterSnatchOverlayFamily func(string) | |
| 150 | - | // afterSnatchedEscapeAccepted observes a standalone ESC after parser state | |
| 151 | - | // is consumed and before its captured role and transport are revalidated. | |
| 152 | - | afterSnatchedEscapeAccepted func() | |
| 153 | - | // afterSnatchedEscapeAttempt reports whether that callback acquired its exact | |
| 154 | - | // role capability after revalidation. | |
| 155 | - | afterSnatchedEscapeAttempt func(bool) | |
| 156 | 147 | // afterConnectionSessionSnapshot is a deterministic test seam after the | |
| 157 | 148 | // connection loop reads its current session and before it captures that role. | |
| 158 | 149 | afterConnectionSessionSnapshot func(attachmentSession) | |
| @@ -200,21 +191,10 @@ type Daemon struct { | |||
| 200 | 191 | // afterParkingWaitArmed observes waitParkingInFlight after it has resolved a | |
| 201 | 192 | // matching in-flight entry and before it blocks on done. | |
| 202 | 193 | afterParkingWaitArmed func() | |
| 203 | - | // afterSnatchedUnrouteBeforePark pauses parkSnatchedAttachment and | |
| 204 | - | // cleanupInterruptedSnatchedAttachment after exact snatched ownership is | |
| 205 | - | // removed and before parkAttachmentAs. The parking-in-flight marker must | |
| 206 | - | // already be published so a concurrent same-token resume can wait the gap. | |
| 207 | - | afterSnatchedUnrouteBeforePark func() | |
| 208 | 194 | // beforeResumeParkedSendMu pauses resumeParked after the initial parked | |
| 209 | 195 | // lookup and before the attachment send lock, so tests can consume or | |
| 210 | 196 | // replace the credential while the handshake waits. | |
| 211 | 197 | beforeResumeParkedSendMu func() | |
| 212 | - | // afterDisplacedCleanupStarted observes deferred displaced cleanup before it | |
| 213 | - | // synchronizes with the attachment gate. | |
| 214 | - | afterDisplacedCleanupStarted func() | |
| 215 | - | // beforeReclaimFirstPaint observes a committed reclaim after displaced cleanup | |
| 216 | - | // is queued and before its generation-bound reset first paint is admitted. | |
| 217 | - | beforeReclaimFirstPaint func(attachmentRoleToken) | |
| 218 | 198 | // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint | |
| 219 | 199 | // reserves its exact capability and before its first observable mutation. | |
| 220 | 200 | afterRoleEffectAdmitted func(attachmentRoleToken) | |
| @@ -339,9 +319,9 @@ type Daemon struct { | |||
| 339 | 319 | type parkedAttachment struct { | |
| 340 | 320 | sess *session | |
| 341 | 321 | ac *attachedClient | |
| 342 | - | role attachmentRole | |
| 343 | 322 | pickerGeneration uint64 | |
| 344 | 323 | timer ports.Timer | |
| 324 | + | claimed bool | |
| 345 | 325 | done chan struct{} | |
| 346 | 326 | doneOnce sync.Once | |
| 347 | 327 | } | |
| @@ -1120,23 +1100,26 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 1120 | 1100 | welcomeToken := sess.attachmentToken(ac, tr) | |
| 1121 | 1101 | welcomeToken.lease = lease | |
| 1122 | 1102 | welcomeTicket, admitted := ac.beginRoleEffect(welcomeToken) | |
| 1123 | - | validRole := welcomeToken.role == attachmentSnatched || welcomeToken.role == attachmentActive && lease != nil | |
| 1103 | + | validRole := welcomeToken.role == attachmentActive | |
| 1124 | 1104 | if expected.transport != tr || !admitted || !validRole { | |
| 1125 | 1105 | if admitted { | |
| 1126 | 1106 | welcomeTicket.End() | |
| 1127 | 1107 | } | |
| 1128 | - | if welcomeToken.role == attachmentSnatched { | |
| 1129 | - | d.parkOrDropSnatchedAttachment(welcomeToken) | |
| 1130 | - | } else { | |
| 1108 | + | if !d.abortResumeClaim(ac) { | |
| 1131 | 1109 | d.clientGone(sess, ac, tr, false) | |
| 1132 | 1110 | } | |
| 1133 | 1111 | return | |
| 1134 | 1112 | } | |
| 1135 | 1113 | if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil { | |
| 1136 | 1114 | welcomeTicket.End() | |
| 1137 | - | if welcomeToken.role == attachmentSnatched { | |
| 1138 | - | d.parkOrDropSnatchedAttachment(welcomeToken) | |
| 1139 | - | } else { | |
| 1115 | + | if !d.abortResumeClaim(ac) { | |
| 1116 | + | d.clientGone(sess, ac, tr, false) | |
| 1117 | + | } | |
| 1118 | + | return | |
| 1119 | + | } | |
| 1120 | + | if !d.commitResumeClaim(ac) { | |
| 1121 | + | welcomeTicket.End() | |
| 1122 | + | if !d.abortResumeClaim(ac) { | |
| 1140 | 1123 | d.clientGone(sess, ac, tr, false) | |
| 1141 | 1124 | } | |
| 1142 | 1125 | return | |
| @@ -1150,19 +1133,8 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 1150 | 1133 | d.clientGone(sess, ac, tr, false) | |
| 1151 | 1134 | return | |
| 1152 | 1135 | } | |
| 1153 | - | if postWelcomeToken.role == attachmentSnatched { | |
| 1154 | - | if err := d.sendInitialSnatchedPanel(postWelcomeToken, postWelcomeTicket); err != nil { | |
| 1155 | - | postWelcomeTicket.End() | |
| 1156 | - | d.parkOrDropSnatchedAttachment(postWelcomeToken) | |
| 1157 | - | return | |
| 1158 | - | } | |
| 1159 | - | postWelcomeTicket.End() | |
| 1160 | - | d.runConnLoop(ac) | |
| 1161 | - | _ = tr.Close() | |
| 1162 | - | return | |
| 1163 | - | } | |
| 1164 | 1136 | postWelcomeLease := postWelcomeToken.lease | |
| 1165 | - | if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil { | |
| 1137 | + | if postWelcomeToken.role != attachmentActive { | |
| 1166 | 1138 | postWelcomeTicket.End() | |
| 1167 | 1139 | d.clientGone(sess, ac, tr, false) | |
| 1168 | 1140 | return | |
| @@ -1177,10 +1149,10 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 1177 | 1149 | } | |
| 1178 | 1150 | ac.markSessionMetaSent(meta) | |
| 1179 | 1151 | } | |
| 1180 | - | if !rc.markAttachmentReady(postWelcomeLease) { | |
| 1152 | + | if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) { | |
| 1181 | 1153 | postWelcomeTicket.End() | |
| 1182 | - | // The attachment was displaced or detached while Welcome was in flight; | |
| 1183 | - | // never let this stale handshake emit an Output frame. | |
| 1154 | + | // The attachment was detached while Welcome was in flight; never let | |
| 1155 | + | // this stale handshake emit an Output frame. | |
| 1184 | 1156 | d.clientGone(sess, ac, tr, false) | |
| 1185 | 1157 | return | |
| 1186 | 1158 | } | |
M internal/usecase/daemon/input.go +1 -1
| @@ -350,7 +350,7 @@ func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool | |||
| 350 | 350 | return sess, effect, true | |
| 351 | 351 | } | |
| 352 | 352 | sess := h.ac.currentSession() | |
| 353 | - | if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched { | |
| 353 | + | if sess == nil { | |
| 354 | 354 | return nil, nil, false | |
| 355 | 355 | } | |
| 356 | 356 | return sess, nil, false | |
D internal/usecase/daemon/move_handoff_failure_test.go +0 -336
| @@ -1,336 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "maps" | |
| 6 | - | "testing" | |
| 7 | - | ||
| 8 | - | "github.com/stretchr/testify/require" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/vev/internal/domain" | |
| 11 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | type moveTabState struct { | |
| 15 | - | ptr *tab | |
| 16 | - | stableID string | |
| 17 | - | tree *layout.Tree | |
| 18 | - | panes map[layout.PaneID]*pane | |
| 19 | - | nextPaneID int | |
| 20 | - | layoutGeneration uint64 | |
| 21 | - | floatingState floatingState | |
| 22 | - | floatingPane *pane | |
| 23 | - | floatingVisible bool | |
| 24 | - | floatingGeneration uint64 | |
| 25 | - | ctxDone bool | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | type moveSessionState struct { | |
| 29 | - | ptr *session | |
| 30 | - | id domain.SessionID | |
| 31 | - | name string | |
| 32 | - | incarnation domain.IncarnationID | |
| 33 | - | tabs []*tab | |
| 34 | - | active int | |
| 35 | - | client *attachedClient | |
| 36 | - | snatched map[*attachedClient]struct{} | |
| 37 | - | moveReservations uint | |
| 38 | - | snapshotGeneration uint64 | |
| 39 | - | snapshotPublished uint64 | |
| 40 | - | snapshotDirty bool | |
| 41 | - | snapshotEligible bool | |
| 42 | - | ctxDone bool | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | type moveLeaseState struct { | |
| 46 | - | lease *attachmentLease | |
| 47 | - | attachment *attachedClient | |
| 48 | - | ready bool | |
| 49 | - | active bool | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | type moveClientState struct { | |
| 53 | - | transport transportSnapshot | |
| 54 | - | roleGeneration uint64 | |
| 55 | - | currentSession *session | |
| 56 | - | sourceRole attachmentRole | |
| 57 | - | destinationRole attachmentRole | |
| 58 | - | sourceLease moveLeaseState | |
| 59 | - | destinationLease moveLeaseState | |
| 60 | - | roleEffectPhase roleEffectPhase | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | type movePaneState struct { | |
| 64 | - | id layout.PaneID | |
| 65 | - | stableID string | |
| 66 | - | owner *paneOwner | |
| 67 | - | ownerGeneration uint64 | |
| 68 | - | ctxDone bool | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | type moveState struct { | |
| 72 | - | closing bool | |
| 73 | - | registry map[domain.SessionID]attachmentSession | |
| 74 | - | source moveSessionState | |
| 75 | - | destination moveSessionState | |
| 76 | - | sourceTab moveTabState | |
| 77 | - | destinationTab moveTabState | |
| 78 | - | movedPane movePaneState | |
| 79 | - | client moveClientState | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | func captureMoveState(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, client *attachedClient) moveState { | |
| 83 | - | clientState := captureMoveClientState(source, destination, client) | |
| 84 | - | d.mu.Lock() | |
| 85 | - | d.notices.routingMu.Lock() | |
| 86 | - | unlockSessions := lockAttachmentSessions(source, destination) | |
| 87 | - | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | |
| 88 | - | movedPane.mu.Lock() | |
| 89 | - | ||
| 90 | - | registry := make(map[domain.SessionID]attachmentSession, len(d.sessions)) | |
| 91 | - | maps.Copy(registry, d.sessions) | |
| 92 | - | state := moveState{ | |
| 93 | - | closing: d.closing, | |
| 94 | - | registry: registry, | |
| 95 | - | source: captureMoveSessionStateLocked(source), | |
| 96 | - | destination: captureMoveSessionStateLocked(destination), | |
| 97 | - | sourceTab: captureMoveTabStateLocked(sourceTab), | |
| 98 | - | destinationTab: captureMoveTabStateLocked(destinationTab), | |
| 99 | - | movedPane: movePaneState{ | |
| 100 | - | id: movedPane.id, | |
| 101 | - | stableID: movedPane.stableID, | |
| 102 | - | owner: movedPane.ownerSnapshot(), | |
| 103 | - | ownerGeneration: movedPane.ownerGeneration, | |
| 104 | - | ctxDone: moveContextDone(movedPane.ctx), | |
| 105 | - | }, | |
| 106 | - | client: clientState, | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | movedPane.mu.Unlock() | |
| 110 | - | unlockTabs() | |
| 111 | - | unlockSessions() | |
| 112 | - | d.notices.routingMu.Unlock() | |
| 113 | - | d.mu.Unlock() | |
| 114 | - | return state | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | func captureMoveSessionStateLocked(sess *session) moveSessionState { | |
| 118 | - | snatched := make(map[*attachedClient]struct{}, len(sess.snatched)) | |
| 119 | - | maps.Copy(snatched, sess.snatched) | |
| 120 | - | return moveSessionState{ | |
| 121 | - | ptr: sess, | |
| 122 | - | id: sess.id, | |
| 123 | - | name: sess.name, | |
| 124 | - | incarnation: sess.incarnation, | |
| 125 | - | tabs: append([]*tab(nil), sess.tabs...), | |
| 126 | - | active: sess.active, | |
| 127 | - | client: sess.client, | |
| 128 | - | snatched: snatched, | |
| 129 | - | moveReservations: sess.moveReservations, | |
| 130 | - | snapshotGeneration: sess.snapshotGeneration, | |
| 131 | - | snapshotPublished: sess.snapshotPublishedGeneration, | |
| 132 | - | snapshotDirty: sess.snapDirty.Load(), | |
| 133 | - | snapshotEligible: sess.snapEligible.Load(), | |
| 134 | - | ctxDone: moveContextDone(sess.ctx), | |
| 135 | - | } | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | func captureMoveTabStateLocked(tb *tab) moveTabState { | |
| 139 | - | panes := make(map[layout.PaneID]*pane, len(tb.panes)) | |
| 140 | - | maps.Copy(panes, tb.panes) | |
| 141 | - | return moveTabState{ | |
| 142 | - | ptr: tb, | |
| 143 | - | stableID: tb.stableID, | |
| 144 | - | tree: tb.tree.Clone(), | |
| 145 | - | panes: panes, | |
| 146 | - | nextPaneID: tb.nextPaneID, | |
| 147 | - | layoutGeneration: tb.layoutGeneration, | |
| 148 | - | floatingState: tb.floating.state, | |
| 149 | - | floatingPane: tb.floating.pane, | |
| 150 | - | floatingVisible: tb.floating.desiredVisible, | |
| 151 | - | floatingGeneration: tb.floating.generation, | |
| 152 | - | ctxDone: moveContextDone(tb.ctx), | |
| 153 | - | } | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | // captureMoveClientState snapshots coordinator and role-effect state before | |
| 157 | - | // callers acquire daemon/session/tab move-state locks. This keeps coordinator | |
| 158 | - | // and role-gate locks above, never beneath, the outer move fixture locks. | |
| 159 | - | func captureMoveClientState(source, destination *session, client *attachedClient) moveClientState { | |
| 160 | - | sourceLease := captureMoveLeaseState(source, client) | |
| 161 | - | destinationLease := captureMoveLeaseState(destination, client) | |
| 162 | - | roleEffectPhase := captureMoveRoleEffectPhase(client) | |
| 163 | - | return moveClientState{ | |
| 164 | - | transport: client.transportSnapshot(), | |
| 165 | - | roleGeneration: client.roleGeneration.Load(), | |
| 166 | - | currentSession: client.currentSession(), | |
| 167 | - | sourceRole: source.attachmentRole(client), | |
| 168 | - | destinationRole: destination.attachmentRole(client), | |
| 169 | - | sourceLease: sourceLease, | |
| 170 | - | destinationLease: destinationLease, | |
| 171 | - | roleEffectPhase: roleEffectPhase, | |
| 172 | - | } | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | func captureMoveLeaseState(sess *session, client *attachedClient) moveLeaseState { | |
| 176 | - | coordinator := sess.renderCoordinator() | |
| 177 | - | if coordinator == nil { | |
| 178 | - | return moveLeaseState{} | |
| 179 | - | } | |
| 180 | - | coordinator.mu.Lock() | |
| 181 | - | defer coordinator.mu.Unlock() | |
| 182 | - | lease := coordinator.lease | |
| 183 | - | if lease == nil || lease.attachment != client { | |
| 184 | - | return moveLeaseState{lease: lease} | |
| 185 | - | } | |
| 186 | - | return moveLeaseState{lease: lease, attachment: lease.attachment, ready: lease.ready, active: lease.active} | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | func captureMoveRoleEffectPhase(client *attachedClient) roleEffectPhase { | |
| 190 | - | client.roleEffects.mu.Lock() | |
| 191 | - | defer client.roleEffects.mu.Unlock() | |
| 192 | - | return client.roleEffects.phase | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | func moveContextDone(ctx context.Context) bool { | |
| 196 | - | return ctx != nil && ctx.Err() != nil | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |
| 200 | - | p1, release1 := newBlockingPTY(t) | |
| 201 | - | p2, release2 := newBlockingPTY(t) | |
| 202 | - | defer release1() | |
| 203 | - | defer release2() | |
| 204 | - | ||
| 205 | - | d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1) | |
| 206 | - | sourceTab := source.tabs[0] | |
| 207 | - | sourceTab.stableID = "source-tab" | |
| 208 | - | movedPane := sourceTab.focusedPane() | |
| 209 | - | ||
| 210 | - | destination := &session{sessionCore: sessionCore{id: "destination", | |
| 211 | - | name: "destination", | |
| 212 | - | ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, | |
| 213 | - | active: 0, | |
| 214 | - | } | |
| 215 | - | destinationTab := destination.tabs[0] | |
| 216 | - | publishTiledPaneOwners(destination, destinationTab) | |
| 217 | - | d.mu.Lock() | |
| 218 | - | d.sessions[destination.id] = destination | |
| 219 | - | d.mu.Unlock() | |
| 220 | - | require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true)) | |
| 221 | - | ||
| 222 | - | before := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | |
| 223 | - | originalTransport := before.client.transport | |
| 224 | - | staleTransport := &closeTrackingTransport{} | |
| 225 | - | staleInjected := make(chan struct{}) | |
| 226 | - | d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) { | |
| 227 | - | if action != "move-pane" || client != sourceClient { | |
| 228 | - | return | |
| 229 | - | } | |
| 230 | - | client.replaceTransport(staleTransport) | |
| 231 | - | close(staleInjected) | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | moveDone := make(chan error, 1) | |
| 235 | - | go func() { | |
| 236 | - | moveDone <- d.movePane(movePaneRequest{ | |
| 237 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 238 | - | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 239 | - | SourcePaneID: domain.PaneStableID(movedPane.stableID), | |
| 240 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 241 | - | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 242 | - | }) | |
| 243 | - | }() | |
| 244 | - | <-staleInjected | |
| 245 | - | err := <-moveDone | |
| 246 | - | ||
| 247 | - | // Restore the exact test-injected transport incarnation before comparing the | |
| 248 | - | // complete state. The move itself must not be able to alter this state. | |
| 249 | - | sourceClient.linkMu.Lock() | |
| 250 | - | sourceClient.tr = originalTransport.transport | |
| 251 | - | sourceClient.transportIncarnation = originalTransport.incarnation | |
| 252 | - | sourceClient.linkMu.Unlock() | |
| 253 | - | d.afterRoleEffectGateFrozen = nil | |
| 254 | - | ||
| 255 | - | require.ErrorIs(t, err, errAttachmentTransition) | |
| 256 | - | after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | |
| 257 | - | require.Equal(t, before, after, "stale handoff validation must reject before any move publication") | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | type moveTabHandoffState struct { | |
| 261 | - | closing bool | |
| 262 | - | registry map[domain.SessionID]attachmentSession | |
| 263 | - | source moveSessionState | |
| 264 | - | destination moveSessionState | |
| 265 | - | movedTab moveTabState | |
| 266 | - | follower moveClientState | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *tab, follower *attachedClient) moveTabHandoffState { | |
| 270 | - | followerState := captureMoveClientState(source, destination, follower) | |
| 271 | - | d.mu.Lock() | |
| 272 | - | d.notices.routingMu.Lock() | |
| 273 | - | unlockSessions := lockAttachmentSessions(source, destination) | |
| 274 | - | moved.mu.Lock() | |
| 275 | - | ||
| 276 | - | registry := make(map[domain.SessionID]attachmentSession, len(d.sessions)) | |
| 277 | - | maps.Copy(registry, d.sessions) | |
| 278 | - | state := moveTabHandoffState{ | |
| 279 | - | closing: d.closing, | |
| 280 | - | registry: registry, | |
| 281 | - | source: captureMoveSessionStateLocked(source), | |
| 282 | - | destination: captureMoveSessionStateLocked(destination), | |
| 283 | - | movedTab: captureMoveTabStateLocked(moved), | |
| 284 | - | follower: followerState, | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | moved.mu.Unlock() | |
| 288 | - | unlockSessions() | |
| 289 | - | d.notices.routingMu.Unlock() | |
| 290 | - | d.mu.Unlock() | |
| 291 | - | return state | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |
| 295 | - | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 296 | - | floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8}) | |
| 297 | - | floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx) | |
| 298 | - | t.Cleanup(floating.cancel) | |
| 299 | - | moved.mu.Lock() | |
| 300 | - | moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 9} | |
| 301 | - | moved.mu.Unlock() | |
| 302 | - | publishPaneOwner(floating, source, moved, 9) | |
| 303 | - | ||
| 304 | - | before := captureMoveTabHandoffState(d, source, destination, moved, follower) | |
| 305 | - | originalTransport := before.follower.transport | |
| 306 | - | staleTransport := &closeTrackingTransport{} | |
| 307 | - | staleInjected := make(chan struct{}) | |
| 308 | - | d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) { | |
| 309 | - | if action != "move-tab" || client != follower { | |
| 310 | - | return | |
| 311 | - | } | |
| 312 | - | client.replaceTransport(staleTransport) | |
| 313 | - | close(staleInjected) | |
| 314 | - | } | |
| 315 | - | ||
| 316 | - | moveDone := make(chan error, 1) | |
| 317 | - | go func() { | |
| 318 | - | moveDone <- d.moveTab(moveTabRequest{ | |
| 319 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 320 | - | SourceTabID: domain.TabStableID(moved.stableID), | |
| 321 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 322 | - | }) | |
| 323 | - | }() | |
| 324 | - | <-staleInjected | |
| 325 | - | err := <-moveDone | |
| 326 | - | ||
| 327 | - | follower.linkMu.Lock() | |
| 328 | - | follower.tr = originalTransport.transport | |
| 329 | - | follower.transportIncarnation = originalTransport.incarnation | |
| 330 | - | follower.linkMu.Unlock() | |
| 331 | - | d.afterRoleEffectGateFrozen = nil | |
| 332 | - | ||
| 333 | - | require.ErrorIs(t, err, errAttachmentTransition) | |
| 334 | - | after := captureMoveTabHandoffState(d, source, destination, moved, follower) | |
| 335 | - | require.Equal(t, before, after, "stale move-tab handoff validation must reject before any move publication") | |
| 336 | - | } | |
M internal/usecase/daemon/move_picker_test.go +0 -37
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "sync/atomic" | |
| 5 | 4 | "testing" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| @@ -279,42 +278,6 @@ func TestMovePickerSourceClientReplacementInvalidatesAction(t *testing.T) { | |||
| 279 | 278 | require.Equal(t, "Source client is no longer active.", history[0].Message) | |
| 280 | 279 | } | |
| 281 | 280 | ||
| 282 | - | func TestMovePickerFinalSourceClosesBeforeSnatchFinalization(t *testing.T) { | |
| 283 | - | d, source, follower, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 284 | - | defer releaseAll(releases) | |
| 285 | - | follower.replaceTransport(&closeTrackingTransport{}) | |
| 286 | - | require.NotNil(t, d.attachCoordinator(source, nil, follower, true)) | |
| 287 | - | ||
| 288 | - | displacedTransport := &closeTrackingTransport{} | |
| 289 | - | displaced := &attachedClient{tr: displacedTransport, output: newOutputStateStream(), size: follower.size} | |
| 290 | - | displaced.initOverlays() | |
| 291 | - | displaced.setSession(destination) | |
| 292 | - | destination.mu.Lock() | |
| 293 | - | destination.client = displaced | |
| 294 | - | destination.mu.Unlock() | |
| 295 | - | require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true)) | |
| 296 | - | ||
| 297 | - | var pickerClosed atomic.Bool | |
| 298 | - | d.afterDisplacedCleanupStarted = func() { | |
| 299 | - | require.False(t, follower.overlays.pickerActive(), "picker must close before snatch finalization") | |
| 300 | - | pickerClosed.Store(true) | |
| 301 | - | } | |
| 302 | - | defer func() { d.afterDisplacedCleanupStarted = nil }() | |
| 303 | - | ||
| 304 | - | require.NoError(t, d.enterPickerForIntent(source, follower, pickerMovePane, moveSourceLocator{ | |
| 305 | - | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 306 | - | TabID: "source-tab", PaneID: "source-pane", | |
| 307 | - | })) | |
| 308 | - | d.handlePickerInput(follower, []byte("\r")) | |
| 309 | - | d.attachmentCleanupWg.Wait() | |
| 310 | - | ||
| 311 | - | require.True(t, pickerClosed.Load()) | |
| 312 | - | require.False(t, follower.overlays.pickerActive()) | |
| 313 | - | frames := displacedTransport.Sends() | |
| 314 | - | require.NotEmpty(t, frames) | |
| 315 | - | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 316 | - | } | |
| 317 | - | ||
| 318 | 281 | func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) { | |
| 319 | 282 | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 320 | 283 | defer releaseAll(releases) | |
D internal/usecase/daemon/move_tab_handoff_test.go +0 -339
| @@ -1,339 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/stretchr/testify/require" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/internal/ports" | |
| 10 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedClient, *session, *attachedClient, *closeTrackingTransport, *tab) { | |
| 14 | - | t.Helper() | |
| 15 | - | d, source, follower, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 16 | - | follower.replaceTransport(&closeTrackingTransport{}) | |
| 17 | - | moved := source.tabs[0] | |
| 18 | - | moved.stableID = "moved-tab" | |
| 19 | - | require.NotNil(t, d.attachCoordinator(source, nil, follower, true)) | |
| 20 | - | ||
| 21 | - | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 22 | - | displacedTransport := &closeTrackingTransport{} | |
| 23 | - | displaced := &attachedClient{ | |
| 24 | - | tr: displacedTransport, output: newOutputStateStream(), size: follower.size, | |
| 25 | - | } | |
| 26 | - | displaced.initOverlays() | |
| 27 | - | displaced.setSession(destination) | |
| 28 | - | destination.mu.Lock() | |
| 29 | - | destination.client = displaced | |
| 30 | - | destination.mu.Unlock() | |
| 31 | - | require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true)) | |
| 32 | - | ||
| 33 | - | return d, source, follower, destination, displaced, displacedTransport, moved | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | func moveTabFinalSource(t *testing.T, d *Daemon, source, destination *session, moved *tab) { | |
| 37 | - | t.Helper() | |
| 38 | - | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 39 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 40 | - | SourceTabID: domain.TabStableID(moved.stableID), | |
| 41 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 42 | - | })) | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func detachedWithReason(frames []ports.Frame, reason uint8) bool { | |
| 46 | - | for _, frame := range frames { | |
| 47 | - | if frame.Type != ports.MsgDetached { | |
| 48 | - | continue | |
| 49 | - | } | |
| 50 | - | detached, err := ports.UnmarshalDetached(frame.Payload) | |
| 51 | - | if err == nil && detached.Reason == reason { | |
| 52 | - | return true | |
| 53 | - | } | |
| 54 | - | } | |
| 55 | - | return false | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) { | |
| 59 | - | d, source, follower, destination, displaced, displacedTransport, moved := setupMoveTabFinalSourceHandoff(t) | |
| 60 | - | ||
| 61 | - | moveTabFinalSource(t, d, source, destination, moved) | |
| 62 | - | d.attachmentCleanupWg.Wait() | |
| 63 | - | ||
| 64 | - | destination.mu.Lock() | |
| 65 | - | require.Same(t, follower, destination.client) | |
| 66 | - | require.Contains(t, destination.snatched, displaced) | |
| 67 | - | require.Same(t, moved, destination.tabs[destination.active]) | |
| 68 | - | destination.mu.Unlock() | |
| 69 | - | require.Same(t, destination, follower.currentSession()) | |
| 70 | - | require.Equal(t, attachmentActive, destination.attachmentRole(follower)) | |
| 71 | - | require.Equal(t, attachmentSnatched, destination.attachmentRole(displaced)) | |
| 72 | - | ||
| 73 | - | frames := displacedTransport.Sends() | |
| 74 | - | require.Len(t, frames, 1) | |
| 75 | - | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 76 | - | output, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 77 | - | require.NoError(t, err) | |
| 78 | - | require.Contains(t, string(output.Data), "Session snatched") | |
| 79 | - | require.False(t, detachedWithReason(frames, ports.ReasonReplaced)) | |
| 80 | - | require.False(t, displacedTransport.Closed()) | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) { | |
| 84 | - | d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 85 | - | ||
| 86 | - | waitingTransport := &closeTrackingTransport{} | |
| 87 | - | waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size} | |
| 88 | - | waiting.initOverlays() | |
| 89 | - | waiting.setSession(destination) | |
| 90 | - | destination.mu.Lock() | |
| 91 | - | destination.addSnatchedLocked(waiting) | |
| 92 | - | destination.mu.Unlock() | |
| 93 | - | ||
| 94 | - | moveTabFinalSource(t, d, source, destination, moved) | |
| 95 | - | d.attachmentCleanupWg.Wait() | |
| 96 | - | ||
| 97 | - | destination.mu.Lock() | |
| 98 | - | require.Contains(t, destination.snatched, displaced) | |
| 99 | - | require.Contains(t, destination.snatched, waiting) | |
| 100 | - | require.Len(t, destination.snatched, 2) | |
| 101 | - | destination.mu.Unlock() | |
| 102 | - | requireSingleOwnerAndLease(t, destination, follower, displaced, waiting) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) { | |
| 106 | - | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 107 | - | ||
| 108 | - | parkedTransport := &closeTrackingTransport{} | |
| 109 | - | parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true} | |
| 110 | - | parked.initOverlays() | |
| 111 | - | require.True(t, d.parkAttachment(source, parked)) | |
| 112 | - | ||
| 113 | - | sourceSnatchedTransport := &closeTrackingTransport{} | |
| 114 | - | sourceSnatched := &attachedClient{tr: sourceSnatchedTransport, output: newOutputStateStream(), size: follower.size} | |
| 115 | - | sourceSnatched.initOverlays() | |
| 116 | - | sourceSnatched.setSession(source) | |
| 117 | - | source.mu.Lock() | |
| 118 | - | source.addSnatchedLocked(sourceSnatched) | |
| 119 | - | source.mu.Unlock() | |
| 120 | - | ||
| 121 | - | moveTabFinalSource(t, d, source, destination, moved) | |
| 122 | - | d.attachmentCleanupWg.Wait() | |
| 123 | - | d.waitNotifies() | |
| 124 | - | ||
| 125 | - | d.mu.Lock() | |
| 126 | - | require.Empty(t, d.parked) | |
| 127 | - | d.mu.Unlock() | |
| 128 | - | require.True(t, parkedTransport.Closed()) | |
| 129 | - | require.True(t, sourceSnatchedTransport.Closed()) | |
| 130 | - | require.Nil(t, sourceSnatched.currentSession()) | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) { | |
| 134 | - | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 135 | - | movedPane := moved.focusedPane() | |
| 136 | - | ||
| 137 | - | follower.overlays.copyMu.Lock() | |
| 138 | - | follower.overlays.copyPane = movedPane | |
| 139 | - | follower.overlays.copyMode = &scopy.Mode{} | |
| 140 | - | follower.overlays.copyMu.Unlock() | |
| 141 | - | follower.sendMu.Lock() | |
| 142 | - | follower.captureFrames = map[*pane]capturedPaneRenderState{movedPane: {title: "stale-source-capture"}} | |
| 143 | - | follower.sendMu.Unlock() | |
| 144 | - | ||
| 145 | - | otherPane := destination.tabs[0].focusedPane() | |
| 146 | - | waiting := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: follower.size} | |
| 147 | - | waiting.initOverlays() | |
| 148 | - | waiting.setSession(destination) | |
| 149 | - | waiting.overlays.copyMu.Lock() | |
| 150 | - | waiting.overlays.copyPane = otherPane | |
| 151 | - | waiting.overlays.copyMode = &scopy.Mode{} | |
| 152 | - | waiting.overlays.copyMu.Unlock() | |
| 153 | - | waiting.sendMu.Lock() | |
| 154 | - | waiting.captureFrames = map[*pane]capturedPaneRenderState{otherPane: {}} | |
| 155 | - | waiting.sendMu.Unlock() | |
| 156 | - | destination.mu.Lock() | |
| 157 | - | destination.addSnatchedLocked(waiting) | |
| 158 | - | destination.mu.Unlock() | |
| 159 | - | ||
| 160 | - | moveTabFinalSource(t, d, source, destination, moved) | |
| 161 | - | ||
| 162 | - | follower.overlays.copyMu.Lock() | |
| 163 | - | require.Nil(t, follower.overlays.copyMode) | |
| 164 | - | require.Nil(t, follower.overlays.copyPane) | |
| 165 | - | follower.overlays.copyMu.Unlock() | |
| 166 | - | follower.sendMu.Lock() | |
| 167 | - | followerCapture, followerCaptured := follower.captureFrames[movedPane] | |
| 168 | - | follower.sendMu.Unlock() | |
| 169 | - | if followerCaptured { | |
| 170 | - | require.NotEqual(t, "stale-source-capture", followerCapture.title, "source capture survived destination first paint") | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | waiting.overlays.copyMu.Lock() | |
| 174 | - | require.NotNil(t, waiting.overlays.copyMode) | |
| 175 | - | require.Same(t, otherPane, waiting.overlays.copyPane) | |
| 176 | - | waiting.overlays.copyMu.Unlock() | |
| 177 | - | waiting.sendMu.Lock() | |
| 178 | - | _, waitingCaptured := waiting.captureFrames[otherPane] | |
| 179 | - | waiting.sendMu.Unlock() | |
| 180 | - | require.True(t, waitingCaptured) | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) { | |
| 184 | - | d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 185 | - | followerRebased := make(chan struct{}) | |
| 186 | - | follower.renderStages.handoffRebase = func() { close(followerRebased) } | |
| 187 | - | ||
| 188 | - | displaced.sendMu.Lock() | |
| 189 | - | panelCleanupStarted := make(chan struct{}) | |
| 190 | - | d.afterDisplacedCleanupStarted = func() { close(panelCleanupStarted) } | |
| 191 | - | defer func() { d.afterDisplacedCleanupStarted = nil }() | |
| 192 | - | ||
| 193 | - | moveDone := make(chan struct{}) | |
| 194 | - | go func() { | |
| 195 | - | moveTabFinalSource(t, d, source, destination, moved) | |
| 196 | - | close(moveDone) | |
| 197 | - | }() | |
| 198 | - | ||
| 199 | - | awaitTestCompletion(t, panelCleanupStarted, "displaced panel cleanup did not start") | |
| 200 | - | awaitTestCompletion(t, followerRebased, "follower reset first paint waited for displaced panel cleanup") | |
| 201 | - | awaitTestCompletion(t, moveDone, "move-tab handoff did not finish after follower first paint") | |
| 202 | - | ||
| 203 | - | displaced.sendMu.Unlock() | |
| 204 | - | d.attachmentCleanupWg.Wait() | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) { | |
| 208 | - | d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 209 | - | ||
| 210 | - | paintAdmitted := make(chan struct{}) | |
| 211 | - | releasePaint := make(chan struct{}) | |
| 212 | - | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |
| 213 | - | if token.ac == follower && token.role == attachmentActive { | |
| 214 | - | close(paintAdmitted) | |
| 215 | - | <-releasePaint | |
| 216 | - | } | |
| 217 | - | } | |
| 218 | - | defer func() { d.afterRoleEffectAdmitted = nil }() | |
| 219 | - | ||
| 220 | - | moveDone := make(chan error, 1) | |
| 221 | - | go func() { | |
| 222 | - | moveDone <- d.moveTab(moveTabRequest{ | |
| 223 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 224 | - | SourceTabID: domain.TabStableID(moved.stableID), | |
| 225 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 226 | - | }) | |
| 227 | - | }() | |
| 228 | - | awaitTestCompletion(t, paintAdmitted, "move-tab follower first paint was not admitted") | |
| 229 | - | ||
| 230 | - | displacedToken := destination.attachmentToken(displaced, displaced.transportSnapshot().transport) | |
| 231 | - | reclaimDone := make(chan struct{}) | |
| 232 | - | go func() { | |
| 233 | - | d.handleSnatchedClientFrame(displacedToken, ports.Frame{ | |
| 234 | - | Type: ports.MsgInput, | |
| 235 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 236 | - | }) | |
| 237 | - | close(reclaimDone) | |
| 238 | - | }() | |
| 239 | - | ||
| 240 | - | close(releasePaint) | |
| 241 | - | require.NoError(t, <-moveDone) | |
| 242 | - | awaitTestCompletion(t, reclaimDone, "concurrent destination reclaim did not finish") | |
| 243 | - | d.attachmentCleanupWg.Wait() | |
| 244 | - | ||
| 245 | - | requireSingleOwnerAndLease(t, destination, follower, displaced) | |
| 246 | - | destination.mu.Lock() | |
| 247 | - | require.Same(t, displaced, destination.client) | |
| 248 | - | destination.mu.Unlock() | |
| 249 | - | require.Equal(t, attachmentSnatched, destination.attachmentRole(follower)) | |
| 250 | - | require.Equal(t, attachmentActive, destination.attachmentRole(displaced)) | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) { | |
| 254 | - | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 255 | - | ||
| 256 | - | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |
| 257 | - | if token.ac == follower { | |
| 258 | - | token.ac.roleGeneration.Add(1) | |
| 259 | - | } | |
| 260 | - | } | |
| 261 | - | defer func() { d.afterRoleEffectAdmitted = nil }() | |
| 262 | - | ||
| 263 | - | beforeTabs := append([]*tab(nil), destination.tabs...) | |
| 264 | - | moveTabFinalSource(t, d, source, destination, moved) | |
| 265 | - | ||
| 266 | - | require.Nil(t, source.tabs) | |
| 267 | - | require.Same(t, destination, follower.currentSession()) | |
| 268 | - | require.Same(t, follower, destination.client) | |
| 269 | - | require.Equal(t, append(beforeTabs, moved), destination.tabs) | |
| 270 | - | require.Same(t, moved, destination.tabs[destination.active]) | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) { | |
| 274 | - | d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 275 | - | ||
| 276 | - | commitEntered := make(chan struct{}) | |
| 277 | - | releaseCommit := make(chan struct{}) | |
| 278 | - | d.beforeMoveTabCommit = func() { | |
| 279 | - | close(commitEntered) | |
| 280 | - | <-releaseCommit | |
| 281 | - | } | |
| 282 | - | defer func() { d.beforeMoveTabCommit = nil }() | |
| 283 | - | ||
| 284 | - | moveDone := make(chan error, 1) | |
| 285 | - | go func() { | |
| 286 | - | moveDone <- d.moveTab(moveTabRequest{ | |
| 287 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 288 | - | SourceTabID: domain.TabStableID(moved.stableID), | |
| 289 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 290 | - | }) | |
| 291 | - | }() | |
| 292 | - | <-commitEntered | |
| 293 | - | ||
| 294 | - | type observation struct { | |
| 295 | - | sourceRegistered bool | |
| 296 | - | movedInSource bool | |
| 297 | - | movedInDestination bool | |
| 298 | - | followerOnDestination bool | |
| 299 | - | displacedSnatched bool | |
| 300 | - | movedActive bool | |
| 301 | - | } | |
| 302 | - | read := func() observation { | |
| 303 | - | d.mu.Lock() | |
| 304 | - | _, sourceLive := d.sessions[source.id] | |
| 305 | - | d.mu.Unlock() | |
| 306 | - | source.mu.Lock() | |
| 307 | - | movedInSource := indexMoveTabLocked(source, moved) >= 0 | |
| 308 | - | source.mu.Unlock() | |
| 309 | - | destination.mu.Lock() | |
| 310 | - | obs := observation{ | |
| 311 | - | sourceRegistered: sourceLive, | |
| 312 | - | movedInSource: movedInSource, | |
| 313 | - | movedInDestination: indexMoveTabLocked(destination, moved) >= 0, | |
| 314 | - | followerOnDestination: destination.client == follower, | |
| 315 | - | displacedSnatched: false, | |
| 316 | - | movedActive: destination.active >= 0 && destination.tabs[destination.active] == moved, | |
| 317 | - | } | |
| 318 | - | if displaced != nil { | |
| 319 | - | _, obs.displacedSnatched = destination.snatched[displaced] | |
| 320 | - | } | |
| 321 | - | destination.mu.Unlock() | |
| 322 | - | return obs | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | require.False(t, d.mu.TryLock(), "daemon observers must be excluded from partial move-tab publication") | |
| 326 | - | require.False(t, source.mu.TryLock(), "source observers must be excluded from partial move-tab publication") | |
| 327 | - | require.False(t, destination.mu.TryLock(), "destination observers must be excluded from partial move-tab publication") | |
| 328 | - | ||
| 329 | - | close(releaseCommit) | |
| 330 | - | require.NoError(t, <-moveDone) | |
| 331 | - | ||
| 332 | - | final := read() | |
| 333 | - | require.False(t, final.sourceRegistered) | |
| 334 | - | require.False(t, final.movedInSource) | |
| 335 | - | require.True(t, final.movedInDestination) | |
| 336 | - | require.True(t, final.followerOnDestination) | |
| 337 | - | require.True(t, final.displacedSnatched) | |
| 338 | - | require.True(t, final.movedActive) | |
| 339 | - | } | |
M internal/usecase/daemon/overlay_runtime.go +0 -106
| @@ -242,112 +242,6 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||
| 242 | 242 | return active || prePublication | |
| 243 | 243 | } | |
| 244 | 244 | ||
| 245 | - | // clearForSnatch clears each overlay family independently. Every family | |
| 246 | - | // revalidates the displaced role generation while holding only its own mutex, | |
| 247 | - | // so cleanup can neither participate in cross-family lock inversion nor erase | |
| 248 | - | // state published by a later promotion. | |
| 249 | - | func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool { | |
| 250 | - | if token.ac == nil || token.ac.overlays == nil { | |
| 251 | - | return false | |
| 252 | - | } | |
| 253 | - | rt := token.ac.overlays | |
| 254 | - | current := func() bool { | |
| 255 | - | return token.ac.roleGeneration.Load() == token.generation && token.ac.transportSnapshotCurrent(token.transport) | |
| 256 | - | } | |
| 257 | - | afterFamily := func(family string) { | |
| 258 | - | if d.afterSnatchOverlayFamily != nil { | |
| 259 | - | d.afterSnatchOverlayFamily(family) | |
| 260 | - | } | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | rt.pickerMu.Lock() | |
| 264 | - | if !current() { | |
| 265 | - | rt.pickerMu.Unlock() | |
| 266 | - | return false | |
| 267 | - | } | |
| 268 | - | previewSession := rt.pickerPreviewSession | |
| 269 | - | previewGeneration := rt.pickerPreviewGeneration | |
| 270 | - | pickerInstance := remotePickerInstance{ac: token.ac, generation: rt.pickerGeneration, model: rt.picker} | |
| 271 | - | rt.picker = nil | |
| 272 | - | rt.pickerIntent = pickerNavigate | |
| 273 | - | rt.pickerSource = moveSourceLocator{} | |
| 274 | - | rt.pickerPending = nil | |
| 275 | - | rt.pickerESC.stop() | |
| 276 | - | rt.pickerPreviewGeneration++ | |
| 277 | - | rt.pickerPreview = nil | |
| 278 | - | rt.pickerPreviewSession = nil | |
| 279 | - | rt.pickerMu.Unlock() | |
| 280 | - | afterFamily("picker") | |
| 281 | - | // Subscription teardown may enter a coordinator and therefore runs outside | |
| 282 | - | // every overlay-family lock. | |
| 283 | - | d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration) | |
| 284 | - | if pickerInstance.model != nil { | |
| 285 | - | d.remotePickerClosed(pickerInstance) | |
| 286 | - | } | |
| 287 | - | ||
| 288 | - | rt.paletteMu.Lock() | |
| 289 | - | if !current() { | |
| 290 | - | rt.paletteMu.Unlock() | |
| 291 | - | return false | |
| 292 | - | } | |
| 293 | - | token.ac.clearPaletteLocked() | |
| 294 | - | rt.paletteMu.Unlock() | |
| 295 | - | afterFamily("palette") | |
| 296 | - | ||
| 297 | - | rt.promptMu.Lock() | |
| 298 | - | if !current() { | |
| 299 | - | rt.promptMu.Unlock() | |
| 300 | - | return false | |
| 301 | - | } | |
| 302 | - | rt.prompt = nil | |
| 303 | - | rt.promptSubmit = nil | |
| 304 | - | rt.promptTransitionSubmit = nil | |
| 305 | - | rt.promptPending = nil | |
| 306 | - | rt.promptMu.Unlock() | |
| 307 | - | afterFamily("prompt") | |
| 308 | - | ||
| 309 | - | rt.resizeMu.Lock() | |
| 310 | - | if !current() { | |
| 311 | - | rt.resizeMu.Unlock() | |
| 312 | - | return false | |
| 313 | - | } | |
| 314 | - | rt.resizeActive = false | |
| 315 | - | rt.resizePending = nil | |
| 316 | - | rt.resizeESC.stop() | |
| 317 | - | rt.resizeMu.Unlock() | |
| 318 | - | afterFamily("resize") | |
| 319 | - | ||
| 320 | - | rt.copyMu.Lock() | |
| 321 | - | if !current() { | |
| 322 | - | rt.copyMu.Unlock() | |
| 323 | - | return false | |
| 324 | - | } | |
| 325 | - | d.stopCopyPendingTimerLocked(token.ac) | |
| 326 | - | rt.copyPending = nil | |
| 327 | - | rt.clearCopyModeLocked() | |
| 328 | - | rt.statusFeedback = "" | |
| 329 | - | rt.copyMu.Unlock() | |
| 330 | - | afterFamily("copy") | |
| 331 | - | ||
| 332 | - | rt.noticeMu.Lock() | |
| 333 | - | if !current() { | |
| 334 | - | rt.noticeMu.Unlock() | |
| 335 | - | return false | |
| 336 | - | } | |
| 337 | - | for i := range rt.noticeToasts { | |
| 338 | - | rt.noticeToasts[i].timer.stop() | |
| 339 | - | } | |
| 340 | - | rt.noticeToasts = nil | |
| 341 | - | rt.noticeOverflow = 0 | |
| 342 | - | rt.noticeSeq++ | |
| 343 | - | rt.noticesOverlay = nil | |
| 344 | - | rt.noticesPending = nil | |
| 345 | - | rt.noticesESC.stop() | |
| 346 | - | rt.noticeMu.Unlock() | |
| 347 | - | afterFamily("notice") | |
| 348 | - | return true | |
| 349 | - | } | |
| 350 | - | ||
| 351 | 245 | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*roleEffectTicket) bool { | |
| 352 | 246 | if rt == nil || rt.ac == nil { | |
| 353 | 247 | return false | |
M internal/usecase/daemon/remote_picker_test.go +0 -34
| @@ -861,40 +861,6 @@ func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) { | |||
| 861 | 861 | } | |
| 862 | 862 | } | |
| 863 | 863 | ||
| 864 | - | func TestRemotePickerPublishCannotRegisterAfterSnatchClear(t *testing.T) { | |
| 865 | - | d := newRemotePickerDaemon(nil) | |
| 866 | - | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 867 | - | ac.roleGeneration.Store(1) | |
| 868 | - | model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 869 | - | registrationReached := make(chan struct{}) | |
| 870 | - | allowRegistration := make(chan struct{}) | |
| 871 | - | registrationWait := make(chan error, 1) | |
| 872 | - | ac.overlays.beforeRemotePickerRegistration = func() { | |
| 873 | - | close(registrationReached) | |
| 874 | - | registrationWait <- waitRemotePickerClose(allowRegistration, "picker registration release") | |
| 875 | - | } | |
| 876 | - | published := make(chan struct{}) | |
| 877 | - | go func() { | |
| 878 | - | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 879 | - | close(published) | |
| 880 | - | }() | |
| 881 | - | receiveRemotePickerClose(t, registrationReached, "picker registration") | |
| 882 | - | ||
| 883 | - | token := attachmentRoleToken{ | |
| 884 | - | sess: sess, ac: ac, role: attachmentSnatched, | |
| 885 | - | generation: 1, transport: ac.transportSnapshot(), | |
| 886 | - | } | |
| 887 | - | require.True(t, d.clearForSnatch(token)) | |
| 888 | - | close(allowRegistration) | |
| 889 | - | require.NoError(t, receiveRemotePicker(t, registrationWait, "picker registration release")) | |
| 890 | - | receiveRemotePickerClose(t, published, "picker publication") | |
| 891 | - | ||
| 892 | - | d.remoteCatalog.mu.Lock() | |
| 893 | - | _, registered := d.remoteCatalog.pickers[ac] | |
| 894 | - | d.remoteCatalog.mu.Unlock() | |
| 895 | - | require.False(t, registered, "a picker cleared before delayed registration must not become a refresh owner") | |
| 896 | - | } | |
| 897 | - | ||
| 898 | 864 | func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) { | |
| 899 | 865 | d := newRemotePickerDaemon(nil) | |
| 900 | 866 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
M internal/usecase/daemon/resume.go +86 -33
| @@ -204,10 +204,6 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool { | |||
| 204 | 204 | } | |
| 205 | 205 | ||
| 206 | 206 | func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |
| 207 | - | return d.parkAttachmentAs(sess, ac, attachmentActive) | |
| 208 | - | } | |
| 209 | - | ||
| 210 | - | func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attachmentRole) bool { | |
| 211 | 207 | if !d.prepareParkAttachment(sess, ac) { | |
| 212 | 208 | return false | |
| 213 | 209 | } | |
| @@ -228,13 +224,6 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach | |||
| 228 | 224 | d.mu.Unlock() | |
| 229 | 225 | return false | |
| 230 | 226 | } | |
| 231 | - | if role == attachmentActive { | |
| 232 | - | sess.mu.Lock() | |
| 233 | - | if sess.client != nil { | |
| 234 | - | role = attachmentSnatched | |
| 235 | - | } | |
| 236 | - | sess.mu.Unlock() | |
| 237 | - | } | |
| 238 | 227 | token := ac.resumeToken | |
| 239 | 228 | var ( | |
| 240 | 229 | oldRetirement *parkedAttachmentRetirement | |
| @@ -250,7 +239,7 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach | |||
| 250 | 239 | } | |
| 251 | 240 | grace := d.resumeParkGrace | |
| 252 | 241 | timer := d.clock.NewTimer(grace) | |
| 253 | - | parked := &parkedAttachment{sess: sess, ac: ac, role: role, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})} | |
| 242 | + | parked := &parkedAttachment{sess: sess, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})} | |
| 254 | 243 | ac.parked = true | |
| 255 | 244 | d.parked[token] = parked | |
| 256 | 245 | d.clearParkingInFlightLocked(token, ac) | |
| @@ -279,6 +268,10 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach | |||
| 279 | 268 | func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { | |
| 280 | 269 | d.mu.Lock() | |
| 281 | 270 | if d.parked[token] == parked { | |
| 271 | + | if parked.claimed { | |
| 272 | + | d.mu.Unlock() | |
| 273 | + | return | |
| 274 | + | } | |
| 282 | 275 | d.removeParkedLocked(token, parked) | |
| 283 | 276 | d.mu.Unlock() | |
| 284 | 277 | d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration) | |
| @@ -308,14 +301,65 @@ func (p *parkedAttachment) closeDone() { | |||
| 308 | 301 | p.doneOnce.Do(func() { close(p.done) }) | |
| 309 | 302 | } | |
| 310 | 303 | ||
| 311 | - | // demoteParkedActiveForSessionLocked preserves resumable predecessors when a | |
| 312 | - | // new active owner is published. Caller holds d.mu. | |
| 313 | - | func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) { | |
| 314 | - | for _, parked := range d.parked { | |
| 315 | - | if parked.sess == sess && parked.role == attachmentActive { | |
| 316 | - | parked.role = attachmentSnatched | |
| 304 | + | // commitResumeClaim consumes the old credential only after Welcome has been | |
| 305 | + | // written successfully. The claim itself is serialized by d.mu, so a racing | |
| 306 | + | // resume cannot acquire the same parked attachment. | |
| 307 | + | func (d *Daemon) commitResumeClaim(ac *attachedClient) bool { | |
| 308 | + | if d == nil || ac == nil { | |
| 309 | + | return false | |
| 310 | + | } | |
| 311 | + | d.mu.Lock() | |
| 312 | + | defer d.mu.Unlock() | |
| 313 | + | token := ac.resumeClaimToken | |
| 314 | + | parked := d.parked[token] | |
| 315 | + | if token == 0 || parked == nil || parked.ac != ac || !parked.claimed { | |
| 316 | + | return token == 0 | |
| 317 | + | } | |
| 318 | + | delete(d.parked, token) | |
| 319 | + | parked.claimed = false | |
| 320 | + | if parked.timer != nil { | |
| 321 | + | parked.timer.Stop() | |
| 322 | + | } | |
| 323 | + | parked.closeDone() | |
| 324 | + | ac.resumeClaimToken = 0 | |
| 325 | + | return true | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // abortResumeClaim restores a parked credential when the transport could not | |
| 329 | + | // complete the pre-claim Welcome handshake. It invalidates the claimed link | |
| 330 | + | // generation before making the credential available to another winner. | |
| 331 | + | func (d *Daemon) abortResumeClaim(ac *attachedClient) bool { | |
| 332 | + | if d == nil || ac == nil { | |
| 333 | + | return false | |
| 334 | + | } | |
| 335 | + | d.mu.Lock() | |
| 336 | + | token := ac.resumeClaimToken | |
| 337 | + | parked := d.parked[token] | |
| 338 | + | if token == 0 || parked == nil || parked.ac != ac || !parked.claimed { | |
| 339 | + | d.mu.Unlock() | |
| 340 | + | return false | |
| 341 | + | } | |
| 342 | + | parked.claimed = false | |
| 343 | + | ac.resumeClaimToken = 0 | |
| 344 | + | ac.resumeToken = token | |
| 345 | + | ac.parked = true | |
| 346 | + | ac.roleGeneration.Add(1) | |
| 347 | + | sess := parked.sess | |
| 348 | + | if sess != nil { | |
| 349 | + | sess.mu.Lock() | |
| 350 | + | if sess.client == ac { | |
| 351 | + | sess.client = nil | |
| 317 | 352 | } | |
| 353 | + | sess.unregisterAttachmentLocked(ac) | |
| 354 | + | sess.mu.Unlock() | |
| 318 | 355 | } | |
| 356 | + | captured := ac.transportSnapshot().transport | |
| 357 | + | ac.setSession(nil) | |
| 358 | + | d.mu.Unlock() | |
| 359 | + | if captured != nil { | |
| 360 | + | _ = ac.closeCapturedTransport(ac.revokeTransport(captured)) | |
| 361 | + | } | |
| 362 | + | return true | |
| 319 | 363 | } | |
| 320 | 364 | ||
| 321 | 365 | type parkedAttachmentRetirement struct { | |
| @@ -494,19 +538,29 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size) | |||
| 494 | 538 | d.beforeResumeParkedSendMu() | |
| 495 | 539 | } | |
| 496 | 540 | ac.sendMu.Lock() | |
| 497 | - | defer ac.sendMu.Unlock() | |
| 498 | 541 | d.mu.Lock() | |
| 499 | - | defer d.mu.Unlock() | |
| 500 | 542 | if d.closing { | |
| 543 | + | d.mu.Unlock() | |
| 544 | + | ac.sendMu.Unlock() | |
| 501 | 545 | return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |
| 502 | 546 | } | |
| 503 | - | if d.parked[h.ResumeToken] != parked { | |
| 547 | + | if d.parked[h.ResumeToken] != parked || parked.claimed { | |
| 504 | 548 | if d.sessions[parked.sess.id] == parked.sess { | |
| 549 | + | d.mu.Unlock() | |
| 550 | + | ac.sendMu.Unlock() | |
| 505 | 551 | return nil, nil, false, errResumeTokenLifecycleRace | |
| 506 | 552 | } | |
| 553 | + | d.mu.Unlock() | |
| 554 | + | ac.sendMu.Unlock() | |
| 507 | 555 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 508 | 556 | } | |
| 509 | - | return d.resumeParkedLocked(h, tr, sz) | |
| 557 | + | sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz) | |
| 558 | + | d.mu.Unlock() | |
| 559 | + | ac.sendMu.Unlock() | |
| 560 | + | if err != nil && resumed != nil { | |
| 561 | + | d.abortResumeClaim(resumed) | |
| 562 | + | } | |
| 563 | + | return sess, resumed, ok, err | |
| 510 | 564 | } | |
| 511 | 565 | ||
| 512 | 566 | // resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu | |
| @@ -523,13 +577,10 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 523 | 577 | d.log.Warn("resume rejected", "session", sess.name, "registered", false) | |
| 524 | 578 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 525 | 579 | } | |
| 526 | - | sess.mu.Lock() | |
| 527 | - | active := sess.client != nil | |
| 528 | - | sess.mu.Unlock() | |
| 529 | - | if active && parked.role == attachmentActive { | |
| 530 | - | parked.role = attachmentSnatched | |
| 531 | - | } | |
| 532 | 580 | ac := parked.ac | |
| 581 | + | if parked.claimed { | |
| 582 | + | return nil, nil, false, errResumeTokenLifecycleRace | |
| 583 | + | } | |
| 533 | 584 | if ac.clientID != h.ClientID || ac.proxied != h.Proxied { | |
| 534 | 585 | reason := "client id mismatch" | |
| 535 | 586 | if ac.proxied != h.Proxied { | |
| @@ -538,9 +589,11 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 538 | 589 | d.log.Warn("resume rejected", "session", sess.name, "err", reason) | |
| 539 | 590 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 540 | 591 | } | |
| 541 | - | delete(d.parked, h.ResumeToken) | |
| 542 | - | parked.timer.Stop() | |
| 543 | - | parked.closeDone() | |
| 592 | + | // Claim the attachment while retaining the old credential until Welcome is | |
| 593 | + | // accepted. A failed handshake can therefore restore this exact parked | |
| 594 | + | // entry; another resume sees claimed and loses atomically. | |
| 595 | + | parked.claimed = true | |
| 596 | + | ac.resumeClaimToken = h.ResumeToken | |
| 544 | 597 | // The parked attachment has no session owner and cannot paint. Retire the | |
| 545 | 598 | // abandoned transport's output chain before binding the replacement so the | |
| 546 | 599 | // mandatory first paint cannot be blocked by ACKs that died with the link. | |
| @@ -565,14 +618,14 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 565 | 618 | target: sess, | |
| 566 | 619 | next: ac, | |
| 567 | 620 | expectedRole: attachmentDetached, | |
| 568 | - | targetRole: parked.role, | |
| 621 | + | targetRole: attachmentActive, | |
| 569 | 622 | expectedTransport: ac.transportSnapshot(), | |
| 570 | 623 | ready: false, | |
| 571 | 624 | }) | |
| 572 | 625 | ac.sendMu.Lock() | |
| 573 | 626 | d.mu.Lock() | |
| 574 | 627 | if err != nil { | |
| 575 | - | return nil, nil, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"} | |
| 628 | + | return sess, ac, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"} | |
| 576 | 629 | } | |
| 577 | 630 | d.deferAttachmentTransitionCleanups(transition) | |
| 578 | 631 | d.touchMRU(sess) | |
M internal/usecase/daemon/resume_test.go +0 -492
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "strings" | |
| 7 | 7 | "sync" | |
| 8 | - | "sync/atomic" | |
| 9 | 8 | "testing" | |
| 10 | 9 | "time" | |
| 11 | 10 | ||
| @@ -418,70 +417,6 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) { | |||
| 418 | 417 | // reconnect race: IntentResume arrives with the live attachment's token before | |
| 419 | 418 | // transport teardown has parked it. Same client must recover; unknown and | |
| 420 | 419 | // mismatched credentials stay fail-closed. | |
| 421 | - | func TestResumeActiveTokenBeforeParkRecoversSameAttachment(t *testing.T) { | |
| 422 | - | pty, release := newBlockingPTY(t) | |
| 423 | - | defer release() | |
| 424 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 425 | - | ||
| 426 | - | oldTr := &closeTrackingTransport{} | |
| 427 | - | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr) | |
| 428 | - | require.NoError(t, err) | |
| 429 | - | token := ac.resumeToken | |
| 430 | - | require.NotZero(t, token) | |
| 431 | - | require.True(t, ac.resumeCapable) | |
| 432 | - | require.Same(t, ac, sess.client) | |
| 433 | - | d.mu.Lock() | |
| 434 | - | _, parked := d.parked[token] | |
| 435 | - | d.mu.Unlock() | |
| 436 | - | require.False(t, parked, "fixture: live attachment must not be parked yet") | |
| 437 | - | ||
| 438 | - | _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{}) | |
| 439 | - | require.Error(t, err) | |
| 440 | - | var unknownErr *protoErr | |
| 441 | - | require.ErrorAs(t, err, &unknownErr) | |
| 442 | - | require.Equal(t, ports.ErrNoSuchSession, unknownErr.code) | |
| 443 | - | require.Contains(t, unknownErr.Error(), "resume token is no longer valid") | |
| 444 | - | require.Same(t, ac, sess.client) | |
| 445 | - | require.Equal(t, token, ac.resumeToken) | |
| 446 | - | require.Same(t, oldTr, ac.transport()) | |
| 447 | - | ||
| 448 | - | wrongClient := helloResumeCapable(ports.IntentResume, "work", token) | |
| 449 | - | wrongClient.ClientID = [16]byte{9, 9, 9, 9} | |
| 450 | - | _, _, err = d.route(wrongClient, &closeTrackingTransport{}) | |
| 451 | - | require.Error(t, err) | |
| 452 | - | var mismatchErr *protoErr | |
| 453 | - | require.ErrorAs(t, err, &mismatchErr) | |
| 454 | - | require.Equal(t, ports.ErrNoSuchSession, mismatchErr.code) | |
| 455 | - | require.Contains(t, mismatchErr.Error(), "resume token is no longer valid") | |
| 456 | - | require.Same(t, ac, sess.client) | |
| 457 | - | require.Equal(t, token, ac.resumeToken) | |
| 458 | - | require.Same(t, oldTr, ac.transport()) | |
| 459 | - | ||
| 460 | - | newTr := &closeTrackingTransport{} | |
| 461 | - | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | |
| 462 | - | require.NoError(t, err, "same-token same-client IntentResume must recover before parking") | |
| 463 | - | require.Same(t, sess, resumedSess) | |
| 464 | - | require.Same(t, ac, resumedAC) | |
| 465 | - | require.Same(t, ac, sess.client) | |
| 466 | - | require.Same(t, newTr, ac.transport()) | |
| 467 | - | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | |
| 468 | - | d.mu.Lock() | |
| 469 | - | _, oldTokenParked := d.parked[token] | |
| 470 | - | d.mu.Unlock() | |
| 471 | - | require.False(t, oldTokenParked, "consumed live credential must not remain resumeable") | |
| 472 | - | ||
| 473 | - | d.clientGone(sess, ac, oldTr, false) | |
| 474 | - | require.Same(t, ac, sess.client, "retired pre-park transport must not detach the rebound attachment") | |
| 475 | - | require.Same(t, newTr, ac.transport()) | |
| 476 | - | require.False(t, newTr.Closed()) | |
| 477 | - | } | |
| 478 | - | ||
| 479 | - | // TestResumeDuringTeardownBeforeParkRecoversSameAttachment covers the opposite | |
| 480 | - | // interleaving from live-before-park recovery: old-link teardown wins | |
| 481 | - | // detachIfCurrentTransport, then pauses before parkAttachment publishes the | |
| 482 | - | // resume token. The parking-in-flight marker must already be visible in that | |
| 483 | - | // gap (published before detach). Same-client IntentResume must wait it out and | |
| 484 | - | // recover; unknown tokens stay fail-closed. | |
| 485 | 420 | func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |
| 486 | 421 | pty, release := newBlockingPTY(t) | |
| 487 | 422 | defer release() | |
| @@ -685,365 +620,6 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 685 | 620 | require.False(t, winnerTr.Closed()) | |
| 686 | 621 | } | |
| 687 | 622 | ||
| 688 | - | func setupResumableSnatchedAttachment(t *testing.T, d *Daemon) (*session, *attachedClient, *attachedClient, *closeTrackingTransport, uint64) { | |
| 689 | - | t.Helper() | |
| 690 | - | oldTr := &closeTrackingTransport{} | |
| 691 | - | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr) | |
| 692 | - | require.NoError(t, err) | |
| 693 | - | token := waiting.resumeToken | |
| 694 | - | require.NotZero(t, token) | |
| 695 | - | ||
| 696 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 697 | - | require.NoError(t, err) | |
| 698 | - | d.attachmentCleanupWg.Wait() | |
| 699 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 700 | - | require.Same(t, active, sess.client) | |
| 701 | - | return sess, waiting, active, oldTr, token | |
| 702 | - | } | |
| 703 | - | ||
| 704 | - | // TestSnatchedResumeDuringTeardownBeforeParkRecoversSameAttachment covers the | |
| 705 | - | // snatched unroute→park gap: parkSnatchedAttachment publishes the parking | |
| 706 | - | // marker before ownership removal, then pauses before parkAttachmentAs. | |
| 707 | - | // Same-client IntentResume must wait it out and recover as snatched without | |
| 708 | - | // replacing the active owner; unknown/mismatched tokens stay fail-closed. | |
| 709 | - | func TestSnatchedResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |
| 710 | - | pty, release := newBlockingPTY(t) | |
| 711 | - | defer release() | |
| 712 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 713 | - | ||
| 714 | - | sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d) | |
| 715 | - | roleToken := sess.attachmentToken(waiting, oldTr) | |
| 716 | - | ||
| 717 | - | reachedGap := make(chan struct{}) | |
| 718 | - | releaseGap := make(chan struct{}) | |
| 719 | - | var releaseOnce sync.Once | |
| 720 | - | t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) }) | |
| 721 | - | d.afterSnatchedUnrouteBeforePark = func() { | |
| 722 | - | close(reachedGap) | |
| 723 | - | <-releaseGap | |
| 724 | - | } | |
| 725 | - | ||
| 726 | - | parkDone := make(chan bool, 1) | |
| 727 | - | go func() { | |
| 728 | - | parkDone <- d.parkSnatchedAttachment(roleToken) | |
| 729 | - | }() | |
| 730 | - | awaitTestCompletion(t, reachedGap, "snatched park did not pause after unroute before park") | |
| 731 | - | ||
| 732 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting), "fixture: unroute must have cleared snatched ownership") | |
| 733 | - | d.mu.Lock() | |
| 734 | - | parkingInGap := d.parking[token] | |
| 735 | - | _, parkedInGap := d.parked[token] | |
| 736 | - | d.mu.Unlock() | |
| 737 | - | require.NotNil(t, parkingInGap, "fixture: parking marker must precede snatched ownership removal") | |
| 738 | - | require.Same(t, waiting, parkingInGap.ac) | |
| 739 | - | require.Same(t, sess, parkingInGap.sess) | |
| 740 | - | require.False(t, parkedInGap, "fixture: park must not have published yet") | |
| 741 | - | require.Same(t, active, sess.client, "fixture: active owner must remain undisturbed") | |
| 742 | - | ||
| 743 | - | _, _, err := d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{}) | |
| 744 | - | require.Error(t, err) | |
| 745 | - | var unknownErr *protoErr | |
| 746 | - | require.ErrorAs(t, err, &unknownErr) | |
| 747 | - | require.Equal(t, ports.ErrNoSuchSession, unknownErr.code) | |
| 748 | - | require.Contains(t, unknownErr.Error(), "resume token is no longer valid") | |
| 749 | - | ||
| 750 | - | wrongClient := helloResumeCapable(ports.IntentResume, "work", token) | |
| 751 | - | wrongClient.ClientID = [16]byte{9, 9, 9, 9} | |
| 752 | - | _, _, err = d.route(wrongClient, &closeTrackingTransport{}) | |
| 753 | - | require.Error(t, err) | |
| 754 | - | var mismatchErr *protoErr | |
| 755 | - | require.ErrorAs(t, err, &mismatchErr) | |
| 756 | - | require.Equal(t, ports.ErrNoSuchSession, mismatchErr.code) | |
| 757 | - | require.Contains(t, mismatchErr.Error(), "resume token is no longer valid") | |
| 758 | - | ||
| 759 | - | newTr := &closeTrackingTransport{} | |
| 760 | - | type routeResult struct { | |
| 761 | - | sess *session | |
| 762 | - | ac *attachedClient | |
| 763 | - | err error | |
| 764 | - | } | |
| 765 | - | waiterArmed := make(chan struct{}) | |
| 766 | - | d.afterParkingWaitArmed = func() { close(waiterArmed) } | |
| 767 | - | result := make(chan routeResult, 1) | |
| 768 | - | go func() { | |
| 769 | - | routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | |
| 770 | - | result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr} | |
| 771 | - | }() | |
| 772 | - | awaitTestCompletion(t, waiterArmed, "resume waiter did not arm on snatched parking marker before park") | |
| 773 | - | ||
| 774 | - | releaseOnce.Do(func() { close(releaseGap) }) | |
| 775 | - | require.True(t, awaitTestValue(t, parkDone, "snatched park did not finish after release"), "parkSnatchedAttachment must succeed") | |
| 776 | - | got := awaitTestValue(t, result, "IntentResume did not finish after snatched teardown-before-park gap") | |
| 777 | - | ||
| 778 | - | require.NoError(t, got.err, "same-client snatched token must recover across unroute-before-park") | |
| 779 | - | require.Same(t, sess, got.sess) | |
| 780 | - | require.Same(t, waiting, got.ac) | |
| 781 | - | require.Same(t, active, sess.client, "snatched resume must not replace the active owner") | |
| 782 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 783 | - | require.Same(t, newTr, waiting.transport()) | |
| 784 | - | require.NotEqual(t, token, waiting.resumeToken, "successful resume rotates the credential") | |
| 785 | - | require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport") | |
| 786 | - | require.False(t, newTr.Closed(), "rebound transport must survive") | |
| 787 | - | d.mu.Lock() | |
| 788 | - | _, oldTokenParked := d.parked[token] | |
| 789 | - | _, stillParking := d.parking[token] | |
| 790 | - | d.mu.Unlock() | |
| 791 | - | require.False(t, oldTokenParked, "consumed credential must not remain resumeable") | |
| 792 | - | require.False(t, stillParking, "parking marker must be consumed after park/resume") | |
| 793 | - | } | |
| 794 | - | ||
| 795 | - | // TestConcurrentSnatchedResumesWaitParkingMarkerBeforePark covers two same-token | |
| 796 | - | // IntentResume handshakes while parkSnatchedAttachment pauses after unroute and | |
| 797 | - | // before park. The parking marker must already be published so losers wait | |
| 798 | - | // instead of treating the credential as unknown across that gap. | |
| 799 | - | func TestConcurrentSnatchedResumesWaitParkingMarkerBeforePark(t *testing.T) { | |
| 800 | - | pty, release := newBlockingPTY(t) | |
| 801 | - | defer release() | |
| 802 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 803 | - | ||
| 804 | - | sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d) | |
| 805 | - | roleToken := sess.attachmentToken(waiting, oldTr) | |
| 806 | - | ||
| 807 | - | reachedGap := make(chan struct{}) | |
| 808 | - | releaseGap := make(chan struct{}) | |
| 809 | - | var releaseOnce sync.Once | |
| 810 | - | t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) }) | |
| 811 | - | d.afterSnatchedUnrouteBeforePark = func() { | |
| 812 | - | close(reachedGap) | |
| 813 | - | <-releaseGap | |
| 814 | - | } | |
| 815 | - | ||
| 816 | - | parkDone := make(chan bool, 1) | |
| 817 | - | go func() { | |
| 818 | - | parkDone <- d.parkSnatchedAttachment(roleToken) | |
| 819 | - | }() | |
| 820 | - | awaitTestCompletion(t, reachedGap, "snatched park did not pause after unroute before park") | |
| 821 | - | ||
| 822 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting), "fixture: unroute must have cleared snatched ownership") | |
| 823 | - | d.mu.Lock() | |
| 824 | - | parkingInGap := d.parking[token] | |
| 825 | - | _, parkedInGap := d.parked[token] | |
| 826 | - | d.mu.Unlock() | |
| 827 | - | require.NotNil(t, parkingInGap, "fixture: parking marker must precede unroute for concurrent resumes") | |
| 828 | - | require.Same(t, waiting, parkingInGap.ac) | |
| 829 | - | require.False(t, parkedInGap, "fixture: park must not have published yet") | |
| 830 | - | ||
| 831 | - | _, _, err := d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{}) | |
| 832 | - | require.Error(t, err) | |
| 833 | - | var unknownErr *protoErr | |
| 834 | - | require.ErrorAs(t, err, &unknownErr) | |
| 835 | - | require.Equal(t, ports.ErrNoSuchSession, unknownErr.code) | |
| 836 | - | ||
| 837 | - | type routeResult struct { | |
| 838 | - | sess *session | |
| 839 | - | ac *attachedClient | |
| 840 | - | err error | |
| 841 | - | } | |
| 842 | - | firstTr := &closeTrackingTransport{} | |
| 843 | - | secondTr := &closeTrackingTransport{} | |
| 844 | - | bothWaitersArmed := make(chan struct{}) | |
| 845 | - | var armedCount atomic.Int32 | |
| 846 | - | d.afterParkingWaitArmed = func() { | |
| 847 | - | if armedCount.Add(1) == 2 { | |
| 848 | - | close(bothWaitersArmed) | |
| 849 | - | } | |
| 850 | - | } | |
| 851 | - | firstResult := make(chan routeResult, 1) | |
| 852 | - | secondResult := make(chan routeResult, 1) | |
| 853 | - | go func() { | |
| 854 | - | routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), firstTr) | |
| 855 | - | firstResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr} | |
| 856 | - | }() | |
| 857 | - | go func() { | |
| 858 | - | routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), secondTr) | |
| 859 | - | secondResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr} | |
| 860 | - | }() | |
| 861 | - | awaitTestCompletion(t, bothWaitersArmed, "both snatched resume waiters did not arm on parking marker before park") | |
| 862 | - | ||
| 863 | - | releaseOnce.Do(func() { close(releaseGap) }) | |
| 864 | - | require.True(t, awaitTestValue(t, parkDone, "snatched park did not finish after release")) | |
| 865 | - | first := awaitTestValue(t, firstResult, "first concurrent snatched IntentResume did not finish") | |
| 866 | - | second := awaitTestValue(t, secondResult, "second concurrent snatched IntentResume did not finish") | |
| 867 | - | ||
| 868 | - | var winner, loser routeResult | |
| 869 | - | var winnerTr, loserTr *closeTrackingTransport | |
| 870 | - | switch { | |
| 871 | - | case first.err == nil && second.err != nil: | |
| 872 | - | winner, loser = first, second | |
| 873 | - | winnerTr, loserTr = firstTr, secondTr | |
| 874 | - | case second.err == nil && first.err != nil: | |
| 875 | - | winner, loser = second, first | |
| 876 | - | winnerTr, loserTr = secondTr, firstTr | |
| 877 | - | default: | |
| 878 | - | t.Fatalf("expected exactly one concurrent snatched resume to succeed; first=%v second=%v", first.err, second.err) | |
| 879 | - | } | |
| 880 | - | ||
| 881 | - | require.Same(t, sess, winner.sess) | |
| 882 | - | require.Same(t, waiting, winner.ac) | |
| 883 | - | require.Same(t, active, sess.client, "snatched resume must not replace the active owner") | |
| 884 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 885 | - | require.Same(t, winnerTr, waiting.transport()) | |
| 886 | - | require.NotEqual(t, token, waiting.resumeToken, "successful resume rotates the credential") | |
| 887 | - | require.True(t, oldTr.Closed(), "winning resume must retire the old transport") | |
| 888 | - | require.False(t, winnerTr.Closed(), "winning rebound transport must survive") | |
| 889 | - | ||
| 890 | - | var loserErr *protoErr | |
| 891 | - | require.ErrorAs(t, loser.err, &loserErr) | |
| 892 | - | require.Equal(t, ports.ErrNoSuchSession, loserErr.code) | |
| 893 | - | require.Contains(t, loserErr.Error(), "resume token is no longer valid") | |
| 894 | - | require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome") | |
| 895 | - | ||
| 896 | - | d.mu.Lock() | |
| 897 | - | _, oldTokenParked := d.parked[token] | |
| 898 | - | _, stillParking := d.parking[token] | |
| 899 | - | d.mu.Unlock() | |
| 900 | - | require.False(t, oldTokenParked, "consumed credential must not remain resumeable") | |
| 901 | - | require.False(t, stillParking, "parking marker must be consumed after park/resume") | |
| 902 | - | } | |
| 903 | - | ||
| 904 | - | // TestStaleSnatchedParkClearsOnlyAbandonedParkingMarker covers a stale | |
| 905 | - | // incarnation that publishes a parking marker then loses exact unroute. Only | |
| 906 | - | // that attachment's abandoned marker is cleared; another attachment's marker | |
| 907 | - | // and the fresh snatched incarnation remain intact. | |
| 908 | - | func TestStaleSnatchedParkClearsOnlyAbandonedParkingMarker(t *testing.T) { | |
| 909 | - | workPTY, releaseWork := newBlockingPTY(t) | |
| 910 | - | defer releaseWork() | |
| 911 | - | otherPTY, releaseOther := newBlockingPTY(t) | |
| 912 | - | defer releaseOther() | |
| 913 | - | // Separate PTYs so work teardown cannot cascade-close the other session. | |
| 914 | - | d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY), stubClock{}) | |
| 915 | - | ||
| 916 | - | sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d) | |
| 917 | - | stale := sess.attachmentToken(waiting, oldTr) | |
| 918 | - | ||
| 919 | - | otherTr := &closeTrackingTransport{} | |
| 920 | - | otherSess, otherAC, err := d.route(helloResumeCapable(ports.IntentNew, "other", 0), otherTr) | |
| 921 | - | require.NoError(t, err) | |
| 922 | - | otherToken := d.markParkingInFlight(otherSess, otherAC) | |
| 923 | - | require.NotZero(t, otherToken) | |
| 924 | - | require.NotEqual(t, token, otherToken) | |
| 925 | - | ||
| 926 | - | fresh := &closeTrackingTransport{} | |
| 927 | - | waiting.replaceTransport(fresh) | |
| 928 | - | ||
| 929 | - | require.False(t, d.parkSnatchedAttachment(stale)) | |
| 930 | - | d.mu.Lock() | |
| 931 | - | _, parked := d.parked[token] | |
| 932 | - | _, stillParking := d.parking[token] | |
| 933 | - | otherMarker := d.parking[otherToken] | |
| 934 | - | d.mu.Unlock() | |
| 935 | - | require.False(t, parked) | |
| 936 | - | require.False(t, stillParking, "stale unroute must clear only the abandoned same-attachment marker") | |
| 937 | - | require.NotNil(t, otherMarker, "stale unroute must not clear another attachment's marker") | |
| 938 | - | require.Same(t, otherAC, otherMarker.ac) | |
| 939 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 940 | - | require.Same(t, fresh, waiting.transport()) | |
| 941 | - | require.False(t, fresh.Closed()) | |
| 942 | - | require.Same(t, active, sess.client) | |
| 943 | - | require.Equal(t, token, waiting.resumeToken) | |
| 944 | - | } | |
| 945 | - | ||
| 946 | - | // TestTerminalSnatchedRemovalClearsOrphanedParkingMarker covers the race where a | |
| 947 | - | // park path publishes a same-attachment parking-in-flight marker, then terminal | |
| 948 | - | // snatched removal wins before park completes. Only that attachment's marker is | |
| 949 | - | // retired and waiters unblock; other markers stay intact and the stale token | |
| 950 | - | // fails closed. A subsequent legitimate snatched park remains resumable. | |
| 951 | - | func TestTerminalSnatchedRemovalClearsOrphanedParkingMarker(t *testing.T) { | |
| 952 | - | workPTY, releaseWork := newBlockingPTY(t) | |
| 953 | - | defer releaseWork() | |
| 954 | - | otherPTY, releaseOther := newBlockingPTY(t) | |
| 955 | - | defer releaseOther() | |
| 956 | - | legitPTY, releaseLegit := newBlockingPTY(t) | |
| 957 | - | defer releaseLegit() | |
| 958 | - | // Separate PTYs so session teardown cannot cascade across marker fixtures. | |
| 959 | - | d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY, legitPTY), stubClock{}) | |
| 960 | - | ||
| 961 | - | sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d) | |
| 962 | - | roleToken := sess.attachmentToken(waiting, oldTr) | |
| 963 | - | ||
| 964 | - | otherTr := &closeTrackingTransport{} | |
| 965 | - | otherSess, otherAC, err := d.route(helloResumeCapable(ports.IntentNew, "other", 0), otherTr) | |
| 966 | - | require.NoError(t, err) | |
| 967 | - | otherToken := d.markParkingInFlight(otherSess, otherAC) | |
| 968 | - | require.NotZero(t, otherToken) | |
| 969 | - | require.NotEqual(t, token, otherToken) | |
| 970 | - | ||
| 971 | - | // Simulate the losing park path publishing before terminal unroute wins. | |
| 972 | - | require.Equal(t, token, d.markParkingInFlight(sess, waiting)) | |
| 973 | - | d.mu.Lock() | |
| 974 | - | orphaned := d.parking[token] | |
| 975 | - | otherMarker := d.parking[otherToken] | |
| 976 | - | d.mu.Unlock() | |
| 977 | - | require.NotNil(t, orphaned, "fixture: park path must publish the parking marker") | |
| 978 | - | require.Same(t, waiting, orphaned.ac) | |
| 979 | - | require.NotNil(t, otherMarker, "fixture: other attachment marker must remain published") | |
| 980 | - | require.Same(t, otherAC, otherMarker.ac) | |
| 981 | - | ||
| 982 | - | waiterArmed := make(chan struct{}) | |
| 983 | - | d.afterParkingWaitArmed = func() { close(waiterArmed) } | |
| 984 | - | waiterDone := make(chan bool, 1) | |
| 985 | - | go func() { | |
| 986 | - | waiterDone <- d.waitParkingInFlight(helloResumeCapable(ports.IntentResume, "work", token)) | |
| 987 | - | }() | |
| 988 | - | awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned snatched marker") | |
| 989 | - | ||
| 990 | - | require.True(t, d.removeSnatchedAttachment(roleToken), "terminal snatched removal must win exact unroute") | |
| 991 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 992 | - | require.Same(t, active, sess.client, "terminal snatched removal must not disturb the active owner") | |
| 993 | - | ||
| 994 | - | waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after terminal snatched removal") | |
| 995 | - | require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared") | |
| 996 | - | ||
| 997 | - | d.mu.Lock() | |
| 998 | - | _, stillParking := d.parking[token] | |
| 999 | - | otherAfter := d.parking[otherToken] | |
| 1000 | - | _, parked := d.parked[token] | |
| 1001 | - | d.mu.Unlock() | |
| 1002 | - | require.False(t, stillParking, "terminal winner must clear the same attachment's orphaned marker") | |
| 1003 | - | require.False(t, parked, "terminal removal must not publish parked ownership") | |
| 1004 | - | require.NotNil(t, otherAfter, "terminal winner must not clear another attachment's marker") | |
| 1005 | - | require.Same(t, otherAC, otherAfter.ac) | |
| 1006 | - | ||
| 1007 | - | _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{}) | |
| 1008 | - | require.Error(t, err) | |
| 1009 | - | var unknownErr *protoErr | |
| 1010 | - | require.ErrorAs(t, err, &unknownErr) | |
| 1011 | - | require.Equal(t, ports.ErrNoSuchSession, unknownErr.code) | |
| 1012 | - | require.Contains(t, unknownErr.Error(), "resume token is no longer valid") | |
| 1013 | - | ||
| 1014 | - | _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}) | |
| 1015 | - | require.Error(t, err) | |
| 1016 | - | var retiredErr *protoErr | |
| 1017 | - | require.ErrorAs(t, err, &retiredErr) | |
| 1018 | - | require.Equal(t, ports.ErrNoSuchSession, retiredErr.code) | |
| 1019 | - | require.Contains(t, retiredErr.Error(), "resume token is no longer valid") | |
| 1020 | - | ||
| 1021 | - | // A later legitimate snatched park on a fresh attachment must still resume. | |
| 1022 | - | legitTr := &closeTrackingTransport{} | |
| 1023 | - | legitSess, legitWaiting, err := d.route(helloResumeCapable(ports.IntentNew, "legit", 0), legitTr) | |
| 1024 | - | require.NoError(t, err) | |
| 1025 | - | legitToken := legitWaiting.resumeToken | |
| 1026 | - | require.NotZero(t, legitToken) | |
| 1027 | - | _, _, err = d.route(helloResumeCapable(ports.IntentAttach, "legit", 0), &closeTrackingTransport{}) | |
| 1028 | - | require.NoError(t, err) | |
| 1029 | - | d.attachmentCleanupWg.Wait() | |
| 1030 | - | require.Equal(t, attachmentSnatched, legitSess.attachmentRole(legitWaiting)) | |
| 1031 | - | require.True(t, d.parkSnatchedAttachment(legitSess.attachmentToken(legitWaiting, legitTr))) | |
| 1032 | - | ||
| 1033 | - | resumeTr := &closeTrackingTransport{} | |
| 1034 | - | routedSess, routedAC, err := d.route(helloResumeCapable(ports.IntentResume, "legit", legitToken), resumeTr) | |
| 1035 | - | require.NoError(t, err) | |
| 1036 | - | require.Same(t, legitSess, routedSess) | |
| 1037 | - | require.Same(t, legitWaiting, routedAC) | |
| 1038 | - | require.Equal(t, attachmentSnatched, legitSess.attachmentRole(legitWaiting)) | |
| 1039 | - | require.Same(t, resumeTr, legitWaiting.transport()) | |
| 1040 | - | } | |
| 1041 | - | ||
| 1042 | - | // TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker covers the race | |
| 1043 | - | // where non-explicit teardown publishes a parking-in-flight marker, then | |
| 1044 | - | // explicit detach wins the seat. The winner must clear only that attachment's | |
| 1045 | - | // orphaned marker and unblock waiters; other attachments' markers and a later | |
| 1046 | - | // parked lifecycle must stay intact. Unknown tokens stay fail-closed. | |
| 1047 | 623 | func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) { | |
| 1048 | 624 | workPTY, releaseWork := newBlockingPTY(t) | |
| 1049 | 625 | defer releaseWork() | |
| @@ -1249,46 +825,6 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||
| 1249 | 825 | // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection | |
| 1250 | 826 | // when the attachment is no longer the exact active or snatched owner, while | |
| 1251 | 827 | // still advertising for both live roles. | |
| 1252 | - | func TestMarkParkingInFlightRequiresExactLiveOwnership(t *testing.T) { | |
| 1253 | - | pty, release := newBlockingPTY(t) | |
| 1254 | - | defer release() | |
| 1255 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 1256 | - | ||
| 1257 | - | oldTr := &closeTrackingTransport{} | |
| 1258 | - | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr) | |
| 1259 | - | require.NoError(t, err) | |
| 1260 | - | token := waiting.resumeToken | |
| 1261 | - | require.NotZero(t, token) | |
| 1262 | - | ||
| 1263 | - | require.Equal(t, token, d.markParkingInFlight(sess, waiting), "active owner must publish") | |
| 1264 | - | d.clearParkingInFlight(token, waiting) | |
| 1265 | - | ||
| 1266 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 1267 | - | require.NoError(t, err) | |
| 1268 | - | d.attachmentCleanupWg.Wait() | |
| 1269 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 1270 | - | require.Same(t, active, sess.client) | |
| 1271 | - | ||
| 1272 | - | require.Equal(t, token, d.markParkingInFlight(sess, waiting), "snatched owner must publish") | |
| 1273 | - | d.clearParkingInFlight(token, waiting) | |
| 1274 | - | ||
| 1275 | - | roleToken := sess.attachmentToken(waiting, oldTr) | |
| 1276 | - | require.True(t, d.unrouteSnatchedAttachment(roleToken, false)) | |
| 1277 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 1278 | - | require.Zero(t, d.markParkingInFlight(sess, waiting), "detached snatched attachment must not publish") | |
| 1279 | - | ||
| 1280 | - | d.mu.Lock() | |
| 1281 | - | _, stillParking := d.parking[token] | |
| 1282 | - | d.mu.Unlock() | |
| 1283 | - | require.False(t, stillParking) | |
| 1284 | - | ||
| 1285 | - | require.Equal(t, active.resumeToken, d.markParkingInFlight(sess, active), "active owner must still publish after snatched unroute") | |
| 1286 | - | d.clearParkingInFlight(active.resumeToken, active) | |
| 1287 | - | } | |
| 1288 | - | ||
| 1289 | - | // TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment covers | |
| 1290 | - | // the TOCTOU where clientGone passes the old-transport precheck, then a live | |
| 1291 | - | // resume rebinds the same attachment before detach runs. | |
| 1292 | 828 | func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) { | |
| 1293 | 829 | pty, release := newBlockingPTY(t) | |
| 1294 | 830 | defer release() | |
| @@ -1828,34 +1364,6 @@ func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) { | |||
| 1828 | 1364 | require.Contains(t, killedErr.Error(), "resume token is no longer valid") | |
| 1829 | 1365 | } | |
| 1830 | 1366 | ||
| 1831 | - | func TestParkedPredecessorResumesSnatchedWithoutStealingActiveAttachment(t *testing.T) { | |
| 1832 | - | pty, release := newBlockingPTY(t) | |
| 1833 | - | defer release() | |
| 1834 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 1835 | - | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 1836 | - | sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 1837 | - | require.NoError(t, err) | |
| 1838 | - | token := oldAC.resumeToken | |
| 1839 | - | d.clientGone(sess, oldAC, oldAC.transport(), false) | |
| 1840 | - | ||
| 1841 | - | activeTr := &closeTrackingTransport{} | |
| 1842 | - | _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr) | |
| 1843 | - | require.NoError(t, err) | |
| 1844 | - | require.NotSame(t, oldAC, activeAC) | |
| 1845 | - | ||
| 1846 | - | d.mu.Lock() | |
| 1847 | - | parked := d.parked[token] | |
| 1848 | - | d.mu.Unlock() | |
| 1849 | - | require.NotNil(t, parked, "normal attach preserves the parked predecessor") | |
| 1850 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 1851 | - | _, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 1852 | - | require.NoError(t, err) | |
| 1853 | - | require.True(t, ok) | |
| 1854 | - | require.Same(t, oldAC, resumedAC) | |
| 1855 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(resumedAC)) | |
| 1856 | - | require.Same(t, activeAC, sess.client) | |
| 1857 | - | } | |
| 1858 | - | ||
| 1859 | 1367 | func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) { | |
| 1860 | 1368 | p, release := newBlockingPTY(t) | |
| 1861 | 1369 | defer release() | |
D internal/usecase/daemon/role_aware_parking_test.go +0 -482
| @@ -1,482 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "sync" | |
| 6 | - | "testing" | |
| 7 | - | ||
| 8 | - | "github.com/stretchr/testify/require" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/vev/internal/domain" | |
| 11 | - | "github.com/bnema/vev/internal/ports" | |
| 12 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | func TestActiveLinkLossParksActiveAndDetachesCoordinator(t *testing.T) { | |
| 17 | - | pty, release := newBlockingPTY(t) | |
| 18 | - | defer release() | |
| 19 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 20 | - | ||
| 21 | - | transport := &closeTrackingTransport{} | |
| 22 | - | sess, active, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport) | |
| 23 | - | require.NoError(t, err) | |
| 24 | - | token := active.resumeToken | |
| 25 | - | rc := sess.renderCoordinator() | |
| 26 | - | require.NotNil(t, rc.attachmentLease(active)) | |
| 27 | - | ||
| 28 | - | d.clientGone(sess, active, transport, false) | |
| 29 | - | ||
| 30 | - | d.mu.Lock() | |
| 31 | - | parked := d.parked[token] | |
| 32 | - | d.mu.Unlock() | |
| 33 | - | require.NotNil(t, parked) | |
| 34 | - | require.Equal(t, attachmentActive, parked.role) | |
| 35 | - | require.Nil(t, sess.client) | |
| 36 | - | require.Nil(t, rc.attachmentLease(active), "active loss should detach coordinator ownership") | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | func TestSnatchedLinkLossParksWithoutDisturbingActiveOwner(t *testing.T) { | |
| 40 | - | pty, release := newBlockingPTY(t) | |
| 41 | - | defer release() | |
| 42 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 43 | - | ||
| 44 | - | oldTransport := &closeTrackingTransport{} | |
| 45 | - | sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 46 | - | require.NoError(t, err) | |
| 47 | - | oldToken := old.resumeToken | |
| 48 | - | ||
| 49 | - | activeTransport := &closeTrackingTransport{} | |
| 50 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 51 | - | require.NoError(t, err) | |
| 52 | - | d.attachmentCleanupWg.Wait() | |
| 53 | - | lease := sess.renderCoordinator().attachmentLease(active) | |
| 54 | - | require.NotNil(t, lease) | |
| 55 | - | ||
| 56 | - | d.runConnLoop(old) | |
| 57 | - | ||
| 58 | - | d.mu.Lock() | |
| 59 | - | parked := d.parked[oldToken] | |
| 60 | - | d.mu.Unlock() | |
| 61 | - | require.NotNil(t, parked, "resumable snatched attachment should survive link loss") | |
| 62 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 63 | - | require.Same(t, active, sess.client) | |
| 64 | - | require.Same(t, lease, sess.renderCoordinator().attachmentLease(active)) | |
| 65 | - | require.False(t, activeTransport.Closed()) | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | type sendErrorTransport struct { | |
| 69 | - | closeTrackingTransport | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | func (*sendErrorTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 73 | - | ||
| 74 | - | func TestSnatchedPanelSendFailureParksResumableAttachment(t *testing.T) { | |
| 75 | - | pty, release := newBlockingPTY(t) | |
| 76 | - | defer release() | |
| 77 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 78 | - | ||
| 79 | - | failed := &sendErrorTransport{} | |
| 80 | - | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), failed) | |
| 81 | - | require.NoError(t, err) | |
| 82 | - | token := waiting.resumeToken | |
| 83 | - | ||
| 84 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 85 | - | require.NoError(t, err) | |
| 86 | - | d.attachmentCleanupWg.Wait() | |
| 87 | - | ||
| 88 | - | d.mu.Lock() | |
| 89 | - | parked := d.parked[token] | |
| 90 | - | d.mu.Unlock() | |
| 91 | - | require.NotNil(t, parked, "failed snatched panel should park a resumable attachment") | |
| 92 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 93 | - | require.Nil(t, waiting.transport(), "parking should revoke the exact failed transport") | |
| 94 | - | require.True(t, failed.Closed()) | |
| 95 | - | require.Same(t, active, sess.client) | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | func TestStaleSnatchedTransportCannotParkFreshIncarnation(t *testing.T) { | |
| 99 | - | pty, release := newBlockingPTY(t) | |
| 100 | - | defer release() | |
| 101 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 102 | - | ||
| 103 | - | oldTransport := &closeTrackingTransport{} | |
| 104 | - | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 105 | - | require.NoError(t, err) | |
| 106 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 107 | - | require.NoError(t, err) | |
| 108 | - | d.attachmentCleanupWg.Wait() | |
| 109 | - | stale := sess.attachmentToken(waiting, oldTransport) | |
| 110 | - | fresh := &closeTrackingTransport{} | |
| 111 | - | waiting.replaceTransport(fresh) | |
| 112 | - | ||
| 113 | - | require.False(t, d.parkOrDropSnatchedAttachment(stale)) | |
| 114 | - | d.mu.Lock() | |
| 115 | - | _, parked := d.parked[waiting.resumeToken] | |
| 116 | - | d.mu.Unlock() | |
| 117 | - | require.False(t, parked) | |
| 118 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 119 | - | require.Same(t, fresh, waiting.transport()) | |
| 120 | - | require.False(t, fresh.Closed()) | |
| 121 | - | require.Same(t, active, sess.client) | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | func TestSnatchedParkExpiryDoesNotAffectActiveOwner(t *testing.T) { | |
| 125 | - | pty, release := newBlockingPTY(t) | |
| 126 | - | defer release() | |
| 127 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 128 | - | ||
| 129 | - | waitingTransport := &closeTrackingTransport{} | |
| 130 | - | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), waitingTransport) | |
| 131 | - | require.NoError(t, err) | |
| 132 | - | token := waiting.resumeToken | |
| 133 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 134 | - | require.NoError(t, err) | |
| 135 | - | d.attachmentCleanupWg.Wait() | |
| 136 | - | lease := sess.renderCoordinator().attachmentLease(active) | |
| 137 | - | ||
| 138 | - | d.runConnLoop(waiting) | |
| 139 | - | d.mu.Lock() | |
| 140 | - | parked := d.parked[token] | |
| 141 | - | d.mu.Unlock() | |
| 142 | - | require.NotNil(t, parked) | |
| 143 | - | ||
| 144 | - | d.expireParked(token, parked) | |
| 145 | - | ||
| 146 | - | d.mu.Lock() | |
| 147 | - | _, retained := d.parked[token] | |
| 148 | - | d.mu.Unlock() | |
| 149 | - | require.False(t, retained) | |
| 150 | - | require.Same(t, active, sess.client) | |
| 151 | - | require.Same(t, lease, sess.renderCoordinator().attachmentLease(active)) | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | func TestNonzeroResumeTokenOverridesOrdinaryRoutingFallbacks(t *testing.T) { | |
| 155 | - | pty, release := newBlockingPTY(t) | |
| 156 | - | defer release() | |
| 157 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 158 | - | ||
| 159 | - | work, active, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), &closeTrackingTransport{}) | |
| 160 | - | require.NoError(t, err) | |
| 161 | - | const unknownToken = uint64(0xdecafbad) | |
| 162 | - | for _, tc := range []struct { | |
| 163 | - | name string | |
| 164 | - | intent uint8 | |
| 165 | - | target string | |
| 166 | - | }{ | |
| 167 | - | {name: "attach existing", intent: ports.IntentAttach, target: "work"}, | |
| 168 | - | {name: "create unique", intent: ports.IntentNew, target: "fallback"}, | |
| 169 | - | {name: "create ephemeral", intent: ports.IntentEphemeral}, | |
| 170 | - | } { | |
| 171 | - | t.Run(tc.name, func(t *testing.T) { | |
| 172 | - | h := helloResumeCapable(tc.intent, tc.target, unknownToken) | |
| 173 | - | routedSession, routedClient, routeErr := d.route(h, &closeTrackingTransport{}) | |
| 174 | - | require.Error(t, routeErr) | |
| 175 | - | require.Nil(t, routedSession) | |
| 176 | - | require.Nil(t, routedClient) | |
| 177 | - | var protocolErr *protoErr | |
| 178 | - | require.ErrorAs(t, routeErr, &protocolErr) | |
| 179 | - | require.Equal(t, ports.ErrNoSuchSession, protocolErr.code) | |
| 180 | - | require.Same(t, active, work.client) | |
| 181 | - | d.mu.Lock() | |
| 182 | - | require.Len(t, d.sessions, 1) | |
| 183 | - | require.Nil(t, d.findByNameLocked("fallback")) | |
| 184 | - | d.mu.Unlock() | |
| 185 | - | }) | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | _, replacement, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 189 | - | require.NoError(t, err, "a zero token keeps ordinary attach semantics") | |
| 190 | - | require.NotSame(t, active, replacement) | |
| 191 | - | require.Same(t, replacement, work.client) | |
| 192 | - | require.Equal(t, attachmentSnatched, work.attachmentRole(active)) | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | func TestHandleHelloExpiredSnatchedResumeFailsClosedAfterSessionEnds(t *testing.T) { | |
| 196 | - | workPTY, releaseWork := newBlockingPTY(t) | |
| 197 | - | keeperPTY, releaseKeeper := newBlockingPTY(t) | |
| 198 | - | fallbackPTY, releaseFallback := newBlockingPTY(t) | |
| 199 | - | defer releaseWork() | |
| 200 | - | defer releaseKeeper() | |
| 201 | - | defer releaseFallback() | |
| 202 | - | d := newTestDaemon(t, newFactorySeq(t, workPTY, keeperPTY, fallbackPTY), stubClock{}) | |
| 203 | - | ||
| 204 | - | oldTransport := &closeTrackingTransport{} | |
| 205 | - | work, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 206 | - | require.NoError(t, err) | |
| 207 | - | oldToken := old.resumeToken | |
| 208 | - | ||
| 209 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 210 | - | require.NoError(t, err) | |
| 211 | - | d.attachmentCleanupWg.Wait() | |
| 212 | - | require.Equal(t, attachmentSnatched, work.attachmentRole(old)) | |
| 213 | - | require.Same(t, active, work.client) | |
| 214 | - | ||
| 215 | - | d.runConnLoop(old) | |
| 216 | - | d.mu.Lock() | |
| 217 | - | parked := d.parked[oldToken] | |
| 218 | - | d.mu.Unlock() | |
| 219 | - | require.NotNil(t, parked) | |
| 220 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 221 | - | ||
| 222 | - | keeper, _, err := d.route(helloResumeCapable(ports.IntentNew, "keeper", 0), &closeTrackingTransport{}) | |
| 223 | - | require.NoError(t, err) | |
| 224 | - | require.NoError(t, d.killSession(work, ports.ReasonSessionKilled, false)) | |
| 225 | - | ||
| 226 | - | d.mu.Lock() | |
| 227 | - | _, tokenRetained := d.parked[oldToken] | |
| 228 | - | stoppedWork, lifecycleRetained := d.stopped["work"] | |
| 229 | - | d.mu.Unlock() | |
| 230 | - | require.False(t, tokenRetained, "terminal lifecycle teardown must purge the parked token") | |
| 231 | - | require.True(t, lifecycleRetained, "the stopped lifecycle makes attach fallback observable") | |
| 232 | - | ||
| 233 | - | reconnect := &closeTrackingTransport{} | |
| 234 | - | d.handleHello(reconnect, ports.Frame{ | |
| 235 | - | Type: ports.MsgHello, | |
| 236 | - | Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", oldToken)), | |
| 237 | - | }) | |
| 238 | - | ||
| 239 | - | frames := reconnect.Sends() | |
| 240 | - | require.Len(t, frames, 1) | |
| 241 | - | require.Equal(t, ports.MsgError, frames[0].Type) | |
| 242 | - | wireErr, err := ports.UnmarshalErrorMsg(frames[0].Payload) | |
| 243 | - | require.NoError(t, err) | |
| 244 | - | require.Equal(t, ports.ErrNoSuchSession, wireErr.Code) | |
| 245 | - | require.True(t, reconnect.Closed()) | |
| 246 | - | d.mu.Lock() | |
| 247 | - | require.Same(t, keeper, d.findByNameLocked("keeper")) | |
| 248 | - | require.Nil(t, d.findByNameLocked("work"), "expired resume must not recreate the stopped session") | |
| 249 | - | require.Equal(t, stoppedWork, d.stopped["work"]) | |
| 250 | - | require.Len(t, d.sessions, 1) | |
| 251 | - | d.mu.Unlock() | |
| 252 | - | require.Nil(t, work.client, "expired resume must not reclaim the ended lifecycle") | |
| 253 | - | require.Nil(t, active.currentSession()) | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | func TestSnatchedResumeHandshakeSendsWelcomeAndPanelWithoutReplacingOwner(t *testing.T) { | |
| 257 | - | pty, release := newBlockingPTY(t) | |
| 258 | - | defer release() | |
| 259 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 260 | - | ||
| 261 | - | oldTransport := &closeTrackingTransport{} | |
| 262 | - | sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 263 | - | require.NoError(t, err) | |
| 264 | - | token := old.resumeToken | |
| 265 | - | d.clientGone(sess, old, oldTransport, false) | |
| 266 | - | ||
| 267 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 268 | - | require.NoError(t, err) | |
| 269 | - | ||
| 270 | - | resumedTransport := &closeTrackingTransport{} | |
| 271 | - | hello := helloResumeCapable(ports.IntentResume, "work", token) | |
| 272 | - | d.handleHello(resumedTransport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 273 | - | ||
| 274 | - | frames := resumedTransport.Sends() | |
| 275 | - | require.GreaterOrEqual(t, len(frames), 2) | |
| 276 | - | require.Equal(t, ports.MsgWelcome, frames[0].Type) | |
| 277 | - | welcome, err := ports.UnmarshalWelcome(frames[0].Payload) | |
| 278 | - | require.NoError(t, err) | |
| 279 | - | newToken := welcome.ResumeToken | |
| 280 | - | require.NotZero(t, newToken) | |
| 281 | - | require.NotEqual(t, token, newToken, "successful snatched resume rotates the credential") | |
| 282 | - | require.Equal(t, newToken, old.resumeToken, "the Welcome publishes the one rotated credential") | |
| 283 | - | require.Equal(t, ports.MsgOutput, frames[1].Type) | |
| 284 | - | output, err := ports.UnmarshalOutput(frames[1].Payload) | |
| 285 | - | require.NoError(t, err) | |
| 286 | - | require.Contains(t, string(output.Data), "Session snatched") | |
| 287 | - | d.mu.Lock() | |
| 288 | - | _, oldTokenParked := d.parked[token] | |
| 289 | - | parked := d.parked[newToken] | |
| 290 | - | d.mu.Unlock() | |
| 291 | - | require.False(t, oldTokenParked, "the consumed credential cannot be reused") | |
| 292 | - | require.NotNil(t, parked) | |
| 293 | - | require.Equal(t, attachmentSnatched, parked.role, "the rotated credential remains bound to the snatched role") | |
| 294 | - | require.Same(t, active, sess.client) | |
| 295 | - | ||
| 296 | - | replay := &closeTrackingTransport{} | |
| 297 | - | d.handleHello(replay, ports.Frame{ | |
| 298 | - | Type: ports.MsgHello, | |
| 299 | - | Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", token)), | |
| 300 | - | }) | |
| 301 | - | replayFrames := replay.Sends() | |
| 302 | - | require.Len(t, replayFrames, 1) | |
| 303 | - | require.Equal(t, ports.MsgError, replayFrames[0].Type) | |
| 304 | - | replayErr, err := ports.UnmarshalErrorMsg(replayFrames[0].Payload) | |
| 305 | - | require.NoError(t, err) | |
| 306 | - | require.Equal(t, ports.ErrNoSuchSession, replayErr.Code) | |
| 307 | - | require.True(t, replay.Closed()) | |
| 308 | - | require.Same(t, active, sess.client, "replaying the old credential must not replace active ownership") | |
| 309 | - | d.mu.Lock() | |
| 310 | - | require.Same(t, parked, d.parked[newToken]) | |
| 311 | - | d.mu.Unlock() | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | func TestResumeWelcomeRechecksRoleAfterReplacement(t *testing.T) { | |
| 315 | - | pty, release := newBlockingPTY(t) | |
| 316 | - | defer release() | |
| 317 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 318 | - | ||
| 319 | - | oldTransport := &closeTrackingTransport{} | |
| 320 | - | sess, resumed, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 321 | - | require.NoError(t, err) | |
| 322 | - | token := resumed.resumeToken | |
| 323 | - | d.clientGone(sess, resumed, oldTransport, false) | |
| 324 | - | ||
| 325 | - | blocked := newWelcomeBlockingTransport(t) | |
| 326 | - | handshakeDone := make(chan struct{}) | |
| 327 | - | go func() { | |
| 328 | - | hello := helloResumeCapable(ports.IntentResume, "work", token) | |
| 329 | - | d.handleHello(blocked.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 330 | - | close(handshakeDone) | |
| 331 | - | }() | |
| 332 | - | awaitTestCompletion(t, blocked.welcomeEntered, "resume did not start Welcome") | |
| 333 | - | welcome := awaitFrame(t, blocked.sends, ports.MsgWelcome) | |
| 334 | - | require.Equal(t, ports.MsgWelcome, welcome.Type) | |
| 335 | - | requireNoCoordinatorOutputFrame(t, blocked.sends) | |
| 336 | - | resumedLease := sess.renderCoordinator().attachmentLease(resumed) | |
| 337 | - | require.NotNil(t, resumedLease) | |
| 338 | - | require.False(t, resumedLease.ready, "blocked Welcome must keep the resumed lease unready") | |
| 339 | - | ||
| 340 | - | resumeGateFrozen := make(chan struct{}) | |
| 341 | - | var frozenOnce sync.Once | |
| 342 | - | d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) { | |
| 343 | - | if ac == resumed { | |
| 344 | - | frozenOnce.Do(func() { close(resumeGateFrozen) }) | |
| 345 | - | } | |
| 346 | - | } | |
| 347 | - | type attachResult struct { | |
| 348 | - | ac *attachedClient | |
| 349 | - | err error | |
| 350 | - | } | |
| 351 | - | replacementResult := make(chan attachResult, 1) | |
| 352 | - | go func() { | |
| 353 | - | _, replacement, routeErr := d.route( | |
| 354 | - | helloResumeCapable(ports.IntentAttach, "work", 0), | |
| 355 | - | &closeTrackingTransport{}, | |
| 356 | - | ) | |
| 357 | - | replacementResult <- attachResult{ac: replacement, err: routeErr} | |
| 358 | - | }() | |
| 359 | - | awaitTestCompletion(t, resumeGateFrozen, "replacement did not freeze the resumed Welcome role") | |
| 360 | - | ||
| 361 | - | blocked.release() | |
| 362 | - | replacement := awaitTestValue(t, replacementResult, "replacement did not finish after Welcome") | |
| 363 | - | require.NoError(t, replacement.err) | |
| 364 | - | d.attachmentCleanupWg.Wait() | |
| 365 | - | panelFrame := awaitFrame(t, blocked.sends, ports.MsgOutput) | |
| 366 | - | panel, err := ports.UnmarshalOutput(panelFrame.Payload) | |
| 367 | - | require.NoError(t, err) | |
| 368 | - | require.Zero(t, panel.BaseStateNum, "post-Welcome snatched panel must reset output state") | |
| 369 | - | require.Contains(t, string(panel.Data), "Session snatched") | |
| 370 | - | requireNoCoordinatorOutputFrame(t, blocked.sends) | |
| 371 | - | ||
| 372 | - | require.Same(t, replacement.ac, sess.client) | |
| 373 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(resumed)) | |
| 374 | - | rc := sess.renderCoordinator() | |
| 375 | - | rc.mu.Lock() | |
| 376 | - | require.False(t, resumedLease.ready, "replacement during Welcome must not ready the stale active lease") | |
| 377 | - | rc.mu.Unlock() | |
| 378 | - | require.Nil(t, rc.attachmentLease(resumed)) | |
| 379 | - | require.NotNil(t, rc.attachmentLease(replacement.ac)) | |
| 380 | - | ||
| 381 | - | blocked.finish() | |
| 382 | - | awaitTestCompletion(t, handshakeDone, "resumed snatched connection did not continue after replacement") | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | func TestSnatchedResumePreservesOwnerCapabilityAndCanReclaim(t *testing.T) { | |
| 386 | - | pty, release := newBlockingPTY(t) | |
| 387 | - | defer release() | |
| 388 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 389 | - | ||
| 390 | - | oldTransport := &closeTrackingTransport{} | |
| 391 | - | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 392 | - | require.NoError(t, err) | |
| 393 | - | token := waiting.resumeToken | |
| 394 | - | waiting.setClientTheme(themeui.Theme{ | |
| 395 | - | Foreground: renderer.RGB{R: 90, G: 10, B: 10}, | |
| 396 | - | Background: renderer.RGB{R: 20, G: 1, B: 1}, | |
| 397 | - | HasFG: true, HasBG: true, Known: true, | |
| 398 | - | }) | |
| 399 | - | d.clientGone(sess, waiting, oldTransport, false) | |
| 400 | - | ||
| 401 | - | activeTransport := &closeTrackingTransport{} | |
| 402 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 403 | - | require.NoError(t, err) | |
| 404 | - | rc := sess.renderCoordinator() | |
| 405 | - | activeGeneration := active.roleGeneration.Load() | |
| 406 | - | activeLease := rc.attachmentLease(active) | |
| 407 | - | require.NotNil(t, activeLease) | |
| 408 | - | activeTheme := themeui.Theme{ | |
| 409 | - | Foreground: renderer.RGB{R: 10, G: 80, B: 20}, | |
| 410 | - | Background: renderer.RGB{R: 1, G: 15, B: 3}, | |
| 411 | - | HasFG: true, HasBG: true, Known: true, | |
| 412 | - | } | |
| 413 | - | active.setClientTheme(activeTheme) | |
| 414 | - | require.True(t, d.applyHostTheme(sess, active, activeTheme, false)) | |
| 415 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 416 | - | ||
| 417 | - | resumedTransport := &closeTrackingTransport{} | |
| 418 | - | resumedSess, resumed, ok, err := d.resumeParked( | |
| 419 | - | helloResumeCapable(ports.IntentResume, "work", token), | |
| 420 | - | resumedTransport, | |
| 421 | - | domain.Size{Cols: 80, Rows: 24}, | |
| 422 | - | ) | |
| 423 | - | require.NoError(t, err) | |
| 424 | - | require.True(t, ok) | |
| 425 | - | require.Same(t, sess, resumedSess) | |
| 426 | - | require.Same(t, waiting, resumed) | |
| 427 | - | require.Same(t, active, sess.client) | |
| 428 | - | require.Equal(t, activeGeneration, active.roleGeneration.Load()) | |
| 429 | - | require.Same(t, activeLease, rc.attachmentLease(active)) | |
| 430 | - | require.Nil(t, rc.attachmentLease(waiting), "snatched resume must not acquire a render lease") | |
| 431 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 432 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 433 | - | ||
| 434 | - | resumedGeneration := waiting.roleGeneration.Load() | |
| 435 | - | require.False(t, d.handleSnatchedClientFrame(sess.attachmentToken(waiting, resumedTransport), ports.Frame{ | |
| 436 | - | Type: ports.MsgInput, | |
| 437 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 438 | - | })) | |
| 439 | - | d.attachmentCleanupWg.Wait() | |
| 440 | - | ||
| 441 | - | require.Same(t, waiting, sess.client) | |
| 442 | - | require.Equal(t, attachmentActive, sess.attachmentRole(waiting)) | |
| 443 | - | require.Greater(t, waiting.roleGeneration.Load(), resumedGeneration) | |
| 444 | - | require.NotNil(t, rc.attachmentLease(waiting)) | |
| 445 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(active)) | |
| 446 | - | } | |
| 447 | - | ||
| 448 | - | func TestOrdinaryAttachDemotesParkedActiveAndAllowsSnatchedResume(t *testing.T) { | |
| 449 | - | pty, release := newBlockingPTY(t) | |
| 450 | - | defer release() | |
| 451 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 452 | - | ||
| 453 | - | oldTransport := &closeTrackingTransport{} | |
| 454 | - | sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 455 | - | require.NoError(t, err) | |
| 456 | - | token := old.resumeToken | |
| 457 | - | d.clientGone(sess, old, oldTransport, false) | |
| 458 | - | ||
| 459 | - | activeTransport := &closeTrackingTransport{} | |
| 460 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 461 | - | require.NoError(t, err) | |
| 462 | - | ||
| 463 | - | d.mu.Lock() | |
| 464 | - | parked := d.parked[token] | |
| 465 | - | d.mu.Unlock() | |
| 466 | - | require.NotNil(t, parked, "ordinary attach must preserve the predecessor's resume token") | |
| 467 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 468 | - | require.Equal(t, token, old.resumeToken) | |
| 469 | - | ||
| 470 | - | resumedTransport := &closeTrackingTransport{} | |
| 471 | - | resumedSess, resumed, ok, err := d.resumeParked( | |
| 472 | - | helloResumeCapable(ports.IntentResume, "work", token), | |
| 473 | - | resumedTransport, | |
| 474 | - | domain.Size{Cols: 80, Rows: 24}, | |
| 475 | - | ) | |
| 476 | - | require.NoError(t, err) | |
| 477 | - | require.True(t, ok) | |
| 478 | - | require.Same(t, sess, resumedSess) | |
| 479 | - | require.Same(t, old, resumed) | |
| 480 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(old)) | |
| 481 | - | require.Same(t, active, sess.client) | |
| 482 | - | } | |
M internal/usecase/daemon/role_effect_gate_test.go +0 -177
| @@ -577,124 +577,6 @@ func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t | |||
| 577 | 577 | require.False(t, newTransport.Closed()) | |
| 578 | 578 | } | |
| 579 | 579 | ||
| 580 | - | func TestDisplacedBlockedRenderCleanupWaitsForStaleDetachFreeze(t *testing.T) { | |
| 581 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 582 | - | oldTransport := newBlockedRenderReplacementTransport(d, sess) | |
| 583 | - | old.replaceTransport(oldTransport) | |
| 584 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 585 | - | lease := rc.attachmentLease(old) | |
| 586 | - | token := sess.attachmentToken(old, oldTransport) | |
| 587 | - | token.lease = lease | |
| 588 | - | old.publishRoleCapability(token) | |
| 589 | - | old.sendMu.Lock() | |
| 590 | - | old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}} | |
| 591 | - | old.sendMu.Unlock() | |
| 592 | - | ||
| 593 | - | staleDetachFrozen := make(chan struct{}) | |
| 594 | - | releaseStaleDetach := make(chan struct{}) | |
| 595 | - | var releaseOnce sync.Once | |
| 596 | - | t.Cleanup(func() { releaseOnce.Do(func() { close(releaseStaleDetach) }) }) | |
| 597 | - | d.afterDetachRoleEffectsFrozen = func() { | |
| 598 | - | close(staleDetachFrozen) | |
| 599 | - | <-releaseStaleDetach | |
| 600 | - | } | |
| 601 | - | cleanupStarted := make(chan struct{}) | |
| 602 | - | d.afterDisplacedCleanupStarted = func() { close(cleanupStarted) } | |
| 603 | - | ||
| 604 | - | paintDone := make(chan struct{}) | |
| 605 | - | go func() { | |
| 606 | - | d.paint(sess, old, true, lease) | |
| 607 | - | close(paintDone) | |
| 608 | - | }() | |
| 609 | - | awaitTestCompletion(t, oldTransport.sendEntered, "old render did not block in transport send") | |
| 610 | - | ||
| 611 | - | newTransport := &closeTrackingTransport{} | |
| 612 | - | next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size} | |
| 613 | - | next.initOverlays() | |
| 614 | - | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 615 | - | transitionErr := make(chan error, 1) | |
| 616 | - | go func() { | |
| 617 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 618 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 619 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 620 | - | }) | |
| 621 | - | transitionDone <- result | |
| 622 | - | transitionErr <- err | |
| 623 | - | }() | |
| 624 | - | ||
| 625 | - | var result attachmentTransitionResult | |
| 626 | - | select { | |
| 627 | - | case result = <-transitionDone: | |
| 628 | - | require.NoError(t, <-transitionErr) | |
| 629 | - | case <-time.After(time.Second): | |
| 630 | - | t.Fatal("replacement did not interrupt the blocked render") | |
| 631 | - | } | |
| 632 | - | awaitTestCompletion(t, paintDone, "interrupted render did not retire") | |
| 633 | - | awaitTestCompletion(t, staleDetachFrozen, "stale send-error detach did not freeze the role gate") | |
| 634 | - | ||
| 635 | - | d.deferAttachmentTransitionCleanups(result) | |
| 636 | - | awaitTestCompletion(t, cleanupStarted, "displaced cleanup did not start during stale detach freeze") | |
| 637 | - | releaseOnce.Do(func() { close(releaseStaleDetach) }) | |
| 638 | - | select { | |
| 639 | - | case <-waitGroupDone(&d.attachmentCleanupWg): | |
| 640 | - | case <-time.After(time.Second): | |
| 641 | - | t.Fatal("displaced cleanup deadlocked behind stale detach") | |
| 642 | - | } | |
| 643 | - | ||
| 644 | - | sess.mu.Lock() | |
| 645 | - | require.Same(t, next, sess.client) | |
| 646 | - | require.NotContains(t, sess.snatched, old, "closed displaced incarnation remained registered") | |
| 647 | - | sess.mu.Unlock() | |
| 648 | - | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 649 | - | require.Nil(t, old.transport(), "closed displaced transport remained attached") | |
| 650 | - | require.Nil(t, old.currentSession(), "terminal displaced cleanup retained session ownership") | |
| 651 | - | old.sendMu.Lock() | |
| 652 | - | require.Empty(t, old.captureFrames, "terminal displaced cleanup retained captures") | |
| 653 | - | old.sendMu.Unlock() | |
| 654 | - | require.False(t, newTransport.Closed(), "stale cleanup affected the new active owner") | |
| 655 | - | } | |
| 656 | - | ||
| 657 | - | func TestInterruptedDisplacedCleanupRejectsNewerReclaimedIncarnation(t *testing.T) { | |
| 658 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 659 | - | staleTransport := &closeTrackingTransport{} | |
| 660 | - | old.replaceTransport(staleTransport) | |
| 661 | - | d.attachCoordinator(sess, nil, old, true) | |
| 662 | - | ||
| 663 | - | nextTransport := &closeTrackingTransport{} | |
| 664 | - | next := &attachedClient{tr: nextTransport, output: newOutputStateStream(), size: old.size} | |
| 665 | - | next.initOverlays() | |
| 666 | - | displaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 667 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 668 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 669 | - | }) | |
| 670 | - | require.NoError(t, err) | |
| 671 | - | stale := displaced.displaced | |
| 672 | - | ||
| 673 | - | _, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 674 | - | target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 675 | - | expectedTransport: old.transportSnapshot(), ready: true, | |
| 676 | - | }) | |
| 677 | - | require.NoError(t, err) | |
| 678 | - | freshTransport := &closeTrackingTransport{} | |
| 679 | - | old.replaceTransport(freshTransport) | |
| 680 | - | _, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 681 | - | target: sess, next: old, expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 682 | - | expectedTransport: old.transportSnapshot(), ready: true, | |
| 683 | - | }) | |
| 684 | - | require.NoError(t, err) | |
| 685 | - | require.NoError(t, staleTransport.Close()) | |
| 686 | - | ||
| 687 | - | require.False(t, d.cleanupInterruptedSnatchedAttachment(stale)) | |
| 688 | - | sess.mu.Lock() | |
| 689 | - | require.Same(t, old, sess.client) | |
| 690 | - | require.Contains(t, sess.snatched, next) | |
| 691 | - | sess.mu.Unlock() | |
| 692 | - | require.Equal(t, attachmentActive, sess.attachmentRole(old)) | |
| 693 | - | require.Same(t, freshTransport, old.transport()) | |
| 694 | - | require.False(t, freshTransport.Closed(), "stale terminal cleanup closed the newer transport") | |
| 695 | - | require.False(t, nextTransport.Closed(), "stale terminal cleanup affected the newer displaced owner") | |
| 696 | - | } | |
| 697 | - | ||
| 698 | 580 | func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) { | |
| 699 | 581 | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 700 | 582 | oldTransport := newDatagramTestTransport() | |
| @@ -1193,65 +1075,6 @@ func TestDelayedKeyCallbackAfterReplacementDropsStaleWork(t *testing.T) { | |||
| 1193 | 1075 | } | |
| 1194 | 1076 | } | |
| 1195 | 1077 | ||
| 1196 | - | func TestRoleEffectGateAdmittedSnatchedPanelFinishesBeforePromotion(t *testing.T) { | |
| 1197 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1198 | - | oldTransport := newDatagramTestTransport() | |
| 1199 | - | old.replaceTransport(oldTransport) | |
| 1200 | - | d.attachCoordinator(sess, nil, old, true) | |
| 1201 | - | ||
| 1202 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1203 | - | next.initOverlays() | |
| 1204 | - | replaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1205 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1206 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1207 | - | }) | |
| 1208 | - | require.NoError(t, err) | |
| 1209 | - | token := replaced.displaced | |
| 1210 | - | ||
| 1211 | - | admitted := make(chan struct{}) | |
| 1212 | - | releaseGate := make(chan struct{}) | |
| 1213 | - | release := releaseTestGate(t, releaseGate) | |
| 1214 | - | afterFrozen := make(chan struct{}) | |
| 1215 | - | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 1216 | - | close(admitted) | |
| 1217 | - | <-releaseGate | |
| 1218 | - | } | |
| 1219 | - | d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) { | |
| 1220 | - | if action == "" && ac == old { | |
| 1221 | - | close(afterFrozen) | |
| 1222 | - | } | |
| 1223 | - | } | |
| 1224 | - | effectDone := make(chan struct{}) | |
| 1225 | - | wantSize := domain.Size{Cols: 100, Rows: 30} | |
| 1226 | - | go func() { | |
| 1227 | - | d.handleSnatchedClientFrame(token, ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: wantSize})}) | |
| 1228 | - | close(effectDone) | |
| 1229 | - | }() | |
| 1230 | - | <-admitted | |
| 1231 | - | ||
| 1232 | - | transitionDone := make(chan error, 1) | |
| 1233 | - | go func() { | |
| 1234 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1235 | - | target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 1236 | - | expectedTransport: old.transportSnapshot(), ready: true, | |
| 1237 | - | }) | |
| 1238 | - | transitionDone <- err | |
| 1239 | - | }() | |
| 1240 | - | <-afterFrozen | |
| 1241 | - | select { | |
| 1242 | - | case err := <-transitionDone: | |
| 1243 | - | t.Fatalf("promotion overtook admitted snatched panel: %v", err) | |
| 1244 | - | default: | |
| 1245 | - | } | |
| 1246 | - | release() | |
| 1247 | - | <-effectDone | |
| 1248 | - | require.NoError(t, <-transitionDone) | |
| 1249 | - | old.sendMu.Lock() | |
| 1250 | - | gotSize := old.size | |
| 1251 | - | old.sendMu.Unlock() | |
| 1252 | - | require.Equal(t, wantSize, gotSize) | |
| 1253 | - | } | |
| 1254 | - | ||
| 1255 | 1078 | func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) { | |
| 1256 | 1079 | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1257 | 1080 | oldTransport := newDatagramTestTransport() | |
M internal/usecase/daemon/session.go +45 -6
| @@ -744,13 +744,22 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp | |||
| 744 | 744 | } | |
| 745 | 745 | d.notices.routingMu.Lock() | |
| 746 | 746 | sess.mu.Lock() | |
| 747 | - | current := sess.client == ac && ac.currentSession() == sess | |
| 747 | + | _, registered := sess.attachments[ac] | |
| 748 | + | current := (registered || sess.client == ac) && ac.currentSession() == sess | |
| 748 | 749 | if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) { | |
| 749 | 750 | current = false | |
| 750 | 751 | } | |
| 751 | 752 | if current { | |
| 752 | - | sess.client = nil | |
| 753 | + | if sess.client == ac { | |
| 754 | + | sess.client = nil | |
| 755 | + | } | |
| 753 | 756 | sess.unregisterAttachmentLocked(ac) | |
| 757 | + | if sess.client == nil { | |
| 758 | + | for candidate := range sess.attachments { | |
| 759 | + | sess.client = candidate | |
| 760 | + | break | |
| 761 | + | } | |
| 762 | + | } | |
| 754 | 763 | ac.roleGeneration.Add(1) | |
| 755 | 764 | ac.setSession(nil) | |
| 756 | 765 | ac.invalidateFrozenRoleCapability() | |
| @@ -805,6 +814,18 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||
| 805 | 814 | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil { | |
| 806 | 815 | return false | |
| 807 | 816 | } | |
| 817 | + | // Attachments without the session's optional primary render lease still | |
| 818 | + | // own an independent connection lifecycle and detach through the same exact | |
| 819 | + | // transport/generation fence. | |
| 820 | + | if token.lease == nil { | |
| 821 | + | if sess, ok := localSession(token.sess); ok { | |
| 822 | + | return d.detachIfCurrentTransport(sess, token.ac, token.transport) | |
| 823 | + | } | |
| 824 | + | if proxy, ok := token.sess.(*proxySession); ok { | |
| 825 | + | return d.detachProxyIfCurrentTransport(proxy, token.ac, token.transport) | |
| 826 | + | } | |
| 827 | + | return false | |
| 828 | + | } | |
| 808 | 829 | frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{done: done}, token.ac) | |
| 809 | 830 | defer frozen.unfreeze() | |
| 810 | 831 | if !frozen.acquired || !frozen.drained { | |
| @@ -1170,6 +1191,7 @@ type sessionKillAdmission struct { | |||
| 1170 | 1191 | ||
| 1171 | 1192 | type sessionKillParticipants struct { | |
| 1172 | 1193 | active *attachedClient | |
| 1194 | + | attachments map[*attachedClient]struct{} | |
| 1173 | 1195 | snatched map[*attachedClient]struct{} | |
| 1174 | 1196 | transports map[*attachedClient]transportSnapshot | |
| 1175 | 1197 | roleGates []*attachedClient | |
| @@ -1214,8 +1236,13 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||
| 1214 | 1236 | } | |
| 1215 | 1237 | unlockSessions := lockAttachmentSessions(source, target) | |
| 1216 | 1238 | snapshot.active = target.client | |
| 1239 | + | snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments)) | |
| 1217 | 1240 | snapshot.snatched = make(map[*attachedClient]struct{}, len(target.snatched)) | |
| 1218 | - | snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.snatched)) | |
| 1241 | + | snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.attachments)+len(target.snatched)) | |
| 1242 | + | for ac := range target.attachments { | |
| 1243 | + | snapshot.attachments[ac] = struct{}{} | |
| 1244 | + | snapshot.transports[ac] = ac.transportSnapshot() | |
| 1245 | + | } | |
| 1219 | 1246 | if snapshot.active != nil { | |
| 1220 | 1247 | snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot() | |
| 1221 | 1248 | } | |
| @@ -1227,7 +1254,7 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||
| 1227 | 1254 | d.notices.routingMu.Unlock() | |
| 1228 | 1255 | d.mu.Unlock() | |
| 1229 | 1256 | ||
| 1230 | - | seen := make(map[*attachedClient]struct{}, 2+len(snapshot.snatched)) | |
| 1257 | + | seen := make(map[*attachedClient]struct{}, 2+len(snapshot.attachments)+len(snapshot.snatched)) | |
| 1231 | 1258 | add := func(ac *attachedClient) { | |
| 1232 | 1259 | if ac == nil { | |
| 1233 | 1260 | return | |
| @@ -1242,6 +1269,9 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||
| 1242 | 1269 | add(admission.token.ac) | |
| 1243 | 1270 | } | |
| 1244 | 1271 | add(snapshot.active) | |
| 1272 | + | for ac := range snapshot.attachments { | |
| 1273 | + | add(ac) | |
| 1274 | + | } | |
| 1245 | 1275 | for ac := range snapshot.snatched { | |
| 1246 | 1276 | add(ac) | |
| 1247 | 1277 | } | |
| @@ -1281,9 +1311,14 @@ func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants | |||
| 1281 | 1311 | // session and coordinator locks. | |
| 1282 | 1312 | func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool { | |
| 1283 | 1313 | target := snapshot.target | |
| 1284 | - | if target.client != snapshot.active || len(target.snatched) != len(snapshot.snatched) { | |
| 1314 | + | if target.client != snapshot.active || len(target.attachments) != len(snapshot.attachments) || len(target.snatched) != len(snapshot.snatched) { | |
| 1285 | 1315 | return false | |
| 1286 | 1316 | } | |
| 1317 | + | for ac := range snapshot.attachments { | |
| 1318 | + | if _, current := target.attachments[ac]; !current { | |
| 1319 | + | return false | |
| 1320 | + | } | |
| 1321 | + | } | |
| 1287 | 1322 | for ac := range snapshot.snatched { | |
| 1288 | 1323 | if _, current := target.snatched[ac]; !current { | |
| 1289 | 1324 | return false | |
| @@ -1593,11 +1628,15 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1593 | 1628 | attached.setSession(nil) | |
| 1594 | 1629 | attached.invalidateFrozenRoleCapability() | |
| 1595 | 1630 | } | |
| 1596 | - | capture(sess.client) | |
| 1631 | + | for attached := range sess.attachments { | |
| 1632 | + | capture(attached) | |
| 1633 | + | } | |
| 1597 | 1634 | for snatched := range sess.snatched { | |
| 1598 | 1635 | capture(snatched) | |
| 1599 | 1636 | } | |
| 1637 | + | capture(sess.client) | |
| 1600 | 1638 | sess.client = nil | |
| 1639 | + | clear(sess.attachments) | |
| 1601 | 1640 | clear(sess.snatched) | |
| 1602 | 1641 | stoppedName := sess.name | |
| 1603 | 1642 | stoppedCwd := sess.cwd | |
D internal/usecase/daemon/snatched_cleanup.go +0 -224
| @@ -1,224 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import "github.com/bnema/vev/internal/ports" | |
| 4 | - | ||
| 5 | - | // removeSnatchedAttachment removes exactly one role generation and transport | |
| 6 | - | // from session routing. It never touches the active attachment or closes a | |
| 7 | - | // transport, allowing callers that already interrupted a blocked send to avoid | |
| 8 | - | // closing the same link twice. | |
| 9 | - | func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool { | |
| 10 | - | return d.unrouteSnatchedAttachment(token, true) | |
| 11 | - | } | |
| 12 | - | ||
| 13 | - | func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool { | |
| 14 | - | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 15 | - | return false | |
| 16 | - | } | |
| 17 | - | frozen := freezeRoleEffectGates(token.ac) | |
| 18 | - | defer frozen.unfreeze() | |
| 19 | - | d.notices.routingMu.Lock() | |
| 20 | - | core := token.sess.core() | |
| 21 | - | core.mu.Lock() | |
| 22 | - | current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched && | |
| 23 | - | token.ac.roleGeneration.Load() == token.generation && | |
| 24 | - | token.ac.transportSnapshotCurrent(token.transport) | |
| 25 | - | if current { | |
| 26 | - | delete(core.snatched, token.ac) | |
| 27 | - | if terminal { | |
| 28 | - | core.unregisterAttachmentLocked(token.ac) | |
| 29 | - | } | |
| 30 | - | token.ac.roleGeneration.Add(1) | |
| 31 | - | token.ac.invalidateFrozenRoleCapability() | |
| 32 | - | } | |
| 33 | - | core.mu.Unlock() | |
| 34 | - | d.notices.routingMu.Unlock() | |
| 35 | - | if !current { | |
| 36 | - | return false | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | token.ac.clearSnatchedInput() | |
| 40 | - | d.unregisterPreview(token.ac) | |
| 41 | - | if terminal { | |
| 42 | - | token.ac.clearPreviousSession() | |
| 43 | - | } | |
| 44 | - | token.ac.setSession(nil) | |
| 45 | - | token.ac.clearCaptureFrames() | |
| 46 | - | if terminal { | |
| 47 | - | // A losing park path may have published a same-attachment marker before | |
| 48 | - | // this terminal unroute won. Retire only that attachment's marker so | |
| 49 | - | // waiters fail closed instead of hanging; park paths (terminal=false) | |
| 50 | - | // keep the marker until parkAttachmentAs or abandonment cleanup. | |
| 51 | - | d.clearParkingInFlight(token.ac.resumeToken, token.ac) | |
| 52 | - | } | |
| 53 | - | return true | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // parkSnatchedAttachment removes only the exact lost snatched incarnation and | |
| 57 | - | // retains its resume identity for a role-preserving reconnect. | |
| 58 | - | func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool { | |
| 59 | - | sess, ok := localSession(token.sess) | |
| 60 | - | if !ok || token.ac == nil || !token.ac.resumeCapable { | |
| 61 | - | return false | |
| 62 | - | } | |
| 63 | - | // Advertise parking before unroute so IntentResume never observes both the | |
| 64 | - | // snatched seat and parking/parked registries empty for a still-valid token. | |
| 65 | - | parkingToken := d.markParkingInFlight(sess, token.ac) | |
| 66 | - | if !d.unrouteSnatchedAttachment(token, false) { | |
| 67 | - | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) | |
| 68 | - | return false | |
| 69 | - | } | |
| 70 | - | if d.afterSnatchedUnrouteBeforePark != nil { | |
| 71 | - | d.afterSnatchedUnrouteBeforePark() | |
| 72 | - | } | |
| 73 | - | if d.parkAttachmentAs(sess, token.ac, attachmentSnatched) { | |
| 74 | - | captured := token.transport.transport | |
| 75 | - | _ = token.ac.revokeTransport(captured) | |
| 76 | - | _ = token.ac.closeCapturedTransport(captured) | |
| 77 | - | return true | |
| 78 | - | } | |
| 79 | - | d.clearParkingInFlight(parkingToken, token.ac) | |
| 80 | - | token.ac.clearPreviousSession() | |
| 81 | - | captured := token.transport.transport | |
| 82 | - | _ = token.ac.revokeTransport(captured) | |
| 83 | - | _ = token.ac.closeCapturedTransport(captured) | |
| 84 | - | return false | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool { | |
| 88 | - | if d.parkSnatchedAttachment(token) { | |
| 89 | - | return true | |
| 90 | - | } | |
| 91 | - | return d.dropSnatchedAttachment(token) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced | |
| 95 | - | // transport that was closed to drain an admitted send. It waits on the gate's | |
| 96 | - | // condition rather than ordinary effect admission, then removes only the exact | |
| 97 | - | // published snatched incarnation. Attachment-local cleanup runs without routing | |
| 98 | - | // or session locks while the terminal capability remains frozen. | |
| 99 | - | func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool { | |
| 100 | - | sess, ok := localSession(token.sess) | |
| 101 | - | if !ok || token.ac == nil || token.transport.transport == nil { | |
| 102 | - | return false | |
| 103 | - | } | |
| 104 | - | frozen := freezeRoleEffectGates(token.ac) | |
| 105 | - | defer frozen.unfreeze() | |
| 106 | - | ||
| 107 | - | d.notices.routingMu.Lock() | |
| 108 | - | sess.mu.Lock() | |
| 109 | - | current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched && | |
| 110 | - | token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 111 | - | token.ac.transportSnapshotCurrent(token.transport) | |
| 112 | - | sess.mu.Unlock() | |
| 113 | - | d.notices.routingMu.Unlock() | |
| 114 | - | if !current { | |
| 115 | - | return false | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | // Advertise parking before ownership removal so IntentResume can wait out | |
| 119 | - | // the unroute→park gap for a still-valid snatched credential. | |
| 120 | - | var parkingToken uint64 | |
| 121 | - | if token.ac.resumeCapable { | |
| 122 | - | parkingToken = d.markParkingInFlight(sess, token.ac) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // The frozen gate prevents a promotion from publishing between overlay | |
| 126 | - | // cleanup and terminal registry publication. | |
| 127 | - | d.clearForSnatch(token) | |
| 128 | - | ||
| 129 | - | d.notices.routingMu.Lock() | |
| 130 | - | sess.mu.Lock() | |
| 131 | - | current = attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched && | |
| 132 | - | token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 133 | - | token.ac.transportSnapshotCurrent(token.transport) | |
| 134 | - | if current { | |
| 135 | - | delete(sess.snatched, token.ac) | |
| 136 | - | token.ac.roleGeneration.Add(1) | |
| 137 | - | token.ac.invalidateFrozenRoleCapability() | |
| 138 | - | } | |
| 139 | - | sess.mu.Unlock() | |
| 140 | - | d.notices.routingMu.Unlock() | |
| 141 | - | if !current { | |
| 142 | - | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) | |
| 143 | - | return false | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | token.ac.setSession(nil) | |
| 147 | - | if d.afterSnatchedUnrouteBeforePark != nil { | |
| 148 | - | d.afterSnatchedUnrouteBeforePark() | |
| 149 | - | } | |
| 150 | - | if token.ac.resumeCapable && d.parkAttachmentAs(sess, token.ac, attachmentSnatched) { | |
| 151 | - | captured := token.transport.transport | |
| 152 | - | _ = token.ac.revokeTransport(captured) | |
| 153 | - | _ = token.ac.closeCapturedTransport(captured) | |
| 154 | - | token.ac.clearSnatchedInput() | |
| 155 | - | d.unregisterPreview(token.ac) | |
| 156 | - | return true | |
| 157 | - | } | |
| 158 | - | d.clearParkingInFlight(parkingToken, token.ac) | |
| 159 | - | captured := token.transport.transport | |
| 160 | - | _ = token.ac.revokeTransport(captured) | |
| 161 | - | _ = token.ac.closeCapturedTransport(captured) | |
| 162 | - | token.ac.clearSnatchedInput() | |
| 163 | - | d.unregisterPreview(token.ac) | |
| 164 | - | token.ac.clearPreviousSession() | |
| 165 | - | token.ac.clearCaptureFrames() | |
| 166 | - | return true | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | // dropSnatchedAttachment removes and closes only the captured snatched link. | |
| 170 | - | func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool { | |
| 171 | - | if !d.removeSnatchedAttachment(token) { | |
| 172 | - | return false | |
| 173 | - | } | |
| 174 | - | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 175 | - | return true | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | // deferAttachmentTransitionCleanups retires coordinator timers and accounting | |
| 179 | - | // after ownership publication. Cleanup never gates the attaching handshake. A | |
| 180 | - | // displaced render that already owns sendMu is interrupted by closing only its | |
| 181 | - | // captured transport; an idle healthy snatched transport remains connected and | |
| 182 | - | // receives a dependency-free reset panel. | |
| 183 | - | func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) { | |
| 184 | - | if terminal := result.terminalDisplaced; terminal.ac != nil && terminal.transport.transport != nil { | |
| 185 | - | terminal.ac.clearSnatchedInput() | |
| 186 | - | d.unregisterPreview(terminal.ac) | |
| 187 | - | terminal.ac.clearPreviousSession() | |
| 188 | - | terminal.ac.clearCaptureFrames() | |
| 189 | - | d.notifyDetachedSnapshotAsync(terminal, ports.ReasonReplaced) | |
| 190 | - | } | |
| 191 | - | if token := result.displaced; token.ac != nil && token.transport.transport != nil { | |
| 192 | - | blockedRender := result.displacedInterrupted | |
| 193 | - | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) { | |
| 194 | - | blockedRender = !token.ac.sendMu.TryLock() | |
| 195 | - | if !blockedRender { | |
| 196 | - | token.ac.sendMu.Unlock() | |
| 197 | - | } | |
| 198 | - | } | |
| 199 | - | d.attachmentCleanupWg.Go(func() { | |
| 200 | - | if d.afterDisplacedCleanupStarted != nil { | |
| 201 | - | d.afterDisplacedCleanupStarted() | |
| 202 | - | } | |
| 203 | - | if blockedRender { | |
| 204 | - | // This path owns terminal cleanup independently of ordinary role | |
| 205 | - | // admission: a stale send-error detach may transiently freeze the gate. | |
| 206 | - | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 207 | - | d.cleanupInterruptedSnatchedAttachment(token) | |
| 208 | - | return | |
| 209 | - | } | |
| 210 | - | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 211 | - | if !admitted { | |
| 212 | - | return | |
| 213 | - | } | |
| 214 | - | defer ticket.End() | |
| 215 | - | if err := d.sendInitialSnatchedPanel(token, ticket); err != nil { | |
| 216 | - | ticket.End() | |
| 217 | - | d.parkOrDropSnatchedAttachment(token) | |
| 218 | - | } | |
| 219 | - | }) | |
| 220 | - | } | |
| 221 | - | for _, cleanup := range result.cleanups { | |
| 222 | - | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 223 | - | } | |
| 224 | - | } | |
D internal/usecase/daemon/snatched_client.go +0 -107
| @@ -1,107 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/ports" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | type initialSnatchedPanelAttempt struct { | |
| 10 | - | done chan struct{} | |
| 11 | - | err error | |
| 12 | - | } | |
| 13 | - | ||
| 14 | - | func (ac *attachedClient) claimInitialSnatchedPanel(generation uint64) (*initialSnatchedPanelAttempt, bool) { | |
| 15 | - | ac.initialSnatchedMu.Lock() | |
| 16 | - | defer ac.initialSnatchedMu.Unlock() | |
| 17 | - | if generation < ac.initialSnatchedGeneration { | |
| 18 | - | return nil, false | |
| 19 | - | } | |
| 20 | - | if generation == ac.initialSnatchedGeneration && ac.initialSnatchedAttempt != nil { | |
| 21 | - | return ac.initialSnatchedAttempt, false | |
| 22 | - | } | |
| 23 | - | attempt := &initialSnatchedPanelAttempt{done: make(chan struct{})} | |
| 24 | - | ac.initialSnatchedGeneration = generation | |
| 25 | - | ac.initialSnatchedAttempt = attempt | |
| 26 | - | return attempt, true | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func (ac *attachedClient) initialSnatchedPanelClaimed(generation uint64) bool { | |
| 30 | - | ac.initialSnatchedMu.Lock() | |
| 31 | - | defer ac.initialSnatchedMu.Unlock() | |
| 32 | - | return ac.initialSnatchedGeneration == generation && ac.initialSnatchedAttempt != nil | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | func (ac *attachedClient) completeInitialSnatchedPanel(attempt *initialSnatchedPanelAttempt, err error) { | |
| 36 | - | ac.initialSnatchedMu.Lock() | |
| 37 | - | attempt.err = err | |
| 38 | - | close(attempt.done) | |
| 39 | - | ac.initialSnatchedMu.Unlock() | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | func (d *Daemon) sendInitialSnatchedPanel(token attachmentRoleToken, ticket *roleEffectTicket) error { | |
| 43 | - | attempt, owner := token.ac.claimInitialSnatchedPanel(token.generation) | |
| 44 | - | if attempt == nil { | |
| 45 | - | return errSnatchedOutputStale | |
| 46 | - | } | |
| 47 | - | if !owner { | |
| 48 | - | <-attempt.done | |
| 49 | - | return attempt.err | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | if !token.current() || !d.clearForSnatch(token) || !d.clearCaptureFramesForSnatch(token) { | |
| 53 | - | token.ac.completeInitialSnatchedPanel(attempt, errSnatchedOutputStale) | |
| 54 | - | return errSnatchedOutputStale | |
| 55 | - | } | |
| 56 | - | err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "", ticket) | |
| 57 | - | token.ac.completeInitialSnatchedPanel(attempt, err) | |
| 58 | - | return err | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool { | |
| 62 | - | ac := token.ac | |
| 63 | - | ac.sendMu.Lock() | |
| 64 | - | defer ac.sendMu.Unlock() | |
| 65 | - | if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) { | |
| 66 | - | return false | |
| 67 | - | } | |
| 68 | - | ac.captureFrames = nil | |
| 69 | - | return true | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | func (d *Daemon) sendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error { | |
| 73 | - | ac := token.ac | |
| 74 | - | ac.sendMu.Lock() | |
| 75 | - | defer ac.sendMu.Unlock() | |
| 76 | - | if ac.roleGeneration.Load() != token.generation { | |
| 77 | - | return errSnatchedOutputStale | |
| 78 | - | } | |
| 79 | - | if !ac.transportSnapshotCurrent(token.transport) { | |
| 80 | - | return errTransportReplaced | |
| 81 | - | } | |
| 82 | - | if token.effect == nil || !token.effect.beginTransportSend(token.transport) { | |
| 83 | - | return errAttachmentTransition | |
| 84 | - | } | |
| 85 | - | err := token.transport.transport.Send(frame) | |
| 86 | - | if err != nil { | |
| 87 | - | token.effect.reportTransportFailure(token.transport) | |
| 88 | - | } | |
| 89 | - | token.effect.endTransportSend() | |
| 90 | - | return err | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // boundedSendSnatchedControl keeps a best-effort control acknowledgement bound | |
| 94 | - | // to the exact admitted role and link incarnation. On deadline it closes only | |
| 95 | - | // that captured link, releasing either the transport Send or a wait on sendMu. | |
| 96 | - | func (d *Daemon) boundedSendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error { | |
| 97 | - | if token.ac == nil || token.effect == nil || token.transport.transport == nil { | |
| 98 | - | return errAttachmentTransition | |
| 99 | - | } | |
| 100 | - | _, err := d.boundedSendWith(token.transport.transport, func() error { | |
| 101 | - | return d.sendSnatchedControl(token, frame) | |
| 102 | - | }) | |
| 103 | - | if errors.Is(err, errSendTimedOut) { | |
| 104 | - | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 105 | - | } | |
| 106 | - | return err | |
| 107 | - | } | |
D internal/usecase/daemon/snatched_client_test.go +0 -1145
| @@ -1,1145 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "io" | |
| 7 | - | "sync" | |
| 8 | - | "testing" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/stretchr/testify/require" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/vev/internal/domain" | |
| 14 | - | "github.com/bnema/vev/internal/ports" | |
| 15 | - | "github.com/bnema/vev/internal/usecase/keys" | |
| 16 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 17 | - | "github.com/bnema/vev/pkg/renderer" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | func TestSessionAttachmentRole(t *testing.T) { | |
| 21 | - | active := &attachedClient{} | |
| 22 | - | waiting := &attachedClient{} | |
| 23 | - | unknown := &attachedClient{} | |
| 24 | - | sess := &session{sessionCore: sessionCore{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}} | |
| 25 | - | ||
| 26 | - | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 27 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 28 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(unknown)) | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) { | |
| 32 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 33 | - | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 34 | - | oldTransport := &closeTrackingTransport{} | |
| 35 | - | old := &attachedClient{tr: oldTransport} | |
| 36 | - | old.setSession(sess) | |
| 37 | - | sess.client = old | |
| 38 | - | d.sessions[sess.id] = sess | |
| 39 | - | ||
| 40 | - | newTransport := &closeTrackingTransport{} | |
| 41 | - | next := &attachedClient{tr: newTransport} | |
| 42 | - | expected := next.transportSnapshot() | |
| 43 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 44 | - | target: sess, | |
| 45 | - | next: next, | |
| 46 | - | expectedRole: attachmentDetached, | |
| 47 | - | targetRole: attachmentActive, | |
| 48 | - | expectedTransport: expected, | |
| 49 | - | }) | |
| 50 | - | require.NoError(t, err) | |
| 51 | - | ||
| 52 | - | sess.mu.Lock() | |
| 53 | - | require.Same(t, next, sess.client) | |
| 54 | - | require.Contains(t, sess.snatched, old) | |
| 55 | - | _, nextSnatched := sess.snatched[next] | |
| 56 | - | require.False(t, nextSnatched) | |
| 57 | - | sess.mu.Unlock() | |
| 58 | - | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 59 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(old)) | |
| 60 | - | require.True(t, result.published.current()) | |
| 61 | - | require.True(t, result.displaced.current()) | |
| 62 | - | require.Equal(t, uint64(1), result.published.generation) | |
| 63 | - | require.Equal(t, uint64(1), result.displaced.generation) | |
| 64 | - | require.Same(t, sess, next.currentSession()) | |
| 65 | - | require.Same(t, sess, old.currentSession()) | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) { | |
| 69 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 70 | - | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 71 | - | oldTransport := &closeTrackingTransport{} | |
| 72 | - | old := &attachedClient{ | |
| 73 | - | tr: oldTransport, | |
| 74 | - | output: newOutputStateStream(), | |
| 75 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 76 | - | } | |
| 77 | - | old.initOverlays() | |
| 78 | - | old.setThemeForTest(themeui.Theme{}) | |
| 79 | - | old.setSession(sess) | |
| 80 | - | sess.client = old | |
| 81 | - | d.sessions[sess.id] = sess | |
| 82 | - | ||
| 83 | - | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 84 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 85 | - | target: sess, | |
| 86 | - | next: next, | |
| 87 | - | expectedRole: attachmentDetached, | |
| 88 | - | targetRole: attachmentActive, | |
| 89 | - | expectedTransport: next.transportSnapshot(), | |
| 90 | - | }) | |
| 91 | - | require.NoError(t, err) | |
| 92 | - | ||
| 93 | - | d.deferAttachmentTransitionCleanups(result) | |
| 94 | - | d.attachmentCleanupWg.Wait() | |
| 95 | - | ||
| 96 | - | require.False(t, oldTransport.Closed(), "snatched transport must stay open") | |
| 97 | - | frames := oldTransport.Sends() | |
| 98 | - | require.Len(t, frames, 1) | |
| 99 | - | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 100 | - | output, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 101 | - | require.NoError(t, err) | |
| 102 | - | require.Contains(t, string(output.Data), "Session snatched") | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | func TestActiveFrameRevalidatesExactRoleBeforeEveryEffect(t *testing.T) { | |
| 106 | - | tests := []struct { | |
| 107 | - | name string | |
| 108 | - | setup func(*attachedClient, *session, *transactionalResizePTY) | |
| 109 | - | frame func() ports.Frame | |
| 110 | - | assert func(*testing.T, *Daemon, *session, *attachedClient, *attachedClient, *transactionalResizePTY, *datagramTestTransport) | |
| 111 | - | }{ | |
| 112 | - | { | |
| 113 | - | name: "forwarded input", | |
| 114 | - | frame: func() ports.Frame { return frameInput([]byte("stale")) }, | |
| 115 | - | assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) { | |
| 116 | - | require.Empty(t, pty.writes(), "stale input reached the PTY") | |
| 117 | - | }, | |
| 118 | - | }, | |
| 119 | - | { | |
| 120 | - | name: "key action", | |
| 121 | - | frame: func() ports.Frame { return frameInput([]byte("\x1b ")) }, | |
| 122 | - | assert: func(t *testing.T, _ *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) { | |
| 123 | - | require.False(t, old.overlays.paletteActive(), "stale key action mutated the displaced overlay") | |
| 124 | - | require.False(t, next.overlays.paletteActive(), "stale key action mutated the active overlay") | |
| 125 | - | }, | |
| 126 | - | }, | |
| 127 | - | { | |
| 128 | - | name: "mouse input", | |
| 129 | - | setup: func(_ *attachedClient, sess *session, _ *transactionalResizePTY) { | |
| 130 | - | pane := sess.activeTab().focusedPane() | |
| 131 | - | pane.mu.Lock() | |
| 132 | - | pane.screen.Write([]byte("\x1b[?1000h")) | |
| 133 | - | pane.mu.Unlock() | |
| 134 | - | }, | |
| 135 | - | frame: func() ports.Frame { return frameInput([]byte("\x1b[<0;1;2M")) }, | |
| 136 | - | assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) { | |
| 137 | - | require.Empty(t, pty.writes(), "stale mouse input reached the PTY") | |
| 138 | - | }, | |
| 139 | - | }, | |
| 140 | - | { | |
| 141 | - | name: "image push", | |
| 142 | - | frame: func() ports.Frame { | |
| 143 | - | return ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: "image/png", Data: []byte("stale-image")})} | |
| 144 | - | }, | |
| 145 | - | assert: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) { | |
| 146 | - | sess.mu.Lock() | |
| 147 | - | files := append([]string(nil), sess.clipFiles...) | |
| 148 | - | sess.mu.Unlock() | |
| 149 | - | require.Empty(t, files, "stale image push created a session clipboard file") | |
| 150 | - | require.Empty(t, pty.writes(), "stale image path reached the PTY") | |
| 151 | - | }, | |
| 152 | - | }, | |
| 153 | - | { | |
| 154 | - | name: "resize", | |
| 155 | - | frame: func() ports.Frame { | |
| 156 | - | return ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 120, Rows: 40}})} | |
| 157 | - | }, | |
| 158 | - | assert: func(t *testing.T, _ *Daemon, sess *session, old, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) { | |
| 159 | - | require.Empty(t, pty.requested(), "stale resize reached a session PTY") | |
| 160 | - | require.Nil(t, sess.renderCoordinator().attachmentLease(old), "stale resize rebound the coordinator to the displaced client") | |
| 161 | - | }, | |
| 162 | - | }, | |
| 163 | - | { | |
| 164 | - | name: "theme", | |
| 165 | - | frame: func() ports.Frame { | |
| 166 | - | return ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{ | |
| 167 | - | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 168 | - | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 169 | - | })} | |
| 170 | - | }, | |
| 171 | - | assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) { | |
| 172 | - | require.False(t, old.getClientTheme().HasFG, "stale theme mutated the displaced client") | |
| 173 | - | }, | |
| 174 | - | }, | |
| 175 | - | { | |
| 176 | - | name: "client notice", | |
| 177 | - | frame: func() ports.Frame { | |
| 178 | - | return ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})} | |
| 179 | - | }, | |
| 180 | - | assert: func(t *testing.T, d *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) { | |
| 181 | - | require.Empty(t, d.notices.history(), "stale notice mutated daemon history") | |
| 182 | - | oldToasts, _ := visibleToasts(old) | |
| 183 | - | nextToasts, _ := visibleToasts(next) | |
| 184 | - | require.Empty(t, oldToasts) | |
| 185 | - | require.Empty(t, nextToasts) | |
| 186 | - | }, | |
| 187 | - | }, | |
| 188 | - | { | |
| 189 | - | name: "ack", | |
| 190 | - | setup: func(old *attachedClient, _ *session, _ *transactionalResizePTY) { | |
| 191 | - | old.output.next = 2 | |
| 192 | - | }, | |
| 193 | - | frame: func() ports.Frame { | |
| 194 | - | return ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})} | |
| 195 | - | }, | |
| 196 | - | assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) { | |
| 197 | - | require.Equal(t, uint64(2), old.output.outstanding(), "stale ack retired displaced output state") | |
| 198 | - | }, | |
| 199 | - | }, | |
| 200 | - | { | |
| 201 | - | name: "ping", | |
| 202 | - | frame: func() ports.Frame { return ports.Frame{Type: ports.MsgPing} }, | |
| 203 | - | assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, _ *transactionalResizePTY, tr *datagramTestTransport) { | |
| 204 | - | select { | |
| 205 | - | case frame := <-tr.sends: | |
| 206 | - | t.Fatalf("stale ping wrote frame type %d", frame.Type) | |
| 207 | - | default: | |
| 208 | - | } | |
| 209 | - | }, | |
| 210 | - | }, | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | for _, tt := range tests { | |
| 214 | - | t.Run(tt.name, func(t *testing.T) { | |
| 215 | - | pty := &transactionalResizePTY{} | |
| 216 | - | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 217 | - | d.tempDir = t.TempDir() | |
| 218 | - | oldTransport := newDatagramTestTransport() | |
| 219 | - | old.replaceTransport(oldTransport) | |
| 220 | - | old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 221 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 222 | - | token := sess.attachmentToken(old, oldTransport) | |
| 223 | - | token.lease = rc.attachmentLease(old) | |
| 224 | - | require.True(t, token.activeCurrent()) | |
| 225 | - | if tt.setup != nil { | |
| 226 | - | tt.setup(old, sess, pty) | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 230 | - | next.initOverlays() | |
| 231 | - | var transition attachmentTransitionResult | |
| 232 | - | d.afterActiveFrameDispatch = func(attachmentRoleToken) { | |
| 233 | - | d.afterActiveFrameDispatch = nil | |
| 234 | - | var err error | |
| 235 | - | transition, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 236 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 237 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 238 | - | }) | |
| 239 | - | require.NoError(t, err) | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | d.handleActiveClientFrame(token, tt.frame()) | |
| 243 | - | for _, cleanup := range transition.cleanups { | |
| 244 | - | cleanup.finish() | |
| 245 | - | } | |
| 246 | - | tt.assert(t, d, sess, old, next, pty, oldTransport) | |
| 247 | - | }) | |
| 248 | - | } | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) { | |
| 252 | - | pty := &transactionalResizePTY{} | |
| 253 | - | d, sess, first, _ := newManualSessionWithPTYs(t, pty) | |
| 254 | - | firstTransport := newDatagramTestTransport() | |
| 255 | - | first.replaceTransport(firstTransport) | |
| 256 | - | ||
| 257 | - | secondTransport := newDatagramTestTransport() | |
| 258 | - | second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size} | |
| 259 | - | second.initOverlays() | |
| 260 | - | firstReplacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 261 | - | target: sess, next: second, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 262 | - | expectedTransport: second.transportSnapshot(), ready: true, | |
| 263 | - | }) | |
| 264 | - | require.NoError(t, err) | |
| 265 | - | for _, cleanup := range firstReplacement.cleanups { | |
| 266 | - | cleanup.finish() | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | thirdTransport := newDatagramTestTransport() | |
| 270 | - | third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size} | |
| 271 | - | third.initOverlays() | |
| 272 | - | secondReplacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 273 | - | target: sess, next: third, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 274 | - | expectedTransport: third.transportSnapshot(), ready: true, | |
| 275 | - | }) | |
| 276 | - | require.NoError(t, err) | |
| 277 | - | for _, cleanup := range secondReplacement.cleanups { | |
| 278 | - | cleanup.finish() | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | otherCtx, otherCancel := context.WithCancel(d.serveCtx) | |
| 282 | - | defer otherCancel() | |
| 283 | - | otherTransport := newDatagramTestTransport() | |
| 284 | - | otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size} | |
| 285 | - | otherClient.initOverlays() | |
| 286 | - | other := &session{sessionCore: sessionCore{id: "other", name: "other", client: otherClient}, ctx: otherCtx, cancel: otherCancel} | |
| 287 | - | otherClient.setSession(other) | |
| 288 | - | d.mu.Lock() | |
| 289 | - | d.sessions[other.id] = other | |
| 290 | - | d.mu.Unlock() | |
| 291 | - | d.attachCoordinator(other, nil, otherClient, true) | |
| 292 | - | ||
| 293 | - | roles := []*attachedClient{first, second, third} | |
| 294 | - | generations := make([]uint64, len(roles)) | |
| 295 | - | for i, ac := range roles { | |
| 296 | - | generations[i] = ac.roleGeneration.Load() | |
| 297 | - | } | |
| 298 | - | var killedLoops sync.WaitGroup | |
| 299 | - | for _, ac := range roles { | |
| 300 | - | killedLoops.Go(func() { d.runConnLoop(ac) }) | |
| 301 | - | } | |
| 302 | - | otherDone := make(chan struct{}) | |
| 303 | - | go func() { | |
| 304 | - | d.runConnLoop(otherClient) | |
| 305 | - | close(otherDone) | |
| 306 | - | }() | |
| 307 | - | defer func() { | |
| 308 | - | _ = firstTransport.Close() | |
| 309 | - | _ = secondTransport.Close() | |
| 310 | - | _ = thirdTransport.Close() | |
| 311 | - | _ = otherTransport.Close() | |
| 312 | - | killedLoops.Wait() | |
| 313 | - | <-otherDone | |
| 314 | - | }() | |
| 315 | - | ||
| 316 | - | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 317 | - | d.waitNotifies() | |
| 318 | - | select { | |
| 319 | - | case <-waitGroupDone(&killedLoops): | |
| 320 | - | case <-time.After(2 * time.Second): | |
| 321 | - | t.Fatal("killed active and snatched connection loops did not retire") | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | sess.mu.Lock() | |
| 325 | - | require.Nil(t, sess.client) | |
| 326 | - | require.Empty(t, sess.snatched) | |
| 327 | - | sess.mu.Unlock() | |
| 328 | - | for i, ac := range roles { | |
| 329 | - | require.Nil(t, ac.currentSession()) | |
| 330 | - | require.Greater(t, ac.roleGeneration.Load(), generations[i], "session kill did not invalidate role generation") | |
| 331 | - | } | |
| 332 | - | for _, transport := range []*datagramTestTransport{firstTransport, secondTransport, thirdTransport} { | |
| 333 | - | detached := awaitFrame(t, transport.sends, ports.MsgDetached) | |
| 334 | - | message, err := ports.UnmarshalDetached(detached.Payload) | |
| 335 | - | require.NoError(t, err) | |
| 336 | - | require.Equal(t, ports.ReasonSessionKilled, message.Reason) | |
| 337 | - | _, recvErr := transport.Recv() | |
| 338 | - | require.ErrorIs(t, recvErr, io.EOF) | |
| 339 | - | } | |
| 340 | - | require.Same(t, otherClient, other.client) | |
| 341 | - | require.Same(t, other, otherClient.currentSession()) | |
| 342 | - | require.Equal(t, attachmentActive, other.attachmentRole(otherClient)) | |
| 343 | - | ||
| 344 | - | otherTransport.recv <- ports.Frame{Type: ports.MsgPing} | |
| 345 | - | require.Equal(t, ports.MsgPong, awaitFrame(t, otherTransport.sends, ports.MsgPong).Type) | |
| 346 | - | select { | |
| 347 | - | case <-otherDone: | |
| 348 | - | t.Fatal("killing another session retired the active client's connection") | |
| 349 | - | default: | |
| 350 | - | } | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | func TestKillSessionRetiresParkedSnatchedAttachment(t *testing.T) { | |
| 354 | - | pty, release := newBlockingPTY(t) | |
| 355 | - | defer release() | |
| 356 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 357 | - | ||
| 358 | - | parkedTransport := &closeTrackingTransport{} | |
| 359 | - | sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport) | |
| 360 | - | require.NoError(t, err) | |
| 361 | - | parkedToken := parkedClient.resumeToken | |
| 362 | - | d.clientGone(sess, parkedClient, parkedTransport, false) | |
| 363 | - | ||
| 364 | - | activeTransport := &closeTrackingTransport{} | |
| 365 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 366 | - | require.NoError(t, err) | |
| 367 | - | d.mu.Lock() | |
| 368 | - | parked := d.parked[parkedToken] | |
| 369 | - | d.mu.Unlock() | |
| 370 | - | require.NotNil(t, parked) | |
| 371 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 372 | - | parkedGeneration := parkedClient.roleGeneration.Load() | |
| 373 | - | ||
| 374 | - | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 375 | - | d.waitNotifies() | |
| 376 | - | ||
| 377 | - | require.Nil(t, parkedClient.transport(), "terminal teardown must revoke the parked link ownership") | |
| 378 | - | require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration, "terminal teardown must invalidate the parked role generation") | |
| 379 | - | require.Zero(t, parkedClient.resumeToken) | |
| 380 | - | require.False(t, parkedClient.parked) | |
| 381 | - | select { | |
| 382 | - | case <-parked.done: | |
| 383 | - | default: | |
| 384 | - | t.Fatal("terminal teardown left the parked expiry goroutine live") | |
| 385 | - | } | |
| 386 | - | require.True(t, parkedTransport.Closed()) | |
| 387 | - | require.Nil(t, parkedClient.currentSession()) | |
| 388 | - | require.Nil(t, active.currentSession()) | |
| 389 | - | require.True(t, activeTransport.Closed()) | |
| 390 | - | } | |
| 391 | - | ||
| 392 | - | func TestDaemonShutdownClosesSnatchedClients(t *testing.T) { | |
| 393 | - | pty, release := newBlockingPTY(t) | |
| 394 | - | defer release() | |
| 395 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 396 | - | ||
| 397 | - | parkedTransport := &closeTrackingTransport{} | |
| 398 | - | sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport) | |
| 399 | - | require.NoError(t, err) | |
| 400 | - | parkedToken := parkedClient.resumeToken | |
| 401 | - | d.clientGone(sess, parkedClient, parkedTransport, false) | |
| 402 | - | ||
| 403 | - | waitingTransport := &closeTrackingTransport{} | |
| 404 | - | _, waiting, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), waitingTransport) | |
| 405 | - | require.NoError(t, err) | |
| 406 | - | activeTransport := &closeTrackingTransport{} | |
| 407 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 408 | - | require.NoError(t, err) | |
| 409 | - | d.attachmentCleanupWg.Wait() | |
| 410 | - | ||
| 411 | - | d.mu.Lock() | |
| 412 | - | parked := d.parked[parkedToken] | |
| 413 | - | d.mu.Unlock() | |
| 414 | - | require.NotNil(t, parked) | |
| 415 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 416 | - | parkedGeneration := parkedClient.roleGeneration.Load() | |
| 417 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 418 | - | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 419 | - | ||
| 420 | - | require.False(t, d.shutdownAll(ports.ReasonServerShutdown)) | |
| 421 | - | d.waitNotifies() | |
| 422 | - | d.attachmentCleanupWg.Wait() | |
| 423 | - | ||
| 424 | - | require.Nil(t, parkedClient.transport(), "shutdown must revoke parked attachment link ownership") | |
| 425 | - | require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration) | |
| 426 | - | select { | |
| 427 | - | case <-parked.done: | |
| 428 | - | default: | |
| 429 | - | t.Fatal("shutdown left the parked expiry goroutine live") | |
| 430 | - | } | |
| 431 | - | for _, client := range []*attachedClient{parkedClient, waiting, active} { | |
| 432 | - | require.Nil(t, client.currentSession()) | |
| 433 | - | } | |
| 434 | - | for _, transport := range []*closeTrackingTransport{parkedTransport, waitingTransport, activeTransport} { | |
| 435 | - | require.True(t, transport.Closed()) | |
| 436 | - | } | |
| 437 | - | for _, transport := range []*closeTrackingTransport{waitingTransport, activeTransport} { | |
| 438 | - | frames := transport.Sends() | |
| 439 | - | require.NotEmpty(t, frames) | |
| 440 | - | detached, err := ports.UnmarshalDetached(frames[len(frames)-1].Payload) | |
| 441 | - | require.NoError(t, err) | |
| 442 | - | require.Equal(t, ports.ReasonServerShutdown, detached.Reason) | |
| 443 | - | } | |
| 444 | - | } | |
| 445 | - | ||
| 446 | - | func TestSnatchedQuitDoesNotResetActivePaneThemeOrSize(t *testing.T) { | |
| 447 | - | pty := &transactionalResizePTY{} | |
| 448 | - | d, sess, waiting, _ := newManualSessionWithPTYs(t, pty) | |
| 449 | - | waitingTransport := &closeTrackingTransport{} | |
| 450 | - | waiting.replaceTransport(waitingTransport) | |
| 451 | - | ||
| 452 | - | activeTransport := &closeTrackingTransport{} | |
| 453 | - | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |
| 454 | - | active.initOverlays() | |
| 455 | - | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 456 | - | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 457 | - | expectedTransport: active.transportSnapshot(), ready: true, | |
| 458 | - | }) | |
| 459 | - | require.NoError(t, err) | |
| 460 | - | for _, cleanup := range transition.cleanups { | |
| 461 | - | cleanup.finish() | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | activeTheme := ports.Theme{ | |
| 465 | - | HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56}, | |
| 466 | - | HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21}, | |
| 467 | - | TrueColor: true, | |
| 468 | - | } | |
| 469 | - | d.applyTheme(sess, active, activeTheme) | |
| 470 | - | activeSize := domain.Size{Cols: 101, Rows: 37} | |
| 471 | - | d.resize(sess, active, activeSize) | |
| 472 | - | require.Equal(t, activeSize, active.size) | |
| 473 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 474 | - | requestedBeforeQuit := pty.requested() | |
| 475 | - | ||
| 476 | - | token := sess.attachmentToken(waiting, waitingTransport) | |
| 477 | - | require.True(t, d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 478 | - | Type: ports.MsgInput, | |
| 479 | - | Payload: ports.MarshalInput(ports.Input{ | |
| 480 | - | Data: []byte{'q'}, | |
| 481 | - | }), | |
| 482 | - | })) | |
| 483 | - | ||
| 484 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 485 | - | require.Same(t, active, sess.client) | |
| 486 | - | require.False(t, activeTransport.Closed()) | |
| 487 | - | require.True(t, waitingTransport.Closed()) | |
| 488 | - | require.Equal(t, activeSize, active.size) | |
| 489 | - | require.Equal(t, requestedBeforeQuit, pty.requested(), "snatched quit must not resize active panes") | |
| 490 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 491 | - | } | |
| 492 | - | ||
| 493 | - | func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) { | |
| 494 | - | writes := make(chan []byte, 1) | |
| 495 | - | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 496 | - | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 497 | - | oldTransport := newDatagramTestTransport() | |
| 498 | - | old.replaceTransport(oldTransport) | |
| 499 | - | ||
| 500 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 501 | - | next.initOverlays() | |
| 502 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 503 | - | target: sess, | |
| 504 | - | next: next, | |
| 505 | - | expectedRole: attachmentDetached, | |
| 506 | - | targetRole: attachmentActive, | |
| 507 | - | expectedTransport: next.transportSnapshot(), | |
| 508 | - | }) | |
| 509 | - | require.NoError(t, err) | |
| 510 | - | d.deferAttachmentTransitionCleanups(result) | |
| 511 | - | d.attachmentCleanupWg.Wait() | |
| 512 | - | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 513 | - | ||
| 514 | - | oldTransport.recv <- frameInput([]byte("restricted")) | |
| 515 | - | require.NoError(t, oldTransport.Close()) | |
| 516 | - | d.runConnLoop(old) | |
| 517 | - | ||
| 518 | - | select { | |
| 519 | - | case got := <-writes: | |
| 520 | - | t.Fatalf("snatched input reached PTY: %q", got) | |
| 521 | - | default: | |
| 522 | - | } | |
| 523 | - | } | |
| 524 | - | ||
| 525 | - | func TestStaleActiveKeyForwardCannotReachPTYAfterSnatch(t *testing.T) { | |
| 526 | - | writes := make(chan []byte, 1) | |
| 527 | - | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 528 | - | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 529 | - | old.replaceTransport(&closeTrackingTransport{}) | |
| 530 | - | ||
| 531 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 532 | - | next.initOverlays() | |
| 533 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 534 | - | target: sess, | |
| 535 | - | next: next, | |
| 536 | - | expectedRole: attachmentDetached, | |
| 537 | - | targetRole: attachmentActive, | |
| 538 | - | expectedTransport: next.transportSnapshot(), | |
| 539 | - | }) | |
| 540 | - | require.NoError(t, err) | |
| 541 | - | ||
| 542 | - | (daemonKeyHandler{d: d, ac: old}).Forward([]byte("stale")) | |
| 543 | - | ||
| 544 | - | select { | |
| 545 | - | case got := <-writes: | |
| 546 | - | t.Fatalf("stale active key forward reached PTY: %q", got) | |
| 547 | - | default: | |
| 548 | - | } | |
| 549 | - | } | |
| 550 | - | ||
| 551 | - | func TestSnatchedConnectionResizeUpdatesPanelWithoutResizingSession(t *testing.T) { | |
| 552 | - | pty := &transactionalResizePTY{} | |
| 553 | - | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 554 | - | oldTransport := newDatagramTestTransport() | |
| 555 | - | old.replaceTransport(oldTransport) | |
| 556 | - | ||
| 557 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 558 | - | next.initOverlays() | |
| 559 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 560 | - | target: sess, | |
| 561 | - | next: next, | |
| 562 | - | expectedRole: attachmentDetached, | |
| 563 | - | targetRole: attachmentActive, | |
| 564 | - | expectedTransport: next.transportSnapshot(), | |
| 565 | - | }) | |
| 566 | - | require.NoError(t, err) | |
| 567 | - | d.deferAttachmentTransitionCleanups(result) | |
| 568 | - | d.attachmentCleanupWg.Wait() | |
| 569 | - | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 570 | - | ||
| 571 | - | want := domain.Size{Cols: 100, Rows: 30} | |
| 572 | - | oldTransport.recv <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: want})} | |
| 573 | - | require.NoError(t, oldTransport.Close()) | |
| 574 | - | d.runConnLoop(old) | |
| 575 | - | ||
| 576 | - | require.Equal(t, want, old.size) | |
| 577 | - | require.Empty(t, pty.requested(), "snatched resize must not resize session PTYs") | |
| 578 | - | panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 579 | - | output, err := ports.UnmarshalOutput(panel.Payload) | |
| 580 | - | require.NoError(t, err) | |
| 581 | - | require.Contains(t, string(output.Data), "Session snatched") | |
| 582 | - | } | |
| 583 | - | ||
| 584 | - | func TestSnatchedConnectionAcknowledgesPanelAndAnswersPing(t *testing.T) { | |
| 585 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 586 | - | oldTransport := newDatagramTestTransport() | |
| 587 | - | old.replaceTransport(oldTransport) | |
| 588 | - | ||
| 589 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 590 | - | next.initOverlays() | |
| 591 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 592 | - | target: sess, | |
| 593 | - | next: next, | |
| 594 | - | expectedRole: attachmentDetached, | |
| 595 | - | targetRole: attachmentActive, | |
| 596 | - | expectedTransport: next.transportSnapshot(), | |
| 597 | - | }) | |
| 598 | - | require.NoError(t, err) | |
| 599 | - | d.deferAttachmentTransitionCleanups(result) | |
| 600 | - | d.attachmentCleanupWg.Wait() | |
| 601 | - | panelFrame := awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 602 | - | panel, err := ports.UnmarshalOutput(panelFrame.Payload) | |
| 603 | - | require.NoError(t, err) | |
| 604 | - | require.Equal(t, uint64(1), old.output.outstanding()) | |
| 605 | - | ||
| 606 | - | oldTransport.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: panel.NewStateNum})} | |
| 607 | - | oldTransport.recv <- ports.Frame{Type: ports.MsgPing} | |
| 608 | - | require.NoError(t, oldTransport.Close()) | |
| 609 | - | d.runConnLoop(old) | |
| 610 | - | ||
| 611 | - | require.Zero(t, old.output.outstanding()) | |
| 612 | - | require.Equal(t, ports.MsgPong, awaitFrame(t, oldTransport.sends, ports.MsgPong).Type) | |
| 613 | - | } | |
| 614 | - | ||
| 615 | - | func TestSnatchedConnectionThemeRepaintsPanelWithoutRecoloringSession(t *testing.T) { | |
| 616 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 617 | - | oldTransport := newDatagramTestTransport() | |
| 618 | - | old.replaceTransport(oldTransport) | |
| 619 | - | ||
| 620 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 621 | - | next.initOverlays() | |
| 622 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 623 | - | target: sess, | |
| 624 | - | next: next, | |
| 625 | - | expectedRole: attachmentDetached, | |
| 626 | - | targetRole: attachmentActive, | |
| 627 | - | expectedTransport: next.transportSnapshot(), | |
| 628 | - | }) | |
| 629 | - | require.NoError(t, err) | |
| 630 | - | d.deferAttachmentTransitionCleanups(result) | |
| 631 | - | d.attachmentCleanupWg.Wait() | |
| 632 | - | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 633 | - | ||
| 634 | - | activeTheme := ports.Theme{ | |
| 635 | - | HasForeground: true, Foreground: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 636 | - | HasBackground: true, Background: renderer.RGB{R: 40, G: 50, B: 60}, | |
| 637 | - | TrueColor: true, | |
| 638 | - | } | |
| 639 | - | d.applyTheme(sess, next, activeTheme) | |
| 640 | - | snatchedTheme := ports.Theme{ | |
| 641 | - | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 642 | - | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 643 | - | TrueColor: true, | |
| 644 | - | } | |
| 645 | - | oldTransport.recv <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snatchedTheme)} | |
| 646 | - | require.NoError(t, oldTransport.Close()) | |
| 647 | - | d.runConnLoop(old) | |
| 648 | - | ||
| 649 | - | require.Equal(t, snatchedTheme.Foreground, old.getAppliedTheme().Raw.Foreground) | |
| 650 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 651 | - | panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 652 | - | output, err := ports.UnmarshalOutput(panel.Payload) | |
| 653 | - | require.NoError(t, err) | |
| 654 | - | require.Contains(t, string(output.Data), "Session snatched") | |
| 655 | - | } | |
| 656 | - | ||
| 657 | - | func TestSnatchedConnectionDetachRemovesOnlySnatchedAttachment(t *testing.T) { | |
| 658 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 659 | - | oldTransport := newDatagramTestTransport() | |
| 660 | - | old.replaceTransport(oldTransport) | |
| 661 | - | ||
| 662 | - | activeTransport := &closeTrackingTransport{} | |
| 663 | - | next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size} | |
| 664 | - | next.initOverlays() | |
| 665 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 666 | - | target: sess, | |
| 667 | - | next: next, | |
| 668 | - | expectedRole: attachmentDetached, | |
| 669 | - | targetRole: attachmentActive, | |
| 670 | - | expectedTransport: next.transportSnapshot(), | |
| 671 | - | }) | |
| 672 | - | require.NoError(t, err) | |
| 673 | - | d.deferAttachmentTransitionCleanups(result) | |
| 674 | - | d.attachmentCleanupWg.Wait() | |
| 675 | - | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 676 | - | ||
| 677 | - | oldTransport.recv <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})} | |
| 678 | - | d.runConnLoop(old) | |
| 679 | - | ||
| 680 | - | detached := awaitFrame(t, oldTransport.sends, ports.MsgDetached) | |
| 681 | - | message, err := ports.UnmarshalDetached(detached.Payload) | |
| 682 | - | require.NoError(t, err) | |
| 683 | - | require.Equal(t, ports.ReasonDetach, message.Reason) | |
| 684 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(old)) | |
| 685 | - | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 686 | - | require.Same(t, next, sess.client) | |
| 687 | - | require.False(t, activeTransport.Closed()) | |
| 688 | - | } | |
| 689 | - | ||
| 690 | - | func TestReplacementClearsSnatchedOverlaysPreviewAndCaptures(t *testing.T) { | |
| 691 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 692 | - | oldTransport := &closeTrackingTransport{} | |
| 693 | - | old.replaceTransport(oldTransport) | |
| 694 | - | ||
| 695 | - | d.enterPicker(sess, old) | |
| 696 | - | d.enterPalette(sess, old) | |
| 697 | - | d.enterPrompt(sess, old, " Rename ", "work", func(string) error { return nil }) | |
| 698 | - | d.enterCopyMode(sess, old) | |
| 699 | - | d.enterNotices(sess, old) | |
| 700 | - | old.overlays.resizeMu.Lock() | |
| 701 | - | old.overlays.resizeActive = true | |
| 702 | - | old.overlays.resizePending = []byte("pending") | |
| 703 | - | old.overlays.resizeMu.Unlock() | |
| 704 | - | old.sendMu.Lock() | |
| 705 | - | old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}} | |
| 706 | - | old.sendMu.Unlock() | |
| 707 | - | require.True(t, old.overlays.Active()) | |
| 708 | - | ||
| 709 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 710 | - | next.initOverlays() | |
| 711 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 712 | - | target: sess, | |
| 713 | - | next: next, | |
| 714 | - | expectedRole: attachmentDetached, | |
| 715 | - | targetRole: attachmentActive, | |
| 716 | - | expectedTransport: next.transportSnapshot(), | |
| 717 | - | }) | |
| 718 | - | require.NoError(t, err) | |
| 719 | - | d.deferAttachmentTransitionCleanups(result) | |
| 720 | - | d.attachmentCleanupWg.Wait() | |
| 721 | - | ||
| 722 | - | require.False(t, old.overlays.Active()) | |
| 723 | - | old.overlays.pickerMu.Lock() | |
| 724 | - | require.Nil(t, old.overlays.pickerPreview) | |
| 725 | - | require.Nil(t, old.overlays.pickerPreviewSession) | |
| 726 | - | old.overlays.pickerMu.Unlock() | |
| 727 | - | old.sendMu.Lock() | |
| 728 | - | require.Empty(t, old.captureFrames) | |
| 729 | - | old.sendMu.Unlock() | |
| 730 | - | require.False(t, oldTransport.Closed()) | |
| 731 | - | } | |
| 732 | - | ||
| 733 | - | type failingSnatchedTransport struct { | |
| 734 | - | mu sync.Mutex | |
| 735 | - | closed bool | |
| 736 | - | } | |
| 737 | - | ||
| 738 | - | func (*failingSnatchedTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 739 | - | func (*failingSnatchedTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 740 | - | func (t *failingSnatchedTransport) Close() error { | |
| 741 | - | t.mu.Lock() | |
| 742 | - | t.closed = true | |
| 743 | - | t.mu.Unlock() | |
| 744 | - | return nil | |
| 745 | - | } | |
| 746 | - | func (t *failingSnatchedTransport) Closed() bool { | |
| 747 | - | t.mu.Lock() | |
| 748 | - | defer t.mu.Unlock() | |
| 749 | - | return t.closed | |
| 750 | - | } | |
| 751 | - | ||
| 752 | - | func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) { | |
| 753 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 754 | - | oldTransport := &failingSnatchedTransport{} | |
| 755 | - | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 756 | - | old.initOverlays() | |
| 757 | - | sess := &session{sessionCore: sessionCore{id: "work", client: old}} | |
| 758 | - | old.setSession(sess) | |
| 759 | - | d.sessions[sess.id] = sess | |
| 760 | - | ||
| 761 | - | activeTransport := &closeTrackingTransport{} | |
| 762 | - | next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size} | |
| 763 | - | next.initOverlays() | |
| 764 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 765 | - | target: sess, | |
| 766 | - | next: next, | |
| 767 | - | expectedRole: attachmentDetached, | |
| 768 | - | targetRole: attachmentActive, | |
| 769 | - | expectedTransport: next.transportSnapshot(), | |
| 770 | - | }) | |
| 771 | - | require.NoError(t, err) | |
| 772 | - | d.deferAttachmentTransitionCleanups(result) | |
| 773 | - | d.attachmentCleanupWg.Wait() | |
| 774 | - | ||
| 775 | - | require.True(t, oldTransport.Closed()) | |
| 776 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(old)) | |
| 777 | - | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 778 | - | require.Same(t, next, sess.client) | |
| 779 | - | require.False(t, activeTransport.Closed()) | |
| 780 | - | } | |
| 781 | - | ||
| 782 | - | func TestSnatchedPanelFailurePreservesActivePaneThemeAndSize(t *testing.T) { | |
| 783 | - | pty := &transactionalResizePTY{} | |
| 784 | - | d, sess, waiting, _ := newManualSessionWithPTYs(t, pty) | |
| 785 | - | failed := &failingSnatchedTransport{} | |
| 786 | - | waiting.replaceTransport(failed) | |
| 787 | - | ||
| 788 | - | activeTransport := &closeTrackingTransport{} | |
| 789 | - | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |
| 790 | - | active.initOverlays() | |
| 791 | - | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 792 | - | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 793 | - | expectedTransport: active.transportSnapshot(), ready: true, | |
| 794 | - | }) | |
| 795 | - | require.NoError(t, err) | |
| 796 | - | ||
| 797 | - | activeTheme := ports.Theme{ | |
| 798 | - | HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56}, | |
| 799 | - | HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21}, | |
| 800 | - | TrueColor: true, | |
| 801 | - | } | |
| 802 | - | d.applyTheme(sess, active, activeTheme) | |
| 803 | - | activeSize := domain.Size{Cols: 103, Rows: 39} | |
| 804 | - | d.resize(sess, active, activeSize) | |
| 805 | - | require.Equal(t, activeSize, active.size) | |
| 806 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 807 | - | requestedBeforeFailure := pty.requested() | |
| 808 | - | ||
| 809 | - | d.deferAttachmentTransitionCleanups(transition) | |
| 810 | - | d.attachmentCleanupWg.Wait() | |
| 811 | - | ||
| 812 | - | require.True(t, failed.Closed()) | |
| 813 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 814 | - | require.Same(t, active, sess.client) | |
| 815 | - | require.False(t, activeTransport.Closed()) | |
| 816 | - | require.Equal(t, activeSize, active.size) | |
| 817 | - | require.Equal(t, requestedBeforeFailure, pty.requested(), "snatched panel failure must not resize active panes") | |
| 818 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 819 | - | } | |
| 820 | - | ||
| 821 | - | func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | |
| 822 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 823 | - | oldTransport := &failingSnatchedTransport{} | |
| 824 | - | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 825 | - | old.initOverlays() | |
| 826 | - | sess := &session{sessionCore: sessionCore{id: "work", client: old}} | |
| 827 | - | old.setSession(sess) | |
| 828 | - | d.sessions[sess.id] = sess | |
| 829 | - | ||
| 830 | - | activeTransport := &closeTrackingTransport{} | |
| 831 | - | next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size} | |
| 832 | - | next.initOverlays() | |
| 833 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 834 | - | target: sess, | |
| 835 | - | next: next, | |
| 836 | - | expectedRole: attachmentDetached, | |
| 837 | - | targetRole: attachmentActive, | |
| 838 | - | expectedTransport: next.transportSnapshot(), | |
| 839 | - | }) | |
| 840 | - | require.NoError(t, err) | |
| 841 | - | for _, cleanup := range result.cleanups { | |
| 842 | - | cleanup.finish() | |
| 843 | - | } | |
| 844 | - | ||
| 845 | - | theme := ports.Theme{ | |
| 846 | - | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 847 | - | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 848 | - | TrueColor: true, | |
| 849 | - | } | |
| 850 | - | require.True(t, d.handleSnatchedClientFrame(result.displaced, ports.Frame{ | |
| 851 | - | Type: ports.MsgTheme, Payload: ports.MarshalTheme(theme), | |
| 852 | - | })) | |
| 853 | - | ||
| 854 | - | require.True(t, oldTransport.Closed()) | |
| 855 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(old)) | |
| 856 | - | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 857 | - | require.Same(t, next, sess.client) | |
| 858 | - | require.False(t, activeTransport.Closed()) | |
| 859 | - | } | |
| 860 | - | ||
| 861 | - | func TestClearForSnatchReleasesEachOverlayFamilyBeforeTakingNext(t *testing.T) { | |
| 862 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 863 | - | tr := &closeTrackingTransport{} | |
| 864 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 865 | - | ac.initOverlays() | |
| 866 | - | ac.roleGeneration.Store(1) | |
| 867 | - | sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}} | |
| 868 | - | ac.setSession(sess) | |
| 869 | - | token := attachmentRoleToken{ | |
| 870 | - | sess: sess, ac: ac, role: attachmentSnatched, | |
| 871 | - | generation: 1, transport: ac.transportSnapshot(), | |
| 872 | - | } | |
| 873 | - | ||
| 874 | - | pickerCleared := make(chan struct{}) | |
| 875 | - | d.afterSnatchOverlayFamily = func(family string) { | |
| 876 | - | if family == "picker" { | |
| 877 | - | close(pickerCleared) | |
| 878 | - | } | |
| 879 | - | } | |
| 880 | - | ac.overlays.resizeMu.Lock() | |
| 881 | - | resizeLocked := true | |
| 882 | - | defer func() { | |
| 883 | - | if resizeLocked { | |
| 884 | - | ac.overlays.resizeMu.Unlock() | |
| 885 | - | } | |
| 886 | - | }() | |
| 887 | - | cleared := make(chan bool, 1) | |
| 888 | - | go func() { cleared <- d.clearForSnatch(token) }() | |
| 889 | - | select { | |
| 890 | - | case <-pickerCleared: | |
| 891 | - | case <-time.After(2 * time.Second): | |
| 892 | - | t.Fatal("picker family did not clear before blocked resize family") | |
| 893 | - | } | |
| 894 | - | require.True(t, ac.overlays.pickerMu.TryLock(), "clear retained pickerMu while waiting for resizeMu") | |
| 895 | - | ac.overlays.pickerMu.Unlock() | |
| 896 | - | ||
| 897 | - | ac.overlays.resizeMu.Unlock() | |
| 898 | - | resizeLocked = false | |
| 899 | - | require.True(t, <-cleared) | |
| 900 | - | } | |
| 901 | - | ||
| 902 | - | func TestClearForSnatchRejectsStaleGenerationWithoutClearingNewOverlay(t *testing.T) { | |
| 903 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 904 | - | tr := &closeTrackingTransport{} | |
| 905 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 906 | - | ac.initOverlays() | |
| 907 | - | ac.roleGeneration.Store(1) | |
| 908 | - | sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}} | |
| 909 | - | ac.setSession(sess) | |
| 910 | - | token := attachmentRoleToken{ | |
| 911 | - | sess: sess, ac: ac, role: attachmentSnatched, | |
| 912 | - | generation: 1, transport: ac.transportSnapshot(), | |
| 913 | - | } | |
| 914 | - | ||
| 915 | - | ac.overlays.pickerMu.Lock() | |
| 916 | - | started := make(chan struct{}) | |
| 917 | - | cleared := make(chan bool, 1) | |
| 918 | - | go func() { | |
| 919 | - | close(started) | |
| 920 | - | cleared <- d.clearForSnatch(token) | |
| 921 | - | }() | |
| 922 | - | <-started | |
| 923 | - | ac.roleGeneration.Add(1) | |
| 924 | - | ac.overlays.resizeMu.Lock() | |
| 925 | - | ac.overlays.resizeActive = true | |
| 926 | - | ac.overlays.resizeMu.Unlock() | |
| 927 | - | ac.overlays.pickerMu.Unlock() | |
| 928 | - | ||
| 929 | - | require.False(t, <-cleared) | |
| 930 | - | require.True(t, ac.overlays.resizeModeActive(), "stale cleanup cleared a newer generation") | |
| 931 | - | } | |
| 932 | - | ||
| 933 | - | func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) { | |
| 934 | - | tests := []struct { | |
| 935 | - | name string | |
| 936 | - | mutate func(*Daemon, *session, *attachedClient) | |
| 937 | - | }{ | |
| 938 | - | { | |
| 939 | - | name: "stale transport", | |
| 940 | - | mutate: func(_ *Daemon, _ *session, next *attachedClient) { | |
| 941 | - | next.replaceTransport(&closeTrackingTransport{}) | |
| 942 | - | }, | |
| 943 | - | }, | |
| 944 | - | { | |
| 945 | - | name: "session lifecycle ended", | |
| 946 | - | mutate: func(d *Daemon, sess *session, _ *attachedClient) { | |
| 947 | - | delete(d.sessions, sess.id) | |
| 948 | - | }, | |
| 949 | - | }, | |
| 950 | - | { | |
| 951 | - | name: "unexpected role", | |
| 952 | - | mutate: func(_ *Daemon, sess *session, next *attachedClient) { | |
| 953 | - | sess.mu.Lock() | |
| 954 | - | sess.addSnatchedLocked(next) | |
| 955 | - | sess.mu.Unlock() | |
| 956 | - | }, | |
| 957 | - | }, | |
| 958 | - | } | |
| 959 | - | ||
| 960 | - | for _, tt := range tests { | |
| 961 | - | t.Run(tt.name, func(t *testing.T) { | |
| 962 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 963 | - | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 964 | - | old := &attachedClient{tr: &closeTrackingTransport{}} | |
| 965 | - | old.setSession(sess) | |
| 966 | - | sess.client = old | |
| 967 | - | d.sessions[sess.id] = sess | |
| 968 | - | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 969 | - | expected := next.transportSnapshot() | |
| 970 | - | tt.mutate(d, sess, next) | |
| 971 | - | ||
| 972 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 973 | - | target: sess, | |
| 974 | - | next: next, | |
| 975 | - | expectedRole: attachmentDetached, | |
| 976 | - | targetRole: attachmentActive, | |
| 977 | - | expectedTransport: expected, | |
| 978 | - | }) | |
| 979 | - | require.ErrorIs(t, err, errAttachmentTransition) | |
| 980 | - | require.Same(t, old, sess.client) | |
| 981 | - | require.Zero(t, old.roleGeneration.Load()) | |
| 982 | - | require.Zero(t, next.roleGeneration.Load()) | |
| 983 | - | }) | |
| 984 | - | } | |
| 985 | - | } | |
| 986 | - | ||
| 987 | - | func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) { | |
| 988 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 989 | - | from := &session{sessionCore: sessionCore{id: "z-source"}} | |
| 990 | - | target := &session{sessionCore: sessionCore{id: "a-target"}} | |
| 991 | - | moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 992 | - | displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size} | |
| 993 | - | moving.initOverlays() | |
| 994 | - | displaced.initOverlays() | |
| 995 | - | moving.setSession(from) | |
| 996 | - | displaced.setSession(target) | |
| 997 | - | from.client = moving | |
| 998 | - | target.client = displaced | |
| 999 | - | d.sessions[from.id] = from | |
| 1000 | - | d.sessions[target.id] = target | |
| 1001 | - | sourceCoordinator := d.attachCoordinator(from, nil, moving, true) | |
| 1002 | - | targetCoordinator := d.attachCoordinator(target, nil, displaced, true) | |
| 1003 | - | ||
| 1004 | - | moving.sendMu.Lock() | |
| 1005 | - | locked := true | |
| 1006 | - | defer func() { | |
| 1007 | - | if locked { | |
| 1008 | - | moving.sendMu.Unlock() | |
| 1009 | - | } | |
| 1010 | - | }() | |
| 1011 | - | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 1012 | - | transitionErr := make(chan error, 1) | |
| 1013 | - | go func() { | |
| 1014 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1015 | - | source: from, target: target, next: moving, | |
| 1016 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1017 | - | expectedTransport: moving.transportSnapshot(), ready: true, | |
| 1018 | - | }) | |
| 1019 | - | transitionDone <- result | |
| 1020 | - | transitionErr <- err | |
| 1021 | - | }() | |
| 1022 | - | ||
| 1023 | - | var result attachmentTransitionResult | |
| 1024 | - | select { | |
| 1025 | - | case result = <-transitionDone: | |
| 1026 | - | require.NoError(t, <-transitionErr) | |
| 1027 | - | case <-time.After(2 * time.Second): | |
| 1028 | - | t.Fatal("ownership publication waited for blocked sendMu") | |
| 1029 | - | } | |
| 1030 | - | ||
| 1031 | - | from.mu.Lock() | |
| 1032 | - | require.Nil(t, from.client) | |
| 1033 | - | from.mu.Unlock() | |
| 1034 | - | target.mu.Lock() | |
| 1035 | - | require.Same(t, moving, target.client) | |
| 1036 | - | require.Contains(t, target.snatched, displaced) | |
| 1037 | - | target.mu.Unlock() | |
| 1038 | - | require.Same(t, target, moving.currentSession()) | |
| 1039 | - | sourceCoordinator.mu.Lock() | |
| 1040 | - | require.NotNil(t, sourceCoordinator.lease) | |
| 1041 | - | require.False(t, sourceCoordinator.lease.active) | |
| 1042 | - | sourceCoordinator.mu.Unlock() | |
| 1043 | - | targetCoordinator.mu.Lock() | |
| 1044 | - | require.Same(t, result.published.lease, targetCoordinator.lease) | |
| 1045 | - | require.True(t, targetCoordinator.lease.active) | |
| 1046 | - | require.Same(t, moving, targetCoordinator.lease.attachment) | |
| 1047 | - | targetCoordinator.mu.Unlock() | |
| 1048 | - | require.True(t, result.published.activeCurrent()) | |
| 1049 | - | ||
| 1050 | - | beforeSendWait := make(chan struct{}) | |
| 1051 | - | d.beforeFirstPaintSendWait = func(token attachmentRoleToken) { | |
| 1052 | - | if token.ac == moving { | |
| 1053 | - | close(beforeSendWait) | |
| 1054 | - | } | |
| 1055 | - | } | |
| 1056 | - | paintDone := make(chan bool, 1) | |
| 1057 | - | go func() { | |
| 1058 | - | paintDone <- d.firstPaintForTransition(result.published) | |
| 1059 | - | }() | |
| 1060 | - | <-beforeSendWait | |
| 1061 | - | require.True(t, d.mu.TryLock(), "first paint retained daemon lock while waiting for sendMu") | |
| 1062 | - | d.mu.Unlock() | |
| 1063 | - | require.True(t, d.notices.routingMu.TryLock(), "first paint retained routing lock while waiting for sendMu") | |
| 1064 | - | d.notices.routingMu.Unlock() | |
| 1065 | - | require.True(t, from.mu.TryLock(), "first paint retained source session lock while waiting for sendMu") | |
| 1066 | - | from.mu.Unlock() | |
| 1067 | - | require.True(t, target.mu.TryLock(), "first paint retained target session lock while waiting for sendMu") | |
| 1068 | - | target.mu.Unlock() | |
| 1069 | - | require.True(t, sourceCoordinator.mu.TryLock(), "first paint retained source coordinator lock while waiting for sendMu") | |
| 1070 | - | sourceCoordinator.mu.Unlock() | |
| 1071 | - | require.True(t, targetCoordinator.mu.TryLock(), "first paint retained target coordinator lock while waiting for sendMu") | |
| 1072 | - | targetCoordinator.mu.Unlock() | |
| 1073 | - | ||
| 1074 | - | moving.sendMu.Unlock() | |
| 1075 | - | locked = false | |
| 1076 | - | require.True(t, <-paintDone) | |
| 1077 | - | } | |
| 1078 | - | ||
| 1079 | - | func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *testing.T) { | |
| 1080 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 1081 | - | tr := &closeTrackingTransport{} | |
| 1082 | - | ac := &attachedClient{ | |
| 1083 | - | tr: tr, | |
| 1084 | - | output: newOutputStateStream(), | |
| 1085 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 1086 | - | } | |
| 1087 | - | ac.initOverlays() | |
| 1088 | - | ac.roleGeneration.Store(2) | |
| 1089 | - | ac.output.next = 4 | |
| 1090 | - | ac.output.acked = 1 | |
| 1091 | - | sess := &session{sessionCore: sessionCore{id: "work", client: ac}} | |
| 1092 | - | ac.setSession(sess) | |
| 1093 | - | d.sessions[sess.id] = sess | |
| 1094 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 1095 | - | rebased := false | |
| 1096 | - | ac.renderStages.handoffRebase = func() { rebased = true } | |
| 1097 | - | ||
| 1098 | - | stale := attachmentRoleToken{ | |
| 1099 | - | sess: sess, ac: ac, role: attachmentActive, | |
| 1100 | - | generation: 1, transport: ac.transportSnapshot(), lease: rc.attachmentLease(ac), rebase: true, | |
| 1101 | - | } | |
| 1102 | - | require.False(t, d.firstPaintForTransition(stale)) | |
| 1103 | - | ||
| 1104 | - | require.False(t, rebased) | |
| 1105 | - | require.Equal(t, uint64(4), ac.output.next) | |
| 1106 | - | require.Equal(t, uint64(1), ac.output.acked) | |
| 1107 | - | require.Empty(t, tr.Sends(), "stale first paint emitted a reset") | |
| 1108 | - | } | |
| 1109 | - | ||
| 1110 | - | func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) { | |
| 1111 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 1112 | - | from := &session{sessionCore: sessionCore{id: domain.SessionID("z-source")}} | |
| 1113 | - | target := &session{sessionCore: sessionCore{id: domain.SessionID("a-target")}} | |
| 1114 | - | moving := &attachedClient{tr: &closeTrackingTransport{}} | |
| 1115 | - | displaced := &attachedClient{tr: &closeTrackingTransport{}} | |
| 1116 | - | moving.setSession(from) | |
| 1117 | - | displaced.setSession(target) | |
| 1118 | - | from.client = moving | |
| 1119 | - | target.client = displaced | |
| 1120 | - | d.sessions[from.id] = from | |
| 1121 | - | d.sessions[target.id] = target | |
| 1122 | - | ||
| 1123 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1124 | - | source: from, | |
| 1125 | - | target: target, | |
| 1126 | - | next: moving, | |
| 1127 | - | expectedRole: attachmentActive, | |
| 1128 | - | targetRole: attachmentActive, | |
| 1129 | - | expectedTransport: moving.transportSnapshot(), | |
| 1130 | - | ready: true, | |
| 1131 | - | }) | |
| 1132 | - | require.NoError(t, err) | |
| 1133 | - | ||
| 1134 | - | from.mu.Lock() | |
| 1135 | - | require.Nil(t, from.client) | |
| 1136 | - | require.NotContains(t, from.snatched, moving) | |
| 1137 | - | from.mu.Unlock() | |
| 1138 | - | target.mu.Lock() | |
| 1139 | - | require.Same(t, moving, target.client) | |
| 1140 | - | require.Contains(t, target.snatched, displaced) | |
| 1141 | - | target.mu.Unlock() | |
| 1142 | - | require.True(t, result.published.current()) | |
| 1143 | - | require.True(t, result.displaced.current()) | |
| 1144 | - | require.Same(t, target, moving.currentSession()) | |
| 1145 | - | } | |
D internal/usecase/daemon/snatched_input.go +0 -261
| @@ -1,261 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/ports" | |
| 7 | - | "github.com/bnema/vev/internal/usecase/keys" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | const snatchedInputSequenceLimit = 64 | |
| 11 | - | ||
| 12 | - | type snatchedInputAction uint8 | |
| 13 | - | ||
| 14 | - | const ( | |
| 15 | - | snatchedInputNone snatchedInputAction = iota | |
| 16 | - | snatchedInputReclaim | |
| 17 | - | snatchedInputQuit | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | // clearSnatchedInput invalidates every parser continuation before stopping its | |
| 21 | - | // timer. Callers must not hold daemon, routing, session, coordinator, send, or | |
| 22 | - | // transport locks because Timer.Stop is an external call. | |
| 23 | - | func (ac *attachedClient) clearSnatchedInput() { | |
| 24 | - | if ac == nil { | |
| 25 | - | return | |
| 26 | - | } | |
| 27 | - | ac.snatchedInputMu.Lock() | |
| 28 | - | ac.snatchedInputPending = nil | |
| 29 | - | ac.snatchedInputDrain = false | |
| 30 | - | pendingESC := ac.snatchedInputESC | |
| 31 | - | ac.snatchedInputESC = pendingByteTimer{} | |
| 32 | - | ac.snatchedInputMu.Unlock() | |
| 33 | - | pendingESC.stop() | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // handleSnatchedInput accepts actions only as exact one-byte input events. | |
| 37 | - | // Terminal escape sequences and arbitrary coalesced input are consumed as | |
| 38 | - | // indivisible events so a byte in a paste, sequence final, or frame tail can | |
| 39 | - | // never become an action. | |
| 40 | - | func (d *Daemon) handleSnatchedInput(token attachmentRoleToken, input ports.Input) bool { | |
| 41 | - | action := d.parseSnatchedInput(token, input.Data) | |
| 42 | - | switch action { | |
| 43 | - | case snatchedInputReclaim: | |
| 44 | - | d.reclaimSnatchedAttachment(token) | |
| 45 | - | case snatchedInputQuit: | |
| 46 | - | return d.quitSnatchedAttachment(token) | |
| 47 | - | } | |
| 48 | - | return false | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | func (d *Daemon) parseSnatchedInput(token attachmentRoleToken, data []byte) snatchedInputAction { | |
| 52 | - | ac := token.ac | |
| 53 | - | if ac == nil || len(data) == 0 { | |
| 54 | - | return snatchedInputNone | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | ac.snatchedInputMu.Lock() | |
| 58 | - | ||
| 59 | - | if ac.snatchedInputDrain { | |
| 60 | - | if snatchedSequenceFinal(data) >= 0 { | |
| 61 | - | ac.snatchedInputDrain = false | |
| 62 | - | } | |
| 63 | - | ac.snatchedInputMu.Unlock() | |
| 64 | - | return snatchedInputNone | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | if len(ac.snatchedInputPending) != 0 { | |
| 68 | - | pendingESC := ac.snatchedInputESC | |
| 69 | - | ac.snatchedInputESC = pendingByteTimer{} | |
| 70 | - | combined := make([]byte, 0, len(ac.snatchedInputPending)+len(data)) | |
| 71 | - | combined = append(combined, ac.snatchedInputPending...) | |
| 72 | - | combined = append(combined, data...) | |
| 73 | - | ac.snatchedInputPending = nil | |
| 74 | - | d.retainSnatchedSequenceLocked(ac, combined) | |
| 75 | - | ac.snatchedInputMu.Unlock() | |
| 76 | - | pendingESC.stop() | |
| 77 | - | return snatchedInputNone | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | if len(data) == 1 { | |
| 81 | - | var action snatchedInputAction | |
| 82 | - | switch data[0] { | |
| 83 | - | case 'r', 'R': | |
| 84 | - | action = snatchedInputReclaim | |
| 85 | - | case 'q', 'Q': | |
| 86 | - | action = snatchedInputQuit | |
| 87 | - | case keys.ESC: | |
| 88 | - | ac.snatchedInputPending = append(ac.snatchedInputPending[:0], keys.ESC) | |
| 89 | - | ac.snatchedInputESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |
| 90 | - | d.fireSnatchedEscape(token, timer) | |
| 91 | - | }) | |
| 92 | - | } | |
| 93 | - | ac.snatchedInputMu.Unlock() | |
| 94 | - | return action | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | // A complete/coalesced event is always ignored. Only an incomplete CSI or | |
| 98 | - | // SS3 prefix is retained to prevent its future final byte from acting. | |
| 99 | - | d.retainSnatchedSequenceLocked(ac, data) | |
| 100 | - | ac.snatchedInputMu.Unlock() | |
| 101 | - | return snatchedInputNone | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | func (d *Daemon) retainSnatchedSequenceLocked(ac *attachedClient, data []byte) { | |
| 105 | - | if !snatchedSequencePrefix(data) || snatchedSequenceFinal(data[2:]) >= 0 { | |
| 106 | - | return | |
| 107 | - | } | |
| 108 | - | if len(data) > snatchedInputSequenceLimit { | |
| 109 | - | ac.snatchedInputDrain = true | |
| 110 | - | return | |
| 111 | - | } | |
| 112 | - | ac.snatchedInputPending = append(ac.snatchedInputPending[:0], data...) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | func snatchedSequencePrefix(data []byte) bool { | |
| 116 | - | return len(data) >= 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O') | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | func snatchedSequenceFinal(data []byte) int { | |
| 120 | - | for i, b := range data { | |
| 121 | - | if b >= 0x40 && b <= 0x7e { | |
| 122 | - | return i | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | return -1 | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | func (d *Daemon) fireSnatchedEscape(token attachmentRoleToken, timer ports.Timer) { | |
| 129 | - | ac := token.ac | |
| 130 | - | if ac == nil { | |
| 131 | - | return | |
| 132 | - | } | |
| 133 | - | ac.snatchedInputMu.Lock() | |
| 134 | - | if ac.snatchedInputESC.timer != timer || len(ac.snatchedInputPending) != 1 || ac.snatchedInputPending[0] != keys.ESC { | |
| 135 | - | ac.snatchedInputMu.Unlock() | |
| 136 | - | return | |
| 137 | - | } | |
| 138 | - | ac.snatchedInputPending = nil | |
| 139 | - | ac.snatchedInputESC.timer = nil | |
| 140 | - | ac.snatchedInputESC.done = nil | |
| 141 | - | ac.snatchedInputMu.Unlock() | |
| 142 | - | ||
| 143 | - | if d.afterSnatchedEscapeAccepted != nil { | |
| 144 | - | d.afterSnatchedEscapeAccepted() | |
| 145 | - | } | |
| 146 | - | if !token.current() { | |
| 147 | - | if d.afterSnatchedEscapeAttempt != nil { | |
| 148 | - | d.afterSnatchedEscapeAttempt(false) | |
| 149 | - | } | |
| 150 | - | return | |
| 151 | - | } | |
| 152 | - | ticket, admitted := ac.beginRoleEffect(token) | |
| 153 | - | if d.afterSnatchedEscapeAttempt != nil { | |
| 154 | - | d.afterSnatchedEscapeAttempt(admitted) | |
| 155 | - | } | |
| 156 | - | if !admitted { | |
| 157 | - | return | |
| 158 | - | } | |
| 159 | - | token.effect = ticket | |
| 160 | - | defer token.endRoleEffect() | |
| 161 | - | d.quitSnatchedAttachment(token) | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // acquireActivationBarrier waits for the requester's serialized output path | |
| 165 | - | // without retaining role, daemon, routing, session, coordinator, or pane locks. | |
| 166 | - | // The caller has already frozen and drained the requester's role gate, so no new | |
| 167 | - | // role-bound sender can race this acquisition. A deadline closes only the exact | |
| 168 | - | // captured link, which releases a transport Send currently holding sendMu. | |
| 169 | - | func (d *Daemon) acquireActivationBarrier(token attachmentRoleToken) (func(), error) { | |
| 170 | - | if token.ac == nil || token.transport.transport == nil || !token.current() { | |
| 171 | - | return nil, errAttachmentTransition | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | acquired := make(chan struct{}) | |
| 175 | - | cancel := make(chan struct{}) | |
| 176 | - | go func() { | |
| 177 | - | token.ac.sendMu.Lock() | |
| 178 | - | select { | |
| 179 | - | case acquired <- struct{}{}: | |
| 180 | - | // The receiver now owns the matching Unlock obligation. | |
| 181 | - | case <-cancel: | |
| 182 | - | token.ac.sendMu.Unlock() | |
| 183 | - | } | |
| 184 | - | }() | |
| 185 | - | ||
| 186 | - | timer := d.clock.NewTimer(detachNotifyTimeout) | |
| 187 | - | select { | |
| 188 | - | case <-acquired: | |
| 189 | - | timer.Stop() | |
| 190 | - | if token.ac.roleGeneration.Load() != token.generation || | |
| 191 | - | !token.ac.transportSnapshotCurrent(token.transport) { | |
| 192 | - | token.ac.sendMu.Unlock() | |
| 193 | - | return nil, errAttachmentTransition | |
| 194 | - | } | |
| 195 | - | return token.ac.sendMu.Unlock, nil | |
| 196 | - | case <-timer.C(): | |
| 197 | - | close(cancel) | |
| 198 | - | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 199 | - | return nil, errSendTimedOut | |
| 200 | - | } | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | func (d *Daemon) reclaimSnatchedAttachment(token attachmentRoleToken) bool { | |
| 204 | - | if token.role != attachmentSnatched || token.effect == nil { | |
| 205 | - | return false | |
| 206 | - | } | |
| 207 | - | token.effect.bindActionEnd(d, "reclaim-snatched") | |
| 208 | - | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 209 | - | source: token.sess, target: token.sess, next: token.ac, | |
| 210 | - | expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 211 | - | expectedTransport: token.transport, sourceToken: &token, | |
| 212 | - | action: "reclaim-snatched", ready: true, activationBarrier: true, | |
| 213 | - | }) | |
| 214 | - | if err != nil { | |
| 215 | - | if errors.Is(err, errSendTimedOut) { | |
| 216 | - | d.attachmentCleanupWg.Go(func() { d.parkOrDropSnatchedAttachment(token) }) | |
| 217 | - | } else { | |
| 218 | - | d.showSnatchedUnavailable(token) | |
| 219 | - | } | |
| 220 | - | return false | |
| 221 | - | } | |
| 222 | - | d.deferAttachmentTransitionCleanups(transition) | |
| 223 | - | if d.beforeReclaimFirstPaint != nil { | |
| 224 | - | d.beforeReclaimFirstPaint(transition.published) | |
| 225 | - | } | |
| 226 | - | d.firstPaintForTransition(transition.published) | |
| 227 | - | return true | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | // showSnatchedUnavailable is a best-effort precommit failure panel. Fresh role | |
| 231 | - | // admission and the generation-aware sender make it inert after a concurrent | |
| 232 | - | // reclaim, quit, transport replacement, or session teardown. | |
| 233 | - | func (d *Daemon) showSnatchedUnavailable(token attachmentRoleToken) { | |
| 234 | - | if !token.current() { | |
| 235 | - | return | |
| 236 | - | } | |
| 237 | - | fresh := token | |
| 238 | - | fresh.effect = nil | |
| 239 | - | ticket, admitted := token.ac.beginRoleEffect(fresh) | |
| 240 | - | if !admitted { | |
| 241 | - | return | |
| 242 | - | } | |
| 243 | - | fresh.effect = ticket | |
| 244 | - | defer fresh.endRoleEffect() | |
| 245 | - | if err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "Session is no longer available.", ticket); err != nil { | |
| 246 | - | fresh.endRoleEffect() | |
| 247 | - | d.parkOrDropSnatchedAttachment(token) | |
| 248 | - | } | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | func (d *Daemon) quitSnatchedAttachment(token attachmentRoleToken) bool { | |
| 252 | - | if token.role != attachmentSnatched || token.effect == nil { | |
| 253 | - | return false | |
| 254 | - | } | |
| 255 | - | token.effect.bindActionEnd(d, "quit-snatched") | |
| 256 | - | _ = d.boundedSendSnatchedControl(token, frameDetached(ports.ReasonDetach)) | |
| 257 | - | // Terminal cleanup freezes the same role gate, so release this action's | |
| 258 | - | // admission before attempting the generation-bound removal. | |
| 259 | - | token.endRoleEffect() | |
| 260 | - | return d.dropSnatchedAttachment(token) | |
| 261 | - | } | |
D internal/usecase/daemon/snatched_input_test.go +0 -363
| @@ -1,363 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "io" | |
| 6 | - | "strings" | |
| 7 | - | "sync" | |
| 8 | - | "testing" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/stretchr/testify/require" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/vev/internal/domain" | |
| 14 | - | "github.com/bnema/vev/internal/ports" | |
| 15 | - | "github.com/bnema/vev/internal/usecase/keys" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | type snatchedInputTimer struct { | |
| 19 | - | ch chan time.Time | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | func newSnatchedInputTimer() *snatchedInputTimer { | |
| 23 | - | return &snatchedInputTimer{ch: make(chan time.Time, 1)} | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | func (t *snatchedInputTimer) C() <-chan time.Time { return t.ch } | |
| 27 | - | func (*snatchedInputTimer) Reset(time.Duration) bool { return false } | |
| 28 | - | func (*snatchedInputTimer) Stop() bool { return true } | |
| 29 | - | func (t *snatchedInputTimer) Fire() { t.ch <- time.Time{} } | |
| 30 | - | ||
| 31 | - | type snatchedInputClock struct { | |
| 32 | - | timers chan *snatchedInputTimer | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | func newSnatchedInputClock() *snatchedInputClock { | |
| 36 | - | return &snatchedInputClock{timers: make(chan *snatchedInputTimer, 8)} | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | func (*snatchedInputClock) Now() time.Time { return time.Time{} } | |
| 40 | - | func (c *snatchedInputClock) NewTimer(delay time.Duration) ports.Timer { | |
| 41 | - | if delay != keys.ESCDelay { | |
| 42 | - | return stubTimer{} | |
| 43 | - | } | |
| 44 | - | timer := newSnatchedInputTimer() | |
| 45 | - | c.timers <- timer | |
| 46 | - | return timer | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | type snatchedCloseSignalTransport struct { | |
| 50 | - | mu sync.Mutex | |
| 51 | - | closed bool | |
| 52 | - | closedCh chan struct{} | |
| 53 | - | closeOnce sync.Once | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | func newSnatchedCloseSignalTransport() *snatchedCloseSignalTransport { | |
| 57 | - | return &snatchedCloseSignalTransport{closedCh: make(chan struct{})} | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func (*snatchedCloseSignalTransport) Send(ports.Frame) error { return nil } | |
| 61 | - | func (*snatchedCloseSignalTransport) Recv() (ports.Frame, error) { | |
| 62 | - | return ports.Frame{}, errors.New("closed") | |
| 63 | - | } | |
| 64 | - | func (t *snatchedCloseSignalTransport) Close() error { | |
| 65 | - | t.mu.Lock() | |
| 66 | - | t.closed = true | |
| 67 | - | t.mu.Unlock() | |
| 68 | - | t.closeOnce.Do(func() { close(t.closedCh) }) | |
| 69 | - | return nil | |
| 70 | - | } | |
| 71 | - | func (t *snatchedCloseSignalTransport) Closed() bool { | |
| 72 | - | t.mu.Lock() | |
| 73 | - | defer t.mu.Unlock() | |
| 74 | - | return t.closed | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | type blockedSnatchedQuitTransport struct { | |
| 78 | - | recv chan ports.Frame | |
| 79 | - | sendStarted chan struct{} | |
| 80 | - | sendDone chan struct{} | |
| 81 | - | closed chan struct{} | |
| 82 | - | closeOnce sync.Once | |
| 83 | - | sendOnce sync.Once | |
| 84 | - | mu sync.Mutex | |
| 85 | - | isClosed bool | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | func newBlockedSnatchedQuitTransport() *blockedSnatchedQuitTransport { | |
| 89 | - | return &blockedSnatchedQuitTransport{ | |
| 90 | - | recv: make(chan ports.Frame, 1), | |
| 91 | - | sendStarted: make(chan struct{}), | |
| 92 | - | sendDone: make(chan struct{}), | |
| 93 | - | closed: make(chan struct{}), | |
| 94 | - | } | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | func (t *blockedSnatchedQuitTransport) Send(frame ports.Frame) error { | |
| 98 | - | if frame.Type != ports.MsgDetached { | |
| 99 | - | return nil | |
| 100 | - | } | |
| 101 | - | t.sendOnce.Do(func() { close(t.sendStarted) }) | |
| 102 | - | <-t.closed | |
| 103 | - | close(t.sendDone) | |
| 104 | - | return io.ErrClosedPipe | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | func (t *blockedSnatchedQuitTransport) Recv() (ports.Frame, error) { | |
| 108 | - | select { | |
| 109 | - | case frame := <-t.recv: | |
| 110 | - | return frame, nil | |
| 111 | - | case <-t.closed: | |
| 112 | - | return ports.Frame{}, io.ErrClosedPipe | |
| 113 | - | } | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | func (t *blockedSnatchedQuitTransport) Close() error { | |
| 117 | - | t.mu.Lock() | |
| 118 | - | t.isClosed = true | |
| 119 | - | t.mu.Unlock() | |
| 120 | - | t.closeOnce.Do(func() { close(t.closed) }) | |
| 121 | - | return nil | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | func (t *blockedSnatchedQuitTransport) Closed() bool { | |
| 125 | - | t.mu.Lock() | |
| 126 | - | defer t.mu.Unlock() | |
| 127 | - | return t.isClosed | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | func newSnatchedInputFixture(t *testing.T, clock ports.Clock) (*Daemon, *session, *attachedClient, *attachedClient, *snatchedCloseSignalTransport) { | |
| 131 | - | t.Helper() | |
| 132 | - | d := newTestDaemon(t, nil, clock) | |
| 133 | - | waitingTransport := newSnatchedCloseSignalTransport() | |
| 134 | - | waiting := &attachedClient{ | |
| 135 | - | tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}, | |
| 136 | - | } | |
| 137 | - | active := &attachedClient{ | |
| 138 | - | tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: waiting.size, | |
| 139 | - | } | |
| 140 | - | waiting.initOverlays() | |
| 141 | - | active.initOverlays() | |
| 142 | - | sess := &session{sessionCore: sessionCore{id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}} | |
| 143 | - | waiting.setSession(sess) | |
| 144 | - | active.setSession(sess) | |
| 145 | - | d.sessions[sess.id] = sess | |
| 146 | - | d.attachCoordinator(sess, nil, active, true) | |
| 147 | - | return d, sess, waiting, active, waitingTransport | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | func sendSnatchedInput(d *Daemon, sess *session, ac *attachedClient, data string) bool { | |
| 151 | - | tr := ac.transport() | |
| 152 | - | token := sess.attachmentToken(ac, tr) | |
| 153 | - | return d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 154 | - | Type: ports.MsgInput, | |
| 155 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte(data)}), | |
| 156 | - | }) | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | func TestSnatchedInputStrictActions(t *testing.T) { | |
| 160 | - | tests := []struct { | |
| 161 | - | name string | |
| 162 | - | chunks []string | |
| 163 | - | fireEscape bool | |
| 164 | - | wantRole attachmentRole | |
| 165 | - | wantClosed bool | |
| 166 | - | }{ | |
| 167 | - | {name: "lowercase reclaim", chunks: []string{"r"}, wantRole: attachmentActive}, | |
| 168 | - | {name: "uppercase reclaim", chunks: []string{"R"}, wantRole: attachmentActive}, | |
| 169 | - | {name: "lowercase quit", chunks: []string{"q"}, wantRole: attachmentDetached, wantClosed: true}, | |
| 170 | - | {name: "uppercase quit", chunks: []string{"Q"}, wantRole: attachmentDetached, wantClosed: true}, | |
| 171 | - | {name: "standalone escape quits after delay", chunks: []string{"\x1b"}, fireEscape: true, wantRole: attachmentDetached, wantClosed: true}, | |
| 172 | - | {name: "coalesced reclaim ignored", chunks: []string{"rr"}, wantRole: attachmentSnatched}, | |
| 173 | - | {name: "paste containing quit ignored", chunks: []string{"paste q"}, wantRole: attachmentSnatched}, | |
| 174 | - | {name: "complete CSI ignored", chunks: []string{"\x1b[r"}, wantRole: attachmentSnatched}, | |
| 175 | - | {name: "complete SS3 ignored", chunks: []string{"\x1bOR"}, wantRole: attachmentSnatched}, | |
| 176 | - | {name: "complete sequence tail ignored", chunks: []string{"\x1b[Aq"}, wantRole: attachmentSnatched}, | |
| 177 | - | {name: "split CSI final reclaim ignored", chunks: []string{"\x1b[", "r"}, wantRole: attachmentSnatched}, | |
| 178 | - | {name: "split SS3 final reclaim ignored", chunks: []string{"\x1bO", "R"}, wantRole: attachmentSnatched}, | |
| 179 | - | {name: "split sequence frame tail ignored", chunks: []string{"\x1b[", "Aq"}, wantRole: attachmentSnatched}, | |
| 180 | - | {name: "escape followed by action ignored as alt chord", chunks: []string{"\x1b", "q"}, wantRole: attachmentSnatched}, | |
| 181 | - | {name: "CSI prefix remains inert", chunks: []string{"\x1b[", "12;"}, wantRole: attachmentSnatched}, | |
| 182 | - | {name: "overflow drain consumes final and frame tail", chunks: []string{"\x1b[", strings.Repeat(";", 80), "rQ"}, wantRole: attachmentSnatched}, | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | for _, tt := range tests { | |
| 186 | - | t.Run(tt.name, func(t *testing.T) { | |
| 187 | - | clock := newSnatchedInputClock() | |
| 188 | - | d, sess, waiting, active, waitingTransport := newSnatchedInputFixture(t, clock) | |
| 189 | - | for _, chunk := range tt.chunks { | |
| 190 | - | sendSnatchedInput(d, sess, waiting, chunk) | |
| 191 | - | } | |
| 192 | - | if tt.fireEscape { | |
| 193 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting), "escape acted before its delay") | |
| 194 | - | require.False(t, waitingTransport.Closed(), "escape closed the transport before its delay") | |
| 195 | - | timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer") | |
| 196 | - | timer.Fire() | |
| 197 | - | awaitTestCompletion(t, waitingTransport.closedCh, "standalone escape did not quit") | |
| 198 | - | } | |
| 199 | - | d.attachmentCleanupWg.Wait() | |
| 200 | - | ||
| 201 | - | require.Equal(t, tt.wantRole, sess.attachmentRole(waiting)) | |
| 202 | - | require.Equal(t, tt.wantClosed, waitingTransport.Closed()) | |
| 203 | - | if tt.wantRole == attachmentActive { | |
| 204 | - | require.Same(t, waiting, sess.client) | |
| 205 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(active)) | |
| 206 | - | } else { | |
| 207 | - | require.Same(t, active, sess.client) | |
| 208 | - | } | |
| 209 | - | }) | |
| 210 | - | } | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) { | |
| 214 | - | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 215 | - | waitingTransport := newBlockedSnatchedQuitTransport() | |
| 216 | - | defer func() { _ = waitingTransport.Close() }() | |
| 217 | - | activeTransport := &closeTrackingTransport{} | |
| 218 | - | d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport) | |
| 219 | - | ||
| 220 | - | otherTransport := &closeTrackingTransport{} | |
| 221 | - | other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: waiting.size} | |
| 222 | - | other.initOverlays() | |
| 223 | - | other.setSession(sess) | |
| 224 | - | sess.mu.Lock() | |
| 225 | - | sess.addSnatchedLocked(other) | |
| 226 | - | sess.mu.Unlock() | |
| 227 | - | otherToken := sess.attachmentToken(other, otherTransport) | |
| 228 | - | require.Equal(t, attachmentSnatched, otherToken.role) | |
| 229 | - | ||
| 230 | - | ownerLease := sess.renderCoordinator().attachmentLease(active) | |
| 231 | - | require.NotNil(t, ownerLease) | |
| 232 | - | activeGeneration := active.roleGeneration.Load() | |
| 233 | - | otherGeneration := other.roleGeneration.Load() | |
| 234 | - | ||
| 235 | - | loopDone := make(chan struct{}) | |
| 236 | - | go func() { | |
| 237 | - | d.runConnLoop(waiting) | |
| 238 | - | close(loopDone) | |
| 239 | - | }() | |
| 240 | - | waitingTransport.recv <- ports.Frame{ | |
| 241 | - | Type: ports.MsgInput, | |
| 242 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}), | |
| 243 | - | } | |
| 244 | - | awaitTestCompletion(t, waitingTransport.sendStarted, "snatched quit did not attempt its detach acknowledgement") | |
| 245 | - | select { | |
| 246 | - | case <-loopDone: | |
| 247 | - | t.Fatal("snatched quit returned before its acknowledgement deadline") | |
| 248 | - | default: | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline") | |
| 252 | - | require.Equal(t, detachNotifyTimeout, timer.duration) | |
| 253 | - | timer.ch <- time.Time{} | |
| 254 | - | awaitTestCompletion(t, waitingTransport.sendDone, "closing the captured link did not release its blocked send") | |
| 255 | - | awaitTestCompletion(t, loopDone, "snatched connection parser did not retire after the acknowledgement deadline") | |
| 256 | - | ||
| 257 | - | require.True(t, waitingTransport.Closed()) | |
| 258 | - | requireRoleGateRetired(t, waiting) | |
| 259 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 260 | - | require.Nil(t, waiting.currentSession()) | |
| 261 | - | require.Nil(t, waiting.transport()) | |
| 262 | - | require.Same(t, active, sess.client) | |
| 263 | - | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 264 | - | require.Same(t, ownerLease, sess.renderCoordinator().attachmentLease(active)) | |
| 265 | - | require.Equal(t, activeGeneration, active.roleGeneration.Load()) | |
| 266 | - | require.False(t, activeTransport.Closed()) | |
| 267 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(other)) | |
| 268 | - | require.Equal(t, otherGeneration, other.roleGeneration.Load()) | |
| 269 | - | require.Same(t, otherTransport, other.transport()) | |
| 270 | - | require.False(t, otherTransport.Closed()) | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | func TestBlockedSnatchedQuitCleanupRejectsReclaimedTransportGeneration(t *testing.T) { | |
| 274 | - | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 275 | - | staleTransport := newBlockedSnatchedQuitTransport() | |
| 276 | - | defer func() { _ = staleTransport.Close() }() | |
| 277 | - | ownerTransport := &closeTrackingTransport{} | |
| 278 | - | d, sess, waiting, owner := newAtomicReclaimFixture(t, clock, staleTransport, ownerTransport) | |
| 279 | - | ||
| 280 | - | ticketEnded := make(chan struct{}) | |
| 281 | - | releaseQuit := make(chan struct{}) | |
| 282 | - | d.afterActionRoleEffectEnded = func(action string) { | |
| 283 | - | if action == "quit-snatched" { | |
| 284 | - | close(ticketEnded) | |
| 285 | - | <-releaseQuit | |
| 286 | - | } | |
| 287 | - | } | |
| 288 | - | loopDone := make(chan struct{}) | |
| 289 | - | go func() { | |
| 290 | - | d.runConnLoop(waiting) | |
| 291 | - | close(loopDone) | |
| 292 | - | }() | |
| 293 | - | staleTransport.recv <- ports.Frame{ | |
| 294 | - | Type: ports.MsgInput, | |
| 295 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'Q'}}), | |
| 296 | - | } | |
| 297 | - | awaitTestCompletion(t, staleTransport.sendStarted, "snatched quit did not block in its captured transport") | |
| 298 | - | timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline") | |
| 299 | - | timer.ch <- time.Time{} | |
| 300 | - | awaitTestCompletion(t, staleTransport.sendDone, "deadline close did not retire the captured send") | |
| 301 | - | awaitTestCompletion(t, ticketEnded, "snatched quit did not end its role ticket before cleanup") | |
| 302 | - | ||
| 303 | - | freshTransport := &closeTrackingTransport{} | |
| 304 | - | waiting.replaceTransport(freshTransport) | |
| 305 | - | reclaimed, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 306 | - | target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 307 | - | expectedTransport: waiting.transportSnapshot(), ready: true, | |
| 308 | - | }) | |
| 309 | - | require.NoError(t, err) | |
| 310 | - | d.deferAttachmentTransitionCleanups(reclaimed) | |
| 311 | - | freshGeneration := reclaimed.published.generation | |
| 312 | - | freshLease := reclaimed.published.lease | |
| 313 | - | ||
| 314 | - | close(releaseQuit) | |
| 315 | - | awaitTestCompletion(t, loopDone, "stale quit parser did not retire after reclaim") | |
| 316 | - | d.attachmentCleanupWg.Wait() | |
| 317 | - | ||
| 318 | - | require.True(t, staleTransport.Closed()) | |
| 319 | - | requireRoleGateRetired(t, waiting) | |
| 320 | - | require.Same(t, waiting, sess.client) | |
| 321 | - | require.Equal(t, attachmentActive, sess.attachmentRole(waiting)) | |
| 322 | - | require.Equal(t, freshGeneration, waiting.roleGeneration.Load()) | |
| 323 | - | require.Same(t, freshTransport, waiting.transport()) | |
| 324 | - | require.False(t, freshTransport.Closed(), "stale quit cleanup closed the reclaimed transport") | |
| 325 | - | require.Same(t, freshLease, sess.renderCoordinator().attachmentLease(waiting)) | |
| 326 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(owner)) | |
| 327 | - | require.False(t, ownerTransport.Closed(), "stale quit cleanup affected the displaced owner") | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | func TestSnatchedInputTimerCannotAffectNewRoleOrTransport(t *testing.T) { | |
| 331 | - | clock := newSnatchedInputClock() | |
| 332 | - | d, sess, waiting, _, oldTransport := newSnatchedInputFixture(t, clock) | |
| 333 | - | ||
| 334 | - | sendSnatchedInput(d, sess, waiting, "\x1b") | |
| 335 | - | timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer") | |
| 336 | - | accepted := make(chan struct{}) | |
| 337 | - | release := make(chan struct{}) | |
| 338 | - | attempted := make(chan bool, 1) | |
| 339 | - | d.afterSnatchedEscapeAccepted = func() { | |
| 340 | - | close(accepted) | |
| 341 | - | <-release | |
| 342 | - | } | |
| 343 | - | d.afterSnatchedEscapeAttempt = func(admitted bool) { attempted <- admitted } | |
| 344 | - | timer.Fire() | |
| 345 | - | awaitTestCompletion(t, accepted, "escape timer callback did not consume parser state") | |
| 346 | - | ||
| 347 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 348 | - | target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 349 | - | expectedTransport: waiting.transportSnapshot(), ready: true, | |
| 350 | - | }) | |
| 351 | - | require.NoError(t, err) | |
| 352 | - | d.deferAttachmentTransitionCleanups(result) | |
| 353 | - | newTransport := newSnatchedCloseSignalTransport() | |
| 354 | - | waiting.replaceTransport(newTransport) | |
| 355 | - | close(release) | |
| 356 | - | require.False(t, awaitTestValue(t, attempted, "escape callback did not revalidate its stale role")) | |
| 357 | - | d.attachmentCleanupWg.Wait() | |
| 358 | - | ||
| 359 | - | require.Equal(t, attachmentActive, sess.attachmentRole(waiting)) | |
| 360 | - | require.Same(t, newTransport, waiting.transport()) | |
| 361 | - | require.False(t, newTransport.Closed(), "stale timer closed the replacement transport") | |
| 362 | - | require.False(t, oldTransport.Closed(), "stale timer closed the old transport after role activation") | |
| 363 | - | } | |
D internal/usecase/daemon/snatched_reclaim_test.go +0 -444
| @@ -1,444 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "sync" | |
| 6 | - | "testing" | |
| 7 | - | "time" | |
| 8 | - | ||
| 9 | - | "github.com/stretchr/testify/require" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/vev/internal/domain" | |
| 12 | - | "github.com/bnema/vev/internal/ports" | |
| 13 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 14 | - | "github.com/bnema/vev/pkg/renderer" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | type failingReclaimTransport struct { | |
| 18 | - | mu sync.Mutex | |
| 19 | - | closed bool | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | func (*failingReclaimTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 23 | - | func (*failingReclaimTransport) Recv() (ports.Frame, error) { | |
| 24 | - | return ports.Frame{}, errors.New("closed") | |
| 25 | - | } | |
| 26 | - | func (t *failingReclaimTransport) Close() error { | |
| 27 | - | t.mu.Lock() | |
| 28 | - | t.closed = true | |
| 29 | - | t.mu.Unlock() | |
| 30 | - | return nil | |
| 31 | - | } | |
| 32 | - | func (t *failingReclaimTransport) Closed() bool { | |
| 33 | - | t.mu.Lock() | |
| 34 | - | defer t.mu.Unlock() | |
| 35 | - | return t.closed | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | func newAtomicReclaimFixture(t *testing.T, clock ports.Clock, waitingTransport, activeTransport ports.Transport) (*Daemon, *session, *attachedClient, *attachedClient) { | |
| 39 | - | t.Helper() | |
| 40 | - | d := newTestDaemon(t, nil, clock) | |
| 41 | - | waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 42 | - | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |
| 43 | - | waiting.initOverlays() | |
| 44 | - | active.initOverlays() | |
| 45 | - | sess := &session{sessionCore: sessionCore{id: "atomic-reclaim", client: active, | |
| 46 | - | snatched: map[*attachedClient]struct{}{waiting: {}}}, | |
| 47 | - | } | |
| 48 | - | waiting.setSession(sess) | |
| 49 | - | active.setSession(sess) | |
| 50 | - | d.sessions[sess.id] = sess | |
| 51 | - | d.attachCoordinator(sess, nil, active, true) | |
| 52 | - | return d, sess, waiting, active | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | func requireSingleOwnerAndLease(t *testing.T, sess *session, clients ...*attachedClient) { | |
| 56 | - | t.Helper() | |
| 57 | - | sess.mu.Lock() | |
| 58 | - | owner := sess.client | |
| 59 | - | snatched := make(map[*attachedClient]struct{}, len(sess.snatched)) | |
| 60 | - | for ac := range sess.snatched { | |
| 61 | - | snatched[ac] = struct{}{} | |
| 62 | - | } | |
| 63 | - | sess.mu.Unlock() | |
| 64 | - | require.NotNil(t, owner) | |
| 65 | - | ||
| 66 | - | activeCount := 0 | |
| 67 | - | for _, ac := range clients { | |
| 68 | - | if ac == owner { | |
| 69 | - | activeCount++ | |
| 70 | - | continue | |
| 71 | - | } | |
| 72 | - | _, waiting := snatched[ac] | |
| 73 | - | require.True(t, waiting, "every non-owner must remain snatched") | |
| 74 | - | } | |
| 75 | - | require.Equal(t, 1, activeCount) | |
| 76 | - | ||
| 77 | - | rc := sess.renderCoordinator() | |
| 78 | - | rc.mu.Lock() | |
| 79 | - | lease := rc.lease | |
| 80 | - | rc.mu.Unlock() | |
| 81 | - | require.NotNil(t, lease) | |
| 82 | - | require.True(t, lease.active) | |
| 83 | - | require.Same(t, owner, lease.attachment) | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | func TestReclaimSnatchedSwapsOwnerLeaseRebasesAndAppliesTheme(t *testing.T) { | |
| 87 | - | d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 88 | - | requesterTransport := &closeTrackingTransport{} | |
| 89 | - | requester.replaceTransport(requesterTransport) | |
| 90 | - | d.attachCoordinator(sess, nil, requester, true) | |
| 91 | - | ||
| 92 | - | active := &attachedClient{ | |
| 93 | - | tr: &closeTrackingTransport{}, | |
| 94 | - | output: newOutputStateStream(), | |
| 95 | - | size: requester.size, | |
| 96 | - | } | |
| 97 | - | active.initOverlays() | |
| 98 | - | displaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 99 | - | target: sess, next: active, | |
| 100 | - | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 101 | - | expectedTransport: active.transportSnapshot(), ready: true, | |
| 102 | - | }) | |
| 103 | - | require.NoError(t, err) | |
| 104 | - | d.deferAttachmentTransitionCleanups(displaced) | |
| 105 | - | d.attachmentCleanupWg.Wait() | |
| 106 | - | ||
| 107 | - | requester.sendMu.Lock() | |
| 108 | - | beforeReclaimState := requester.output.next | |
| 109 | - | requester.sendMu.Unlock() | |
| 110 | - | requesterTheme := themeui.Theme{ | |
| 111 | - | Foreground: renderer.RGB{R: 11, G: 22, B: 33}, | |
| 112 | - | Background: renderer.RGB{R: 44, G: 55, B: 66}, | |
| 113 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, | |
| 114 | - | } | |
| 115 | - | requester.setClientTheme(requesterTheme) | |
| 116 | - | ||
| 117 | - | token := sess.attachmentToken(requester, requesterTransport) | |
| 118 | - | require.Equal(t, attachmentSnatched, token.role) | |
| 119 | - | require.False(t, d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 120 | - | Type: ports.MsgInput, | |
| 121 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 122 | - | })) | |
| 123 | - | ||
| 124 | - | sess.mu.Lock() | |
| 125 | - | require.Same(t, requester, sess.client) | |
| 126 | - | _, activeIsSnatched := sess.snatched[active] | |
| 127 | - | _, requesterIsSnatched := sess.snatched[requester] | |
| 128 | - | sess.mu.Unlock() | |
| 129 | - | require.True(t, activeIsSnatched) | |
| 130 | - | require.False(t, requesterIsSnatched) | |
| 131 | - | rc := sess.renderCoordinator() | |
| 132 | - | lease := rc.attachmentLease(requester) | |
| 133 | - | require.NotNil(t, lease) | |
| 134 | - | require.Nil(t, rc.attachmentLease(active)) | |
| 135 | - | require.True(t, lease.active) | |
| 136 | - | ||
| 137 | - | requester.sendMu.Lock() | |
| 138 | - | require.Greater(t, requester.output.next, beforeReclaimState, "reclaim must emit a reset first paint") | |
| 139 | - | require.GreaterOrEqual(t, requester.output.acked, beforeReclaimState, "reclaim must rebase the old panel output chain") | |
| 140 | - | requester.sendMu.Unlock() | |
| 141 | - | assertSessionDefaultColors(t, sess, requesterTheme.Foreground, requesterTheme.Background) | |
| 142 | - | ||
| 143 | - | d.attachmentCleanupWg.Wait() | |
| 144 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(active)) | |
| 145 | - | activeTransport, ok := active.transport().(*closeTrackingTransport) | |
| 146 | - | require.True(t, ok, "active transport has unexpected type") | |
| 147 | - | require.False(t, activeTransport.Closed()) | |
| 148 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, requester.size) | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | func TestFailedReclaimBeforeCommitPreservesOwnerAndShowsUnavailable(t *testing.T) { | |
| 152 | - | waitingTransport := &closeTrackingTransport{} | |
| 153 | - | activeTransport := &closeTrackingTransport{} | |
| 154 | - | d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport) | |
| 155 | - | token := sess.attachmentToken(waiting, waitingTransport) | |
| 156 | - | ||
| 157 | - | d.mu.Lock() | |
| 158 | - | delete(d.sessions, sess.id) | |
| 159 | - | d.mu.Unlock() | |
| 160 | - | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 161 | - | Type: ports.MsgInput, | |
| 162 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 163 | - | }) | |
| 164 | - | ||
| 165 | - | requireSingleOwnerAndLease(t, sess, waiting, active) | |
| 166 | - | require.Same(t, active, sess.client) | |
| 167 | - | require.False(t, waitingTransport.Closed()) | |
| 168 | - | frames := waitingTransport.Sends() | |
| 169 | - | require.Len(t, frames, 1) | |
| 170 | - | output, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 171 | - | require.NoError(t, err) | |
| 172 | - | require.Contains(t, string(output.Data), "Session is no longer available.") | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | func TestPostcommitPanelFailureRetiresOnlyDisplacedClient(t *testing.T) { | |
| 176 | - | waitingTransport := &closeTrackingTransport{} | |
| 177 | - | activeTransport := &failingReclaimTransport{} | |
| 178 | - | d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport) | |
| 179 | - | token := sess.attachmentToken(waiting, waitingTransport) | |
| 180 | - | ||
| 181 | - | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 182 | - | Type: ports.MsgInput, | |
| 183 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 184 | - | }) | |
| 185 | - | d.attachmentCleanupWg.Wait() | |
| 186 | - | ||
| 187 | - | require.Same(t, waiting, sess.client) | |
| 188 | - | require.Equal(t, attachmentActive, sess.attachmentRole(waiting)) | |
| 189 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(active)) | |
| 190 | - | require.NotNil(t, sess.renderCoordinator().attachmentLease(waiting)) | |
| 191 | - | require.Nil(t, sess.renderCoordinator().attachmentLease(active)) | |
| 192 | - | require.False(t, waitingTransport.Closed()) | |
| 193 | - | require.True(t, activeTransport.Closed()) | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | func TestBlockedRequesterActivationDeadlinePreservesOwnerLease(t *testing.T) { | |
| 197 | - | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 198 | - | waitingTransport := newSnatchedCloseSignalTransport() | |
| 199 | - | activeTransport := &closeTrackingTransport{} | |
| 200 | - | d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport) | |
| 201 | - | ||
| 202 | - | waiting.sendMu.Lock() | |
| 203 | - | locked := true | |
| 204 | - | defer func() { | |
| 205 | - | if locked { | |
| 206 | - | waiting.sendMu.Unlock() | |
| 207 | - | } | |
| 208 | - | }() | |
| 209 | - | done := make(chan struct{}) | |
| 210 | - | go func() { | |
| 211 | - | token := sess.attachmentToken(waiting, waitingTransport) | |
| 212 | - | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 213 | - | Type: ports.MsgInput, | |
| 214 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 215 | - | }) | |
| 216 | - | close(done) | |
| 217 | - | }() | |
| 218 | - | ||
| 219 | - | timer := awaitTestValue(t, clock.timers, "reclaim did not install its activation deadline") | |
| 220 | - | require.Equal(t, detachNotifyTimeout, timer.duration) | |
| 221 | - | timer.ch <- time.Time{} | |
| 222 | - | awaitTestCompletion(t, done, "deadline did not release blocked reclaim") | |
| 223 | - | require.Same(t, active, sess.client) | |
| 224 | - | require.Same(t, active, sess.renderCoordinator().attachmentLease(active).attachment) | |
| 225 | - | require.True(t, waitingTransport.Closed()) | |
| 226 | - | ||
| 227 | - | waiting.sendMu.Unlock() | |
| 228 | - | locked = false | |
| 229 | - | d.attachmentCleanupWg.Wait() | |
| 230 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 231 | - | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | func TestQuitInvalidatesPausedSnatchedFinalizerWithoutLatePanel(t *testing.T) { | |
| 235 | - | waitingTransport := &closeTrackingTransport{} | |
| 236 | - | activeTransport := &closeTrackingTransport{} | |
| 237 | - | d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport) | |
| 238 | - | ||
| 239 | - | finalizerStarted := make(chan struct{}) | |
| 240 | - | releaseFinalizer := make(chan struct{}) | |
| 241 | - | d.afterDisplacedCleanupStarted = func() { | |
| 242 | - | close(finalizerStarted) | |
| 243 | - | <-releaseFinalizer | |
| 244 | - | } | |
| 245 | - | waitingToken := sess.attachmentToken(waiting, waitingTransport) | |
| 246 | - | d.handleSnatchedClientFrame(waitingToken, ports.Frame{ | |
| 247 | - | Type: ports.MsgInput, | |
| 248 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 249 | - | }) | |
| 250 | - | awaitTestCompletion(t, finalizerStarted, "displaced finalizer did not pause") | |
| 251 | - | ||
| 252 | - | activeToken := sess.attachmentToken(active, activeTransport) | |
| 253 | - | require.Equal(t, attachmentSnatched, activeToken.role) | |
| 254 | - | require.True(t, d.handleSnatchedClientFrame(activeToken, ports.Frame{ | |
| 255 | - | Type: ports.MsgInput, | |
| 256 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}), | |
| 257 | - | })) | |
| 258 | - | close(releaseFinalizer) | |
| 259 | - | d.attachmentCleanupWg.Wait() | |
| 260 | - | ||
| 261 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(active)) | |
| 262 | - | frames := activeTransport.Sends() | |
| 263 | - | require.Len(t, frames, 1, "stale finalizer emitted output after quit") | |
| 264 | - | require.Equal(t, ports.MsgDetached, frames[0].Type) | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | func TestReclaimOrdersDisplacedPanelAfterAdmittedOldPaint(t *testing.T) { | |
| 268 | - | d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 269 | - | requesterTransport := &closeTrackingTransport{} | |
| 270 | - | requester.replaceTransport(requesterTransport) | |
| 271 | - | d.attachCoordinator(sess, nil, requester, true) | |
| 272 | - | ||
| 273 | - | activeTransport := &closeTrackingTransport{} | |
| 274 | - | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: requester.size} | |
| 275 | - | active.initOverlays() | |
| 276 | - | initial, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 277 | - | target: sess, next: active, | |
| 278 | - | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 279 | - | expectedTransport: active.transportSnapshot(), ready: true, | |
| 280 | - | }) | |
| 281 | - | require.NoError(t, err) | |
| 282 | - | d.deferAttachmentTransitionCleanups(initial) | |
| 283 | - | d.attachmentCleanupWg.Wait() | |
| 284 | - | baselineActiveFrames := len(activeTransport.Sends()) | |
| 285 | - | ||
| 286 | - | paintAdmitted := make(chan struct{}) | |
| 287 | - | releasePaint := make(chan struct{}) | |
| 288 | - | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |
| 289 | - | if token.ac == active && token.role == attachmentActive { | |
| 290 | - | close(paintAdmitted) | |
| 291 | - | <-releasePaint | |
| 292 | - | } | |
| 293 | - | } | |
| 294 | - | activeToken := sess.attachmentToken(active, activeTransport) | |
| 295 | - | paintDone := make(chan bool, 1) | |
| 296 | - | go func() { paintDone <- d.firstPaintForTransition(activeToken) }() | |
| 297 | - | awaitTestCompletion(t, paintAdmitted, "old active paint was not admitted") | |
| 298 | - | ||
| 299 | - | reclaimFrozen := make(chan struct{}) | |
| 300 | - | var frozenOnce sync.Once | |
| 301 | - | d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) { | |
| 302 | - | if action == "reclaim-snatched" { | |
| 303 | - | frozenOnce.Do(func() { close(reclaimFrozen) }) | |
| 304 | - | } | |
| 305 | - | } | |
| 306 | - | reclaimDone := make(chan struct{}) | |
| 307 | - | go func() { | |
| 308 | - | token := sess.attachmentToken(requester, requesterTransport) | |
| 309 | - | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 310 | - | Type: ports.MsgInput, | |
| 311 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 312 | - | }) | |
| 313 | - | close(reclaimDone) | |
| 314 | - | }() | |
| 315 | - | awaitTestCompletion(t, reclaimFrozen, "reclaim did not freeze role effects behind old paint") | |
| 316 | - | close(releasePaint) | |
| 317 | - | require.True(t, awaitTestValue(t, paintDone, "old paint did not finish")) | |
| 318 | - | awaitTestCompletion(t, reclaimDone, "reclaim did not finish after old paint") | |
| 319 | - | d.attachmentCleanupWg.Wait() | |
| 320 | - | ||
| 321 | - | frames := activeTransport.Sends()[baselineActiveFrames:] | |
| 322 | - | require.NotEmpty(t, frames) | |
| 323 | - | last := frames[len(frames)-1] | |
| 324 | - | require.Equal(t, ports.MsgOutput, last.Type) | |
| 325 | - | output, err := ports.UnmarshalOutput(last.Payload) | |
| 326 | - | require.NoError(t, err) | |
| 327 | - | require.Contains(t, string(output.Data), "Session snatched", "old paint overtook the displaced reset panel") | |
| 328 | - | requireSingleOwnerAndLease(t, sess, requester, active) | |
| 329 | - | } | |
| 330 | - | ||
| 331 | - | func TestLaterReclaimMakesPausedFirstPaintStale(t *testing.T) { | |
| 332 | - | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 333 | - | firstTransport := &closeTrackingTransport{} | |
| 334 | - | first.replaceTransport(firstTransport) | |
| 335 | - | d.attachCoordinator(sess, nil, first, true) | |
| 336 | - | ||
| 337 | - | secondTransport := &closeTrackingTransport{} | |
| 338 | - | second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size} | |
| 339 | - | second.initOverlays() | |
| 340 | - | initial, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 341 | - | target: sess, next: second, | |
| 342 | - | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 343 | - | expectedTransport: second.transportSnapshot(), ready: true, | |
| 344 | - | }) | |
| 345 | - | require.NoError(t, err) | |
| 346 | - | d.deferAttachmentTransitionCleanups(initial) | |
| 347 | - | d.attachmentCleanupWg.Wait() | |
| 348 | - | baselineFirstFrames := len(firstTransport.Sends()) | |
| 349 | - | ||
| 350 | - | firstPaintPaused := make(chan struct{}) | |
| 351 | - | releaseFirstPaint := make(chan struct{}) | |
| 352 | - | d.beforeReclaimFirstPaint = func(token attachmentRoleToken) { | |
| 353 | - | if token.ac != first { | |
| 354 | - | return | |
| 355 | - | } | |
| 356 | - | close(firstPaintPaused) | |
| 357 | - | <-releaseFirstPaint | |
| 358 | - | } | |
| 359 | - | firstDone := make(chan struct{}) | |
| 360 | - | go func() { | |
| 361 | - | token := sess.attachmentToken(first, firstTransport) | |
| 362 | - | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 363 | - | Type: ports.MsgInput, | |
| 364 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 365 | - | }) | |
| 366 | - | close(firstDone) | |
| 367 | - | }() | |
| 368 | - | awaitTestCompletion(t, firstPaintPaused, "first reclaim did not pause before first paint") | |
| 369 | - | require.Same(t, first, sess.client, "first reclaim was not committed before its paint") | |
| 370 | - | ||
| 371 | - | secondToken := sess.attachmentToken(second, secondTransport) | |
| 372 | - | require.Equal(t, attachmentSnatched, secondToken.role) | |
| 373 | - | d.handleSnatchedClientFrame(secondToken, ports.Frame{ | |
| 374 | - | Type: ports.MsgInput, | |
| 375 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 376 | - | }) | |
| 377 | - | require.Same(t, second, sess.client) | |
| 378 | - | close(releaseFirstPaint) | |
| 379 | - | awaitTestCompletion(t, firstDone, "stale first paint did not return") | |
| 380 | - | d.attachmentCleanupWg.Wait() | |
| 381 | - | ||
| 382 | - | newFirstFrames := firstTransport.Sends()[baselineFirstFrames:] | |
| 383 | - | require.Len(t, newFirstFrames, 1, "stale first paint emitted after a later reclaim") | |
| 384 | - | require.Equal(t, ports.MsgOutput, newFirstFrames[0].Type) | |
| 385 | - | output, err := ports.UnmarshalOutput(newFirstFrames[0].Payload) | |
| 386 | - | require.NoError(t, err) | |
| 387 | - | require.Contains(t, string(output.Data), "Session snatched") | |
| 388 | - | requireSingleOwnerAndLease(t, sess, first, second) | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | func TestAnyWaiterCanReclaimAndConcurrentRequestsKeepSingleOwnerLease(t *testing.T) { | |
| 392 | - | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 393 | - | firstTransport := &closeTrackingTransport{} | |
| 394 | - | first.replaceTransport(firstTransport) | |
| 395 | - | d.attachCoordinator(sess, nil, first, true) | |
| 396 | - | ||
| 397 | - | secondTransport := &closeTrackingTransport{} | |
| 398 | - | second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size} | |
| 399 | - | second.initOverlays() | |
| 400 | - | toSecond, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 401 | - | target: sess, next: second, | |
| 402 | - | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 403 | - | expectedTransport: second.transportSnapshot(), ready: true, | |
| 404 | - | }) | |
| 405 | - | require.NoError(t, err) | |
| 406 | - | d.deferAttachmentTransitionCleanups(toSecond) | |
| 407 | - | d.attachmentCleanupWg.Wait() | |
| 408 | - | ||
| 409 | - | thirdTransport := &closeTrackingTransport{} | |
| 410 | - | third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size} | |
| 411 | - | third.initOverlays() | |
| 412 | - | toThird, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 413 | - | target: sess, next: third, | |
| 414 | - | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 415 | - | expectedTransport: third.transportSnapshot(), ready: true, | |
| 416 | - | }) | |
| 417 | - | require.NoError(t, err) | |
| 418 | - | d.deferAttachmentTransitionCleanups(toThird) | |
| 419 | - | d.attachmentCleanupWg.Wait() | |
| 420 | - | requireSingleOwnerAndLease(t, sess, first, second, third) | |
| 421 | - | ||
| 422 | - | firstToken := sess.attachmentToken(first, firstTransport) | |
| 423 | - | secondToken := sess.attachmentToken(second, secondTransport) | |
| 424 | - | require.Equal(t, attachmentSnatched, firstToken.role) | |
| 425 | - | require.Equal(t, attachmentSnatched, secondToken.role) | |
| 426 | - | start := make(chan struct{}) | |
| 427 | - | done := make(chan struct{}, 2) | |
| 428 | - | for _, token := range []attachmentRoleToken{firstToken, secondToken} { | |
| 429 | - | go func() { | |
| 430 | - | <-start | |
| 431 | - | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 432 | - | Type: ports.MsgInput, | |
| 433 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 434 | - | }) | |
| 435 | - | done <- struct{}{} | |
| 436 | - | }() | |
| 437 | - | } | |
| 438 | - | close(start) | |
| 439 | - | awaitTestCompletion(t, done, "first concurrent reclaim did not return") | |
| 440 | - | awaitTestCompletion(t, done, "second concurrent reclaim did not return") | |
| 441 | - | d.attachmentCleanupWg.Wait() | |
| 442 | - | ||
| 443 | - | requireSingleOwnerAndLease(t, sess, first, second, third) | |
| 444 | - | } | |
D internal/usecase/daemon/snatched_render.go +0 -169
| @@ -1,169 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/domain" | |
| 7 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 8 | - | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | var snatchedModal = ui.Modal{ | |
| 13 | - | FixedWidth: 42, FixedHeight: 7, | |
| 14 | - | Title: " Session snatched ", Anchor: domain.AnchorCenter, | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string) renderer.Frame { | |
| 18 | - | width, height := max(size.Cols, 1), max(size.Rows, 1) | |
| 19 | - | frame := renderer.NewFrame(width, height) | |
| 20 | - | ui.FillRect(frame, domain.Rect{Width: width, Height: height}, renderer.Cell{Rune: ' ', Style: styles.PickerBase}) | |
| 21 | - | if width < snatchedModal.FixedWidth || height < snatchedModal.FixedHeight { | |
| 22 | - | drawSnatchedCompact(frame, styles, feedback) | |
| 23 | - | return frame | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | presentation := snatchedModal.Resolve(domain.Size{Cols: width, Rows: height}) | |
| 27 | - | inner := snatchedModal.CompositePresentation(frame, presentation, styles.BorderMuted, styles.PickerBase) | |
| 28 | - | message := "This session is now active elsewhere." | |
| 29 | - | if feedback != "" { | |
| 30 | - | message = feedback | |
| 31 | - | } | |
| 32 | - | drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase) | |
| 33 | - | drawCenteredSegments(frame, inner, inner.Y+3, []textSegment{ | |
| 34 | - | {" r ", styles.HintKey}, | |
| 35 | - | {" Resume here", styles.PickerBase}, | |
| 36 | - | {" ", styles.PickerBase}, | |
| 37 | - | {" q ", styles.HintKey}, | |
| 38 | - | {" / ", styles.PickerDescription}, | |
| 39 | - | {" Esc ", styles.HintKey}, | |
| 40 | - | {" Quit", styles.PickerBase}, | |
| 41 | - | }) | |
| 42 | - | return frame | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func drawSnatchedCompact(frame renderer.Frame, styles themeui.Styles, feedback string) { | |
| 46 | - | status := "Session snatched" | |
| 47 | - | if feedback != "" { | |
| 48 | - | status = "Session unavailable" | |
| 49 | - | } | |
| 50 | - | bounds := domain.Rect{Width: frame.Width, Height: frame.Height} | |
| 51 | - | startY := max((frame.Height-3)/2, 0) | |
| 52 | - | drawCenteredLine(frame, bounds, startY, status, styles.PickerBase) | |
| 53 | - | drawCenteredSegments(frame, bounds, startY+1, []textSegment{ | |
| 54 | - | {" r ", styles.HintKey}, | |
| 55 | - | {" Resume", styles.PickerBase}, | |
| 56 | - | }) | |
| 57 | - | drawCenteredSegments(frame, bounds, startY+2, []textSegment{ | |
| 58 | - | {" q ", styles.HintKey}, | |
| 59 | - | {" Quit", styles.PickerBase}, | |
| 60 | - | }) | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | // textSegment is one differently-styled run within a centered hint line. | |
| 64 | - | type textSegment struct { | |
| 65 | - | text string | |
| 66 | - | style renderer.Style | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) { | |
| 70 | - | drawCenteredSegments(frame, bounds, y, []textSegment{{text, style}}) | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | // drawCenteredSegments draws consecutive differently-styled segments as one | |
| 74 | - | // logical line, centered as a whole within bounds. | |
| 75 | - | func drawCenteredSegments(frame renderer.Frame, bounds domain.Rect, y int, segments []textSegment) { | |
| 76 | - | total := 0 | |
| 77 | - | for _, seg := range segments { | |
| 78 | - | for _, r := range seg.text { | |
| 79 | - | total += renderer.RuneWidth(r) | |
| 80 | - | } | |
| 81 | - | } | |
| 82 | - | x := bounds.X + max((bounds.Width-total)/2, 0) | |
| 83 | - | limit := bounds.X + bounds.Width | |
| 84 | - | for _, seg := range segments { | |
| 85 | - | ui.DrawText(frame, x, y, limit, seg.text, seg.style) | |
| 86 | - | for _, r := range seg.text { | |
| 87 | - | x += renderer.RuneWidth(r) | |
| 88 | - | } | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | var errSnatchedOutputStale = errors.New("snatched output role changed") | |
| 93 | - | ||
| 94 | - | // sendSnatchedPanel serializes a dependency-free panel reset onto one exact | |
| 95 | - | // attachment role and transport lifetime. It does not inspect session state or | |
| 96 | - | // capture pane content. | |
| 97 | - | func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapshot, generation uint64, feedback string, effects ...*roleEffectTicket) error { | |
| 98 | - | if expected.transport == nil { | |
| 99 | - | return errors.New("snatched client transport is nil") | |
| 100 | - | } | |
| 101 | - | var effect *roleEffectTicket | |
| 102 | - | if len(effects) != 0 { | |
| 103 | - | effect = effects[0] | |
| 104 | - | } | |
| 105 | - | tr, err := d.boundedSendWith(expected.transport, func() error { | |
| 106 | - | ac.sendMu.Lock() | |
| 107 | - | defer ac.sendMu.Unlock() | |
| 108 | - | if !ac.transportSnapshotCurrent(expected) { | |
| 109 | - | return errTransportReplaced | |
| 110 | - | } | |
| 111 | - | if ac.roleGeneration.Load() != generation { | |
| 112 | - | return errSnatchedOutputStale | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | applied := ac.getAppliedTheme() | |
| 116 | - | frame := snatchedPanelFrame(ac.size, applied.Resolved.Styles, feedback) | |
| 117 | - | ac.rebaseOutput() | |
| 118 | - | var data []byte | |
| 119 | - | var cursor cursorCandidate | |
| 120 | - | var preparedANSI *preparedOutput | |
| 121 | - | var preparedScreen *preparedStructuredOutput | |
| 122 | - | var sendErr error | |
| 123 | - | if ac.proxied { | |
| 124 | - | screenOutput := ac.ensureScreenOutput() | |
| 125 | - | if screenOutput == nil { | |
| 126 | - | return errors.New("proxied attachment has no structured output stream") | |
| 127 | - | } | |
| 128 | - | preparedScreen, sendErr = screenOutput.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, cursorOut{hidden: true}, true, ac.echoAck.Load()) | |
| 129 | - | if sendErr == nil { | |
| 130 | - | data = preparedScreen.data | |
| 131 | - | } | |
| 132 | - | } else { | |
| 133 | - | preparedANSI, sendErr = ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true) | |
| 134 | - | if sendErr == nil { | |
| 135 | - | cursor = ac.prepareCursorTail(cursorOut{hidden: true}, true) | |
| 136 | - | data = append(append([]byte(nil), preparedANSI.data...), cursor.data...) | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | if sendErr != nil { | |
| 140 | - | return sendErr | |
| 141 | - | } | |
| 142 | - | if effect != nil && !effect.beginTransportSend(expected) { | |
| 143 | - | return errAttachmentTransition | |
| 144 | - | } | |
| 145 | - | if ac.proxied { | |
| 146 | - | sendErr = preparedScreen.send(expected.transport.Send) | |
| 147 | - | } else { | |
| 148 | - | sendErr = preparedANSI.send(data, ac.echoAck.Load(), expected.transport.Send) | |
| 149 | - | } | |
| 150 | - | if effect != nil { | |
| 151 | - | if sendErr != nil { | |
| 152 | - | effect.reportTransportFailure(expected) | |
| 153 | - | } | |
| 154 | - | effect.endTransportSend() | |
| 155 | - | } | |
| 156 | - | if sendErr == nil { | |
| 157 | - | if ac.proxied { | |
| 158 | - | ac.lastCursor = cursorOut{hidden: true} | |
| 159 | - | } else { | |
| 160 | - | ac.lastCursor = cursor.next | |
| 161 | - | } | |
| 162 | - | } | |
| 163 | - | return sendErr | |
| 164 | - | }) | |
| 165 | - | if errors.Is(err, errSendTimedOut) { | |
| 166 | - | _ = ac.closeCapturedTransport(tr) | |
| 167 | - | } | |
| 168 | - | return err | |
| 169 | - | } | |
D internal/usecase/daemon/snatched_render_test.go +0 -340
| @@ -1,340 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "strings" | |
| 6 | - | "sync" | |
| 7 | - | "testing" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/vev/internal/domain" | |
| 13 | - | "github.com/bnema/vev/internal/ports" | |
| 14 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 15 | - | "github.com/bnema/vev/pkg/renderer" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | func TestSnatchedPanelFrame(t *testing.T) { | |
| 19 | - | styles := themeui.Styles{ | |
| 20 | - | PickerBase: renderer.Style{Foreground: 1}, | |
| 21 | - | PickerSelection: renderer.Style{Foreground: 2}, | |
| 22 | - | BorderMuted: renderer.Style{Foreground: 3}, | |
| 23 | - | HintKey: renderer.Style{Foreground: 4}, | |
| 24 | - | PickerDescription: renderer.Style{Foreground: 5}, | |
| 25 | - | } | |
| 26 | - | tests := []struct { | |
| 27 | - | name string | |
| 28 | - | size domain.Size | |
| 29 | - | feedback string | |
| 30 | - | wantSize domain.Size | |
| 31 | - | want []string | |
| 32 | - | wantKeys string | |
| 33 | - | wantSep string | |
| 34 | - | wantBounds domain.Rect | |
| 35 | - | compact bool | |
| 36 | - | }{ | |
| 37 | - | { | |
| 38 | - | name: "centered bordered panel", | |
| 39 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 40 | - | wantSize: domain.Size{Cols: 80, Rows: 24}, | |
| 41 | - | want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"}, | |
| 42 | - | wantKeys: " r q Esc ", | |
| 43 | - | wantSep: " / ", | |
| 44 | - | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | |
| 45 | - | }, | |
| 46 | - | { | |
| 47 | - | name: "compact fallback", | |
| 48 | - | size: domain.Size{Cols: 24, Rows: 4}, | |
| 49 | - | wantSize: domain.Size{Cols: 24, Rows: 4}, | |
| 50 | - | want: []string{"Session snatched", "r Resume", "q Quit"}, | |
| 51 | - | wantKeys: " r q ", | |
| 52 | - | compact: true, | |
| 53 | - | }, | |
| 54 | - | { | |
| 55 | - | name: "compact unavailable feedback", | |
| 56 | - | size: domain.Size{Cols: 24, Rows: 4}, | |
| 57 | - | feedback: "Session is no longer available.", | |
| 58 | - | wantSize: domain.Size{Cols: 24, Rows: 4}, | |
| 59 | - | want: []string{"Session unavailable", "r Resume", "q Quit"}, | |
| 60 | - | wantKeys: " r q ", | |
| 61 | - | compact: true, | |
| 62 | - | }, | |
| 63 | - | { | |
| 64 | - | name: "bordered unavailable feedback", | |
| 65 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 66 | - | feedback: "Session is no longer available.", | |
| 67 | - | wantSize: domain.Size{Cols: 80, Rows: 24}, | |
| 68 | - | want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"}, | |
| 69 | - | wantKeys: " r q Esc ", | |
| 70 | - | wantSep: " / ", | |
| 71 | - | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | |
| 72 | - | }, | |
| 73 | - | { | |
| 74 | - | name: "zero dimensions clamp", | |
| 75 | - | size: domain.Size{}, | |
| 76 | - | wantSize: domain.Size{Cols: 1, Rows: 1}, | |
| 77 | - | compact: true, | |
| 78 | - | }, | |
| 79 | - | { | |
| 80 | - | name: "negative dimensions clamp", | |
| 81 | - | size: domain.Size{Cols: -10, Rows: -20}, | |
| 82 | - | wantSize: domain.Size{Cols: 1, Rows: 1}, | |
| 83 | - | compact: true, | |
| 84 | - | }, | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | for _, tt := range tests { | |
| 88 | - | t.Run(tt.name, func(t *testing.T) { | |
| 89 | - | frame := snatchedPanelFrame(tt.size, styles, tt.feedback) | |
| 90 | - | require.Equal(t, tt.wantSize.Cols, frame.Width) | |
| 91 | - | require.Equal(t, tt.wantSize.Rows, frame.Height) | |
| 92 | - | require.NoError(t, frame.Validate()) | |
| 93 | - | ||
| 94 | - | text := snatchedFrameText(frame) | |
| 95 | - | for _, want := range tt.want { | |
| 96 | - | require.Contains(t, text, want) | |
| 97 | - | } | |
| 98 | - | require.Equal(t, tt.wantKeys, snatchedStyledText(frame, styles.HintKey), "keycap badges must use the HintKey style") | |
| 99 | - | require.Equal(t, tt.wantSep, snatchedStyledText(frame, styles.PickerDescription), "hint separator must use the muted description style") | |
| 100 | - | ||
| 101 | - | if tt.compact { | |
| 102 | - | require.NotContains(t, text, "┌") | |
| 103 | - | require.NotContains(t, text, "┘") | |
| 104 | - | return | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | require.Equal(t, tt.wantBounds, snatchedModal.Resolve(tt.wantSize).Bounds) | |
| 108 | - | topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y) | |
| 109 | - | bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1) | |
| 110 | - | require.Equal(t, '┌', topLeft.Rune) | |
| 111 | - | require.Equal(t, '┘', bottomRight.Rune) | |
| 112 | - | require.True(t, topLeft.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style") | |
| 113 | - | require.True(t, bottomRight.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style") | |
| 114 | - | requireFreshSnatchedBackground(t, frame, tt.wantBounds, styles.PickerBase) | |
| 115 | - | }) | |
| 116 | - | } | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | func TestSendSnatchedPanelRebasesOutput(t *testing.T) { | |
| 120 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 121 | - | tr := &closeTrackingTransport{} | |
| 122 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 123 | - | ac.output.next = 7 | |
| 124 | - | ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})}) | |
| 125 | - | ac.roleGeneration.Store(1) | |
| 126 | - | ||
| 127 | - | expected := ac.transportSnapshot() | |
| 128 | - | require.NoError(t, d.sendSnatchedPanel(ac, expected, 1, "")) | |
| 129 | - | ||
| 130 | - | frames := tr.Sends() | |
| 131 | - | require.Len(t, frames, 1) | |
| 132 | - | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 133 | - | out, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 134 | - | require.NoError(t, err) | |
| 135 | - | require.Zero(t, out.BaseStateNum) | |
| 136 | - | require.Equal(t, uint64(8), out.NewStateNum) | |
| 137 | - | require.Contains(t, string(out.Data), "Session snatched") | |
| 138 | - | require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden") | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | func TestSendSnatchedPanelUsesStructuredSnapshotForProxied(t *testing.T) { | |
| 142 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 143 | - | tr := &closeTrackingTransport{} | |
| 144 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream(), proxied: true, size: domain.Size{Cols: 80, Rows: 24}} | |
| 145 | - | ac.output.next = 7 | |
| 146 | - | ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})}) | |
| 147 | - | ac.roleGeneration.Store(1) | |
| 148 | - | ||
| 149 | - | require.NoError(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, "")) | |
| 150 | - | frames := tr.Sends() | |
| 151 | - | require.Len(t, frames, 1) | |
| 152 | - | require.Equal(t, ports.MsgScreenUpdate, frames[0].Type) | |
| 153 | - | update, err := ports.UnmarshalScreenUpdate(frames[0].Payload) | |
| 154 | - | require.NoError(t, err) | |
| 155 | - | require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind) | |
| 156 | - | require.Zero(t, update.BaseStateNum) | |
| 157 | - | require.Equal(t, uint64(8), update.NewStateNum, "snatched panel must advance the shared output state") | |
| 158 | - | require.False(t, update.Cursor.Visible, "structured snatched panel must hide the cursor semantically") | |
| 159 | - | require.Len(t, update.Spans, ac.size.Rows, "structured snapshots must carry every full row") | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) { | |
| 163 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 164 | - | failed := cacheFailTransport{} | |
| 165 | - | ac := &attachedClient{ | |
| 166 | - | tr: failed, | |
| 167 | - | output: newOutputStateStream(), | |
| 168 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 169 | - | lastCursor: cursorOut{valid: true, row: 2, col: 3, style: 1, hasStyle: true}, | |
| 170 | - | } | |
| 171 | - | ac.output.next = 7 | |
| 172 | - | ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})}) | |
| 173 | - | ac.roleGeneration.Store(1) | |
| 174 | - | beforeCursor := ac.lastCursor | |
| 175 | - | ||
| 176 | - | require.Error(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, "")) | |
| 177 | - | require.Equal(t, beforeCursor, ac.lastCursor) | |
| 178 | - | require.Equal(t, uint64(7), ac.output.next) | |
| 179 | - | ||
| 180 | - | healthy := &closeTrackingTransport{} | |
| 181 | - | ac.replaceTransport(healthy) | |
| 182 | - | require.NoError(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, "")) | |
| 183 | - | require.Equal(t, cursorOut{valid: true, hidden: true}, ac.lastCursor) | |
| 184 | - | require.Equal(t, uint64(8), ac.output.next) | |
| 185 | - | frames := healthy.Sends() | |
| 186 | - | require.Len(t, frames, 1) | |
| 187 | - | out, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 188 | - | require.NoError(t, err) | |
| 189 | - | require.Zero(t, out.BaseStateNum) | |
| 190 | - | require.Equal(t, uint64(8), out.NewStateNum) | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) { | |
| 194 | - | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 195 | - | d := newTestDaemon(t, nil, clock) | |
| 196 | - | old := newBlockingSnatchedTransport() | |
| 197 | - | fresh := &closeTrackingTransport{} | |
| 198 | - | ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 199 | - | ac.roleGeneration.Store(1) | |
| 200 | - | expected := ac.transportSnapshot() | |
| 201 | - | ||
| 202 | - | result := make(chan error, 1) | |
| 203 | - | go func() { | |
| 204 | - | result <- d.sendSnatchedPanel(ac, expected, 1, "") | |
| 205 | - | }() | |
| 206 | - | ||
| 207 | - | timer := awaitTestValue(t, clock.timers, "snatched send did not install its timeout") | |
| 208 | - | awaitTestCompletion(t, old.sendEntered, "snatched send did not block in the captured transport") | |
| 209 | - | ac.replaceTransport(fresh) | |
| 210 | - | timer.ch <- time.Time{} | |
| 211 | - | ||
| 212 | - | require.ErrorIs(t, awaitTestValue(t, result, "snatched send did not time out"), errSendTimedOut) | |
| 213 | - | defer func() { _ = old.Close() }() // Release the blocked worker if the assertion below fails. | |
| 214 | - | require.True(t, old.Closed(), "timed-out captured transport was not closed") | |
| 215 | - | require.False(t, fresh.Closed(), "replacement transport must remain open") | |
| 216 | - | ||
| 217 | - | sendMuReleased := make(chan uint64, 1) | |
| 218 | - | go func() { | |
| 219 | - | ac.sendMu.Lock() | |
| 220 | - | generation := ac.roleGeneration.Load() | |
| 221 | - | ac.sendMu.Unlock() | |
| 222 | - | sendMuReleased <- generation | |
| 223 | - | }() | |
| 224 | - | require.Equal(t, uint64(1), awaitTestValue(t, sendMuReleased, "closing the timed-out transport did not release sendMu")) | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | type blockingSnatchedTransport struct { | |
| 228 | - | sendEntered chan struct{} | |
| 229 | - | closed chan struct{} | |
| 230 | - | sendOnce sync.Once | |
| 231 | - | closeOnce sync.Once | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | func newBlockingSnatchedTransport() *blockingSnatchedTransport { | |
| 235 | - | return &blockingSnatchedTransport{ | |
| 236 | - | sendEntered: make(chan struct{}), | |
| 237 | - | closed: make(chan struct{}), | |
| 238 | - | } | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | func (t *blockingSnatchedTransport) Send(ports.Frame) error { | |
| 242 | - | t.sendOnce.Do(func() { close(t.sendEntered) }) | |
| 243 | - | <-t.closed | |
| 244 | - | return errors.New("transport closed") | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | func (*blockingSnatchedTransport) Recv() (ports.Frame, error) { | |
| 248 | - | return ports.Frame{}, errors.New("not implemented") | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | func (t *blockingSnatchedTransport) Close() error { | |
| 252 | - | t.closeOnce.Do(func() { close(t.closed) }) | |
| 253 | - | return nil | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | func (t *blockingSnatchedTransport) Closed() bool { | |
| 257 | - | select { | |
| 258 | - | case <-t.closed: | |
| 259 | - | return true | |
| 260 | - | default: | |
| 261 | - | return false | |
| 262 | - | } | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | func TestSendSnatchedPanelRejectsStaleTransport(t *testing.T) { | |
| 266 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 267 | - | old := &closeTrackingTransport{} | |
| 268 | - | fresh := &closeTrackingTransport{} | |
| 269 | - | ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 270 | - | ac.roleGeneration.Store(1) | |
| 271 | - | stale := ac.transportSnapshot() | |
| 272 | - | ac.replaceTransport(fresh) | |
| 273 | - | ||
| 274 | - | require.ErrorIs(t, d.sendSnatchedPanel(ac, stale, 1, ""), errTransportReplaced) | |
| 275 | - | require.Empty(t, old.Sends()) | |
| 276 | - | require.Empty(t, fresh.Sends()) | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | func TestSendSnatchedPanelRejectsStaleRole(t *testing.T) { | |
| 280 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 281 | - | tr := &closeTrackingTransport{} | |
| 282 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 283 | - | ac.roleGeneration.Store(2) | |
| 284 | - | expected := ac.transportSnapshot() | |
| 285 | - | ||
| 286 | - | require.ErrorIs(t, d.sendSnatchedPanel(ac, expected, 1, ""), errSnatchedOutputStale) | |
| 287 | - | require.Empty(t, tr.Sends()) | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | func requireFreshSnatchedBackground(t *testing.T, frame renderer.Frame, panel domain.Rect, style renderer.Style) { | |
| 291 | - | t.Helper() | |
| 292 | - | for y := range frame.Height { | |
| 293 | - | for x := range frame.Width { | |
| 294 | - | if x >= panel.X && x < panel.X+panel.Width && y >= panel.Y && y < panel.Y+panel.Height { | |
| 295 | - | continue | |
| 296 | - | } | |
| 297 | - | cell := frame.At(x, y) | |
| 298 | - | require.Equal(t, ' ', cell.Rune, "cell (%d,%d) outside panel retained content", x, y) | |
| 299 | - | require.True(t, cell.Style.Equal(style), "cell (%d,%d) outside panel did not use captured base style", x, y) | |
| 300 | - | } | |
| 301 | - | } | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | // snatchedStyledText concatenates, in reading order, the runes of every cell | |
| 305 | - | // drawn with exactly the given style, so tests can assert which text runs a | |
| 306 | - | // style covers without depending on layout coordinates. | |
| 307 | - | func snatchedStyledText(frame renderer.Frame, style renderer.Style) string { | |
| 308 | - | var out strings.Builder | |
| 309 | - | for y := 0; y < frame.Height; y++ { | |
| 310 | - | for _, cell := range frame.Row(y) { | |
| 311 | - | if cell.Continuation || !cell.Style.Equal(style) { | |
| 312 | - | continue | |
| 313 | - | } | |
| 314 | - | r := cell.Rune | |
| 315 | - | if r == 0 { | |
| 316 | - | r = ' ' | |
| 317 | - | } | |
| 318 | - | out.WriteRune(r) | |
| 319 | - | } | |
| 320 | - | } | |
| 321 | - | return out.String() | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | func snatchedFrameText(frame renderer.Frame) string { | |
| 325 | - | var out strings.Builder | |
| 326 | - | for y := 0; y < frame.Height; y++ { | |
| 327 | - | for _, cell := range frame.Row(y) { | |
| 328 | - | if cell.Continuation { | |
| 329 | - | continue | |
| 330 | - | } | |
| 331 | - | r := cell.Rune | |
| 332 | - | if r == 0 { | |
| 333 | - | r = ' ' | |
| 334 | - | } | |
| 335 | - | out.WriteRune(r) | |
| 336 | - | } | |
| 337 | - | out.WriteByte('\n') | |
| 338 | - | } | |
| 339 | - | return out.String() | |
| 340 | - | } | |
D internal/usecase/daemon/stalled_attachment_test.go +0 -89
| @@ -1,89 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | "time" | |
| 6 | - | ||
| 7 | - | "github.com/stretchr/testify/mock" | |
| 8 | - | "github.com/stretchr/testify/require" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/vev/internal/domain" | |
| 11 | - | "github.com/bnema/vev/internal/ports" | |
| 12 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | // TestFinishAttachWaitsForAdmittedOldRender verifies role-effect | |
| 16 | - | // linearization: an old render already admitted and blocked in Send completes | |
| 17 | - | // before replacement publication can change its capability. | |
| 18 | - | func TestFinishAttachWaitsForAdmittedOldRender(t *testing.T) { | |
| 19 | - | pty, releasePTY := newBlockingPTY(t) | |
| 20 | - | defer releasePTY() | |
| 21 | - | clock := newCoordinatorMockClock(t, 4) | |
| 22 | - | d := newTestDaemon(t, newFactory(t, pty), clock.clock) | |
| 23 | - | ||
| 24 | - | oldTransport := portsmocks.NewMockTransport(t) | |
| 25 | - | oldSendEntered := make(chan struct{}) | |
| 26 | - | releaseOldSend := make(chan struct{}) | |
| 27 | - | oldTransport.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error { | |
| 28 | - | close(oldSendEntered) | |
| 29 | - | <-releaseOldSend | |
| 30 | - | return nil | |
| 31 | - | }).Once() | |
| 32 | - | oldTransport.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 33 | - | oldTransport.EXPECT().Close().Return(nil).Maybe() | |
| 34 | - | ||
| 35 | - | hello := ports.Hello{ | |
| 36 | - | Version: ports.ProtocolVersion, | |
| 37 | - | Intent: ports.IntentNew, | |
| 38 | - | Name: "stalled-attachment", | |
| 39 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 40 | - | TermEnv: "xterm-256color", | |
| 41 | - | } | |
| 42 | - | sess, old, err := d.route(hello, oldTransport) | |
| 43 | - | require.NoError(t, err) | |
| 44 | - | rc := sess.renderCoordinator() | |
| 45 | - | lease := rc.attachmentLease(old) | |
| 46 | - | require.True(t, rc.markAttachmentReady(lease)) | |
| 47 | - | ||
| 48 | - | rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}) | |
| 49 | - | awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{} | |
| 50 | - | <-oldSendEntered | |
| 51 | - | ||
| 52 | - | replacement := portsmocks.NewMockTransport(t) | |
| 53 | - | replacement.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 54 | - | replacement.EXPECT().Close().Return(nil).Maybe() | |
| 55 | - | type attachResult struct { | |
| 56 | - | client *attachedClient | |
| 57 | - | err error | |
| 58 | - | } | |
| 59 | - | attached := make(chan attachResult, 1) | |
| 60 | - | go func() { | |
| 61 | - | d.mu.Lock() | |
| 62 | - | ac, err := d.finishAttach(sess, replacement, hello.Size, terminalEnv{}, hello) | |
| 63 | - | attached <- attachResult{client: ac, err: err} | |
| 64 | - | }() | |
| 65 | - | ||
| 66 | - | select { | |
| 67 | - | case <-attached: | |
| 68 | - | t.Fatal("replacement published before the admitted old render completed") | |
| 69 | - | case <-time.After(20 * time.Millisecond): | |
| 70 | - | } | |
| 71 | - | close(releaseOldSend) | |
| 72 | - | select { | |
| 73 | - | case result := <-attached: | |
| 74 | - | require.NoError(t, result.err) | |
| 75 | - | require.Same(t, replacement, result.client.transport()) | |
| 76 | - | case <-time.After(time.Second): | |
| 77 | - | t.Fatal("replacement did not publish after the admitted render completed") | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | d.attachmentCleanupWg.Wait() | |
| 81 | - | require.Same(t, oldTransport, old.transport(), "the displaced attachment remains snatched") | |
| 82 | - | replacement.AssertNotCalled(t, "Close") | |
| 83 | - | ||
| 84 | - | // Retire the session before mock cleanup so asynchronous terminal Detached | |
| 85 | - | // notification cannot race the clock's C expectation after the test returns. | |
| 86 | - | releasePTY() | |
| 87 | - | awaitTestCompletion(t, d.done, "session did not retire after its final PTY closed") | |
| 88 | - | d.waitNotifies() | |
| 89 | - | } | |