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 | return attachmentDetached | 39 | return attachmentDetached | ||
| 40 | } | 40 | } | ||
| 41 | core := entry.core() | 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 | return attachmentActive | 46 | return attachmentActive | ||
| 44 | } | 47 | } | ||
| 45 | if _, ok := core.snatched[ac]; ok { | 48 | if _, ok := core.snatched[ac]; ok { | ||
| @@ -156,9 +159,15 @@ func (t attachmentRoleToken) current() bool { | |||||
| 156 | // activeCurrent admits post-transition effects only for the exact published | 159 | // activeCurrent admits post-transition effects only for the exact published | ||
| 157 | // role, transport incarnation, current-session link, and coordinator lease. | 160 | // role, transport incarnation, current-session link, and coordinator lease. | ||
| 158 | func (t attachmentRoleToken) activeCurrent() bool { | 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 | return false | 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 | rc := t.sess.core().coordinator.Load() | 171 | rc := t.sess.core().coordinator.Load() | ||
| 163 | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | 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 | if t.effect != nil && !t.effect.ended.Load() { | 205 | if t.effect != nil && !t.effect.ended.Load() { | ||
| 197 | return true | 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 | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | 209 | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | ||
| 201 | t.ac.currentAttachmentSession() != t.sess || attachmentSessionRoleLocked(t.sess, t.ac) != attachmentActive { | 210 | t.ac.currentAttachmentSession() != t.sess || attachmentSessionRoleLocked(t.sess, t.ac) != attachmentActive { | ||
| 202 | return false | 211 | return false | ||
| 203 | } | 212 | } | ||
| 213 | + | if t.lease == nil { | |||
| 214 | + | return true | |||
| 215 | + | } | |||
| 204 | rc := t.sess.core().coordinator.Load() | 216 | rc := t.sess.core().coordinator.Load() | ||
| 205 | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | 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 | preflighted bool | 48 | preflighted bool | ||
| 49 | roleEffectsFrozen bool | 49 | roleEffectsFrozen bool | ||
| 50 | expectedTargetTransportInterrupted bool | 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 | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool { | 53 | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool { | ||
| @@ -110,9 +106,7 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) ( | |||||
| 110 | targetCore = req.target.core() | 106 | targetCore = req.target.core() | ||
| 111 | } | 107 | } | ||
| 112 | if sourceCore == nil || req.target != nil && targetCore == nil || req.next == nil || d.closing || | 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 || | 109 | + | 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) { | |||
| 116 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | 110 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | ||
| 117 | } | 111 | } | ||
| 118 | if req.target != nil { | 112 | if req.target != nil { | ||
| @@ -127,12 +121,17 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) ( | |||||
| 127 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | 121 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | ||
| 128 | } | 122 | } | ||
| 129 | req.preflighted = true | 123 | req.preflighted = true | ||
| 130 | - | return req, attachmentTransitionParticipants{ | 124 | + | participants := attachmentTransitionParticipants{clients: []*attachedClient{req.next}} |
| 131 | - | clients: []*attachedClient{req.next, req.expectedTargetCurrent}, | 125 | + | // A new attachment joins a session; it does not displace or interrupt an |
| 132 | - | interrupts: []roleTransportInterrupt{{ | 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 | ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport, | 131 | ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport, | ||
| 134 | - | }}, | 132 | + | }) |
| 135 | - | }, nil | 133 | + | } |
| 134 | + | return req, participants, nil | |||
| 136 | } | 135 | } | ||
| 137 | 136 | ||||
| 138 | // freezeAttachmentTransition drains the snapshotted participants without any | 137 | // freezeAttachmentTransition drains the snapshotted participants without any | ||
| @@ -142,25 +141,12 @@ func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, par | |||||
| 142 | if d.afterRoleEffectParticipantsSnapshotted != nil { | 141 | if d.afterRoleEffectParticipantsSnapshotted != nil { | ||
| 143 | d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients) | 142 | d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients) | ||
| 144 | } | 143 | } | ||
| 145 | - | var drainDeadline *roleEffectDrainDeadline | 144 | + | frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, afterFrozen: func(ac *attachedClient) { |
| 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) { | |||
| 152 | if d.afterRoleEffectGateFrozen != nil { | 145 | if d.afterRoleEffectGateFrozen != nil { | ||
| 153 | d.afterRoleEffectGateFrozen(req.action, ac) | 146 | d.afterRoleEffectGateFrozen(req.action, ac) | ||
| 154 | } | 147 | } | ||
| 155 | }}, participants.clients...) | 148 | }}, participants.clients...) | ||
| 156 | - | if drainDeadline != nil { | |||
| 157 | - | drainDeadline.stop() | |||
| 158 | - | } | |||
| 159 | if !frozen.acquired || !frozen.drained { | 149 | if !frozen.acquired || !frozen.drained { | ||
| 160 | - | if req.activationBarrier { | |||
| 161 | - | _ = req.next.closeCapturedTransport(req.expectedTransport.transport) | |||
| 162 | - | return frozen, false, errSendTimedOut | |||
| 163 | - | } | |||
| 164 | return frozen, false, errAttachmentTransition | 150 | return frozen, false, errAttachmentTransition | ||
| 165 | } | 151 | } | ||
| 166 | interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport) | 152 | interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport) | ||
| @@ -191,20 +177,6 @@ func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (a | |||||
| 191 | return d.transitionAttachmentLocked(req) | 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 | func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | 180 | func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | ||
| 209 | req, participants, err := d.snapshotAttachmentTransition(req) | 181 | req, participants, err := d.snapshotAttachmentTransition(req) | ||
| 210 | if err != nil { | 182 | if err != nil { | ||
| @@ -221,36 +193,19 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||||
| 221 | } | 193 | } | ||
| 222 | req.expectedTargetTransportInterrupted = interrupted | 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 | result, err := d.publishAttachmentTransition(req) | 196 | result, err := d.publishAttachmentTransition(req) | ||
| 238 | if err != nil { | 197 | if err != nil { | ||
| 239 | return result, err | 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 | // Warm-proxy timers are external clock operations. Apply them only after | 200 | // Warm-proxy timers are external clock operations. Apply them only after | ||
| 252 | // frozen ownership publication and every architecture lock have completed; | 201 | // frozen ownership publication and every architecture lock have completed; | ||
| 253 | // the lifecycle helper revalidates exact registry pointers and clients. | 202 | // the lifecycle helper revalidates exact registry pointers and clients. | ||
| 254 | d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveRole) | 203 | d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveRole) | ||
| 255 | return result, nil | 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 | var targetCoordinator *renderCoordinator | 142 | var targetCoordinator *renderCoordinator | ||
| 143 | if req.targetRole == attachmentActive { | 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 | sourceCoordinator := sourceCore.coordinator.Load() | 153 | sourceCoordinator := sourceCore.coordinator.Load() | ||
| 147 | if attachmentSessionRoleLocked(source, req.next) != req.expectedRole || | 154 | if attachmentSessionRoleLocked(source, req.next) != req.expectedRole || | ||
| @@ -160,7 +167,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||||
| 160 | } | 167 | } | ||
| 161 | invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | 168 | invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | ||
| 162 | !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab)) | 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 | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | 171 | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | ||
| 165 | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | 172 | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | ||
| 166 | invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) | 173 | invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) | ||
| @@ -176,11 +183,6 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||||
| 176 | // part of the same locked publication. It reports role-preserving transitions. | 183 | // part of the same locked publication. It reports role-preserving transitions. | ||
| 177 | func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool { | 184 | func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool { | ||
| 178 | req := publication.req | 185 | req := publication.req | ||
| 179 | - | if req.targetRole == attachmentActive { | |||
| 180 | - | if target, ok := localSession(req.target); ok { | |||
| 181 | - | d.demoteParkedActiveForSessionLocked(target) | |||
| 182 | - | } | |||
| 183 | - | } | |||
| 184 | if req.copySourceEnvironment { | 186 | if req.copySourceEnvironment { | ||
| 185 | source, sourceOK := localSession(publication.source) | 187 | source, sourceOK := localSession(publication.source) | ||
| 186 | target, targetOK := localSession(req.target) | 188 | target, targetOK := localSession(req.target) | ||
| @@ -202,43 +204,24 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||||
| 202 | registerAttachmentSessionLocked(publication.source, req.next) | 204 | registerAttachmentSessionLocked(publication.source, req.next) | ||
| 203 | registerAttachmentSessionLocked(req.target, req.next) | 205 | registerAttachmentSessionLocked(req.target, req.next) | ||
| 204 | if publication.old != nil && publication.old != req.next { | 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 | registerAttachmentSessionLocked(req.target, publication.old) | 210 | registerAttachmentSessionLocked(req.target, publication.old) | ||
| 206 | } | 211 | } | ||
| 207 | - | if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next { | 212 | + | if publication.source != req.target && publication.source.core().client == req.next { |
| 208 | - | publication.displacedTransport = publication.old.transportSnapshot() | 213 | + | publication.source.core().client = nil |
| 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) | |||
| 219 | } | 214 | } | ||
| 215 | + | delete(publication.source.core().snatched, req.next) | |||
| 220 | if req.targetRole == attachmentActive { | 216 | if req.targetRole == attachmentActive { | ||
| 221 | delete(req.target.core().snatched, req.next) | 217 | delete(req.target.core().snatched, req.next) | ||
| 222 | - | if publication.old != nil && publication.old != req.next { | 218 | + | if req.target.core().client == nil { |
| 223 | - | if publication.terminalDisplaced { | 219 | + | req.target.core().client = req.next |
| 224 | - | delete(req.target.core().snatched, publication.old) | |||
| 225 | - | req.target.core().unregisterAttachmentLocked(publication.old) | |||
| 226 | - | } else { | |||
| 227 | - | addSnatchedLocked(req.target, publication.old) | |||
| 228 | - | } | |||
| 229 | } | 220 | } | ||
| 230 | - | req.target.core().client = req.next | |||
| 231 | } else { | 221 | } else { | ||
| 232 | addSnatchedLocked(req.target, req.next) | 222 | addSnatchedLocked(req.target, req.next) | ||
| 233 | } | 223 | } | ||
| 234 | publication.nextGeneration = req.next.roleGeneration.Add(1) | 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 | req.next.setSession(req.target) | 225 | req.next.setSession(req.target) | ||
| 243 | } | 226 | } | ||
| 244 | 227 | ||||
| @@ -251,7 +234,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att | |||||
| 251 | result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next)) | 234 | result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next)) | ||
| 252 | } | 235 | } | ||
| 253 | var lease *attachmentLease | 236 | var lease *attachmentLease | ||
| 254 | - | if publication.targetCoordinator != nil { | 237 | + | if publication.targetCoordinator != nil && publication.old == nil { |
| 255 | var cleanup renderLifecycleCleanup | 238 | var cleanup renderLifecycleCleanup | ||
| 256 | cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready) | 239 | cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready) | ||
| 257 | result.cleanups = append(result.cleanups, cleanup) | 240 | result.cleanups = append(result.cleanups, cleanup) | ||
M internal/usecase/daemon/client.go +9 -19
| @@ -50,7 +50,11 @@ type attachedClient struct { | |||||
| 50 | roleEffects roleEffectGate | 50 | roleEffects roleEffectGate | ||
| 51 | resumeCapable bool | 51 | resumeCapable bool | ||
| 52 | resumeToken uint64 | 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 | // proxied is negotiated once by Hello and remains immutable for this | 58 | // proxied is negotiated once by Hello and remains immutable for this | ||
| 55 | // attachment, including across transport resume. | 59 | // attachment, including across transport resume. | ||
| 56 | proxied bool | 60 | proxied bool | ||
| @@ -75,14 +79,8 @@ type attachedClient struct { | |||||
| 75 | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | 79 | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | ||
| 76 | // snapshots cannot leak when an attachment switches tabs or sessions. | 80 | // snapshots cannot leak when an attachment switches tabs or sessions. | ||
| 77 | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | 81 | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | ||
| 78 | - | // initialSnatchedMu elects exactly one reset-panel sender per role generation. | 82 | + | size domain.Size |
| 79 | - | // A Welcome handshake and displaced-client cleanup may both discover the | 83 | + | keys *keys.Router |
| 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 | |||
| 86 | // view is attachment-local navigation state. It is never inferred from a | 84 | // view is attachment-local navigation state. It is never inferred from a | ||
| 87 | // session-wide active tab, so multiple attachments can observe different | 85 | // session-wide active tab, so multiple attachments can observe different | ||
| 88 | // tabs and panes without changing shared session ownership. | 86 | // tabs and panes without changing shared session ownership. | ||
| @@ -98,15 +96,8 @@ type attachedClient struct { | |||||
| 98 | // previousSession is guarded independently. It is retained through temporary | 96 | // previousSession is guarded independently. It is retained through temporary | ||
| 99 | // setSession(nil) hand-offs and cleared only on terminal teardown. | 97 | // setSession(nil) hand-offs and cleared only on terminal teardown. | ||
| 100 | previousSession Guarded[attachmentSession] | 98 | previousSession Guarded[attachmentSession] | ||
| 101 | - | // snatchedInputMu serializes the restricted input parser with its delayed | 99 | + | linkMu sync.Mutex |
| 102 | - | // standalone-ESC callback. It is independent of routing and transport locks; | 100 | + | sendMu sync.Mutex |
| 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 | |||
| 110 | } | 101 | } | ||
| 111 | 102 | ||||
| 112 | type cursorOut struct { | 103 | type cursorOut struct { | ||
| @@ -261,7 +252,6 @@ func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport { | |||||
| 261 | ac.tr = nil | 252 | ac.tr = nil | ||
| 262 | ac.transportIncarnation++ | 253 | ac.transportIncarnation++ | ||
| 263 | ac.linkMu.Unlock() | 254 | ac.linkMu.Unlock() | ||
| 264 | - | ac.clearSnatchedInput() | |||
| 265 | return tr | 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 | // runConnLoop is the per-connection input router: it pumps client messages | 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 | 39 | + | // until detach, EOF, or a transport error. The attachment generation is |
| 40 | - | // a transport displaced while blocked in Recv immediately adopts its restricted | 40 | + | // resolved after every Recv so a stale transport cannot execute a new frame. |
| 41 | - | // snatched routing rather than executing one stale active frame. | |||
| 42 | func (d *Daemon) runConnLoop(ac *attachedClient) { | 41 | func (d *Daemon) runConnLoop(ac *attachedClient) { | ||
| 43 | tr := ac.transport() | 42 | tr := ac.transport() | ||
| 44 | if tr == nil { | 43 | if tr == nil { | ||
| @@ -51,13 +50,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 51 | f, err := tr.Recv() | 50 | f, err := tr.Recv() | ||
| 52 | if err != nil { | 51 | if err != nil { | ||
| 53 | for range connectionRoleAttempts { | 52 | for range connectionRoleAttempts { | ||
| 54 | - | sess, token, ok := d.currentConnectionRole(ac, tr) | 53 | + | sess, _, ok := d.currentConnectionRole(ac, tr) |
| 55 | if !ok { | 54 | if !ok { | ||
| 56 | return | 55 | return | ||
| 57 | } | 56 | } | ||
| 58 | - | if token.role == attachmentSnatched { | 57 | + | if proxy, ok := sess.(*proxySession); ok { |
| 59 | - | d.parkOrDropSnatchedAttachment(token) | |||
| 60 | - | } else if proxy, ok := sess.(*proxySession); ok { | |||
| 61 | d.detachProxyOnSendError(proxy, ac, tr) | 58 | d.detachProxyOnSendError(proxy, ac, tr) | ||
| 62 | } else if local, ok := localSession(sess); ok { | 59 | } else if local, ok := localSession(sess); ok { | ||
| 63 | d.clientGone(local, ac, tr, false) | 60 | d.clientGone(local, ac, tr, false) | ||
| @@ -75,21 +72,13 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 75 | if !ok { | 72 | if !ok { | ||
| 76 | return | 73 | return | ||
| 77 | } | 74 | } | ||
| 78 | - | switch token.role { | 75 | + | if token.role == attachmentActive && d.handleActiveClientFrame(token, f) { |
| 79 | - | case attachmentActive: | 76 | + | return |
| 80 | - | if d.handleActiveClientFrame(token, f) { | |||
| 81 | - | return | |||
| 82 | - | } | |||
| 83 | - | case attachmentSnatched: | |||
| 84 | - | if d.handleSnatchedClientFrame(token, f) { | |||
| 85 | - | return | |||
| 86 | - | } | |||
| 87 | } | 77 | } | ||
| 88 | } | 78 | } | ||
| 89 | } | 79 | } | ||
| 90 | 80 | ||||
| 91 | - | // handleActiveClientFrame owns the ordinary attached-client protocol. Keeping | 81 | + | // handleActiveClientFrame owns the attached-client protocol. |
| 92 | - | // it separate makes the restricted snatched protocol an explicit allow-list. | |||
| 93 | func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool { | 82 | func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool { | ||
| 94 | if !token.activeEffect() { | 83 | if !token.activeEffect() { | ||
| 95 | return false | 84 | return false | ||
| @@ -287,76 +276,3 @@ func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool { | |||||
| 287 | } | 276 | } | ||
| 288 | return true | 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 | if rc := sess.renderCoordinator(); rc != nil { | 65 | if rc := sess.renderCoordinator(); rc != nil { | ||
| 66 | rc.noteDetach(ac) | 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 | sess.mu.Lock() | 74 | sess.mu.Lock() | ||
| 69 | ephemeral := sess.ephemeral | 75 | ephemeral := sess.ephemeral | ||
M internal/usecase/daemon/daemon.go +15 -43
| @@ -144,15 +144,6 @@ type Daemon struct { | |||||
| 144 | // after publishResizeCommit acquires attachment sendMu and before it acquires | 144 | // after publishResizeCommit acquires attachment sendMu and before it acquires | ||
| 145 | // owner fences. Tests must not perform external work from this callback. | 145 | // owner fences. Tests must not perform external work from this callback. | ||
| 146 | afterResizeCommitSendLocked func() | 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 | // afterConnectionSessionSnapshot is a deterministic test seam after the | 147 | // afterConnectionSessionSnapshot is a deterministic test seam after the | ||
| 157 | // connection loop reads its current session and before it captures that role. | 148 | // connection loop reads its current session and before it captures that role. | ||
| 158 | afterConnectionSessionSnapshot func(attachmentSession) | 149 | afterConnectionSessionSnapshot func(attachmentSession) | ||
| @@ -200,21 +191,10 @@ type Daemon struct { | |||||
| 200 | // afterParkingWaitArmed observes waitParkingInFlight after it has resolved a | 191 | // afterParkingWaitArmed observes waitParkingInFlight after it has resolved a | ||
| 201 | // matching in-flight entry and before it blocks on done. | 192 | // matching in-flight entry and before it blocks on done. | ||
| 202 | afterParkingWaitArmed func() | 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 | // beforeResumeParkedSendMu pauses resumeParked after the initial parked | 194 | // beforeResumeParkedSendMu pauses resumeParked after the initial parked | ||
| 209 | // lookup and before the attachment send lock, so tests can consume or | 195 | // lookup and before the attachment send lock, so tests can consume or | ||
| 210 | // replace the credential while the handshake waits. | 196 | // replace the credential while the handshake waits. | ||
| 211 | beforeResumeParkedSendMu func() | 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 | // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint | 198 | // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint | ||
| 219 | // reserves its exact capability and before its first observable mutation. | 199 | // reserves its exact capability and before its first observable mutation. | ||
| 220 | afterRoleEffectAdmitted func(attachmentRoleToken) | 200 | afterRoleEffectAdmitted func(attachmentRoleToken) | ||
| @@ -339,9 +319,9 @@ type Daemon struct { | |||||
| 339 | type parkedAttachment struct { | 319 | type parkedAttachment struct { | ||
| 340 | sess *session | 320 | sess *session | ||
| 341 | ac *attachedClient | 321 | ac *attachedClient | ||
| 342 | - | role attachmentRole | |||
| 343 | pickerGeneration uint64 | 322 | pickerGeneration uint64 | ||
| 344 | timer ports.Timer | 323 | timer ports.Timer | ||
| 324 | + | claimed bool | |||
| 345 | done chan struct{} | 325 | done chan struct{} | ||
| 346 | doneOnce sync.Once | 326 | doneOnce sync.Once | ||
| 347 | } | 327 | } | ||
| @@ -1120,23 +1100,26 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||||
| 1120 | welcomeToken := sess.attachmentToken(ac, tr) | 1100 | welcomeToken := sess.attachmentToken(ac, tr) | ||
| 1121 | welcomeToken.lease = lease | 1101 | welcomeToken.lease = lease | ||
| 1122 | welcomeTicket, admitted := ac.beginRoleEffect(welcomeToken) | 1102 | welcomeTicket, admitted := ac.beginRoleEffect(welcomeToken) | ||
| 1123 | - | validRole := welcomeToken.role == attachmentSnatched || welcomeToken.role == attachmentActive && lease != nil | 1103 | + | validRole := welcomeToken.role == attachmentActive |
| 1124 | if expected.transport != tr || !admitted || !validRole { | 1104 | if expected.transport != tr || !admitted || !validRole { | ||
| 1125 | if admitted { | 1105 | if admitted { | ||
| 1126 | welcomeTicket.End() | 1106 | welcomeTicket.End() | ||
| 1127 | } | 1107 | } | ||
| 1128 | - | if welcomeToken.role == attachmentSnatched { | 1108 | + | if !d.abortResumeClaim(ac) { |
| 1129 | - | d.parkOrDropSnatchedAttachment(welcomeToken) | |||
| 1130 | - | } else { | |||
| 1131 | d.clientGone(sess, ac, tr, false) | 1109 | d.clientGone(sess, ac, tr, false) | ||
| 1132 | } | 1110 | } | ||
| 1133 | return | 1111 | return | ||
| 1134 | } | 1112 | } | ||
| 1135 | if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil { | 1113 | if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil { | ||
| 1136 | welcomeTicket.End() | 1114 | welcomeTicket.End() | ||
| 1137 | - | if welcomeToken.role == attachmentSnatched { | 1115 | + | if !d.abortResumeClaim(ac) { |
| 1138 | - | d.parkOrDropSnatchedAttachment(welcomeToken) | 1116 | + | d.clientGone(sess, ac, tr, false) |
| 1139 | - | } else { | 1117 | + | } |
| 1118 | + | return | |||
| 1119 | + | } | |||
| 1120 | + | if !d.commitResumeClaim(ac) { | |||
| 1121 | + | welcomeTicket.End() | |||
| 1122 | + | if !d.abortResumeClaim(ac) { | |||
| 1140 | d.clientGone(sess, ac, tr, false) | 1123 | d.clientGone(sess, ac, tr, false) | ||
| 1141 | } | 1124 | } | ||
| 1142 | return | 1125 | return | ||
| @@ -1150,19 +1133,8 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||||
| 1150 | d.clientGone(sess, ac, tr, false) | 1133 | d.clientGone(sess, ac, tr, false) | ||
| 1151 | return | 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 | postWelcomeLease := postWelcomeToken.lease | 1136 | postWelcomeLease := postWelcomeToken.lease | ||
| 1165 | - | if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil { | 1137 | + | if postWelcomeToken.role != attachmentActive { |
| 1166 | postWelcomeTicket.End() | 1138 | postWelcomeTicket.End() | ||
| 1167 | d.clientGone(sess, ac, tr, false) | 1139 | d.clientGone(sess, ac, tr, false) | ||
| 1168 | return | 1140 | return | ||
| @@ -1177,10 +1149,10 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||||
| 1177 | } | 1149 | } | ||
| 1178 | ac.markSessionMetaSent(meta) | 1150 | ac.markSessionMetaSent(meta) | ||
| 1179 | } | 1151 | } | ||
| 1180 | - | if !rc.markAttachmentReady(postWelcomeLease) { | 1152 | + | if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) { |
| 1181 | postWelcomeTicket.End() | 1153 | postWelcomeTicket.End() | ||
| 1182 | - | // The attachment was displaced or detached while Welcome was in flight; | 1154 | + | // The attachment was detached while Welcome was in flight; never let |
| 1183 | - | // never let this stale handshake emit an Output frame. | 1155 | + | // this stale handshake emit an Output frame. |
| 1184 | d.clientGone(sess, ac, tr, false) | 1156 | d.clientGone(sess, ac, tr, false) | ||
| 1185 | return | 1157 | return | ||
| 1186 | } | 1158 | } | ||
M internal/usecase/daemon/input.go +1 -1
| @@ -350,7 +350,7 @@ func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool | |||||
| 350 | return sess, effect, true | 350 | return sess, effect, true | ||
| 351 | } | 351 | } | ||
| 352 | sess := h.ac.currentSession() | 352 | sess := h.ac.currentSession() | ||
| 353 | - | if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched { | 353 | + | if sess == nil { |
| 354 | return nil, nil, false | 354 | return nil, nil, false | ||
| 355 | } | 355 | } | ||
| 356 | return sess, nil, false | 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 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "sync/atomic" | |||
| 5 | "testing" | 4 | "testing" | ||
| 6 | "time" | 5 | "time" | ||
| 7 | 6 | ||||
| @@ -279,42 +278,6 @@ func TestMovePickerSourceClientReplacementInvalidatesAction(t *testing.T) { | |||||
| 279 | require.Equal(t, "Source client is no longer active.", history[0].Message) | 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 | func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) { | 281 | func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) { | ||
| 319 | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | 282 | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | ||
| 320 | defer releaseAll(releases) | 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 | return active || prePublication | 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 | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*roleEffectTicket) bool { | 245 | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*roleEffectTicket) bool { | ||
| 352 | if rt == nil || rt.ac == nil { | 246 | if rt == nil || rt.ac == nil { | ||
| 353 | return false | 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 | func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) { | 864 | func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) { | ||
| 899 | d := newRemotePickerDaemon(nil) | 865 | d := newRemotePickerDaemon(nil) | ||
| 900 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | 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 | func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | 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 | if !d.prepareParkAttachment(sess, ac) { | 207 | if !d.prepareParkAttachment(sess, ac) { | ||
| 212 | return false | 208 | return false | ||
| 213 | } | 209 | } | ||
| @@ -228,13 +224,6 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach | |||||
| 228 | d.mu.Unlock() | 224 | d.mu.Unlock() | ||
| 229 | return false | 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 | token := ac.resumeToken | 227 | token := ac.resumeToken | ||
| 239 | var ( | 228 | var ( | ||
| 240 | oldRetirement *parkedAttachmentRetirement | 229 | oldRetirement *parkedAttachmentRetirement | ||
| @@ -250,7 +239,7 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach | |||||
| 250 | } | 239 | } | ||
| 251 | grace := d.resumeParkGrace | 240 | grace := d.resumeParkGrace | ||
| 252 | timer := d.clock.NewTimer(grace) | 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 | ac.parked = true | 243 | ac.parked = true | ||
| 255 | d.parked[token] = parked | 244 | d.parked[token] = parked | ||
| 256 | d.clearParkingInFlightLocked(token, ac) | 245 | d.clearParkingInFlightLocked(token, ac) | ||
| @@ -279,6 +268,10 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach | |||||
| 279 | func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { | 268 | func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { | ||
| 280 | d.mu.Lock() | 269 | d.mu.Lock() | ||
| 281 | if d.parked[token] == parked { | 270 | if d.parked[token] == parked { | ||
| 271 | + | if parked.claimed { | |||
| 272 | + | d.mu.Unlock() | |||
| 273 | + | return | |||
| 274 | + | } | |||
| 282 | d.removeParkedLocked(token, parked) | 275 | d.removeParkedLocked(token, parked) | ||
| 283 | d.mu.Unlock() | 276 | d.mu.Unlock() | ||
| 284 | d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration) | 277 | d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration) | ||
| @@ -308,14 +301,65 @@ func (p *parkedAttachment) closeDone() { | |||||
| 308 | p.doneOnce.Do(func() { close(p.done) }) | 301 | p.doneOnce.Do(func() { close(p.done) }) | ||
| 309 | } | 302 | } | ||
| 310 | 303 | ||||
| 311 | - | // demoteParkedActiveForSessionLocked preserves resumable predecessors when a | 304 | + | // commitResumeClaim consumes the old credential only after Welcome has been |
| 312 | - | // new active owner is published. Caller holds d.mu. | 305 | + | // written successfully. The claim itself is serialized by d.mu, so a racing |
| 313 | - | func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) { | 306 | + | // resume cannot acquire the same parked attachment. |
| 314 | - | for _, parked := range d.parked { | 307 | + | func (d *Daemon) commitResumeClaim(ac *attachedClient) bool { |
| 315 | - | if parked.sess == sess && parked.role == attachmentActive { | 308 | + | if d == nil || ac == nil { |
| 316 | - | parked.role = attachmentSnatched | 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 | type parkedAttachmentRetirement struct { | 365 | type parkedAttachmentRetirement struct { | ||
| @@ -494,19 +538,29 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size) | |||||
| 494 | d.beforeResumeParkedSendMu() | 538 | d.beforeResumeParkedSendMu() | ||
| 495 | } | 539 | } | ||
| 496 | ac.sendMu.Lock() | 540 | ac.sendMu.Lock() | ||
| 497 | - | defer ac.sendMu.Unlock() | |||
| 498 | d.mu.Lock() | 541 | d.mu.Lock() | ||
| 499 | - | defer d.mu.Unlock() | |||
| 500 | if d.closing { | 542 | if d.closing { | ||
| 543 | + | d.mu.Unlock() | |||
| 544 | + | ac.sendMu.Unlock() | |||
| 501 | return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | 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 | if d.sessions[parked.sess.id] == parked.sess { | 548 | if d.sessions[parked.sess.id] == parked.sess { | ||
| 549 | + | d.mu.Unlock() | |||
| 550 | + | ac.sendMu.Unlock() | |||
| 505 | return nil, nil, false, errResumeTokenLifecycleRace | 551 | return nil, nil, false, errResumeTokenLifecycleRace | ||
| 506 | } | 552 | } | ||
| 553 | + | d.mu.Unlock() | |||
| 554 | + | ac.sendMu.Unlock() | |||
| 507 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | 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 | // resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu | 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 | d.log.Warn("resume rejected", "session", sess.name, "registered", false) | 577 | d.log.Warn("resume rejected", "session", sess.name, "registered", false) | ||
| 524 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | 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 | ac := parked.ac | 580 | ac := parked.ac | ||
| 581 | + | if parked.claimed { | |||
| 582 | + | return nil, nil, false, errResumeTokenLifecycleRace | |||
| 583 | + | } | |||
| 533 | if ac.clientID != h.ClientID || ac.proxied != h.Proxied { | 584 | if ac.clientID != h.ClientID || ac.proxied != h.Proxied { | ||
| 534 | reason := "client id mismatch" | 585 | reason := "client id mismatch" | ||
| 535 | if ac.proxied != h.Proxied { | 586 | if ac.proxied != h.Proxied { | ||
| @@ -538,9 +589,11 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||||
| 538 | d.log.Warn("resume rejected", "session", sess.name, "err", reason) | 589 | d.log.Warn("resume rejected", "session", sess.name, "err", reason) | ||
| 539 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | 590 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | ||
| 540 | } | 591 | } | ||
| 541 | - | delete(d.parked, h.ResumeToken) | 592 | + | // Claim the attachment while retaining the old credential until Welcome is |
| 542 | - | parked.timer.Stop() | 593 | + | // accepted. A failed handshake can therefore restore this exact parked |
| 543 | - | parked.closeDone() | 594 | + | // entry; another resume sees claimed and loses atomically. |
| 595 | + | parked.claimed = true | |||
| 596 | + | ac.resumeClaimToken = h.ResumeToken | |||
| 544 | // The parked attachment has no session owner and cannot paint. Retire the | 597 | // The parked attachment has no session owner and cannot paint. Retire the | ||
| 545 | // abandoned transport's output chain before binding the replacement so the | 598 | // abandoned transport's output chain before binding the replacement so the | ||
| 546 | // mandatory first paint cannot be blocked by ACKs that died with the link. | 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 | target: sess, | 618 | target: sess, | ||
| 566 | next: ac, | 619 | next: ac, | ||
| 567 | expectedRole: attachmentDetached, | 620 | expectedRole: attachmentDetached, | ||
| 568 | - | targetRole: parked.role, | 621 | + | targetRole: attachmentActive, |
| 569 | expectedTransport: ac.transportSnapshot(), | 622 | expectedTransport: ac.transportSnapshot(), | ||
| 570 | ready: false, | 623 | ready: false, | ||
| 571 | }) | 624 | }) | ||
| 572 | ac.sendMu.Lock() | 625 | ac.sendMu.Lock() | ||
| 573 | d.mu.Lock() | 626 | d.mu.Lock() | ||
| 574 | if err != nil { | 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 | d.deferAttachmentTransitionCleanups(transition) | 630 | d.deferAttachmentTransitionCleanups(transition) | ||
| 578 | d.touchMRU(sess) | 631 | d.touchMRU(sess) | ||
M internal/usecase/daemon/resume_test.go +0 -492
| @@ -5,7 +5,6 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "sync" | 7 | "sync" | ||
| 8 | - | "sync/atomic" | |||
| 9 | "testing" | 8 | "testing" | ||
| 10 | "time" | 9 | "time" | ||
| 11 | 10 | ||||
| @@ -418,70 +417,6 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) { | |||||
| 418 | // reconnect race: IntentResume arrives with the live attachment's token before | 417 | // reconnect race: IntentResume arrives with the live attachment's token before | ||
| 419 | // transport teardown has parked it. Same client must recover; unknown and | 418 | // transport teardown has parked it. Same client must recover; unknown and | ||
| 420 | // mismatched credentials stay fail-closed. | 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 | func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | 420 | func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | ||
| 486 | pty, release := newBlockingPTY(t) | 421 | pty, release := newBlockingPTY(t) | ||
| 487 | defer release() | 422 | defer release() | ||
| @@ -685,365 +620,6 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||||
| 685 | require.False(t, winnerTr.Closed()) | 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 | func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) { | 623 | func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) { | ||
| 1048 | workPTY, releaseWork := newBlockingPTY(t) | 624 | workPTY, releaseWork := newBlockingPTY(t) | ||
| 1049 | defer releaseWork() | 625 | defer releaseWork() | ||
| @@ -1249,46 +825,6 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||||
| 1249 | // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection | 825 | // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection | ||
| 1250 | // when the attachment is no longer the exact active or snatched owner, while | 826 | // when the attachment is no longer the exact active or snatched owner, while | ||
| 1251 | // still advertising for both live roles. | 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 | func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) { | 828 | func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) { | ||
| 1293 | pty, release := newBlockingPTY(t) | 829 | pty, release := newBlockingPTY(t) | ||
| 1294 | defer release() | 830 | defer release() | ||
| @@ -1828,34 +1364,6 @@ func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) { | |||||
| 1828 | require.Contains(t, killedErr.Error(), "resume token is no longer valid") | 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 | func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) { | 1367 | func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) { | ||
| 1860 | p, release := newBlockingPTY(t) | 1368 | p, release := newBlockingPTY(t) | ||
| 1861 | defer release() | 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 | require.False(t, newTransport.Closed()) | 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 | func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) { | 580 | func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) { | ||
| 699 | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | 581 | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | ||
| 700 | oldTransport := newDatagramTestTransport() | 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 | func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) { | 1078 | func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) { | ||
| 1256 | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | 1079 | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | ||
| 1257 | oldTransport := newDatagramTestTransport() | 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 | d.notices.routingMu.Lock() | 745 | d.notices.routingMu.Lock() | ||
| 746 | sess.mu.Lock() | 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 | if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) { | 749 | if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) { | ||
| 749 | current = false | 750 | current = false | ||
| 750 | } | 751 | } | ||
| 751 | if current { | 752 | if current { | ||
| 752 | - | sess.client = nil | 753 | + | if sess.client == ac { |
| 754 | + | sess.client = nil | |||
| 755 | + | } | |||
| 753 | sess.unregisterAttachmentLocked(ac) | 756 | sess.unregisterAttachmentLocked(ac) | ||
| 757 | + | if sess.client == nil { | |||
| 758 | + | for candidate := range sess.attachments { | |||
| 759 | + | sess.client = candidate | |||
| 760 | + | break | |||
| 761 | + | } | |||
| 762 | + | } | |||
| 754 | ac.roleGeneration.Add(1) | 763 | ac.roleGeneration.Add(1) | ||
| 755 | ac.setSession(nil) | 764 | ac.setSession(nil) | ||
| 756 | ac.invalidateFrozenRoleCapability() | 765 | ac.invalidateFrozenRoleCapability() | ||
| @@ -805,6 +814,18 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||||
| 805 | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil { | 814 | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil { | ||
| 806 | return false | 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 | frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{done: done}, token.ac) | 829 | frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{done: done}, token.ac) | ||
| 809 | defer frozen.unfreeze() | 830 | defer frozen.unfreeze() | ||
| 810 | if !frozen.acquired || !frozen.drained { | 831 | if !frozen.acquired || !frozen.drained { | ||
| @@ -1170,6 +1191,7 @@ type sessionKillAdmission struct { | |||||
| 1170 | 1191 | ||||
| 1171 | type sessionKillParticipants struct { | 1192 | type sessionKillParticipants struct { | ||
| 1172 | active *attachedClient | 1193 | active *attachedClient | ||
| 1194 | + | attachments map[*attachedClient]struct{} | |||
| 1173 | snatched map[*attachedClient]struct{} | 1195 | snatched map[*attachedClient]struct{} | ||
| 1174 | transports map[*attachedClient]transportSnapshot | 1196 | transports map[*attachedClient]transportSnapshot | ||
| 1175 | roleGates []*attachedClient | 1197 | roleGates []*attachedClient | ||
| @@ -1214,8 +1236,13 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||||
| 1214 | } | 1236 | } | ||
| 1215 | unlockSessions := lockAttachmentSessions(source, target) | 1237 | unlockSessions := lockAttachmentSessions(source, target) | ||
| 1216 | snapshot.active = target.client | 1238 | snapshot.active = target.client | ||
| 1239 | + | snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments)) | |||
| 1217 | snapshot.snatched = make(map[*attachedClient]struct{}, len(target.snatched)) | 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 | if snapshot.active != nil { | 1246 | if snapshot.active != nil { | ||
| 1220 | snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot() | 1247 | snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot() | ||
| 1221 | } | 1248 | } | ||
| @@ -1227,7 +1254,7 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||||
| 1227 | d.notices.routingMu.Unlock() | 1254 | d.notices.routingMu.Unlock() | ||
| 1228 | d.mu.Unlock() | 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 | add := func(ac *attachedClient) { | 1258 | add := func(ac *attachedClient) { | ||
| 1232 | if ac == nil { | 1259 | if ac == nil { | ||
| 1233 | return | 1260 | return | ||
| @@ -1242,6 +1269,9 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||||
| 1242 | add(admission.token.ac) | 1269 | add(admission.token.ac) | ||
| 1243 | } | 1270 | } | ||
| 1244 | add(snapshot.active) | 1271 | add(snapshot.active) | ||
| 1272 | + | for ac := range snapshot.attachments { | |||
| 1273 | + | add(ac) | |||
| 1274 | + | } | |||
| 1245 | for ac := range snapshot.snatched { | 1275 | for ac := range snapshot.snatched { | ||
| 1246 | add(ac) | 1276 | add(ac) | ||
| 1247 | } | 1277 | } | ||
| @@ -1281,9 +1311,14 @@ func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants | |||||
| 1281 | // session and coordinator locks. | 1311 | // session and coordinator locks. | ||
| 1282 | func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool { | 1312 | func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool { | ||
| 1283 | target := snapshot.target | 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 | return false | 1315 | return false | ||
| 1286 | } | 1316 | } | ||
| 1317 | + | for ac := range snapshot.attachments { | |||
| 1318 | + | if _, current := target.attachments[ac]; !current { | |||
| 1319 | + | return false | |||
| 1320 | + | } | |||
| 1321 | + | } | |||
| 1287 | for ac := range snapshot.snatched { | 1322 | for ac := range snapshot.snatched { | ||
| 1288 | if _, current := target.snatched[ac]; !current { | 1323 | if _, current := target.snatched[ac]; !current { | ||
| 1289 | return false | 1324 | return false | ||
| @@ -1593,11 +1628,15 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||||
| 1593 | attached.setSession(nil) | 1628 | attached.setSession(nil) | ||
| 1594 | attached.invalidateFrozenRoleCapability() | 1629 | attached.invalidateFrozenRoleCapability() | ||
| 1595 | } | 1630 | } | ||
| 1596 | - | capture(sess.client) | 1631 | + | for attached := range sess.attachments { |
| 1632 | + | capture(attached) | |||
| 1633 | + | } | |||
| 1597 | for snatched := range sess.snatched { | 1634 | for snatched := range sess.snatched { | ||
| 1598 | capture(snatched) | 1635 | capture(snatched) | ||
| 1599 | } | 1636 | } | ||
| 1637 | + | capture(sess.client) | |||
| 1600 | sess.client = nil | 1638 | sess.client = nil | ||
| 1639 | + | clear(sess.attachments) | |||
| 1601 | clear(sess.snatched) | 1640 | clear(sess.snatched) | ||
| 1602 | stoppedName := sess.name | 1641 | stoppedName := sess.name | ||
| 1603 | stoppedCwd := sess.cwd | 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 | - | } | |||