feat(daemon): keep replaced clients in snatched state
- commit
- ddc019c48694ed0fb5628417f4940894d821f6db
- author
- brice · · 2026-07-27 23:09 UTC
- parent
- b70b3b4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
36 files · +6003 -628
M internal/usecase/daemon/attention.go +9 -1
| @@ -47,6 +47,10 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) { | |||
| 47 | 47 | // happened — it also covers "no target exists", which is routine and not an | |
| 48 | 48 | // error. Only a failure to reach a target that does exist is a genuine error. | |
| 49 | 49 | func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error { | |
| 50 | + | return d.jumpAttentionForRole(sess, ac, attachmentRoleToken{}) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token attachmentRoleToken) error { | |
| 50 | 54 | if sess == nil || ac == nil { | |
| 51 | 55 | return nil | |
| 52 | 56 | } | |
| @@ -62,7 +66,11 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error { | |||
| 62 | 66 | if !ok { | |
| 63 | 67 | return nil | |
| 64 | 68 | } | |
| 65 | - | return d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}) | |
| 69 | + | pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex} | |
| 70 | + | if token.ac == nil { | |
| 71 | + | return d.switchToTarget(sess, ac, pickerTarget) | |
| 72 | + | } | |
| 73 | + | return d.switchActiveTargetForRole(token, pickerTarget) | |
| 66 | 74 | } | |
| 67 | 75 | ||
| 68 | 76 | func oldestAttentionTab(sess *session) (int, bool) { | |
M internal/usecase/daemon/client.go +515 -205
| @@ -39,10 +39,12 @@ type attachedClient struct { | |||
| 39 | 39 | overlays *overlayRuntime | |
| 40 | 40 | overlayOnce sync.Once | |
| 41 | 41 | clientID [16]byte | |
| 42 | - | // roleGeneration invalidates role-bound sends. A transition promoting a | |
| 43 | - | // panel-sending client must serialize through sendMu/the activation barrier | |
| 44 | - | // before publishing its generation. | |
| 42 | + | // roleGeneration is the wire-facing capability generation. roleEffects is | |
| 43 | + | // the linearization gate for every role-bound observable operation. A role | |
| 44 | + | // transition freezes and drains this gate before changing either generation | |
| 45 | + | // or the session/coordinator registries. | |
| 45 | 46 | roleGeneration atomic.Uint64 | |
| 47 | + | roleEffects roleEffectGate | |
| 46 | 48 | resumeCapable bool | |
| 47 | 49 | resumeToken uint64 | |
| 48 | 50 | parked bool | |
| @@ -236,14 +238,6 @@ func (ac *attachedClient) transportSnapshotCurrent(expected transportSnapshot) b | |||
| 236 | 238 | return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation | |
| 237 | 239 | } | |
| 238 | 240 | ||
| 239 | - | func (ac *attachedClient) closeTransport() error { | |
| 240 | - | tr := ac.transport() | |
| 241 | - | if tr == nil { | |
| 242 | - | return nil | |
| 243 | - | } | |
| 244 | - | return tr.Close() | |
| 245 | - | } | |
| 246 | - | ||
| 247 | 241 | func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error { | |
| 248 | 242 | if tr == nil { | |
| 249 | 243 | return nil | |
| @@ -267,22 +261,6 @@ func (ac *attachedClient) ackOutputState(state uint64) { | |||
| 267 | 261 | ac.output.ack(state) | |
| 268 | 262 | } | |
| 269 | 263 | ||
| 270 | - | // send serialises a frame onto the client's transport. | |
| 271 | - | func (ac *attachedClient) send(f ports.Frame) error { | |
| 272 | - | _, err := ac.sendTransport(f) | |
| 273 | - | return err | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error) { | |
| 277 | - | ac.sendMu.Lock() | |
| 278 | - | defer ac.sendMu.Unlock() | |
| 279 | - | tr := ac.transport() | |
| 280 | - | if tr == nil { | |
| 281 | - | return nil, errors.New("client transport is nil") | |
| 282 | - | } | |
| 283 | - | return tr, tr.Send(f) | |
| 284 | - | } | |
| 285 | - | ||
| 286 | 264 | var errTransportReplaced = errors.New("client transport was replaced") | |
| 287 | 265 | ||
| 288 | 266 | // sendExpectedTransport writes only when expected is still the attachment's | |
| @@ -299,6 +277,21 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po | |||
| 299 | 277 | return expected.transport.Send(f) | |
| 300 | 278 | } | |
| 301 | 279 | ||
| 280 | + | func (ac *attachedClient) sendExpectedTransportForRole(expected transportSnapshot, f ports.Frame, ticket *roleEffectTicket) error { | |
| 281 | + | ac.sendMu.Lock() | |
| 282 | + | defer ac.sendMu.Unlock() | |
| 283 | + | if ticket == nil || ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) || | |
| 284 | + | expected.transport == nil || !ticket.beginTransportSend(expected) { | |
| 285 | + | return errAttachmentTransition | |
| 286 | + | } | |
| 287 | + | err := expected.transport.Send(f) | |
| 288 | + | if err != nil { | |
| 289 | + | ticket.reportTransportFailure(expected) | |
| 290 | + | } | |
| 291 | + | ticket.endTransportSend() | |
| 292 | + | return err | |
| 293 | + | } | |
| 294 | + | ||
| 302 | 295 | // boundedSend sends f to ac with a deadline watchdog: if the send (including | |
| 303 | 296 | // waiting on sendMu behind a wedged paint) does not complete within | |
| 304 | 297 | // detachNotifyTimeout, the transport is force-closed, failing the in-flight | |
| @@ -349,6 +342,45 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po | |||
| 349 | 342 | }) | |
| 350 | 343 | } | |
| 351 | 344 | ||
| 345 | + | // boundedSendOutputErrTransportForRole sends a queued side effect only through | |
| 346 | + | // the exact active attachment capability captured when the work was queued. | |
| 347 | + | // The fresh ticket keeps role publication behind an admitted send; only the | |
| 348 | + | // external Send interval is marked interruptible for replacement liveness. | |
| 349 | + | func (d *Daemon) boundedSendOutputErrTransportForRole(token attachmentRoleToken, ticket *roleEffectTicket, b []byte) (ports.Transport, error) { | |
| 350 | + | if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil { | |
| 351 | + | return token.transport.transport, errAttachmentTransition | |
| 352 | + | } | |
| 353 | + | frame := token.ac.output.sideEffect(b, token.ac.echoAck.Load()) | |
| 354 | + | expected := token.transport | |
| 355 | + | send := func(owned bool) error { | |
| 356 | + | ac := token.ac | |
| 357 | + | ac.sendMu.Lock() | |
| 358 | + | defer ac.sendMu.Unlock() | |
| 359 | + | if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) { | |
| 360 | + | return errAttachmentTransition | |
| 361 | + | } | |
| 362 | + | interruptible := ticket.beginTransportSend(expected) | |
| 363 | + | if !interruptible { | |
| 364 | + | return errAttachmentTransition | |
| 365 | + | } | |
| 366 | + | var err error | |
| 367 | + | if owned { | |
| 368 | + | err = expected.transport.(ports.OwnedSynchronousTransport).SendSynchronous(frame) | |
| 369 | + | } else { | |
| 370 | + | err = expected.transport.Send(frame) | |
| 371 | + | } | |
| 372 | + | if err != nil { | |
| 373 | + | ticket.reportTransportFailure(expected) | |
| 374 | + | } | |
| 375 | + | ticket.endTransportSend() | |
| 376 | + | return err | |
| 377 | + | } | |
| 378 | + | if _, owned := expected.transport.(ports.OwnedSynchronousTransport); owned { | |
| 379 | + | return expected.transport, send(true) | |
| 380 | + | } | |
| 381 | + | return d.boundedSendWith(expected.transport, func() error { return send(false) }) | |
| 382 | + | } | |
| 383 | + | ||
| 352 | 384 | var errSendTimedOut = errors.New("send timed out") | |
| 353 | 385 | ||
| 354 | 386 | func (d *Daemon) boundedSendWith(tr ports.Transport, send func() error) (ports.Transport, error) { | |
| @@ -376,12 +408,18 @@ func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, tr ports.Transpor | |||
| 376 | 408 | } | |
| 377 | 409 | } | |
| 378 | 410 | ||
| 379 | - | // notifyDetachedAsync delivers a best-effort Detached notice off the caller's | |
| 380 | - | // goroutine and then closes the transport. Session teardown (killSession) must | |
| 381 | - | // complete regardless of client state, so the notice is both asynchronous and | |
| 382 | - | // deadline-bounded; Serve waits for pending notices before force-closing | |
| 383 | - | // connections so a graceful notice is not raced by the hard close. | |
| 384 | - | func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) { | |
| 411 | + | type detachedAttachmentSnapshot struct { | |
| 412 | + | ac *attachedClient | |
| 413 | + | transport transportSnapshot | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // notifyDetachedSnapshotAsync sends and closes only the transport incarnation | |
| 417 | + | // captured by atomic session teardown. It cannot touch a later link installed | |
| 418 | + | // on the same attachedClient object. | |
| 419 | + | func (d *Daemon) notifyDetachedSnapshotAsync(snapshot detachedAttachmentSnapshot, reason uint8) { | |
| 420 | + | if snapshot.ac == nil || snapshot.transport.transport == nil { | |
| 421 | + | return | |
| 422 | + | } | |
| 385 | 423 | done := make(chan struct{}) | |
| 386 | 424 | d.mu.Lock() | |
| 387 | 425 | // Prune completed entries so the slice stays bounded by the number of | |
| @@ -399,8 +437,17 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) { | |||
| 399 | 437 | ||
| 400 | 438 | go func() { | |
| 401 | 439 | defer close(done) | |
| 402 | - | d.boundedSend(ac, frameDetached(reason)) | |
| 403 | - | _ = ac.closeTransport() | |
| 440 | + | _, err := d.boundedSendWith(snapshot.transport.transport, func() error { | |
| 441 | + | return snapshot.ac.sendExpectedTransport(snapshot.transport, frameDetached(reason)) | |
| 442 | + | }) | |
| 443 | + | if errors.Is(err, errSendTimedOut) { | |
| 444 | + | _ = snapshot.ac.closeCapturedTransport(snapshot.transport.transport) | |
| 445 | + | } | |
| 446 | + | revoked := snapshot.ac.revokeTransport(snapshot.transport.transport) | |
| 447 | + | if revoked == nil { | |
| 448 | + | revoked = snapshot.transport.transport | |
| 449 | + | } | |
| 450 | + | _ = snapshot.ac.closeCapturedTransport(revoked) | |
| 404 | 451 | d.log.Info("client detached", "reason", reason) | |
| 405 | 452 | }() | |
| 406 | 453 | } | |
| @@ -428,13 +475,39 @@ type attachClientOptions struct { | |||
| 428 | 475 | maxOutputInFlight uint8 | |
| 429 | 476 | } | |
| 430 | 477 | ||
| 431 | - | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) { | |
| 432 | - | ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, opts) | |
| 433 | - | cleanup.finish() | |
| 434 | - | return ac, old | |
| 478 | + | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) { | |
| 479 | + | d.mu.Lock() | |
| 480 | + | ac := d.prepareAttachedClientLocked(tr, sz, opts) | |
| 481 | + | d.mu.Unlock() | |
| 482 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 483 | + | target: sess, | |
| 484 | + | next: ac, | |
| 485 | + | expectedRole: attachmentDetached, | |
| 486 | + | targetRole: attachmentActive, | |
| 487 | + | expectedTransport: ac.transportSnapshot(), | |
| 488 | + | ready: false, | |
| 489 | + | }) | |
| 490 | + | if err != nil { | |
| 491 | + | if tr != nil { | |
| 492 | + | _ = tr.Close() | |
| 493 | + | } | |
| 494 | + | return nil, nil, err | |
| 495 | + | } | |
| 496 | + | d.finishAttachedClient(sess, ac, opts) | |
| 497 | + | d.deferAttachmentTransitionCleanups(result) | |
| 498 | + | return ac, result.displaced.ac, nil | |
| 435 | 499 | } | |
| 436 | 500 | ||
| 437 | - | func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, renderLifecycleCleanup) { | |
| 501 | + | func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) { | |
| 502 | + | d.touchMRU(sess) | |
| 503 | + | d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable) | |
| 504 | + | d.applyHostTheme(sess, ac, themeui.Theme{}, true) | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | // prepareAttachedClientLocked allocates one detached attachment. Caller holds | |
| 508 | + | // d.mu only to allocate its resume token; role publication happens later after | |
| 509 | + | // the caller releases every architecture lock. | |
| 510 | + | func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient { | |
| 438 | 511 | resumeToken := uint64(0) | |
| 439 | 512 | if opts.resumeCapable { | |
| 440 | 513 | resumeToken = d.nextResumeTokenLocked() | |
| @@ -448,54 +521,8 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma | |||
| 448 | 521 | resumeToken: resumeToken, | |
| 449 | 522 | } | |
| 450 | 523 | ac.initOverlays() | |
| 451 | - | ac.setSession(sess) | |
| 452 | 524 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | |
| 453 | - | // Daemon attachment callers hold d.mu, serialising this preparation with | |
| 454 | - | // other publications. routingMu makes the whole replacement atomic with | |
| 455 | - | // global notice selection: a selected old client cannot become stale before | |
| 456 | - | // its toast is published, and a new client is never missed after publication. | |
| 457 | - | d.notices.routingMu.Lock() | |
| 458 | - | sess.mu.Lock() | |
| 459 | - | old := sess.client | |
| 460 | - | name := sess.name | |
| 461 | - | sess.mu.Unlock() | |
| 462 | - | _, cleanup := d.attachCoordinatorDeferred(sess, old, ac, false) | |
| 463 | - | sess.mu.Lock() | |
| 464 | - | sess.client = ac | |
| 465 | - | sess.mu.Unlock() | |
| 466 | - | d.notices.routingMu.Unlock() | |
| 467 | - | d.touchMRU(sess) | |
| 468 | - | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |
| 469 | - | d.applyHostTheme(sess, ac, themeui.Theme{}, true) | |
| 470 | - | return ac, old, cleanup | |
| 471 | - | } | |
| 472 | - | ||
| 473 | - | // handoffCoordinator prepares a cross-session ownership transfer before the | |
| 474 | - | // destination publishes sess.client. Callers may hold d.mu, but this function | |
| 475 | - | // acquires no daemon or session locks while it holds sendMu. | |
| 476 | - | func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup { | |
| 477 | - | cleanups := make([]renderLifecycleCleanup, 0, 2) | |
| 478 | - | if rc := from.renderCoordinator(); rc != nil { | |
| 479 | - | cleanups = append(cleanups, rc.beginDetach(current)) | |
| 480 | - | } | |
| 481 | - | if current.renderStages.handoffRebase != nil { | |
| 482 | - | current.renderStages.handoffRebase() | |
| 483 | - | } | |
| 484 | - | current.sendMu.Lock() | |
| 485 | - | current.output.rebase() | |
| 486 | - | // Capture snapshots are attachment-owned. Discard them while the attachment | |
| 487 | - | // is exclusively held so the transfer releases panes no longer reachable | |
| 488 | - | // from the target session. | |
| 489 | - | current.captureFrames = nil | |
| 490 | - | current.sendMu.Unlock() | |
| 491 | - | _, cleanup := d.attachCoordinatorDeferred(target, old, current, true) | |
| 492 | - | return append(cleanups, cleanup) | |
| 493 | - | } | |
| 494 | - | ||
| 495 | - | func finishRenderLifecycleCleanups(cleanups []renderLifecycleCleanup) { | |
| 496 | - | for _, cleanup := range cleanups { | |
| 497 | - | cleanup.finish() | |
| 498 | - | } | |
| 525 | + | return ac | |
| 499 | 526 | } | |
| 500 | 527 | ||
| 501 | 528 | // attachCoordinator is the sole direct attachment handoff. It creates at | |
| @@ -508,37 +535,7 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, | |||
| 508 | 535 | } | |
| 509 | 536 | ||
| 510 | 537 | func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) { | |
| 511 | - | sess.mu.Lock() | |
| 512 | - | rc := sess.renderCoordinator() | |
| 513 | - | if rc == nil { | |
| 514 | - | rc = newRenderCoordinator(renderCoordinatorOptions{ | |
| 515 | - | clock: d.clock, | |
| 516 | - | observer: d.runtimeObserver, | |
| 517 | - | wake: func(w renderWake) { | |
| 518 | - | // Never reread sess.client here: a wake is bound to both its | |
| 519 | - | // attachment and its coordinator incarnation. The second check in | |
| 520 | - | // paint occurs under sendMu, closing a park/resume race after this | |
| 521 | - | // unlocked coordinator validation. | |
| 522 | - | if w.lease != nil && rc.wakeCurrent(w) { | |
| 523 | - | d.paint(sess, w.lease.attachment, w.reset, w.lease) | |
| 524 | - | } | |
| 525 | - | }, | |
| 526 | - | ackReady: func() bool { | |
| 527 | - | sess.mu.Lock() | |
| 528 | - | attached := sess.client | |
| 529 | - | sess.mu.Unlock() | |
| 530 | - | if attached == nil { | |
| 531 | - | return false | |
| 532 | - | } | |
| 533 | - | attached.sendMu.Lock() | |
| 534 | - | ready := !attached.output.atCapacity() | |
| 535 | - | attached.sendMu.Unlock() | |
| 536 | - | return ready | |
| 537 | - | }, | |
| 538 | - | }) | |
| 539 | - | sess.installRenderCoordinator(rc) | |
| 540 | - | } | |
| 541 | - | sess.mu.Unlock() | |
| 538 | + | rc := d.ensureRenderCoordinator(sess) | |
| 542 | 539 | var cleanup renderLifecycleCleanup | |
| 543 | 540 | if old != nil { | |
| 544 | 541 | cleanup = rc.beginReplace(old, current, ready) | |
| @@ -548,6 +545,45 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached | |||
| 548 | 545 | return rc, cleanup | |
| 549 | 546 | } | |
| 550 | 547 | ||
| 548 | + | // ensureRenderCoordinator publishes the session's single coordinator without | |
| 549 | + | // binding an attachment lease. Centralized attachment transitions bind the | |
| 550 | + | // lease later while their ordered session locks are still held. | |
| 551 | + | func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator { | |
| 552 | + | sess.mu.Lock() | |
| 553 | + | defer sess.mu.Unlock() | |
| 554 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 555 | + | return rc | |
| 556 | + | } | |
| 557 | + | var rc *renderCoordinator | |
| 558 | + | rc = newRenderCoordinator(renderCoordinatorOptions{ | |
| 559 | + | clock: d.clock, | |
| 560 | + | observer: d.runtimeObserver, | |
| 561 | + | wake: func(w renderWake) { | |
| 562 | + | // Never reread sess.client here: a wake is bound to both its | |
| 563 | + | // attachment and its coordinator incarnation. The second check in | |
| 564 | + | // paint occurs under sendMu, closing a park/resume race after this | |
| 565 | + | // unlocked coordinator validation. | |
| 566 | + | if w.lease != nil && rc.wakeCurrent(w) { | |
| 567 | + | d.paint(sess, w.lease.attachment, w.reset, w.lease) | |
| 568 | + | } | |
| 569 | + | }, | |
| 570 | + | ackReady: func() bool { | |
| 571 | + | sess.mu.Lock() | |
| 572 | + | attached := sess.client | |
| 573 | + | sess.mu.Unlock() | |
| 574 | + | if attached == nil { | |
| 575 | + | return false | |
| 576 | + | } | |
| 577 | + | attached.sendMu.Lock() | |
| 578 | + | ready := attached.output == nil || !attached.output.atCapacity() | |
| 579 | + | attached.sendMu.Unlock() | |
| 580 | + | return ready | |
| 581 | + | }, | |
| 582 | + | }) | |
| 583 | + | sess.installRenderCoordinator(rc) | |
| 584 | + | return rc | |
| 585 | + | } | |
| 586 | + | ||
| 551 | 587 | // resetScreenDefaultColors clears the known default foreground/background | |
| 552 | 588 | // colors on every tab in sess. Called once a client has been detached, this | |
| 553 | 589 | // makes child OSC 10/11 queries go back to being swallowed (Known=false) | |
| @@ -563,43 +599,48 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) { | |||
| 563 | 599 | d.applyHostTheme(sess, nil, themeui.Theme{}, true) | |
| 564 | 600 | } | |
| 565 | 601 | ||
| 566 | - | // retireReplacedClient owns old-link retirement and obsolete render-worker | |
| 567 | - | // joins outside the replacement handshake. If a render already owns sendMu, | |
| 568 | - | // close immediately interrupts it; no Detached frame can safely overtake that | |
| 569 | - | // render. Otherwise the old link gets its bounded protocol notice first. | |
| 570 | - | func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecycleCleanup) { | |
| 571 | - | if old == nil { | |
| 572 | - | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 573 | - | return | |
| 602 | + | // firstPaintForTransition is the only active post-transition paint entry | |
| 603 | + | // point. It rejects a stale role capability before rebasing output and carries | |
| 604 | + | // the exact coordinator lease through every resize and render effect. | |
| 605 | + | func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool { | |
| 606 | + | if !token.activeCurrent() { | |
| 607 | + | return false | |
| 574 | 608 | } | |
| 575 | - | oldTransport := old.transportSnapshot().transport | |
| 576 | - | blockedRender := !old.sendMu.TryLock() | |
| 577 | - | if !blockedRender { | |
| 578 | - | old.sendMu.Unlock() | |
| 609 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 610 | + | if !admitted { | |
| 611 | + | return false | |
| 579 | 612 | } | |
| 580 | - | ||
| 581 | - | d.attachmentCleanupWg.Go(func() { | |
| 582 | - | if !blockedRender { | |
| 583 | - | // A healthy idle client observes the required replacement notice. Its | |
| 584 | - | // watchdog still bounds a peer that stops draining after this check. | |
| 585 | - | d.boundedSend(old, frameDetached(ports.ReasonReplaced)) | |
| 613 | + | token.effect = ticket | |
| 614 | + | defer token.endRoleEffect() | |
| 615 | + | if d.afterRoleEffectAdmitted != nil { | |
| 616 | + | d.afterRoleEffectAdmitted(token) | |
| 617 | + | } | |
| 618 | + | if token.rebase { | |
| 619 | + | token.ac.sendMu.Lock() | |
| 620 | + | if token.ac.renderStages.handoffRebase != nil { | |
| 621 | + | token.ac.renderStages.handoffRebase() | |
| 586 | 622 | } | |
| 587 | - | _ = old.closeCapturedTransport(old.revokeTransport(oldTransport)) | |
| 588 | - | d.unregisterPreview(old) | |
| 589 | - | old.clearPreviousSession() | |
| 590 | - | old.setSession(nil) | |
| 591 | - | d.log.Info("client displaced") | |
| 592 | - | }) | |
| 593 | - | // Join independently: the close above releases a blocked render while the | |
| 594 | - | // new attachment is already fully published. | |
| 595 | - | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 623 | + | if token.ac.output != nil { | |
| 624 | + | token.ac.output.rebase() | |
| 625 | + | } | |
| 626 | + | // Captures belong to the old session even when pane-local IDs happen | |
| 627 | + | // to be reused by the destination. | |
| 628 | + | token.ac.captureFrames = nil | |
| 629 | + | token.ac.sendMu.Unlock() | |
| 630 | + | } | |
| 631 | + | d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease) | |
| 632 | + | return true | |
| 596 | 633 | } | |
| 597 | 634 | ||
| 598 | 635 | // firstPaint guarantees the freshly attached client sees the full screen: if | |
| 599 | 636 | // the tab size differs from the client's it resizes first, then immediately | |
| 600 | - | // emits a full redraw. Attach must not wait for the resize-idle fallback | |
| 601 | - | // timer. | |
| 637 | + | // emits a full redraw. It is retained for direct test/headless setup; active | |
| 638 | + | // role transitions use firstPaintForTransition with their exact lease. | |
| 602 | 639 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | |
| 640 | + | d.firstPaintWithLease(sess, ac, clientSize, nil) | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSize domain.Size, lease *attachmentLease) { | |
| 603 | 644 | // Global notices raised while nothing was attached surface on this client. | |
| 604 | 645 | // Drained before the early return below so a session without an active tab | |
| 605 | 646 | // cannot swallow the queue. | |
| @@ -614,20 +655,30 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 614 | 655 | ||
| 615 | 656 | outerResizeAccepted := false | |
| 616 | 657 | if clientSize.Valid() && wsz != tabSize(clientSize) { | |
| 617 | - | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) | |
| 658 | + | if lease == nil { | |
| 659 | + | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) | |
| 660 | + | } else { | |
| 661 | + | outerResizeAccepted = d.requestTransactionalResizeForLease(sess, ac, lease, clientSize, true) | |
| 662 | + | } | |
| 618 | 663 | } | |
| 619 | 664 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 620 | 665 | // Activation can synchronously resize a retained floating pane. An accepted | |
| 621 | 666 | // synchronous outer request already includes that pane and emits the reset, | |
| 622 | 667 | // but activation still performs its warmup work. | |
| 623 | - | activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted) | |
| 668 | + | activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease) | |
| 624 | 669 | if !outerResizeAccepted && !activationResized { | |
| 625 | - | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 670 | + | if lease == nil { | |
| 671 | + | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 672 | + | } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) { | |
| 673 | + | rc.fireCurrent(false) | |
| 674 | + | } | |
| 626 | 675 | } | |
| 627 | 676 | } | |
| 628 | 677 | ||
| 629 | 678 | // runConnLoop is the per-connection input router: it pumps client messages | |
| 630 | - | // until detach, EOF, or a transport error. | |
| 679 | + | // until detach, EOF, or a transport error. Role is resolved after every Recv so | |
| 680 | + | // a transport displaced while blocked in Recv immediately adopts its restricted | |
| 681 | + | // snatched routing rather than executing one stale active frame. | |
| 631 | 682 | func (d *Daemon) runConnLoop(ac *attachedClient) { | |
| 632 | 683 | tr := ac.transport() | |
| 633 | 684 | if tr == nil { | |
| @@ -643,7 +694,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 643 | 694 | } | |
| 644 | 695 | f, err := tr.Recv() | |
| 645 | 696 | if err != nil { | |
| 646 | - | d.clientGone(sess, ac, tr, false) | |
| 697 | + | token := sess.attachmentToken(ac, tr) | |
| 698 | + | if token.role == attachmentSnatched { | |
| 699 | + | d.dropSnatchedAttachment(token) | |
| 700 | + | } else { | |
| 701 | + | d.clientGone(sess, ac, tr, false) | |
| 702 | + | } | |
| 647 | 703 | return | |
| 648 | 704 | } | |
| 649 | 705 | if !ac.currentTransportIs(tr) { | |
| @@ -653,46 +709,177 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 653 | 709 | if sess == nil { | |
| 654 | 710 | return | |
| 655 | 711 | } | |
| 656 | - | switch f.Type { | |
| 657 | - | case ports.MsgInput: | |
| 658 | - | if in, derr := ports.UnmarshalInput(f.Payload); derr == nil { | |
| 659 | - | d.handleSequencedInput(sess, ac, in.InputSeq, in.Data) | |
| 712 | + | token := sess.attachmentToken(ac, tr) | |
| 713 | + | switch token.role { | |
| 714 | + | case attachmentActive: | |
| 715 | + | if d.handleActiveClientFrame(token, f) { | |
| 716 | + | return | |
| 660 | 717 | } | |
| 661 | - | case ports.MsgResize: | |
| 662 | - | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil { | |
| 663 | - | d.resize(sess, ac, rz.Size) | |
| 718 | + | case attachmentSnatched: | |
| 719 | + | if d.handleSnatchedClientFrame(token, f) { | |
| 720 | + | return | |
| 664 | 721 | } | |
| 665 | - | case ports.MsgTheme: | |
| 666 | - | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | |
| 667 | - | d.applyTheme(sess, ac, th) | |
| 722 | + | default: | |
| 723 | + | return | |
| 724 | + | } | |
| 725 | + | } | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | // handleActiveClientFrame owns the ordinary attached-client protocol. Keeping | |
| 729 | + | // it separate makes the restricted snatched protocol an explicit allow-list. | |
| 730 | + | func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool { | |
| 731 | + | if !token.activeEffect() { | |
| 732 | + | return false | |
| 733 | + | } | |
| 734 | + | if d.afterActiveFrameDispatch != nil { | |
| 735 | + | d.afterActiveFrameDispatch(token) | |
| 736 | + | } | |
| 737 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 738 | + | if !admitted { | |
| 739 | + | return false | |
| 740 | + | } | |
| 741 | + | token.effect = ticket | |
| 742 | + | defer token.endRoleEffect() | |
| 743 | + | if d.afterRoleEffectAdmitted != nil { | |
| 744 | + | d.afterRoleEffectAdmitted(token) | |
| 745 | + | } | |
| 746 | + | switch f.Type { | |
| 747 | + | case ports.MsgInput: | |
| 748 | + | if in, derr := ports.UnmarshalInput(f.Payload); derr == nil { | |
| 749 | + | d.handleSequencedInputForRole(token, in.InputSeq, in.Data) | |
| 750 | + | } | |
| 751 | + | case ports.MsgResize: | |
| 752 | + | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() { | |
| 753 | + | d.requestTransactionalResizeForLease(token.sess, token.ac, token.lease, rz.Size, false) | |
| 754 | + | } | |
| 755 | + | case ports.MsgTheme: | |
| 756 | + | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | |
| 757 | + | d.applyThemeForRole(token, th) | |
| 758 | + | } | |
| 759 | + | case ports.MsgImagePush: | |
| 760 | + | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | |
| 761 | + | d.handleSequencedImagePushForRole(token, ip.InputSeq, ip) | |
| 762 | + | } | |
| 763 | + | case ports.MsgClientNotice: | |
| 764 | + | if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil { | |
| 765 | + | d.handleClientNoticeForRole(token, notice) | |
| 766 | + | } else { | |
| 767 | + | d.log.Warn("malformed client notice", "err", derr) | |
| 768 | + | } | |
| 769 | + | case ports.MsgDetach: | |
| 770 | + | if token.activeEffect() { | |
| 771 | + | d.clientGoneForRole(token, true) | |
| 772 | + | return true | |
| 773 | + | } | |
| 774 | + | case ports.MsgAck: | |
| 775 | + | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | |
| 776 | + | d.ackActiveOutput(token, ack.AckedStateNum) | |
| 777 | + | } | |
| 778 | + | case ports.MsgPing: | |
| 779 | + | if err := token.sendActiveControl(framePong()); err != nil { | |
| 780 | + | token.endRoleEffect() | |
| 781 | + | d.detachOnSendError(token.sess, token.ac, token.transport.transport) | |
| 782 | + | return true | |
| 783 | + | } | |
| 784 | + | default: | |
| 785 | + | // Unknown/out-of-band client messages are ignored so a newer | |
| 786 | + | // client can add message types without breaking an older daemon. | |
| 787 | + | } | |
| 788 | + | return false | |
| 789 | + | } | |
| 790 | + | ||
| 791 | + | func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool { | |
| 792 | + | ac := token.ac | |
| 793 | + | if token.effect == nil || token.effect.ended.Load() { | |
| 794 | + | return false | |
| 795 | + | } | |
| 796 | + | ac.sendMu.Lock() | |
| 797 | + | ac.output.ack(state) | |
| 798 | + | ac.sendMu.Unlock() | |
| 799 | + | if rc := token.sess.renderCoordinator(); rc != nil { | |
| 800 | + | rc.notifyAckForLease(token.lease) | |
| 801 | + | } | |
| 802 | + | return true | |
| 803 | + | } | |
| 804 | + | ||
| 805 | + | // handleSnatchedClientFrame is intentionally an allow-list. Strict resume and | |
| 806 | + | // quit actions are introduced separately; until then ordinary input is ignored. | |
| 807 | + | func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Frame) bool { | |
| 808 | + | if !token.current() { | |
| 809 | + | return false | |
| 810 | + | } | |
| 811 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 812 | + | if !admitted { | |
| 813 | + | return false | |
| 814 | + | } | |
| 815 | + | token.effect = ticket | |
| 816 | + | defer token.endRoleEffect() | |
| 817 | + | if d.afterRoleEffectAdmitted != nil { | |
| 818 | + | d.afterRoleEffectAdmitted(token) | |
| 819 | + | } | |
| 820 | + | switch f.Type { | |
| 821 | + | case ports.MsgInput: | |
| 822 | + | if in, err := ports.UnmarshalInput(f.Payload); err == nil { | |
| 823 | + | d.handleSnatchedInput(token, in) | |
| 824 | + | } | |
| 825 | + | case ports.MsgResize: | |
| 826 | + | if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() { | |
| 827 | + | ac := token.ac | |
| 828 | + | ac.sendMu.Lock() | |
| 829 | + | if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) { | |
| 830 | + | ac.sendMu.Unlock() | |
| 831 | + | return false | |
| 668 | 832 | } | |
| 669 | - | case ports.MsgImagePush: | |
| 670 | - | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | |
| 671 | - | d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip) | |
| 833 | + | ac.size = resize.Size | |
| 834 | + | ac.sendMu.Unlock() | |
| 835 | + | if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil { | |
| 836 | + | token.endRoleEffect() | |
| 837 | + | d.dropSnatchedAttachment(token) | |
| 838 | + | return true | |
| 672 | 839 | } | |
| 673 | - | case ports.MsgClientNotice: | |
| 674 | - | if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil { | |
| 675 | - | d.handleClientNotice(sess, ac, notice) | |
| 676 | - | } else { | |
| 677 | - | d.log.Warn("malformed client notice", "err", derr) | |
| 840 | + | } | |
| 841 | + | case ports.MsgTheme: | |
| 842 | + | if theme, err := ports.UnmarshalTheme(f.Payload); err == nil { | |
| 843 | + | ac := token.ac | |
| 844 | + | ac.sendMu.Lock() | |
| 845 | + | if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) { | |
| 846 | + | ac.sendMu.Unlock() | |
| 847 | + | return false | |
| 678 | 848 | } | |
| 679 | - | case ports.MsgDetach: | |
| 680 | - | d.clientGone(sess, ac, tr, true) | |
| 681 | - | return | |
| 682 | - | case ports.MsgAck: | |
| 683 | - | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | |
| 684 | - | ac.ackOutputState(ack.AckedStateNum) | |
| 685 | - | if rc := sess.renderCoordinator(); rc != nil { | |
| 686 | - | rc.notifyAck() | |
| 687 | - | } | |
| 849 | + | clientTheme := themeFromMessage(theme) | |
| 850 | + | ac.setClientTheme(clientTheme) | |
| 851 | + | ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme)) | |
| 852 | + | ac.sendMu.Unlock() | |
| 853 | + | if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil { | |
| 854 | + | token.endRoleEffect() | |
| 855 | + | d.dropSnatchedAttachment(token) | |
| 856 | + | return true | |
| 688 | 857 | } | |
| 689 | - | case ports.MsgPing: | |
| 690 | - | _ = ac.send(framePong()) | |
| 691 | - | default: | |
| 692 | - | // Unknown/out-of-band client messages are ignored so a newer | |
| 693 | - | // client can add message types without breaking an older daemon. | |
| 694 | 858 | } | |
| 859 | + | case ports.MsgAck: | |
| 860 | + | if ack, err := ports.UnmarshalAck(f.Payload); err == nil { | |
| 861 | + | token.ac.ackOutputState(ack.AckedStateNum) | |
| 862 | + | } | |
| 863 | + | case ports.MsgPing: | |
| 864 | + | if err := d.sendSnatchedControl(token, framePong()); err != nil { | |
| 865 | + | token.endRoleEffect() | |
| 866 | + | d.dropSnatchedAttachment(token) | |
| 867 | + | return true | |
| 868 | + | } | |
| 869 | + | case ports.MsgDetach: | |
| 870 | + | _ = d.sendSnatchedControl(token, frameDetached(ports.ReasonDetach)) | |
| 871 | + | token.endRoleEffect() | |
| 872 | + | d.dropSnatchedAttachment(token) | |
| 873 | + | return true | |
| 695 | 874 | } | |
| 875 | + | return false | |
| 876 | + | } | |
| 877 | + | ||
| 878 | + | // handleSnatchedInput is the narrow seam for the future strict snatched-action | |
| 879 | + | // parser. Ordinary terminal input is deliberately ignored and never enters the | |
| 880 | + | // key router or PTY path. | |
| 881 | + | func (d *Daemon) handleSnatchedInput(token attachmentRoleToken, _ ports.Input) { | |
| 882 | + | _ = token | |
| 696 | 883 | } | |
| 697 | 884 | ||
| 698 | 885 | // clientGone detaches ac if it is still the session's current client. The | |
| @@ -704,6 +891,23 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 704 | 891 | if !d.detachIfCurrent(sess, ac) { | |
| 705 | 892 | return // already displaced by a newer client; nothing to do | |
| 706 | 893 | } | |
| 894 | + | d.finishClientGone(sess, ac, failed, explicit) | |
| 895 | + | } | |
| 896 | + | ||
| 897 | + | func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool { | |
| 898 | + | if token.effect == nil { | |
| 899 | + | return false | |
| 900 | + | } | |
| 901 | + | token.effect.bindActionEnd(d, "detach") | |
| 902 | + | token.effect.End() | |
| 903 | + | if !d.detachIfRoleCurrent(token) { | |
| 904 | + | return false | |
| 905 | + | } | |
| 906 | + | d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit) | |
| 907 | + | return true | |
| 908 | + | } | |
| 909 | + | ||
| 910 | + | func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) { | |
| 707 | 911 | if rc := sess.renderCoordinator(); rc != nil { | |
| 708 | 912 | rc.noteDetach(ac) | |
| 709 | 913 | } | |
| @@ -740,8 +944,8 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 740 | 944 | ||
| 741 | 945 | // detachIfCurrent clears the client iff ac is the current one, reporting | |
| 742 | 946 | ||
| 743 | - | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | |
| 744 | - | clientTheme := themeui.Theme{ | |
| 947 | + | func themeFromMessage(msg ports.Theme) themeui.Theme { | |
| 948 | + | return themeui.Theme{ | |
| 745 | 949 | Foreground: msg.Foreground, | |
| 746 | 950 | Background: msg.Background, | |
| 747 | 951 | Palette: msg.Palette, | |
| @@ -753,6 +957,10 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||
| 753 | 957 | SchemeKnown: msg.SchemeKnown, | |
| 754 | 958 | Light: msg.Light, | |
| 755 | 959 | } | |
| 960 | + | } | |
| 961 | + | ||
| 962 | + | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | |
| 963 | + | clientTheme := themeFromMessage(msg) | |
| 756 | 964 | ac.setClientTheme(clientTheme) | |
| 757 | 965 | ||
| 758 | 966 | if !d.applyHostTheme(sess, ac, clientTheme, false) { | |
| @@ -761,6 +969,24 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||
| 761 | 969 | d.invalidateRender(sess, ac, true, "client.go") | |
| 762 | 970 | } | |
| 763 | 971 | ||
| 972 | + | func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) bool { | |
| 973 | + | if !token.activeEffect() { | |
| 974 | + | return false | |
| 975 | + | } | |
| 976 | + | clientTheme := themeFromMessage(msg) | |
| 977 | + | token.ac.setClientTheme(clientTheme) | |
| 978 | + | if !token.activeEffect() || !d.applyHostTheme(token.sess, token.ac, clientTheme, false) { | |
| 979 | + | return false | |
| 980 | + | } | |
| 981 | + | if !token.activeEffect() { | |
| 982 | + | return false | |
| 983 | + | } | |
| 984 | + | if rc := token.sess.renderCoordinator(); rc != nil { | |
| 985 | + | return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) | |
| 986 | + | } | |
| 987 | + | return false | |
| 988 | + | } | |
| 989 | + | ||
| 764 | 990 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |
| 765 | 991 | d.requestTransactionalResize(sess, ac, sz, false) | |
| 766 | 992 | } | |
| @@ -771,32 +997,78 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 771 | 997 | // publishing sess.client. Never retain sess.mu while touching notice or overlay | |
| 772 | 998 | // state, and retain the routingMu -> sess.mu order used by attachment paths. | |
| 773 | 999 | func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) { | |
| 1000 | + | if sess == nil || ac == nil { | |
| 1001 | + | return | |
| 1002 | + | } | |
| 1003 | + | tr := ac.transport() | |
| 1004 | + | token := sess.attachmentToken(ac, tr) | |
| 1005 | + | if token.lease == nil { | |
| 1006 | + | // Direct test/headless callers retain pointer-based routing. Production | |
| 1007 | + | // client frames always carry the exact lease through the role path below. | |
| 1008 | + | d.handleClientNoticeDirect(sess, ac, notice) | |
| 1009 | + | return | |
| 1010 | + | } | |
| 1011 | + | d.handleClientNoticeForRole(token, notice) | |
| 1012 | + | } | |
| 1013 | + | ||
| 1014 | + | func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice ports.ClientNotice) { | |
| 774 | 1015 | d.notices.routingMu.Lock() | |
| 775 | - | defer d.notices.routingMu.Unlock() | |
| 1016 | + | if !token.activeEffect() { | |
| 1017 | + | d.notices.routingMu.Unlock() | |
| 1018 | + | return | |
| 1019 | + | } | |
| 1020 | + | if d.notices.beforeClientNoticeMutation != nil { | |
| 1021 | + | d.notices.beforeClientNoticeMutation() | |
| 1022 | + | } | |
| 1023 | + | if !token.activeEffect() { | |
| 1024 | + | d.notices.routingMu.Unlock() | |
| 1025 | + | return | |
| 1026 | + | } | |
| 1027 | + | repaint := d.mutateClientNotice(token.sess, token.ac, notice) | |
| 1028 | + | d.notices.routingMu.Unlock() | |
| 1029 | + | if repaint && token.activeEffect() { | |
| 1030 | + | d.repaintForNotice(token.ac) | |
| 1031 | + | } | |
| 1032 | + | } | |
| 776 | 1033 | ||
| 1034 | + | func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) { | |
| 1035 | + | d.notices.routingMu.Lock() | |
| 777 | 1036 | sess.mu.Lock() | |
| 778 | 1037 | current := sess.client == ac | |
| 779 | 1038 | sess.mu.Unlock() | |
| 780 | 1039 | if !current { | |
| 1040 | + | d.notices.routingMu.Unlock() | |
| 781 | 1041 | return | |
| 782 | 1042 | } | |
| 783 | 1043 | if d.notices.beforeClientNoticeMutation != nil { | |
| 784 | 1044 | d.notices.beforeClientNoticeMutation() | |
| 785 | 1045 | } | |
| 1046 | + | repaint := d.mutateClientNotice(sess, ac, notice) | |
| 1047 | + | d.notices.routingMu.Unlock() | |
| 1048 | + | if repaint { | |
| 1049 | + | d.repaintForNotice(ac) | |
| 1050 | + | } | |
| 1051 | + | } | |
| 786 | 1052 | ||
| 1053 | + | func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool { | |
| 787 | 1054 | switch notice.Action { | |
| 788 | 1055 | case ports.ClientNoticeClipboardFallback: | |
| 789 | - | d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V") | |
| 1056 | + | return d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V") | |
| 790 | 1057 | case ports.ClientNoticeClipboardTooLarge: | |
| 791 | - | d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste") | |
| 1058 | + | return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste") | |
| 792 | 1059 | case ports.ClientNoticeLinkDegraded: | |
| 793 | - | d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded") | |
| 1060 | + | return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded") | |
| 794 | 1061 | case ports.ClientNoticeLinkConnected: | |
| 795 | - | d.dismissToast(ac, domain.NoticeConnection, sess.id) | |
| 1062 | + | return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id) | |
| 1063 | + | default: | |
| 1064 | + | return false | |
| 796 | 1065 | } | |
| 797 | 1066 | } | |
| 798 | 1067 | ||
| 799 | - | func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) { | |
| 1068 | + | // recordClientNotice mutates notice history and the selected attachment while | |
| 1069 | + | // routingMu keeps replacement from changing that selection. Rendering is left | |
| 1070 | + | // to the caller after it releases routingMu. | |
| 1071 | + | func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool { | |
| 800 | 1072 | n := d.notices.record(domain.Notification{ | |
| 801 | 1073 | Code: code, | |
| 802 | 1074 | Severity: sev, | |
| @@ -804,7 +1076,7 @@ func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domai | |||
| 804 | 1076 | Time: d.clock.Now(), | |
| 805 | 1077 | SessionID: sess.id, | |
| 806 | 1078 | }) | |
| 807 | - | d.showToast(ac, n) | |
| 1079 | + | return d.publishToast(ac, n) | |
| 808 | 1080 | } | |
| 809 | 1081 | ||
| 810 | 1082 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | |
| @@ -820,20 +1092,58 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por | |||
| 820 | 1092 | return | |
| 821 | 1093 | } | |
| 822 | 1094 | if d.detachIfCurrent(sess, ac) { | |
| 823 | - | if rc := sess.renderCoordinator(); rc != nil { | |
| 824 | - | rc.noteDetach(ac) | |
| 825 | - | } | |
| 826 | - | d.unregisterPreview(ac) | |
| 827 | - | if d.parkAttachment(sess, ac) { | |
| 828 | - | _ = ac.closeCapturedTransport(failed) | |
| 829 | - | d.log.Warn("parked client after send error", "session", sess.name) | |
| 830 | - | return | |
| 831 | - | } | |
| 832 | - | d.resetScreenDefaultColors(sess) | |
| 833 | - | ac.clearPreviousSession() | |
| 1095 | + | d.finishSendErrorDetach(sess, ac, failed) | |
| 1096 | + | } | |
| 1097 | + | } | |
| 1098 | + | ||
| 1099 | + | func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) { | |
| 1100 | + | d.detachOnRoleSendErrorUntil(token, failed, nil) | |
| 1101 | + | } | |
| 1102 | + | ||
| 1103 | + | func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) { | |
| 1104 | + | if failed != token.transport.transport { | |
| 1105 | + | return | |
| 1106 | + | } | |
| 1107 | + | if d.detachIfRoleCurrentUntil(token, done) { | |
| 1108 | + | d.finishSendErrorDetach(token.sess, token.ac, failed) | |
| 1109 | + | } | |
| 1110 | + | } | |
| 1111 | + | ||
| 1112 | + | // reserveRoleSendErrorCleanup accounts for cleanup before End releases the | |
| 1113 | + | // role gate. This closes the WaitGroup Add/Wait race with terminal teardown; | |
| 1114 | + | // the returned launch function must be invoked immediately after ticket End. | |
| 1115 | + | func (d *Daemon) reserveRoleSendErrorCleanup(token attachmentRoleToken, failed ports.Transport) func() { | |
| 1116 | + | d.attachmentCleanupWg.Add(1) | |
| 1117 | + | return func() { | |
| 1118 | + | go func() { | |
| 1119 | + | defer d.attachmentCleanupWg.Done() | |
| 1120 | + | if d.afterRoleSendErrorCleanup != nil { | |
| 1121 | + | defer d.afterRoleSendErrorCleanup() | |
| 1122 | + | } | |
| 1123 | + | if d.beforeRoleSendErrorCleanup != nil { | |
| 1124 | + | d.beforeRoleSendErrorCleanup(token) | |
| 1125 | + | } | |
| 1126 | + | deadline := newRoleEffectDrainDeadline(d.clock) | |
| 1127 | + | defer deadline.stop() | |
| 1128 | + | d.detachOnRoleSendErrorUntil(token, failed, deadline.Done) | |
| 1129 | + | }() | |
| 1130 | + | } | |
| 1131 | + | } | |
| 1132 | + | ||
| 1133 | + | func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) { | |
| 1134 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 1135 | + | rc.noteDetach(ac) | |
| 1136 | + | } | |
| 1137 | + | d.unregisterPreview(ac) | |
| 1138 | + | if d.parkAttachment(sess, ac) { | |
| 834 | 1139 | _ = ac.closeCapturedTransport(failed) | |
| 835 | - | d.log.Warn("detached client after send error", "session", sess.name) | |
| 1140 | + | d.log.Warn("parked client after send error", "session", sess.name) | |
| 1141 | + | return | |
| 836 | 1142 | } | |
| 1143 | + | d.resetScreenDefaultColors(sess) | |
| 1144 | + | ac.clearPreviousSession() | |
| 1145 | + | _ = ac.closeCapturedTransport(failed) | |
| 1146 | + | d.log.Warn("detached client after send error", "session", sess.name) | |
| 837 | 1147 | } | |
| 838 | 1148 | ||
| 839 | 1149 | // killSession removes a session and tears down its resources. It is | |
M internal/usecase/daemon/clipboard.go +96 -14
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | package daemon | |
| 5 | 5 | ||
| 6 | 6 | import ( | |
| 7 | + | "errors" | |
| 7 | 8 | "os" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| @@ -25,12 +26,27 @@ var ( | |||
| 25 | 26 | clipPasteCloseMarker = []byte("\x1b[201~") | |
| 26 | 27 | ) | |
| 27 | 28 | ||
| 28 | - | // handleSequencedImagePush routes an ImagePush through the same input-sequence | |
| 29 | - | // surface as ordinary keystrokes. Echo prediction is still conservative; the | |
| 30 | - | // sequence is carried so resume/dedup plumbing can order image pushes with | |
| 31 | - | // surrounding input when that state reader grows beyond ordinary input. | |
| 32 | - | func (d *Daemon) handleSequencedImagePush(sess *session, _ *attachedClient, _ uint64, ip ports.ImagePush) { | |
| 33 | - | d.handleImagePush(sess, ip) | |
| 29 | + | func (d *Daemon) handleSequencedImagePushForRole(token attachmentRoleToken, _ uint64, ip ports.ImagePush) { | |
| 30 | + | if !token.activeEffect() { | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | d.handleImagePushForRole(token, ip) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (d *Daemon) handleImagePushForRole(token attachmentRoleToken, ip ports.ImagePush) { | |
| 37 | + | if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.activeEffect() { | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | path, err := d.writeClipboardImageForRole(token, ip) | |
| 41 | + | if err != nil { | |
| 42 | + | d.log.Error("writing clipboard image failed", "err", err) | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | if !token.activeEffect() { | |
| 46 | + | _ = os.Remove(path) | |
| 47 | + | return | |
| 48 | + | } | |
| 49 | + | d.injectClipboardPathForRole(token, path) | |
| 34 | 50 | } | |
| 35 | 51 | ||
| 36 | 52 | // handleImagePush writes an ImagePush's bytes to a temp file and injects the | |
| @@ -58,6 +74,33 @@ func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) { | |||
| 58 | 74 | // writeClipboardImage writes ip's bytes to a new 0600 temp file and records | |
| 59 | 75 | // the path on sess for best-effort cleanup at session end (killSession). | |
| 60 | 76 | func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) { | |
| 77 | + | path, err := d.createClipboardImage(ip) | |
| 78 | + | if err != nil { | |
| 79 | + | return "", err | |
| 80 | + | } | |
| 81 | + | sess.mu.Lock() | |
| 82 | + | sess.clipFiles = append(sess.clipFiles, path) | |
| 83 | + | sess.mu.Unlock() | |
| 84 | + | return path, nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.ImagePush) (string, error) { | |
| 88 | + | path, err := d.createClipboardImage(ip) | |
| 89 | + | if err != nil { | |
| 90 | + | return "", err | |
| 91 | + | } | |
| 92 | + | token.sess.mu.Lock() | |
| 93 | + | if !token.activeEffectSessionLocked() { | |
| 94 | + | token.sess.mu.Unlock() | |
| 95 | + | _ = os.Remove(path) | |
| 96 | + | return "", errAttachmentTransition | |
| 97 | + | } | |
| 98 | + | token.sess.clipFiles = append(token.sess.clipFiles, path) | |
| 99 | + | token.sess.mu.Unlock() | |
| 100 | + | return path, nil | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (d *Daemon) createClipboardImage(ip ports.ImagePush) (string, error) { | |
| 61 | 104 | dir := d.tempDir | |
| 62 | 105 | if dir == "" { | |
| 63 | 106 | dir = os.TempDir() | |
| @@ -76,9 +119,6 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, | |||
| 76 | 119 | _ = os.Remove(path) | |
| 77 | 120 | return "", err | |
| 78 | 121 | } | |
| 79 | - | sess.mu.Lock() | |
| 80 | - | sess.clipFiles = append(sess.clipFiles, path) | |
| 81 | - | sess.mu.Unlock() | |
| 82 | 122 | return path, nil | |
| 83 | 123 | } | |
| 84 | 124 | ||
| @@ -86,6 +126,17 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, | |||
| 86 | 126 | // the same PTY write path as ordinary input, wrapped in bracketed-paste | |
| 87 | 127 | // markers iff the pane's Screen reports bracketed-paste mode enabled. | |
| 88 | 128 | func (d *Daemon) injectClipboardPath(sess *session, path string) { | |
| 129 | + | d.injectClipboardPathToTarget(sess, path, nil) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) { | |
| 133 | + | if !token.activeEffect() { | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | d.injectClipboardPathToTarget(token.sess, path, &token) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) { | |
| 89 | 140 | tb := sess.activeTab() | |
| 90 | 141 | if tb == nil { | |
| 91 | 142 | return | |
| @@ -109,6 +160,9 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) { | |||
| 109 | 160 | wrapped = append(wrapped, clipPasteCloseMarker...) | |
| 110 | 161 | data = wrapped | |
| 111 | 162 | } | |
| 163 | + | if token != nil && !token.activeEffect() { | |
| 164 | + | return | |
| 165 | + | } | |
| 112 | 166 | d.writeToPane(sess, p, data) | |
| 113 | 167 | } | |
| 114 | 168 | ||
| @@ -132,8 +186,8 @@ func clipboardExt(mime string) string { | |||
| 132 | 186 | } | |
| 133 | 187 | ||
| 134 | 188 | type clipboardForward struct { | |
| 135 | - | ac *attachedClient | |
| 136 | - | seq []byte | |
| 189 | + | token attachmentRoleToken | |
| 190 | + | seq []byte | |
| 137 | 191 | } | |
| 138 | 192 | ||
| 139 | 193 | // forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set | |
| @@ -161,11 +215,31 @@ func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) { | |||
| 161 | 215 | } | |
| 162 | 216 | } | |
| 163 | 217 | ac := sess.client | |
| 218 | + | sess.mu.Unlock() | |
| 164 | 219 | if ac == nil { | |
| 220 | + | return | |
| 221 | + | } | |
| 222 | + | transport := ac.transport() | |
| 223 | + | token := sess.attachmentToken(ac, transport) | |
| 224 | + | if !token.activeCurrent() { | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | sess.mu.Lock() | |
| 229 | + | if sess.ctx != nil { | |
| 230 | + | select { | |
| 231 | + | case <-sess.ctx.Done(): | |
| 232 | + | sess.mu.Unlock() | |
| 233 | + | return | |
| 234 | + | default: | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | if sess.client != ac || ac.roleGeneration.Load() != token.generation || | |
| 238 | + | !ac.transportSnapshotCurrent(token.transport) || ac.currentSession() != sess { | |
| 165 | 239 | sess.mu.Unlock() | |
| 166 | 240 | return | |
| 167 | 241 | } | |
| 168 | - | sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq}) | |
| 242 | + | sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{token: token, seq: seq}) | |
| 169 | 243 | if !sess.clipboardWorkerRunning { | |
| 170 | 244 | sess.clipboardWorkerRunning = true | |
| 171 | 245 | go d.clipboardWorker(sess) | |
| @@ -179,9 +253,17 @@ func (d *Daemon) clipboardWorker(sess *session) { | |||
| 179 | 253 | if !ok { | |
| 180 | 254 | return | |
| 181 | 255 | } | |
| 182 | - | failed, err := d.boundedSendOutputErrTransport(item.ac, item.seq) | |
| 256 | + | ticket, admitted := item.token.ac.beginRoleEffect(item.token) | |
| 257 | + | if !admitted { | |
| 258 | + | continue | |
| 259 | + | } | |
| 260 | + | failed, err := d.boundedSendOutputErrTransportForRole(item.token, ticket, item.seq) | |
| 261 | + | if errors.Is(err, errSendTimedOut) { | |
| 262 | + | _ = item.token.ac.closeCapturedTransport(failed) | |
| 263 | + | } | |
| 264 | + | ticket.End() | |
| 183 | 265 | if err != nil { | |
| 184 | - | d.detachOnSendError(sess, item.ac, failed) | |
| 266 | + | d.detachOnRoleSendError(item.token, failed) | |
| 185 | 267 | } | |
| 186 | 268 | } | |
| 187 | 269 | } | |
M internal/usecase/daemon/clipboard_test.go +74 -4
| @@ -3,10 +3,12 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/base64" | |
| 6 | + | "errors" | |
| 6 | 7 | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 9 | 10 | "strings" | |
| 11 | + | "sync" | |
| 10 | 12 | "testing" | |
| 11 | 13 | "time" | |
| 12 | 14 | ||
| @@ -200,6 +202,13 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY { | |||
| 200 | 202 | return p | |
| 201 | 203 | } | |
| 202 | 204 | ||
| 205 | + | func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) { | |
| 206 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 207 | + | token := sess.attachmentToken(ac, tr) | |
| 208 | + | token.lease = rc.attachmentLease(ac) | |
| 209 | + | ac.publishRoleCapability(token) | |
| 210 | + | } | |
| 211 | + | ||
| 203 | 212 | func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |
| 204 | 213 | d := newTestDaemon(t, nil, stubClock{}) | |
| 205 | 214 | p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07")) | |
| @@ -213,14 +222,22 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |||
| 213 | 222 | sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 214 | 223 | ac.setSession(sess) | |
| 215 | 224 | d.sessions[sess.id] = sess | |
| 225 | + | publishActiveClipboardCapability(d, sess, ac, tr) | |
| 216 | 226 | ||
| 217 | 227 | d.sessWg.Add(1) | |
| 218 | 228 | d.ptyReader(sess, win, win.focusedPane()) | |
| 219 | 229 | ||
| 220 | - | f := awaitFrame(t, sends, ports.MsgOutput) | |
| 221 | - | out, err := ports.UnmarshalOutput(f.Payload) | |
| 222 | - | require.NoError(t, err) | |
| 223 | - | require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07") | |
| 230 | + | var clipboardOutput string | |
| 231 | + | for range 3 { | |
| 232 | + | f := awaitFrame(t, sends, ports.MsgOutput) | |
| 233 | + | out, err := ports.UnmarshalOutput(f.Payload) | |
| 234 | + | require.NoError(t, err) | |
| 235 | + | clipboardOutput = string(out.Data) | |
| 236 | + | if strings.Contains(clipboardOutput, "\x1b]52;c;aGVsbG8=\x07") { | |
| 237 | + | break | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | require.Contains(t, clipboardOutput, "\x1b]52;c;aGVsbG8=\x07") | |
| 224 | 241 | } | |
| 225 | 242 | ||
| 226 | 243 | func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) { | |
| @@ -289,6 +306,57 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) { | |||
| 289 | 306 | }) | |
| 290 | 307 | } | |
| 291 | 308 | ||
| 309 | + | type staleClipboardErrorTransport struct { | |
| 310 | + | mu sync.Mutex | |
| 311 | + | sends int | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func (t *staleClipboardErrorTransport) Send(ports.Frame) error { | |
| 315 | + | t.mu.Lock() | |
| 316 | + | t.sends++ | |
| 317 | + | t.mu.Unlock() | |
| 318 | + | return errors.New("stale clipboard send") | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func (*staleClipboardErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 322 | + | func (*staleClipboardErrorTransport) Close() error { return nil } | |
| 323 | + | ||
| 324 | + | func (t *staleClipboardErrorTransport) sendCount() int { | |
| 325 | + | t.mu.Lock() | |
| 326 | + | defer t.mu.Unlock() | |
| 327 | + | return t.sends | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) { | |
| 331 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 332 | + | oldTransport := &staleClipboardErrorTransport{} | |
| 333 | + | old.replaceTransport(oldTransport) | |
| 334 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 335 | + | token := sess.attachmentToken(old, oldTransport) | |
| 336 | + | token.lease = rc.attachmentLease(old) | |
| 337 | + | old.publishRoleCapability(token) | |
| 338 | + | ||
| 339 | + | sess.mu.Lock() | |
| 340 | + | sess.clipboardWorkerRunning = true | |
| 341 | + | sess.mu.Unlock() | |
| 342 | + | d.forwardClipboardAsync(sess, base64.StdEncoding.EncodeToString([]byte("queued"))) | |
| 343 | + | ||
| 344 | + | newTransport := &closeTrackingTransport{} | |
| 345 | + | next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size} | |
| 346 | + | next.initOverlays() | |
| 347 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 348 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 349 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 350 | + | }) | |
| 351 | + | require.NoError(t, err) | |
| 352 | + | ||
| 353 | + | d.clipboardWorker(sess) | |
| 354 | + | require.Zero(t, oldTransport.sendCount(), "stale queued work reached its captured failing transport") | |
| 355 | + | require.Empty(t, newTransport.Sends(), "stale queued work was redirected to the replacement") | |
| 356 | + | require.Same(t, next, sess.client, "stale clipboard send handling detached the current client") | |
| 357 | + | require.False(t, newTransport.Closed()) | |
| 358 | + | } | |
| 359 | + | ||
| 292 | 360 | func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) { | |
| 293 | 361 | d := newTestDaemon(t, nil, stubClock{}) | |
| 294 | 362 | tr := newBlockingClipboardTransport() | |
| @@ -298,6 +366,8 @@ func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) { | |||
| 298 | 366 | defer cancel() | |
| 299 | 367 | sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac} | |
| 300 | 368 | ac.setSession(sess) | |
| 369 | + | d.sessions[sess.id] = sess | |
| 370 | + | publishActiveClipboardCapability(d, sess, ac, tr) | |
| 301 | 371 | ||
| 302 | 372 | first := base64.StdEncoding.EncodeToString([]byte("first")) | |
| 303 | 373 | second := base64.StdEncoding.EncodeToString([]byte("second")) | |
M internal/usecase/daemon/daemon.go +93 -27
| @@ -112,18 +112,53 @@ type Daemon struct { | |||
| 112 | 112 | // and before coordinator epoch admission. It is a deterministic regression | |
| 113 | 113 | // seam for stale resize publication. | |
| 114 | 114 | beforeSessionResizePublication func() | |
| 115 | - | ptys ports.PTYFactory | |
| 116 | - | clock ports.Clock | |
| 117 | - | log *slog.Logger | |
| 118 | - | runtimeObserver ports.RuntimeObserver | |
| 119 | - | baseEnv []string | |
| 120 | - | shell string | |
| 121 | - | shellArgs []string | |
| 122 | - | shellOverride bool | |
| 123 | - | persistEnabled bool | |
| 124 | - | catalogue ports.Catalogue | |
| 125 | - | catalogueRecords []domain.CatalogueRecord | |
| 126 | - | catalogueRecordsProvided bool | |
| 115 | + | // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs | |
| 116 | + | // after one overlay-family mutex is released and before the next is taken. | |
| 117 | + | afterSnatchOverlayFamily func(string) | |
| 118 | + | // afterActiveFrameDispatch is a deterministic test seam after the connection | |
| 119 | + | // loop's first role check and before a decoded active frame takes effect. | |
| 120 | + | afterActiveFrameDispatch func(attachmentRoleToken) | |
| 121 | + | // afterRoleEffectParticipantsSnapshotted observes immutable role-gate | |
| 122 | + | // participants after architecture preflight locks are released and before | |
| 123 | + | // the globally ordered freeze begins. | |
| 124 | + | afterRoleEffectParticipantsSnapshotted func(string, []*attachedClient) | |
| 125 | + | // afterRoleEffectGateFrozen observes each participant after it is frozen in | |
| 126 | + | // immutable identity order and before drain/publication continues. | |
| 127 | + | afterRoleEffectGateFrozen func(string, *attachedClient) | |
| 128 | + | // afterRoleEffectsFrozen observes the lock-free boundary after all affected | |
| 129 | + | // attachment gates are frozen and drained, before architecture publication. | |
| 130 | + | afterRoleEffectsFrozen func() | |
| 131 | + | // afterDetachRoleEffectsFrozen observes terminal detach after it wins the | |
| 132 | + | // attachment gate but before it checks session ownership. | |
| 133 | + | afterDetachRoleEffectsFrozen func() | |
| 134 | + | // afterDisplacedCleanupStarted observes deferred displaced cleanup before it | |
| 135 | + | // synchronizes with the attachment gate. | |
| 136 | + | afterDisplacedCleanupStarted func() | |
| 137 | + | // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint | |
| 138 | + | // reserves its exact capability and before its first observable mutation. | |
| 139 | + | afterRoleEffectAdmitted func(attachmentRoleToken) | |
| 140 | + | // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a | |
| 141 | + | // fresh exact capability before producing PTY, action, or overlay effects. | |
| 142 | + | afterDelayedKeyEffectAttempt func(bool) | |
| 143 | + | // afterActionRoleEffectEnded observes action-specific admission release. It | |
| 144 | + | // is a deterministic seam for proving no role-bound mutation follows release. | |
| 145 | + | afterActionRoleEffectEnded func(string) | |
| 146 | + | // beforeRoleSendErrorCleanup pauses asynchronous render-failure retirement | |
| 147 | + | // after the render ticket ends and before exact lifecycle validation. | |
| 148 | + | beforeRoleSendErrorCleanup func(attachmentRoleToken) | |
| 149 | + | afterRoleSendErrorCleanup func() | |
| 150 | + | ptys ports.PTYFactory | |
| 151 | + | clock ports.Clock | |
| 152 | + | log *slog.Logger | |
| 153 | + | runtimeObserver ports.RuntimeObserver | |
| 154 | + | baseEnv []string | |
| 155 | + | shell string | |
| 156 | + | shellArgs []string | |
| 157 | + | shellOverride bool | |
| 158 | + | persistEnabled bool | |
| 159 | + | catalogue ports.Catalogue | |
| 160 | + | catalogueRecords []domain.CatalogueRecord | |
| 161 | + | catalogueRecordsProvided bool | |
| 127 | 162 | // snapshotRepository is the sole checkpoint storage contract. | |
| 128 | 163 | snapshotRepository ports.SnapshotRepository | |
| 129 | 164 | recovery *recoveryusecase.Coordinator | |
| @@ -656,7 +691,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho | |||
| 656 | 691 | ephemeral := s.ephemeral | |
| 657 | 692 | s.mu.Unlock() | |
| 658 | 693 | s.stopInMemoryLifecycle() | |
| 659 | - | if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline); err != nil { | |
| 694 | + | if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline, nil); err != nil { | |
| 660 | 695 | checkpointIncomplete = true | |
| 661 | 696 | d.log.Error("closing session with unpersisted terminal state", "err", err) | |
| 662 | 697 | } | |
| @@ -918,21 +953,32 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 918 | 953 | lease = rc.attachmentLease(ac) | |
| 919 | 954 | } | |
| 920 | 955 | expected := ac.transportSnapshot() | |
| 921 | - | if lease == nil || expected.transport != tr { | |
| 956 | + | welcomeToken := sess.attachmentToken(ac, tr) | |
| 957 | + | welcomeToken.lease = lease | |
| 958 | + | welcomeTicket, admitted := ac.beginRoleEffect(welcomeToken) | |
| 959 | + | if lease == nil || expected.transport != tr || !admitted { | |
| 922 | 960 | d.clientGone(sess, ac, tr, false) | |
| 923 | 961 | return | |
| 924 | 962 | } | |
| 925 | - | if err := ac.sendExpectedTransport(expected, frameWelcome(sess, ac)); err != nil { | |
| 963 | + | if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil { | |
| 964 | + | welcomeTicket.End() | |
| 926 | 965 | d.clientGone(sess, ac, tr, false) | |
| 927 | 966 | return | |
| 928 | 967 | } | |
| 929 | 968 | if !rc.markAttachmentReady(lease) { | |
| 969 | + | welcomeTicket.End() | |
| 930 | 970 | // The attachment was displaced or detached while Welcome was in flight; | |
| 931 | 971 | // never let this stale handshake emit an Output frame. | |
| 932 | 972 | d.clientGone(sess, ac, tr, false) | |
| 933 | 973 | return | |
| 934 | 974 | } | |
| 935 | - | d.firstPaint(sess, ac, h.Size) | |
| 975 | + | welcomeTicket.End() | |
| 976 | + | paintToken := sess.attachmentToken(ac, tr) | |
| 977 | + | paintToken.lease = lease | |
| 978 | + | if !d.firstPaintForTransition(paintToken) { | |
| 979 | + | d.clientGone(sess, ac, tr, false) | |
| 980 | + | return | |
| 981 | + | } | |
| 936 | 982 | d.runConnLoop(ac) | |
| 937 | 983 | _ = tr.Close() | |
| 938 | 984 | } | |
| @@ -946,23 +992,39 @@ type protoErr struct { | |||
| 946 | 992 | func (e *protoErr) Error() string { return e.text } | |
| 947 | 993 | ||
| 948 | 994 | // finishAttach completes an attachment prepared while d.mu is held. It | |
| 949 | - | // publishes the terminal state before releasing d.mu, then queues replacement | |
| 950 | - | // teardown so obsolete worker joins never delay the new handshake. | |
| 951 | - | func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient { | |
| 995 | + | // publishes terminal and role state before releasing d.mu, then defers | |
| 996 | + | // coordinator cleanup so obsolete workers never delay the new handshake. | |
| 997 | + | func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) (*attachedClient, error) { | |
| 952 | 998 | // Session state is the sole source for future PTY children. Update it before | |
| 953 | 999 | // publishing the attachment; existing PTYs keep their original environment. | |
| 954 | 1000 | sess.mu.Lock() | |
| 955 | 1001 | sess.env = copyEnvironment(h.Env) | |
| 956 | 1002 | sess.terminal = term | |
| 957 | 1003 | sess.mu.Unlock() | |
| 958 | - | ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{ | |
| 1004 | + | opts := attachClientOptions{ | |
| 959 | 1005 | clientID: h.ClientID, | |
| 960 | 1006 | resumeCapable: true, | |
| 961 | 1007 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | |
| 962 | - | }) | |
| 1008 | + | } | |
| 1009 | + | ac := d.prepareAttachedClientLocked(tr, sz, opts) | |
| 963 | 1010 | d.mu.Unlock() | |
| 964 | - | d.retireReplacedClient(old, cleanup) | |
| 965 | - | return ac | |
| 1011 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1012 | + | target: sess, | |
| 1013 | + | next: ac, | |
| 1014 | + | expectedRole: attachmentDetached, | |
| 1015 | + | targetRole: attachmentActive, | |
| 1016 | + | expectedTransport: ac.transportSnapshot(), | |
| 1017 | + | ready: false, | |
| 1018 | + | }) | |
| 1019 | + | if err != nil { | |
| 1020 | + | if tr != nil { | |
| 1021 | + | _ = tr.Close() | |
| 1022 | + | } | |
| 1023 | + | return nil, err | |
| 1024 | + | } | |
| 1025 | + | d.finishAttachedClient(sess, ac, opts) | |
| 1026 | + | d.deferAttachmentTransitionCleanups(result) | |
| 1027 | + | return ac, nil | |
| 966 | 1028 | } | |
| 967 | 1029 | ||
| 968 | 1030 | func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | |
| @@ -1064,7 +1126,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1064 | 1126 | } | |
| 1065 | 1127 | } | |
| 1066 | 1128 | d.purgeParkedForSessionLocked(sess) | |
| 1067 | - | return sess, d.finishAttach(sess, tr, sz, term, h), nil | |
| 1129 | + | ac, err := d.finishAttach(sess, tr, sz, term, h) | |
| 1130 | + | return sess, ac, err | |
| 1068 | 1131 | ||
| 1069 | 1132 | case ports.IntentEphemeral: | |
| 1070 | 1133 | name := d.allocEphemeralNameLocked() | |
| @@ -1074,7 +1137,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1074 | 1137 | return nil, nil, err | |
| 1075 | 1138 | } | |
| 1076 | 1139 | d.purgeParkedForSessionLocked(sess) | |
| 1077 | - | return sess, d.finishAttach(sess, tr, sz, term, h), nil | |
| 1140 | + | ac, err := d.finishAttach(sess, tr, sz, term, h) | |
| 1141 | + | return sess, ac, err | |
| 1078 | 1142 | ||
| 1079 | 1143 | case ports.IntentNew: | |
| 1080 | 1144 | if h.Name == "" { | |
| @@ -1095,7 +1159,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1095 | 1159 | return nil, nil, err | |
| 1096 | 1160 | } | |
| 1097 | 1161 | d.purgeParkedForSessionLocked(sess) | |
| 1098 | - | return sess, d.finishAttach(sess, tr, sz, term, h), nil | |
| 1162 | + | ac, err := d.finishAttach(sess, tr, sz, term, h) | |
| 1163 | + | return sess, ac, err | |
| 1099 | 1164 | ||
| 1100 | 1165 | case ports.IntentAttach: | |
| 1101 | 1166 | sess := d.findByNameLocked(h.Name) | |
| @@ -1114,7 +1179,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1114 | 1179 | } | |
| 1115 | 1180 | } | |
| 1116 | 1181 | d.purgeParkedForSessionLocked(sess) | |
| 1117 | - | return sess, d.finishAttach(sess, tr, sz, term, h), nil | |
| 1182 | + | ac, err := d.finishAttach(sess, tr, sz, term, h) | |
| 1183 | + | return sess, ac, err | |
| 1118 | 1184 | ||
| 1119 | 1185 | default: | |
| 1120 | 1186 | d.mu.Unlock() | |
M internal/usecase/daemon/floating.go +20 -3
| @@ -160,15 +160,29 @@ func (d *Daemon) activateTab(sess *session, tb *tab) bool { | |||
| 160 | 160 | // activateTabAfterResize retains tab activation's warmup work while avoiding a | |
| 161 | 161 | // second resize when a synchronous outer resize request was already accepted. | |
| 162 | 162 | func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool { | |
| 163 | + | return d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, nil, nil) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // activateTabAfterResizeForLease keeps transition-owned resize effects bound to | |
| 167 | + | // the exact coordinator incarnation. A nil lease preserves direct/headless | |
| 168 | + | // activation behavior. | |
| 169 | + | func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerResizeAccepted bool, ac *attachedClient, lease *attachmentLease) bool { | |
| 163 | 170 | if d == nil || sess == nil || tb == nil { | |
| 164 | 171 | return false | |
| 165 | 172 | } | |
| 166 | 173 | // A headless session has no actual terminal destination. Deferring warmup | |
| 167 | 174 | // until firstPaint keeps restored tabs cold and avoids launching children | |
| 168 | 175 | // merely because a tab was manipulated during teardown. | |
| 169 | - | sess.mu.Lock() | |
| 170 | - | ac := sess.client | |
| 171 | - | sess.mu.Unlock() | |
| 176 | + | if ac == nil { | |
| 177 | + | sess.mu.Lock() | |
| 178 | + | ac = sess.client | |
| 179 | + | sess.mu.Unlock() | |
| 180 | + | } else { | |
| 181 | + | rc := sess.renderCoordinator() | |
| 182 | + | if rc == nil || !rc.leaseCurrent(lease, true) { | |
| 183 | + | return false | |
| 184 | + | } | |
| 185 | + | } | |
| 172 | 186 | d.exitCopyMode(ac) | |
| 173 | 187 | if ac == nil { | |
| 174 | 188 | return false | |
| @@ -182,6 +196,9 @@ func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccep | |||
| 182 | 196 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 183 | 197 | tb.mu.Unlock() | |
| 184 | 198 | if hasFloating { | |
| 199 | + | if lease != nil { | |
| 200 | + | return d.requestTransactionalResizeForLease(sess, ac, lease, size, true) | |
| 201 | + | } | |
| 185 | 202 | return d.requestTransactionalResize(sess, ac, size, true) | |
| 186 | 203 | } | |
| 187 | 204 | return false | |
M internal/usecase/daemon/input.go +80 -10
| @@ -19,6 +19,13 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6 | |||
| 19 | 19 | d.handleInput(sess, ac, data) | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | + | func (d *Daemon) handleSequencedInputForRole(token attachmentRoleToken, _ uint64, data []byte) { | |
| 23 | + | if !token.activeEffect() { | |
| 24 | + | return | |
| 25 | + | } | |
| 26 | + | d.handleInputForRole(token, data) | |
| 27 | + | } | |
| 28 | + | ||
| 22 | 29 | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |
| 23 | 30 | ac.initOverlays() | |
| 24 | 31 | ac.mouseScan.Scan(data, | |
| @@ -32,6 +39,30 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||
| 32 | 39 | ) | |
| 33 | 40 | } | |
| 34 | 41 | ||
| 42 | + | func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) { | |
| 43 | + | ac := token.ac | |
| 44 | + | ac.initOverlays() | |
| 45 | + | ac.mouseScan.Scan(data, | |
| 46 | + | func(ev mouse.Event) { | |
| 47 | + | if token.activeEffect() { | |
| 48 | + | d.handleMouse(ac, ev) | |
| 49 | + | } | |
| 50 | + | }, | |
| 51 | + | func(b []byte) { | |
| 52 | + | if !token.activeEffect() { | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | if ac.overlays.HandleInput(d, b, token.effect) { | |
| 56 | + | return | |
| 57 | + | } | |
| 58 | + | if !token.activeEffect() { | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, roleToken: token}) | |
| 62 | + | }, | |
| 63 | + | ) | |
| 64 | + | } | |
| 65 | + | ||
| 35 | 66 | func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |
| 36 | 67 | frameEvent := ev | |
| 37 | 68 | ac.initOverlays() | |
| @@ -266,16 +297,49 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte { | |||
| 266 | 297 | } | |
| 267 | 298 | ||
| 268 | 299 | type daemonKeyHandler struct { | |
| 269 | - | d *Daemon | |
| 270 | - | ac *attachedClient | |
| 271 | - | actions daemonActionRunner | |
| 300 | + | d *Daemon | |
| 301 | + | ac *attachedClient | |
| 302 | + | actions daemonActionRunner | |
| 303 | + | roleToken attachmentRoleToken | |
| 272 | 304 | } | |
| 273 | 305 | ||
| 274 | - | func (h daemonKeyHandler) Forward(data []byte) { | |
| 306 | + | // acquireRoleEffect preserves a synchronous frame's existing admission and | |
| 307 | + | // gives delayed router callbacks a fresh ticket for the exact captured role. | |
| 308 | + | // Direct/headless callers without a role token retain their existing behavior. | |
| 309 | + | func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) { | |
| 310 | + | if h.roleToken.ac != nil { | |
| 311 | + | if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() { | |
| 312 | + | return h.roleToken.sess, effect, false | |
| 313 | + | } | |
| 314 | + | effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken) | |
| 315 | + | if h.d.afterDelayedKeyEffectAttempt != nil { | |
| 316 | + | h.d.afterDelayedKeyEffectAttempt(admitted) | |
| 317 | + | } | |
| 318 | + | if !admitted { | |
| 319 | + | return nil, nil, false | |
| 320 | + | } | |
| 321 | + | if h.d.afterRoleEffectAdmitted != nil { | |
| 322 | + | token := h.roleToken | |
| 323 | + | token.effect = effect | |
| 324 | + | h.d.afterRoleEffectAdmitted(token) | |
| 325 | + | } | |
| 326 | + | return h.roleToken.sess, effect, true | |
| 327 | + | } | |
| 275 | 328 | sess := h.ac.currentSession() | |
| 329 | + | if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched { | |
| 330 | + | return nil, nil, false | |
| 331 | + | } | |
| 332 | + | return sess, nil, false | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func (h daemonKeyHandler) Forward(data []byte) { | |
| 336 | + | sess, effect, owned := h.acquireRoleEffect() | |
| 276 | 337 | if sess == nil { | |
| 277 | 338 | return | |
| 278 | 339 | } | |
| 340 | + | if owned { | |
| 341 | + | defer effect.End() | |
| 342 | + | } | |
| 279 | 343 | tb := sess.activeTab() | |
| 280 | 344 | if tb == nil { | |
| 281 | 345 | return | |
| @@ -287,10 +351,13 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||
| 287 | 351 | } | |
| 288 | 352 | ||
| 289 | 353 | func (h daemonKeyHandler) Action(action keys.Action) { | |
| 290 | - | sess := h.ac.currentSession() | |
| 354 | + | sess, effect, owned := h.acquireRoleEffect() | |
| 291 | 355 | if sess == nil { | |
| 292 | 356 | return | |
| 293 | 357 | } | |
| 358 | + | if owned { | |
| 359 | + | defer effect.End() | |
| 360 | + | } | |
| 294 | 361 | runResizeAction := func(request daemonActionRequest) { | |
| 295 | 362 | request.target = resolveDaemonActionTarget(sess) | |
| 296 | 363 | runner := h.actions | |
| @@ -309,7 +376,10 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 309 | 376 | case keys.ActionOpenPalette: | |
| 310 | 377 | h.d.enterPalette(sess, h.ac) | |
| 311 | 378 | case keys.ActionJumpAttention: | |
| 312 | - | if err := h.d.jumpAttention(sess, h.ac); err != nil { | |
| 379 | + | effect.bindActionEnd(h.d, "jump-attention") | |
| 380 | + | token := h.roleToken | |
| 381 | + | token.effect = effect | |
| 382 | + | if err := h.d.jumpAttentionForRole(sess, h.ac, token); err != nil { | |
| 313 | 383 | h.d.reportError(sess, err) | |
| 314 | 384 | } | |
| 315 | 385 | case keys.ActionToggleFloatingPane: | |
| @@ -318,19 +388,19 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 318 | 388 | h.d.reportError(sess, err) | |
| 319 | 389 | } | |
| 320 | 390 | case keys.ActionFocusPaneLeft: | |
| 321 | - | if err := h.d.focusDir(sess, h.ac, layout.Left); err != nil { | |
| 391 | + | if err := h.d.focusDir(sess, h.ac, layout.Left, effect); err != nil && err != errAttachmentTransition { | |
| 322 | 392 | h.d.reportError(sess, err) | |
| 323 | 393 | } | |
| 324 | 394 | case keys.ActionFocusPaneRight: | |
| 325 | - | if err := h.d.focusDir(sess, h.ac, layout.Right); err != nil { | |
| 395 | + | if err := h.d.focusDir(sess, h.ac, layout.Right, effect); err != nil && err != errAttachmentTransition { | |
| 326 | 396 | h.d.reportError(sess, err) | |
| 327 | 397 | } | |
| 328 | 398 | case keys.ActionFocusPaneUp: | |
| 329 | - | if err := h.d.focusDir(sess, h.ac, layout.Up); err != nil { | |
| 399 | + | if err := h.d.focusDir(sess, h.ac, layout.Up, effect); err != nil && err != errAttachmentTransition { | |
| 330 | 400 | h.d.reportError(sess, err) | |
| 331 | 401 | } | |
| 332 | 402 | case keys.ActionFocusPaneDown: | |
| 333 | - | if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil { | |
| 403 | + | if err := h.d.focusDir(sess, h.ac, layout.Down, effect); err != nil && err != errAttachmentTransition { | |
| 334 | 404 | h.d.reportError(sess, err) | |
| 335 | 405 | } | |
| 336 | 406 | case keys.ActionGrowPaneWidth: | |
A internal/usecase/daemon/navigation_role_handoff_test.go +192 -0
| @@ -0,0 +1,192 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |
| 15 | + | tests := []struct { | |
| 16 | + | name string | |
| 17 | + | action string | |
| 18 | + | run func(*Daemon, *session, *session, *attachedClient, *roleEffectTicket) error | |
| 19 | + | check func(*testing.T, *Daemon, *session, *session) | |
| 20 | + | }{ | |
| 21 | + | { | |
| 22 | + | name: "picker selection", action: "picker-select", | |
| 23 | + | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error { | |
| 24 | + | d.enterPicker(source, ac) | |
| 25 | + | return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select") | |
| 26 | + | }, | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | name: "palette session", action: "palette-session", | |
| 30 | + | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error { | |
| 31 | + | return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session") | |
| 32 | + | }, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | name: "palette recent session", action: "palette-recent-session", | |
| 36 | + | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error { | |
| 37 | + | return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1) | |
| 38 | + | }, | |
| 39 | + | }, | |
| 40 | + | { | |
| 41 | + | name: "session overflow", action: "overflow-session", | |
| 42 | + | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error { | |
| 43 | + | source.mu.Lock() | |
| 44 | + | source.name = "alpha" | |
| 45 | + | source.mu.Unlock() | |
| 46 | + | target.mu.Lock() | |
| 47 | + | target.name = "charlie" | |
| 48 | + | target.mu.Unlock() | |
| 49 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}}) | |
| 50 | + | return d.focusDir(source, ac, layout.Down, effect) | |
| 51 | + | }, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "stopped session resume", action: "stopped-session", | |
| 55 | + | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error { | |
| 56 | + | d.mu.Lock() | |
| 57 | + | delete(d.sessions, target.id) | |
| 58 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionStopped} | |
| 59 | + | d.mu.Unlock() | |
| 60 | + | expectedCreatedAt := int64(9) | |
| 61 | + | return d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session") | |
| 62 | + | }, | |
| 63 | + | check: func(t *testing.T, d *Daemon, _, _ *session) { | |
| 64 | + | d.mu.Lock() | |
| 65 | + | _, stopped := d.stopped["stopped"] | |
| 66 | + | resumed := d.findByNameLocked("stopped") | |
| 67 | + | d.mu.Unlock() | |
| 68 | + | require.True(t, stopped) | |
| 69 | + | require.Nil(t, resumed) | |
| 70 | + | }, | |
| 71 | + | }, | |
| 72 | + | { | |
| 73 | + | name: "transition prompt creation", action: "create-session", | |
| 74 | + | run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *roleEffectTicket) error { | |
| 75 | + | return d.createSessionAndSwitchForRole(effect.roleToken(), "created") | |
| 76 | + | }, | |
| 77 | + | check: func(t *testing.T, d *Daemon, _, _ *session) { | |
| 78 | + | d.mu.Lock() | |
| 79 | + | created := d.findByNameLocked("created") | |
| 80 | + | d.mu.Unlock() | |
| 81 | + | require.Nil(t, created, "stale prompt submission created a target session") | |
| 82 | + | }, | |
| 83 | + | }, | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | for _, tt := range tests { | |
| 87 | + | t.Run(tt.name, func(t *testing.T) { | |
| 88 | + | d, source, old, _, releases := newManualTabSession(t, 2) | |
| 89 | + | defer releaseAll(releases) | |
| 90 | + | d.ptys = newFactorySeq(t) | |
| 91 | + | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 92 | + | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 93 | + | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 94 | + | }} | |
| 95 | + | d.mu.Lock() | |
| 96 | + | d.sessions[target.id] = target | |
| 97 | + | d.mu.Unlock() | |
| 98 | + | ||
| 99 | + | rc := d.attachCoordinator(source, nil, old, true) | |
| 100 | + | token := source.attachmentToken(old, old.transport()) | |
| 101 | + | token.lease = rc.attachmentLease(old) | |
| 102 | + | old.publishRoleCapability(token) | |
| 103 | + | effect, admitted := old.beginRoleEffect(token) | |
| 104 | + | require.True(t, admitted) | |
| 105 | + | ||
| 106 | + | admissionEnded := make(chan struct{}) | |
| 107 | + | releaseAction := make(chan struct{}) | |
| 108 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 109 | + | if action == tt.action { | |
| 110 | + | close(admissionEnded) | |
| 111 | + | <-releaseAction | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | actionDone := make(chan error, 1) | |
| 115 | + | go func() { actionDone <- tt.run(d, source, target, old, effect) }() | |
| 116 | + | select { | |
| 117 | + | case <-admissionEnded: | |
| 118 | + | case <-time.After(time.Second): | |
| 119 | + | t.Fatal("handoff did not release its role ticket") | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | replacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 123 | + | source: source, target: source, next: old, | |
| 124 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 125 | + | expectedTransport: old.transportSnapshot(), ready: true, | |
| 126 | + | }) | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | close(releaseAction) | |
| 129 | + | require.Error(t, <-actionDone) | |
| 130 | + | ||
| 131 | + | require.Same(t, source, old.currentSession()) | |
| 132 | + | source.mu.Lock() | |
| 133 | + | require.Same(t, old, source.client) | |
| 134 | + | source.mu.Unlock() | |
| 135 | + | target.mu.Lock() | |
| 136 | + | require.Nil(t, target.client, "stale handoff attached to the target") | |
| 137 | + | require.Zero(t, target.active, "stale handoff changed target tab focus") | |
| 138 | + | target.mu.Unlock() | |
| 139 | + | if tt.check != nil { | |
| 140 | + | tt.check(t, d, source, target) | |
| 141 | + | } | |
| 142 | + | d.deferAttachmentTransitionCleanups(replacement) | |
| 143 | + | }) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) { | |
| 148 | + | d, source, old, _ := newManualSessionWithPTYs(t, nil) | |
| 149 | + | d.mu.Lock() | |
| 150 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped} | |
| 151 | + | d.mu.Unlock() | |
| 152 | + | ||
| 153 | + | rc := d.attachCoordinator(source, nil, old, true) | |
| 154 | + | token := source.attachmentToken(old, old.transport()) | |
| 155 | + | token.lease = rc.attachmentLease(old) | |
| 156 | + | old.publishRoleCapability(token) | |
| 157 | + | effect, admitted := old.beginRoleEffect(token) | |
| 158 | + | require.True(t, admitted) | |
| 159 | + | ||
| 160 | + | ended := make(chan struct{}) | |
| 161 | + | release := make(chan struct{}) | |
| 162 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 163 | + | if action == "picker-stopped" { | |
| 164 | + | close(ended) | |
| 165 | + | <-release | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | expectedCreatedAt := int64(7) | |
| 169 | + | done := make(chan error, 1) | |
| 170 | + | go func() { | |
| 171 | + | done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped") | |
| 172 | + | }() | |
| 173 | + | <-ended | |
| 174 | + | ||
| 175 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 176 | + | next.initOverlays() | |
| 177 | + | replacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 178 | + | target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 179 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 180 | + | }) | |
| 181 | + | require.NoError(t, err) | |
| 182 | + | close(release) | |
| 183 | + | require.Error(t, <-done) | |
| 184 | + | ||
| 185 | + | d.mu.Lock() | |
| 186 | + | _, stillStopped := d.stopped["stopped"] | |
| 187 | + | resumed := d.findByNameLocked("stopped") | |
| 188 | + | d.mu.Unlock() | |
| 189 | + | require.True(t, stillStopped) | |
| 190 | + | require.Nil(t, resumed) | |
| 191 | + | d.deferAttachmentTransitionCleanups(replacement) | |
| 192 | + | } | |
A internal/usecase/daemon/navigation_role_incarnation_test.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | rebind func(*testing.T, *Daemon, *session, *attachedClient) | |
| 15 | + | }{ | |
| 16 | + | { | |
| 17 | + | name: "role generation and lease replaced on same attachment", | |
| 18 | + | rebind: func(t *testing.T, d *Daemon, source *session, ac *attachedClient) { | |
| 19 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 20 | + | source: source, target: source, next: ac, | |
| 21 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 22 | + | expectedTransport: ac.transportSnapshot(), ready: true, | |
| 23 | + | }) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | d.deferAttachmentTransitionCleanups(result) | |
| 26 | + | }, | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | name: "transport incarnation rebound on same attachment", | |
| 30 | + | rebind: func(t *testing.T, _ *Daemon, source *session, ac *attachedClient) { | |
| 31 | + | ac.replaceTransport(&closeTrackingTransport{}) | |
| 32 | + | current := source.attachmentToken(ac, ac.transport()) | |
| 33 | + | current.lease = source.renderCoordinator().attachmentLease(ac) | |
| 34 | + | ac.publishRoleCapability(current) | |
| 35 | + | }, | |
| 36 | + | }, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | for _, tt := range tests { | |
| 40 | + | t.Run(tt.name, func(t *testing.T) { | |
| 41 | + | d, source, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 42 | + | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 43 | + | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 44 | + | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 45 | + | }} | |
| 46 | + | d.mu.Lock() | |
| 47 | + | d.sessions[target.id] = target | |
| 48 | + | d.mu.Unlock() | |
| 49 | + | ||
| 50 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 51 | + | token := source.attachmentToken(ac, ac.transport()) | |
| 52 | + | token.lease = rc.attachmentLease(ac) | |
| 53 | + | ac.publishRoleCapability(token) | |
| 54 | + | effect, admitted := ac.beginRoleEffect(token) | |
| 55 | + | require.True(t, admitted) | |
| 56 | + | ||
| 57 | + | ended := make(chan struct{}) | |
| 58 | + | release := make(chan struct{}) | |
| 59 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 60 | + | if action == "test-handoff" { | |
| 61 | + | close(ended) | |
| 62 | + | <-release | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | done := make(chan error, 1) | |
| 66 | + | go func() { | |
| 67 | + | done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff") | |
| 68 | + | }() | |
| 69 | + | <-ended | |
| 70 | + | tt.rebind(t, d, source, ac) | |
| 71 | + | close(release) | |
| 72 | + | require.ErrorIs(t, <-done, errAttachmentTransition) | |
| 73 | + | ||
| 74 | + | require.Same(t, source, ac.currentSession()) | |
| 75 | + | target.mu.Lock() | |
| 76 | + | require.Nil(t, target.client) | |
| 77 | + | require.Zero(t, target.active) | |
| 78 | + | target.mu.Unlock() | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
M internal/usecase/daemon/notify.go +5 -6
| @@ -443,24 +443,23 @@ func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain. | |||
| 443 | 443 | ||
| 444 | 444 | // dismissToast removes only the specified visible toast. In particular, a | |
| 445 | 445 | // link-connected event must not clear clipboard or other notice state. | |
| 446 | - | func (d *Daemon) dismissToast(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) { | |
| 446 | + | func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) bool { | |
| 447 | 447 | if ac == nil || ac.overlays == nil { | |
| 448 | - | return | |
| 448 | + | return false | |
| 449 | 449 | } | |
| 450 | 450 | rt := ac.overlays | |
| 451 | 451 | rt.noticeMu.Lock() | |
| 452 | + | defer rt.noticeMu.Unlock() | |
| 452 | 453 | i := rt.indexOfToastLocked(code, sid) | |
| 453 | 454 | if i < 0 { | |
| 454 | - | rt.noticeMu.Unlock() | |
| 455 | - | return | |
| 455 | + | return false | |
| 456 | 456 | } | |
| 457 | 457 | rt.noticeToasts[i].timer.stop() | |
| 458 | 458 | rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...) | |
| 459 | 459 | if len(rt.noticeToasts) == 0 { | |
| 460 | 460 | rt.noticeOverflow = 0 | |
| 461 | 461 | } | |
| 462 | - | rt.noticeMu.Unlock() | |
| 463 | - | d.repaintForNotice(ac) | |
| 462 | + | return true | |
| 464 | 463 | } | |
| 465 | 464 | ||
| 466 | 465 | // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the | |
M internal/usecase/daemon/notify_test.go +20 -24
| @@ -71,23 +71,23 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) { | |||
| 71 | 71 | tr, _ := newCapturingTransport(t) | |
| 72 | 72 | replaced := make(chan *attachedClient, 1) | |
| 73 | 73 | go func() { | |
| 74 | - | current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 74 | + | current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 75 | 75 | replaced <- current | |
| 76 | 76 | }() | |
| 77 | 77 | ||
| 78 | 78 | // Releasing the mutation lets the already-validated notice publish before | |
| 79 | - | // replacement can own the session. Thus no stale attachment can mutate a | |
| 80 | - | // toast stack after sess.client changes. | |
| 79 | + | // replacement can own the session. Snatch cleanup then clears the displaced | |
| 80 | + | // attachment's presentation state without moving the notice to the new one. | |
| 81 | 81 | close(releaseMutation) | |
| 82 | 82 | <-noticeDone | |
| 83 | 83 | current := <-replaced | |
| 84 | + | d.attachmentCleanupWg.Wait() | |
| 84 | 85 | ||
| 85 | 86 | history := d.notices.history() | |
| 86 | 87 | require.Len(t, history, 1) | |
| 87 | 88 | require.Equal(t, domain.NoticeClipboard, history[0].Code) | |
| 88 | 89 | oldToasts, _ := visibleToasts(old) | |
| 89 | - | require.Len(t, oldToasts, 1) | |
| 90 | - | require.Equal(t, domain.NoticeClipboard, oldToasts[0].Code) | |
| 90 | + | require.Empty(t, oldToasts) | |
| 91 | 91 | newToasts, _ := visibleToasts(current) | |
| 92 | 92 | require.Empty(t, newToasts) | |
| 93 | 93 | sess.mu.Lock() | |
| @@ -97,10 +97,9 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) { | |||
| 97 | 97 | ||
| 98 | 98 | func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) { | |
| 99 | 99 | for _, tt := range []struct { | |
| 100 | - | name string | |
| 101 | - | setup func(*Daemon, *attachedClient, domain.SessionID) | |
| 102 | - | notice ports.ClientNotice | |
| 103 | - | wantToast domain.NoticeCode | |
| 100 | + | name string | |
| 101 | + | setup func(*Daemon, *attachedClient, domain.SessionID) | |
| 102 | + | notice ports.ClientNotice | |
| 104 | 103 | }{ | |
| 105 | 104 | { | |
| 106 | 105 | name: "record", | |
| @@ -111,28 +110,23 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) { | |||
| 111 | 110 | setup: func(d *Daemon, ac *attachedClient, sid domain.SessionID) { | |
| 112 | 111 | d.showToast(ac, notice(domain.NoticeConnection, "connection degraded", sid)) | |
| 113 | 112 | }, | |
| 114 | - | notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected}, | |
| 115 | - | wantToast: domain.NoticeConnection, | |
| 113 | + | notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected}, | |
| 116 | 114 | }, | |
| 117 | 115 | } { | |
| 118 | 116 | t.Run(tt.name, func(t *testing.T) { | |
| 119 | 117 | d, sess, old, _ := newNoticeFixture(t, newNoticeClock()) | |
| 120 | - | tr, _ := newCapturingTransport(t) | |
| 121 | - | current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 122 | 118 | if tt.setup != nil { | |
| 123 | 119 | tt.setup(d, old, sess.id) | |
| 124 | 120 | } | |
| 121 | + | tr, _ := newCapturingTransport(t) | |
| 122 | + | current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 123 | + | d.attachmentCleanupWg.Wait() | |
| 125 | 124 | ||
| 126 | 125 | d.handleClientNotice(sess, old, tt.notice) | |
| 127 | 126 | ||
| 128 | 127 | require.Empty(t, d.notices.history(), "a replaced client must not record notices") | |
| 129 | 128 | oldToasts, _ := visibleToasts(old) | |
| 130 | - | if tt.setup == nil { | |
| 131 | - | require.Empty(t, oldToasts) | |
| 132 | - | } else { | |
| 133 | - | require.Len(t, oldToasts, 1) | |
| 134 | - | require.Equal(t, tt.wantToast, oldToasts[0].Code, "a replaced client must not dismiss toasts") | |
| 135 | - | } | |
| 129 | + | require.Empty(t, oldToasts, "snatch cleanup must remove displaced notice presentation") | |
| 136 | 130 | currentToasts, _ := visibleToasts(current) | |
| 137 | 131 | require.Empty(t, currentToasts) | |
| 138 | 132 | }) | |
| @@ -140,7 +134,8 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) { | |||
| 140 | 134 | } | |
| 141 | 135 | ||
| 142 | 136 | func TestMalformedClientNoticeIsIgnored(t *testing.T) { | |
| 143 | - | d, _, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |
| 137 | + | d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |
| 138 | + | d.attachCoordinator(sess, nil, ac, true) | |
| 144 | 139 | tr, ok := ac.tr.(*portsmocks.MockTransport) | |
| 145 | 140 | require.True(t, ok, "attached client transport must be a MockTransport") | |
| 146 | 141 | frames := []ports.Frame{ | |
| @@ -757,17 +752,18 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) { | |||
| 757 | 752 | tr, _ := newCapturingTransport(t) | |
| 758 | 753 | replaced := make(chan *attachedClient, 1) | |
| 759 | 754 | go func() { | |
| 760 | - | ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 755 | + | ac, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 761 | 756 | replaced <- ac | |
| 762 | 757 | }() | |
| 763 | 758 | close(releaseRoute) | |
| 764 | 759 | <-globalDone | |
| 765 | 760 | current := <-replaced | |
| 761 | + | d.attachmentCleanupWg.Wait() | |
| 766 | 762 | ||
| 767 | - | // Replacement cannot make old a stale delivery target: it publishes only | |
| 768 | - | // after the selected attachment has received the global toast. | |
| 763 | + | // Replacement publishes only after delivery, then snatch cleanup removes | |
| 764 | + | // the displaced attachment's presentation state. | |
| 769 | 765 | oldToasts, _ := visibleToasts(old) | |
| 770 | - | require.Len(t, oldToasts, 1) | |
| 766 | + | require.Empty(t, oldToasts) | |
| 771 | 767 | newToasts, _ := visibleToasts(current) | |
| 772 | 768 | require.Empty(t, newToasts) | |
| 773 | 769 | sess.mu.Lock() | |
M internal/usecase/daemon/overlay_runtime.go +113 -8
| @@ -32,10 +32,11 @@ type overlayRuntime struct { | |||
| 32 | 32 | paletteFeedback string | |
| 33 | 33 | palettePending []byte | |
| 34 | 34 | ||
| 35 | - | promptMu sync.Mutex | |
| 36 | - | prompt *promptui.Model | |
| 37 | - | promptSubmit func(string) error | |
| 38 | - | promptPending []byte | |
| 35 | + | promptMu sync.Mutex | |
| 36 | + | prompt *promptui.Model | |
| 37 | + | promptSubmit func(string) error | |
| 38 | + | promptTransitionSubmit func(string, attachmentRoleToken) error | |
| 39 | + | promptPending []byte | |
| 39 | 40 | ||
| 40 | 41 | resizeMu sync.Mutex | |
| 41 | 42 | resizeActive bool | |
| @@ -228,21 +229,125 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||
| 228 | 229 | return active || prePublication | |
| 229 | 230 | } | |
| 230 | 231 | ||
| 231 | - | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |
| 232 | + | // clearForSnatch clears each overlay family independently. Every family | |
| 233 | + | // revalidates the displaced role generation while holding only its own mutex, | |
| 234 | + | // so cleanup can neither participate in cross-family lock inversion nor erase | |
| 235 | + | // state published by a later promotion. | |
| 236 | + | func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool { | |
| 237 | + | if token.ac == nil || token.ac.overlays == nil { | |
| 238 | + | return false | |
| 239 | + | } | |
| 240 | + | rt := token.ac.overlays | |
| 241 | + | current := func() bool { | |
| 242 | + | return token.ac.roleGeneration.Load() == token.generation && token.ac.transportSnapshotCurrent(token.transport) | |
| 243 | + | } | |
| 244 | + | afterFamily := func(family string) { | |
| 245 | + | if d.afterSnatchOverlayFamily != nil { | |
| 246 | + | d.afterSnatchOverlayFamily(family) | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | rt.pickerMu.Lock() | |
| 251 | + | if !current() { | |
| 252 | + | rt.pickerMu.Unlock() | |
| 253 | + | return false | |
| 254 | + | } | |
| 255 | + | previewSession := rt.pickerPreviewSession | |
| 256 | + | previewGeneration := rt.pickerPreviewGeneration | |
| 257 | + | rt.picker = nil | |
| 258 | + | rt.pickerPending = nil | |
| 259 | + | rt.pickerESC.stop() | |
| 260 | + | rt.pickerPreviewGeneration++ | |
| 261 | + | rt.pickerPreview = nil | |
| 262 | + | rt.pickerPreviewSession = nil | |
| 263 | + | rt.pickerMu.Unlock() | |
| 264 | + | afterFamily("picker") | |
| 265 | + | // Subscription teardown may enter a coordinator and therefore runs outside | |
| 266 | + | // every overlay-family lock. | |
| 267 | + | d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration) | |
| 268 | + | ||
| 269 | + | rt.paletteMu.Lock() | |
| 270 | + | if !current() { | |
| 271 | + | rt.paletteMu.Unlock() | |
| 272 | + | return false | |
| 273 | + | } | |
| 274 | + | token.ac.clearPaletteLocked() | |
| 275 | + | rt.paletteMu.Unlock() | |
| 276 | + | afterFamily("palette") | |
| 277 | + | ||
| 278 | + | rt.promptMu.Lock() | |
| 279 | + | if !current() { | |
| 280 | + | rt.promptMu.Unlock() | |
| 281 | + | return false | |
| 282 | + | } | |
| 283 | + | rt.prompt = nil | |
| 284 | + | rt.promptSubmit = nil | |
| 285 | + | rt.promptTransitionSubmit = nil | |
| 286 | + | rt.promptPending = nil | |
| 287 | + | rt.promptMu.Unlock() | |
| 288 | + | afterFamily("prompt") | |
| 289 | + | ||
| 290 | + | rt.resizeMu.Lock() | |
| 291 | + | if !current() { | |
| 292 | + | rt.resizeMu.Unlock() | |
| 293 | + | return false | |
| 294 | + | } | |
| 295 | + | rt.resizeActive = false | |
| 296 | + | rt.resizePending = nil | |
| 297 | + | rt.resizeESC.stop() | |
| 298 | + | rt.resizeMu.Unlock() | |
| 299 | + | afterFamily("resize") | |
| 300 | + | ||
| 301 | + | rt.copyMu.Lock() | |
| 302 | + | if !current() { | |
| 303 | + | rt.copyMu.Unlock() | |
| 304 | + | return false | |
| 305 | + | } | |
| 306 | + | d.stopCopyPendingTimerLocked(token.ac) | |
| 307 | + | rt.copyPending = nil | |
| 308 | + | rt.clearCopyModeLocked() | |
| 309 | + | rt.statusFeedback = "" | |
| 310 | + | rt.copyMu.Unlock() | |
| 311 | + | afterFamily("copy") | |
| 312 | + | ||
| 313 | + | rt.noticeMu.Lock() | |
| 314 | + | if !current() { | |
| 315 | + | rt.noticeMu.Unlock() | |
| 316 | + | return false | |
| 317 | + | } | |
| 318 | + | for i := range rt.noticeToasts { | |
| 319 | + | rt.noticeToasts[i].timer.stop() | |
| 320 | + | } | |
| 321 | + | rt.noticeToasts = nil | |
| 322 | + | rt.noticeOverflow = 0 | |
| 323 | + | rt.noticeSeq++ | |
| 324 | + | rt.noticesOverlay = nil | |
| 325 | + | rt.noticesPending = nil | |
| 326 | + | rt.noticesESC.stop() | |
| 327 | + | rt.noticeMu.Unlock() | |
| 328 | + | afterFamily("notice") | |
| 329 | + | return true | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*roleEffectTicket) bool { | |
| 232 | 333 | if rt == nil || rt.ac == nil { | |
| 233 | 334 | return false | |
| 234 | 335 | } | |
| 235 | 336 | ac := rt.ac | |
| 337 | + | var effect *roleEffectTicket | |
| 338 | + | if len(effects) != 0 { | |
| 339 | + | effect = effects[0] | |
| 340 | + | } | |
| 236 | 341 | if rt.promptActive() { | |
| 237 | - | d.handlePromptInput(ac, data) | |
| 342 | + | d.handlePromptInput(ac, data, effect) | |
| 238 | 343 | return true | |
| 239 | 344 | } | |
| 240 | 345 | if rt.paletteActive() { | |
| 241 | - | d.handlePaletteInput(ac, data) | |
| 346 | + | d.handlePaletteInput(ac, data, effect) | |
| 242 | 347 | return true | |
| 243 | 348 | } | |
| 244 | 349 | if rt.pickerActive() { | |
| 245 | - | d.handlePickerInput(ac, data) | |
| 350 | + | d.handlePickerInput(ac, data, effect) | |
| 246 | 351 | return true | |
| 247 | 352 | } | |
| 248 | 353 | if rt.noticesActive() { | |
M internal/usecase/daemon/palette.go +65 -8
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "sort" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -147,7 +148,7 @@ func (d *Daemon) recordPaletteUse(code string) { | |||
| 147 | 148 | d.paletteRecent = recent | |
| 148 | 149 | } | |
| 149 | 150 | ||
| 150 | - | func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |
| 151 | + | func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | |
| 151 | 152 | sess := ac.currentSession() | |
| 152 | 153 | if sess == nil { | |
| 153 | 154 | return | |
| @@ -160,6 +161,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 160 | 161 | var generation uint64 | |
| 161 | 162 | var rawQuery string | |
| 162 | 163 | changed, cancel, execute := false, false, false | |
| 164 | + | var effect *roleEffectTicket | |
| 165 | + | if len(effects) != 0 { | |
| 166 | + | effect = effects[0] | |
| 167 | + | } | |
| 163 | 168 | ||
| 164 | 169 | ac.overlays.paletteMu.Lock() | |
| 165 | 170 | if ac.overlays.palette == nil { | |
| @@ -270,7 +275,16 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 270 | 275 | } else { | |
| 271 | 276 | target.Stopped = true | |
| 272 | 277 | } | |
| 273 | - | if err := d.switchToTarget(sess, ac, target); err != nil { | |
| 278 | + | var err error | |
| 279 | + | if effect != nil { | |
| 280 | + | err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session") | |
| 281 | + | } else { | |
| 282 | + | err = d.switchToTarget(sess, ac, target) | |
| 283 | + | } | |
| 284 | + | if effect != nil && errors.Is(err, errAttachmentTransition) { | |
| 285 | + | return | |
| 286 | + | } | |
| 287 | + | if err != nil { | |
| 274 | 288 | ac.paletteFailure(generation, rawQuery, "requested session is unavailable") | |
| 275 | 289 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 276 | 290 | return | |
| @@ -288,8 +302,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 288 | 302 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 289 | 303 | return | |
| 290 | 304 | } | |
| 291 | - | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent} | |
| 305 | + | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent, effect: effect} | |
| 292 | 306 | if err := exec.JumpRecentSession(rank); err != nil { | |
| 307 | + | if errors.Is(err, errAttachmentTransition) { | |
| 308 | + | return | |
| 309 | + | } | |
| 293 | 310 | ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") | |
| 294 | 311 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 295 | 312 | return | |
| @@ -300,12 +317,30 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 300 | 317 | } | |
| 301 | 318 | return | |
| 302 | 319 | } | |
| 303 | - | if !ac.closeExecutedPalette(generation, rawQuery) { | |
| 320 | + | roleHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach" | |
| 321 | + | if !roleHandoff && !ac.closeExecutedPalette(generation, rawQuery) { | |
| 304 | 322 | return | |
| 305 | 323 | } | |
| 306 | 324 | sess.dispatchMu.Lock() | |
| 307 | - | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args) | |
| 325 | + | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect}, args) | |
| 308 | 326 | sess.dispatchMu.Unlock() | |
| 327 | + | if errors.Is(err, errAttachmentTransition) { | |
| 328 | + | return | |
| 329 | + | } | |
| 330 | + | if err == nil && roleHandoff { | |
| 331 | + | if current := ac.currentSession(); current != nil { | |
| 332 | + | currentToken := current.attachmentToken(ac, ac.transport()) | |
| 333 | + | fresh, admitted := ac.beginRoleEffect(currentToken) | |
| 334 | + | if admitted { | |
| 335 | + | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 336 | + | d.invalidateRender(current, ac, true, "palette.go") | |
| 337 | + | } | |
| 338 | + | fresh.End() | |
| 339 | + | } | |
| 340 | + | } else { | |
| 341 | + | ac.closeExecutedPalette(generation, rawQuery) | |
| 342 | + | } | |
| 343 | + | } | |
| 309 | 344 | if err != nil { | |
| 310 | 345 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | |
| 311 | 346 | d.reportError(sess, err) | |
| @@ -356,6 +391,7 @@ type paletteExec struct { | |||
| 356 | 391 | ac *attachedClient | |
| 357 | 392 | recent []recentSession | |
| 358 | 393 | actions daemonActionRunner | |
| 394 | + | effect *roleEffectTicket | |
| 359 | 395 | } | |
| 360 | 396 | ||
| 361 | 397 | func (e paletteExec) runAction(request daemonActionRequest) error { | |
| @@ -378,8 +414,11 @@ func (e paletteExec) CreateTab() error { | |||
| 378 | 414 | } | |
| 379 | 415 | ||
| 380 | 416 | func (e paletteExec) CreateSession() error { | |
| 381 | - | e.d.enterPrompt(e.sess, e.ac, " Create session ", "", func(name string) error { | |
| 382 | - | return e.d.createSessionAndSwitch(e.sess, e.ac, name) | |
| 417 | + | e.d.enterTransitionPrompt(e.sess, e.ac, " Create session ", "", func(name string, token attachmentRoleToken) error { | |
| 418 | + | if token.ac == nil { | |
| 419 | + | return e.d.createSessionAndSwitch(e.sess, e.ac, name) | |
| 420 | + | } | |
| 421 | + | return e.d.createSessionAndSwitchForRole(token, name) | |
| 383 | 422 | }) | |
| 384 | 423 | return nil | |
| 385 | 424 | } | |
| @@ -443,11 +482,20 @@ func (e paletteExec) ToggleFloatingPane() error { | |||
| 443 | 482 | } | |
| 444 | 483 | ||
| 445 | 484 | func (e paletteExec) BackSession() error { | |
| 485 | + | if e.effect != nil { | |
| 486 | + | return e.d.backSessionForRole(e.effect.roleToken()) | |
| 487 | + | } | |
| 446 | 488 | e.d.backSession(e.sess, e.ac) | |
| 447 | 489 | return nil | |
| 448 | 490 | } | |
| 449 | 491 | ||
| 450 | 492 | func (e paletteExec) Detach() error { | |
| 493 | + | if e.effect != nil { | |
| 494 | + | if !e.d.clientGoneForRole(e.effect.roleToken(), true) { | |
| 495 | + | return errAttachmentTransition | |
| 496 | + | } | |
| 497 | + | return nil | |
| 498 | + | } | |
| 451 | 499 | e.d.clientGone(e.sess, e.ac, e.ac.transport(), true) | |
| 452 | 500 | return nil | |
| 453 | 501 | } | |
| @@ -515,7 +563,16 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||
| 515 | 563 | if e.d.beforeRecentSessionHandoff != nil { | |
| 516 | 564 | e.d.beforeRecentSessionHandoff() | |
| 517 | 565 | } | |
| 518 | - | if err := e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil { | |
| 566 | + | var err error | |
| 567 | + | if e.effect != nil { | |
| 568 | + | err = e.d.switchToTargetForRole(e.effect.roleToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session") | |
| 569 | + | } else { | |
| 570 | + | err = e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) | |
| 571 | + | } | |
| 572 | + | if e.effect != nil && errors.Is(err, errAttachmentTransition) { | |
| 573 | + | return err | |
| 574 | + | } | |
| 575 | + | if err != nil { | |
| 519 | 576 | return command.ErrInvalidArguments | |
| 520 | 577 | } | |
| 521 | 578 | return nil | |
M internal/usecase/daemon/palette_test.go +17 -4
| @@ -287,12 +287,19 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) { | |||
| 287 | 287 | ||
| 288 | 288 | d.handleInput(current, ac, []byte("\x1b ")) | |
| 289 | 289 | awaitFrame(t, sends, ports.MsgOutput) | |
| 290 | + | generation := ac.roleGeneration.Load() | |
| 290 | 291 | d.handleInput(current, ac, []byte("stopped\r")) | |
| 291 | 292 | ||
| 292 | - | require.Equal(t, "stopped", ac.currentSession().name) | |
| 293 | - | require.Equal(t, int64(42), ac.currentSession().createdAt) | |
| 293 | + | resumed := ac.currentSession() | |
| 294 | + | require.Equal(t, "stopped", resumed.name) | |
| 295 | + | require.Equal(t, int64(42), resumed.createdAt) | |
| 296 | + | require.Equal(t, attachmentActive, resumed.attachmentRole(ac)) | |
| 297 | + | require.Greater(t, ac.roleGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition") | |
| 294 | 298 | require.False(t, ac.overlays.paletteActive()) | |
| 295 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 299 | + | firstPaint := awaitFrame(t, sends, ports.MsgOutput) | |
| 300 | + | firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload) | |
| 301 | + | require.NoError(t, err) | |
| 302 | + | require.Zero(t, firstOutput.BaseStateNum, "stopped-session first paint must reset moving output state") | |
| 296 | 303 | awaitFrame(t, sends, ports.MsgOutput) | |
| 297 | 304 | } | |
| 298 | 305 | ||
| @@ -530,11 +537,15 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||
| 530 | 537 | require.True(t, ac.overlays.promptActive()) | |
| 531 | 538 | require.Contains(t, string(promptOutput.Data), "Create session") | |
| 532 | 539 | ||
| 540 | + | generation := ac.roleGeneration.Load() | |
| 533 | 541 | d.handleInput(sess, ac, []byte("scratch\r")) | |
| 534 | 542 | // The submit first paints the newly attached session while the prompt is | |
| 535 | 543 | // still open, then handlePromptInput closes the prompt and repaints the | |
| 536 | 544 | // client's current session. The final frame must be for the new session. | |
| 537 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 545 | + | firstPaint := awaitFrame(t, sends, ports.MsgOutput) | |
| 546 | + | firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload) | |
| 547 | + | require.NoError(t, err) | |
| 548 | + | require.Zero(t, firstOutput.BaseStateNum, "new-session first paint must reset moving output state") | |
| 538 | 549 | finalRepaint := awaitFrame(t, sends, ports.MsgOutput) | |
| 539 | 550 | finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload) | |
| 540 | 551 | require.NoError(t, err) | |
| @@ -547,6 +558,8 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||
| 547 | 558 | require.Equal(t, "scratch", newSess.name) | |
| 548 | 559 | require.False(t, newSess.ephemeral) | |
| 549 | 560 | require.Same(t, ac, newSess.client) | |
| 561 | + | require.Equal(t, attachmentActive, newSess.attachmentRole(ac)) | |
| 562 | + | require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition") | |
| 550 | 563 | require.Contains(t, string(finalOutput.Data), "scratch") | |
| 551 | 564 | require.NotContains(t, string(finalOutput.Data), "Create session") | |
| 552 | 565 | } | |
M internal/usecase/daemon/pane_actions.go +4 -1
| @@ -254,7 +254,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 254 | 254 | return nil | |
| 255 | 255 | } | |
| 256 | 256 | ||
| 257 | - | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error { | |
| 257 | + | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effects ...*roleEffectTicket) error { | |
| 258 | 258 | target := resolveDaemonActionTarget(sess) | |
| 259 | 259 | oldFocus := layout.PaneID("") | |
| 260 | 260 | if target.pane != nil { | |
| @@ -279,6 +279,9 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 279 | 279 | if ac == nil { | |
| 280 | 280 | return errNoNeighbor | |
| 281 | 281 | } | |
| 282 | + | if len(effects) != 0 && effects[0] != nil { | |
| 283 | + | return d.switchToTargetForRole(effects[0].roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session") | |
| 284 | + | } | |
| 282 | 285 | return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget) | |
| 283 | 286 | } | |
| 284 | 287 | ||
M internal/usecase/daemon/picker.go +259 -107
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | "sort" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| @@ -117,7 +118,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||
| 117 | 118 | } | |
| 118 | 119 | } | |
| 119 | 120 | ||
| 120 | - | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |
| 121 | + | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | |
| 121 | 122 | sess := ac.currentSession() | |
| 122 | 123 | if sess == nil { | |
| 123 | 124 | return | |
| @@ -148,11 +149,23 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||
| 148 | 149 | rt.pickerMu.Unlock() | |
| 149 | 150 | ||
| 150 | 151 | if result.action == 'x' { | |
| 152 | + | var effect *roleEffectTicket | |
| 153 | + | if len(effects) != 0 { | |
| 154 | + | effect = effects[0] | |
| 155 | + | } | |
| 156 | + | effect.bindActionEnd(d, "picker-delete") | |
| 151 | 157 | if ok { | |
| 152 | - | if err := d.killPickerTarget(target); err != nil { | |
| 158 | + | if err := d.killPickerTargetForRole(target, effect.roleToken()); err != nil { | |
| 153 | 159 | d.reportError(sess, err) | |
| 154 | 160 | } | |
| 155 | 161 | } | |
| 162 | + | if effect != nil && effect.ended.Load() { | |
| 163 | + | fresh, admitted := ac.beginRoleEffect(effect.token) | |
| 164 | + | if !admitted { | |
| 165 | + | return | |
| 166 | + | } | |
| 167 | + | defer fresh.End() | |
| 168 | + | } | |
| 156 | 169 | d.refreshPicker(ac) | |
| 157 | 170 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 158 | 171 | return | |
| @@ -160,12 +173,24 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||
| 160 | 173 | if result.changed { | |
| 161 | 174 | d.registerPreviewForSelection(ac) | |
| 162 | 175 | } | |
| 163 | - | if result.exit { | |
| 176 | + | navigating := (result.action == '\r' || result.action == '\n') && ok | |
| 177 | + | if result.exit && !navigating { | |
| 164 | 178 | d.closePicker(ac) | |
| 165 | 179 | } | |
| 166 | - | if (result.action == '\r' || result.action == '\n') && ok { | |
| 167 | - | if err := d.switchToTarget(sess, ac, target); err != nil { | |
| 180 | + | if navigating { | |
| 181 | + | var err error | |
| 182 | + | if len(effects) != 0 && effects[0] != nil { | |
| 183 | + | err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select") | |
| 184 | + | } else { | |
| 185 | + | d.closePicker(ac) | |
| 186 | + | err = d.switchToTarget(sess, ac, target) | |
| 187 | + | } | |
| 188 | + | if errors.Is(err, errAttachmentTransition) { | |
| 189 | + | return | |
| 190 | + | } | |
| 191 | + | if err != nil { | |
| 168 | 192 | d.reportError(sess, err) | |
| 193 | + | return | |
| 169 | 194 | } | |
| 170 | 195 | return | |
| 171 | 196 | } | |
| @@ -347,6 +372,43 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 347 | 372 | // command switching behavior. | |
| 348 | 373 | type sessionHandoffGuard struct { | |
| 349 | 374 | expectedSource *tab | |
| 375 | + | closePicker bool | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // switchActiveTargetForRole hands a frame-bound navigation request to the | |
| 379 | + | // centralized transition. The transition releases admission at its freeze seam | |
| 380 | + | // and revalidates the exact initiating token before changing target focus or | |
| 381 | + | // attachment ownership. | |
| 382 | + | func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error { | |
| 383 | + | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | |
| 384 | + | return nil | |
| 385 | + | } | |
| 386 | + | d.mu.Lock() | |
| 387 | + | targetSess := d.sessions[target.Session] | |
| 388 | + | d.mu.Unlock() | |
| 389 | + | if targetSess == nil || targetSess == token.sess { | |
| 390 | + | return nil | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 394 | + | source: token.sess, target: targetSess, next: token.ac, | |
| 395 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 396 | + | expectedTransport: token.transport, sourceToken: &token, action: "jump-attention", | |
| 397 | + | activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true, | |
| 398 | + | }) | |
| 399 | + | if err != nil { | |
| 400 | + | // Losing the exact source role is a benign stale action, not a notice for | |
| 401 | + | // whichever attachment replaced the initiator. | |
| 402 | + | if !token.activeCurrent() { | |
| 403 | + | return nil | |
| 404 | + | } | |
| 405 | + | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | |
| 406 | + | } | |
| 407 | + | d.touchMRU(targetSess) | |
| 408 | + | token.ac.recordPreviousSession(token.sess) | |
| 409 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 410 | + | d.firstPaintForTransition(transition.published) | |
| 411 | + | return nil | |
| 350 | 412 | } | |
| 351 | 413 | ||
| 352 | 414 | // switchToTarget resolves named lifecycle targets and commits their transition | |
| @@ -356,15 +418,34 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||
| 356 | 418 | return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{}) | |
| 357 | 419 | } | |
| 358 | 420 | ||
| 359 | - | // switchToTargetGuarded is the navigation-only variant whose guard is checked | |
| 360 | - | // as part of the source ownership transfer. | |
| 421 | + | // switchToTargetForRole is the sole client-originated navigation entry. The | |
| 422 | + | // intent captures the exact initiating capability before admission is released; | |
| 423 | + | // every active, stopped, and same-session target then uses transitionAttachment | |
| 424 | + | // for frozen, atomic source-token preflight. | |
| 425 | + | func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 426 | + | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.effect == nil { | |
| 427 | + | return nil | |
| 428 | + | } | |
| 429 | + | token.effect.bindActionEnd(d, action) | |
| 430 | + | token.effect.End() | |
| 431 | + | return d.switchToTargetGuardedForRole(token.sess, token.ac, target, guard, &token, action) | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // switchToTargetGuarded is retained for daemon-internal and headless callers. | |
| 361 | 435 | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | |
| 436 | + | return d.switchToTargetGuardedForRole(from, ac, target, guard, nil, "") | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) error { | |
| 362 | 440 | if target.Name != "" { | |
| 363 | 441 | ctx := d.serveCtx | |
| 364 | 442 | if ctx == nil { | |
| 365 | 443 | ctx = context.Background() | |
| 366 | 444 | } | |
| 367 | 445 | if err := d.waitForTargetRestore(ctx, target.Name); err != nil { | |
| 446 | + | if sourceToken != nil && !sourceToken.activeCurrent() { | |
| 447 | + | return errAttachmentTransition | |
| 448 | + | } | |
| 368 | 449 | d.invalidateRender(from, ac, true, "picker.go") | |
| 369 | 450 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | |
| 370 | 451 | } | |
| @@ -373,8 +454,7 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target | |||
| 373 | 454 | d.mu.Lock() | |
| 374 | 455 | var ( | |
| 375 | 456 | targetSess *session | |
| 376 | - | old *attachedClient | |
| 377 | - | cleanups []renderLifecycleCleanup | |
| 457 | + | transition attachmentTransitionResult | |
| 378 | 458 | switched bool | |
| 379 | 459 | cause error | |
| 380 | 460 | ) | |
| @@ -382,39 +462,56 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target | |||
| 382 | 462 | active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) | |
| 383 | 463 | if ok { | |
| 384 | 464 | if isStopped { | |
| 385 | - | targetSess, cleanups, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |
| 465 | + | targetSess, transition, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, sourceToken, guard, action) | |
| 386 | 466 | } else { | |
| 387 | 467 | // The snapshot ID may describe the stopped representation. The | |
| 388 | 468 | // locked name/lifecycle resolver chose this active incarnation. | |
| 389 | 469 | resolvedTarget := target | |
| 390 | 470 | resolvedTarget.Session = active.id | |
| 391 | - | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard) | |
| 471 | + | targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action) | |
| 392 | 472 | } | |
| 393 | 473 | } | |
| 394 | 474 | } else if active := d.sessions[target.Session]; active != nil { | |
| 395 | - | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard) | |
| 475 | + | targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action) | |
| 396 | 476 | } | |
| 397 | 477 | d.mu.Unlock() | |
| 398 | 478 | ||
| 399 | 479 | if !switched { | |
| 480 | + | if sourceToken != nil && !sourceToken.activeCurrent() { | |
| 481 | + | return errAttachmentTransition | |
| 482 | + | } | |
| 400 | 483 | if guard.expectedSource != nil { | |
| 401 | 484 | return errNoNeighbor | |
| 402 | 485 | } | |
| 403 | 486 | d.invalidateRender(from, ac, true, "picker.go") | |
| 404 | 487 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause) | |
| 405 | 488 | } | |
| 406 | - | finishRenderLifecycleCleanups(cleanups) | |
| 407 | - | if old != nil && old != ac { | |
| 408 | - | d.unregisterPreview(old) | |
| 409 | - | old.clearPreviousSession() | |
| 410 | - | old.setSession(nil) | |
| 411 | - | d.notifyDetachedAsync(old, ports.ReasonReplaced) | |
| 489 | + | if guard.closePicker { | |
| 490 | + | if sourceToken != nil { | |
| 491 | + | fresh, admitted := ac.beginRoleEffect(transition.published) | |
| 492 | + | if admitted { | |
| 493 | + | d.closePicker(ac) | |
| 494 | + | fresh.End() | |
| 495 | + | } | |
| 496 | + | } else { | |
| 497 | + | d.closePicker(ac) | |
| 498 | + | } | |
| 412 | 499 | } | |
| 500 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 413 | 501 | if targetSess == from { | |
| 414 | - | d.activateTab(from, from.activeTab()) | |
| 415 | - | d.invalidateRender(from, ac, true, "picker.go") | |
| 502 | + | if sourceToken != nil { | |
| 503 | + | fresh, admitted := ac.beginRoleEffect(transition.published) | |
| 504 | + | if admitted { | |
| 505 | + | d.activateTab(from, from.activeTab()) | |
| 506 | + | d.invalidateRender(from, ac, true, "picker.go") | |
| 507 | + | fresh.End() | |
| 508 | + | } | |
| 509 | + | } else { | |
| 510 | + | d.activateTab(from, from.activeTab()) | |
| 511 | + | d.invalidateRender(from, ac, true, "picker.go") | |
| 512 | + | } | |
| 416 | 513 | } else { | |
| 417 | - | d.firstPaint(targetSess, ac, ac.size) | |
| 514 | + | d.firstPaintForTransition(transition.published) | |
| 418 | 515 | } | |
| 419 | 516 | return nil | |
| 420 | 517 | } | |
| @@ -439,121 +536,154 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi | |||
| 439 | 536 | return nil, stopped, true, true | |
| 440 | 537 | } | |
| 441 | 538 | ||
| 442 | - | // switchToActiveTargetLocked commits an active target handoff. Caller holds | |
| 443 | - | // d.mu and releases it before completing render cleanup or first paint. | |
| 444 | - | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard) (*session, *attachedClient, []renderLifecycleCleanup, bool) { | |
| 445 | - | // The caller already holds d.mu. Keep handoff ownership atomic with global | |
| 446 | - | // routing so it cannot select an attachment midway between sessions. | |
| 447 | - | d.notices.routingMu.Lock() | |
| 448 | - | defer d.notices.routingMu.Unlock() | |
| 539 | + | // switchToActiveTargetLocked commits an active target handoff through the | |
| 540 | + | // centralized attachment transition. Caller holds d.mu. | |
| 541 | + | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) (*session, attachmentTransitionResult, bool) { | |
| 449 | 542 | if d.sessions[target.Session] != targetSess { | |
| 450 | - | return nil, nil, nil, false | |
| 543 | + | return nil, attachmentTransitionResult{}, false | |
| 451 | 544 | } | |
| 452 | 545 | if targetSess == from { | |
| 453 | - | targetSess.mu.Lock() | |
| 454 | - | defer targetSess.mu.Unlock() | |
| 455 | - | if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | |
| 456 | - | return nil, nil, nil, false | |
| 546 | + | if sourceToken == nil { | |
| 547 | + | targetSess.mu.Lock() | |
| 548 | + | defer targetSess.mu.Unlock() | |
| 549 | + | if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | |
| 550 | + | return nil, attachmentTransitionResult{}, false | |
| 551 | + | } | |
| 552 | + | targetSess.active = target.TabIndex | |
| 553 | + | return targetSess, attachmentTransitionResult{}, true | |
| 457 | 554 | } | |
| 458 | - | targetSess.active = target.TabIndex | |
| 459 | - | return targetSess, nil, nil, true | |
| 555 | + | d.mu.Unlock() | |
| 556 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 557 | + | source: from, target: targetSess, next: ac, | |
| 558 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 559 | + | expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action, | |
| 560 | + | activateTargetTab: true, targetTabIndex: target.TabIndex, preserveRole: true, | |
| 561 | + | }) | |
| 562 | + | d.mu.Lock() | |
| 563 | + | return targetSess, transition, err == nil | |
| 460 | 564 | } | |
| 461 | 565 | ||
| 462 | - | targetSess.mu.Lock() | |
| 463 | - | matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) | |
| 464 | - | if !matches { | |
| 465 | - | targetSess.mu.Unlock() | |
| 466 | - | return nil, nil, nil, false | |
| 566 | + | unlock := lockAttachmentSessions(from, targetSess) | |
| 567 | + | if from.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) { | |
| 568 | + | unlock() | |
| 569 | + | return nil, attachmentTransitionResult{}, false | |
| 467 | 570 | } | |
| 468 | - | from.mu.Lock() | |
| 469 | - | if from.client != ac { | |
| 470 | - | from.mu.Unlock() | |
| 471 | - | targetSess.mu.Unlock() | |
| 472 | - | return nil, nil, nil, false | |
| 473 | - | } | |
| 474 | - | var guardedSource *tab | |
| 475 | 571 | if guard.expectedSource != nil { | |
| 476 | 572 | if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource { | |
| 477 | - | from.mu.Unlock() | |
| 478 | - | targetSess.mu.Unlock() | |
| 479 | - | return nil, nil, nil, false | |
| 573 | + | unlock() | |
| 574 | + | return nil, attachmentTransitionResult{}, false | |
| 480 | 575 | } | |
| 481 | - | guardedSource = guard.expectedSource | |
| 482 | - | guardedSource.mu.Lock() | |
| 483 | - | if guardedSource.floating.state == floatingVisible { | |
| 484 | - | guardedSource.mu.Unlock() | |
| 485 | - | from.mu.Unlock() | |
| 486 | - | targetSess.mu.Unlock() | |
| 487 | - | return nil, nil, nil, false | |
| 576 | + | guard.expectedSource.mu.Lock() | |
| 577 | + | sourceHasFloating := guard.expectedSource.floating.state == floatingVisible | |
| 578 | + | guard.expectedSource.mu.Unlock() | |
| 579 | + | if sourceHasFloating { | |
| 580 | + | unlock() | |
| 581 | + | return nil, attachmentTransitionResult{}, false | |
| 488 | 582 | } | |
| 489 | 583 | } | |
| 490 | - | term := from.terminal | |
| 491 | - | env := copyEnvironment(from.env) | |
| 492 | - | from.client = nil | |
| 493 | - | ac.setSession(nil) | |
| 494 | - | if guardedSource != nil { | |
| 495 | - | guardedSource.mu.Unlock() | |
| 496 | - | } | |
| 497 | - | from.mu.Unlock() | |
| 584 | + | unlock() | |
| 498 | 585 | ||
| 499 | - | old := targetSess.client | |
| 500 | - | targetSess.terminal = term | |
| 501 | - | targetSess.env = copyEnvironment(env) | |
| 502 | - | if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) { | |
| 503 | - | targetSess.active = target.TabIndex | |
| 504 | - | } | |
| 505 | - | targetSess.mu.Unlock() | |
| 506 | - | ||
| 507 | - | // Prepare source invalidation, output rebase, and destination coordinator | |
| 508 | - | // identity before publishing the destination attachment. | |
| 509 | - | cleanups := d.handoffCoordinator(from, targetSess, old, ac) | |
| 510 | - | ac.setSession(targetSess) | |
| 511 | - | targetSess.mu.Lock() | |
| 512 | - | targetSess.client = ac | |
| 513 | - | targetSess.mu.Unlock() | |
| 586 | + | // Release d.mu before the gate freeze/drain. transitionAttachment reacquires | |
| 587 | + | // d.mu and revalidates both registered lifecycles for publication. | |
| 588 | + | d.mu.Unlock() | |
| 589 | + | expectedTransport := ac.transportSnapshot() | |
| 590 | + | if sourceToken != nil { | |
| 591 | + | expectedTransport = sourceToken.transport | |
| 592 | + | } | |
| 593 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 594 | + | source: from, | |
| 595 | + | target: targetSess, | |
| 596 | + | next: ac, | |
| 597 | + | expectedRole: attachmentActive, | |
| 598 | + | targetRole: attachmentActive, | |
| 599 | + | expectedTransport: expectedTransport, | |
| 600 | + | sourceToken: sourceToken, | |
| 601 | + | action: action, | |
| 602 | + | expectedSourceTab: guard.expectedSource, | |
| 603 | + | activateTargetTab: target.TabIndex >= 0, | |
| 604 | + | targetTabIndex: target.TabIndex, | |
| 605 | + | copySourceEnvironment: true, | |
| 606 | + | ready: true, | |
| 607 | + | }) | |
| 608 | + | d.mu.Lock() | |
| 609 | + | if err != nil { | |
| 610 | + | return nil, attachmentTransitionResult{}, false | |
| 611 | + | } | |
| 514 | 612 | d.touchMRU(targetSess) | |
| 515 | 613 | ac.recordPreviousSession(from) | |
| 516 | - | return targetSess, old, cleanups, true | |
| 614 | + | return targetSess, transition, true | |
| 517 | 615 | } | |
| 518 | 616 | ||
| 519 | 617 | // resumeStoppedAndSwitchLocked creates the stopped representation and commits | |
| 520 | 618 | // the handoff while d.mu is held. Creation failure leaves the source client | |
| 521 | 619 | // and stopped record untouched. | |
| 522 | - | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) { | |
| 620 | + | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentRoleToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) { | |
| 523 | 621 | if stopped.record.Name != "" && stopped.state == ports.SessionBroken { | |
| 524 | - | return nil, nil, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name} | |
| 622 | + | return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name} | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | if sourceToken != nil { | |
| 626 | + | var targetSess *session | |
| 627 | + | d.mu.Unlock() | |
| 628 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 629 | + | source: from, next: ac, expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 630 | + | expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action, | |
| 631 | + | expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true, | |
| 632 | + | createTargetLocked: func() (*session, error) { | |
| 633 | + | current, ok := d.stopped[target.Name] | |
| 634 | + | if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) { | |
| 635 | + | return nil, errAttachmentTransition | |
| 636 | + | } | |
| 637 | + | from.mu.Lock() | |
| 638 | + | cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env) | |
| 639 | + | from.mu.Unlock() | |
| 640 | + | created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, current.tabNames) | |
| 641 | + | targetSess = created | |
| 642 | + | return created, createErr | |
| 643 | + | }, | |
| 644 | + | }) | |
| 645 | + | d.mu.Lock() | |
| 646 | + | if err != nil { | |
| 647 | + | if targetSess != nil { | |
| 648 | + | d.mu.Unlock() | |
| 649 | + | _ = d.killSession(targetSess, ports.ReasonSessionKilled, true) | |
| 650 | + | d.mu.Lock() | |
| 651 | + | } | |
| 652 | + | return targetSess, attachmentTransitionResult{}, false, err | |
| 653 | + | } | |
| 654 | + | d.touchMRU(targetSess) | |
| 655 | + | ac.recordPreviousSession(from) | |
| 656 | + | return targetSess, transition, true, nil | |
| 525 | 657 | } | |
| 526 | 658 | ||
| 527 | - | // The caller already holds d.mu. Keep handoff ownership atomic with global | |
| 528 | - | // routing so it cannot select an attachment midway between sessions. | |
| 529 | - | d.notices.routingMu.Lock() | |
| 530 | - | defer d.notices.routingMu.Unlock() | |
| 531 | 659 | from.mu.Lock() | |
| 532 | 660 | if from.client != ac { | |
| 533 | 661 | from.mu.Unlock() | |
| 534 | - | return nil, nil, false, nil | |
| 662 | + | return nil, attachmentTransitionResult{}, false, nil | |
| 535 | 663 | } | |
| 536 | 664 | term := from.terminal | |
| 537 | 665 | env := copyEnvironment(from.env) | |
| 666 | + | from.mu.Unlock() | |
| 538 | 667 | cwd := d.dirOrHome(stopped.cwd) | |
| 539 | 668 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames) | |
| 540 | 669 | if err != nil { | |
| 541 | - | from.mu.Unlock() | |
| 542 | 670 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | |
| 543 | - | return nil, nil, false, err | |
| 671 | + | return nil, attachmentTransitionResult{}, false, err | |
| 544 | 672 | } | |
| 545 | - | from.client = nil | |
| 546 | - | ac.setSession(nil) | |
| 547 | - | from.mu.Unlock() | |
| 548 | 673 | ||
| 549 | - | cleanups := d.handoffCoordinator(from, targetSess, nil, ac) | |
| 550 | - | ac.setSession(targetSess) | |
| 551 | - | targetSess.mu.Lock() | |
| 552 | - | targetSess.client = ac | |
| 553 | - | targetSess.mu.Unlock() | |
| 674 | + | d.mu.Unlock() | |
| 675 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 676 | + | source: from, target: targetSess, next: ac, | |
| 677 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 678 | + | expectedTransport: ac.transportSnapshot(), ready: true, | |
| 679 | + | }) | |
| 680 | + | d.mu.Lock() | |
| 681 | + | if err != nil { | |
| 682 | + | return targetSess, attachmentTransitionResult{}, false, err | |
| 683 | + | } | |
| 554 | 684 | d.touchMRU(targetSess) | |
| 555 | 685 | ac.recordPreviousSession(from) | |
| 556 | - | return targetSess, cleanups, true, nil | |
| 686 | + | return targetSess, transition, true, nil | |
| 557 | 687 | } | |
| 558 | 688 | ||
| 559 | 689 | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | |
| @@ -564,13 +694,13 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) | |||
| 564 | 694 | // targets must use switchToTarget so resolution and commit share d.mu. | |
| 565 | 695 | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |
| 566 | 696 | d.mu.Lock() | |
| 567 | - | _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}) | |
| 697 | + | _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "") | |
| 568 | 698 | d.mu.Unlock() | |
| 569 | 699 | if !switched { | |
| 570 | 700 | return nil | |
| 571 | 701 | } | |
| 572 | - | finishRenderLifecycleCleanups(cleanups) | |
| 573 | - | return old | |
| 702 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 703 | + | return transition.displaced.ac | |
| 574 | 704 | } | |
| 575 | 705 | ||
| 576 | 706 | // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves | |
| @@ -579,23 +709,45 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 579 | 709 | d.mu.Lock() | |
| 580 | 710 | stopped, ok := d.stopped[target.Name] | |
| 581 | 711 | var ( | |
| 582 | - | targetSess *session | |
| 583 | - | cleanups []renderLifecycleCleanup | |
| 712 | + | transition attachmentTransitionResult | |
| 584 | 713 | switched bool | |
| 585 | 714 | ) | |
| 586 | 715 | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |
| 587 | - | targetSess, cleanups, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |
| 716 | + | _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "") | |
| 588 | 717 | } | |
| 589 | 718 | d.mu.Unlock() | |
| 590 | 719 | if !switched { | |
| 591 | 720 | d.invalidateRender(from, ac, true, "picker.go") | |
| 592 | 721 | return false | |
| 593 | 722 | } | |
| 594 | - | finishRenderLifecycleCleanups(cleanups) | |
| 595 | - | d.firstPaint(targetSess, ac, ac.size) | |
| 723 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 724 | + | d.firstPaintForTransition(transition.published) | |
| 596 | 725 | return true | |
| 597 | 726 | } | |
| 598 | 727 | ||
| 728 | + | func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentRoleToken) error { | |
| 729 | + | if token.ac == nil { | |
| 730 | + | return d.killPickerTarget(target) | |
| 731 | + | } | |
| 732 | + | if target.Stopped { | |
| 733 | + | token.effect.bindActionEnd(d, "picker-delete") | |
| 734 | + | token.effect.End() | |
| 735 | + | frozen := freezeRoleEffectGates(token.ac) | |
| 736 | + | defer frozen.unfreeze() | |
| 737 | + | if !d.sourceRoleTokenCurrentFrozen(token) { | |
| 738 | + | return nil | |
| 739 | + | } | |
| 740 | + | return d.killPickerTarget(target) | |
| 741 | + | } | |
| 742 | + | d.mu.Lock() | |
| 743 | + | targetSess := d.sessions[target.Session] | |
| 744 | + | d.mu.Unlock() | |
| 745 | + | if targetSess == nil { | |
| 746 | + | return nil | |
| 747 | + | } | |
| 748 | + | return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete") | |
| 749 | + | } | |
| 750 | + | ||
| 599 | 751 | func (d *Daemon) killPickerTarget(target picker.Target) error { | |
| 600 | 752 | if target.Stopped { | |
| 601 | 753 | if err := d.retryStoppedPurge(target.Name); err != nil { | |
M internal/usecase/daemon/picker_test.go +45 -16
| @@ -505,7 +505,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 505 | 505 | require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state") | |
| 506 | 506 | } | |
| 507 | 507 | ||
| 508 | - | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |
| 508 | + | func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) { | |
| 509 | 509 | p1, releasePTY1 := newBlockingPTY(t) | |
| 510 | 510 | p2, releasePTY2 := newBlockingPTY(t) | |
| 511 | 511 | defer releasePTY1() | |
| @@ -540,11 +540,24 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||
| 540 | 540 | require.Same(t, ac1, sess2.client) | |
| 541 | 541 | require.Nil(t, sess1.client) | |
| 542 | 542 | require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch") | |
| 543 | - | det := awaitFrame(t, sends2, ports.MsgDetached) | |
| 544 | - | dm, err := ports.UnmarshalDetached(det.Payload) | |
| 543 | + | ||
| 544 | + | d.attachmentCleanupWg.Wait() | |
| 545 | + | var displacedFrames []ports.Frame | |
| 546 | + | for len(sends2) > 0 { | |
| 547 | + | displacedFrames = append(displacedFrames, <-sends2) | |
| 548 | + | } | |
| 549 | + | require.Len(t, displacedFrames, 1, "healthy displaced client receives only its snatched panel") | |
| 550 | + | require.Equal(t, ports.MsgOutput, displacedFrames[0].Type) | |
| 551 | + | displacedOutput, err := ports.UnmarshalOutput(displacedFrames[0].Payload) | |
| 545 | 552 | require.NoError(t, err) | |
| 546 | - | require.Equal(t, ports.ReasonReplaced, dm.Reason) | |
| 547 | - | awaitFrame(t, sends1, ports.MsgOutput) | |
| 553 | + | require.Contains(t, string(displacedOutput.Data), "Session snatched") | |
| 554 | + | require.Same(t, sess2, ac2.currentSession()) | |
| 555 | + | require.Equal(t, attachmentSnatched, sess2.attachmentRole(ac2)) | |
| 556 | + | ||
| 557 | + | movingFrame := awaitFrame(t, sends1, ports.MsgOutput) | |
| 558 | + | movingOutput, err := ports.UnmarshalOutput(movingFrame.Payload) | |
| 559 | + | require.NoError(t, err) | |
| 560 | + | require.Zero(t, movingOutput.BaseStateNum, "moving client must reset before painting the destination") | |
| 548 | 561 | } | |
| 549 | 562 | ||
| 550 | 563 | func TestPickerDisplacementCancelsSupersededResize(t *testing.T) { | |
| @@ -623,7 +636,7 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||
| 623 | 636 | require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage") | |
| 624 | 637 | } | |
| 625 | 638 | ||
| 626 | - | func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |
| 639 | + | func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing.T) { | |
| 627 | 640 | p1, releasePTY1 := newBlockingPTY(t) | |
| 628 | 641 | p2, releasePTY2 := newBlockingPTY(t) | |
| 629 | 642 | defer releasePTY1() | |
| @@ -662,25 +675,41 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||
| 662 | 675 | }() | |
| 663 | 676 | <-enteredSend | |
| 664 | 677 | ||
| 665 | - | switchDone := make(chan struct{}) | |
| 678 | + | switchDone := make(chan attachmentTransitionResult, 1) | |
| 679 | + | switchErr := make(chan error, 1) | |
| 666 | 680 | go func() { | |
| 667 | - | cleanups := d.handoffCoordinator(sess1, sess2, nil, ac) | |
| 668 | - | finishRenderLifecycleCleanups(cleanups) | |
| 669 | - | close(switchDone) | |
| 681 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 682 | + | source: sess1, | |
| 683 | + | target: sess2, | |
| 684 | + | next: ac, | |
| 685 | + | expectedRole: attachmentActive, | |
| 686 | + | targetRole: attachmentActive, | |
| 687 | + | expectedTransport: ac.transportSnapshot(), | |
| 688 | + | ready: true, | |
| 689 | + | }) | |
| 690 | + | switchDone <- result | |
| 691 | + | switchErr <- err | |
| 670 | 692 | }() | |
| 671 | - | awaitTestCompletion(t, handoffAtSendMu, "session switch did not reach the handoff sendMu boundary") | |
| 672 | 693 | ||
| 673 | - | // The handoff is immediately about to acquire sendMu, which paint still owns | |
| 674 | - | // through its blocked transport send. | |
| 694 | + | var result attachmentTransitionResult | |
| 695 | + | select { | |
| 696 | + | case result = <-switchDone: | |
| 697 | + | require.NoError(t, <-switchErr) | |
| 698 | + | case <-time.After(2 * time.Second): | |
| 699 | + | t.Fatal("session switch waited for in-flight transport send") | |
| 700 | + | } | |
| 701 | + | require.True(t, result.published.activeCurrent()) | |
| 675 | 702 | select { | |
| 676 | - | case <-switchDone: | |
| 677 | - | t.Fatal("session switch completed while paint owned sendMu") | |
| 703 | + | case <-handoffAtSendMu: | |
| 704 | + | t.Fatal("output rebase ran during architecture publication") | |
| 678 | 705 | default: | |
| 679 | 706 | } | |
| 680 | 707 | ||
| 681 | 708 | close(releaseSend) | |
| 682 | 709 | awaitTestCompletion(t, paintDone, "paint did not finish after Send was released") | |
| 683 | - | awaitTestCompletion(t, switchDone, "session handoff did not complete after paint finished") | |
| 710 | + | for _, cleanup := range result.cleanups { | |
| 711 | + | cleanup.finish() | |
| 712 | + | } | |
| 684 | 713 | } | |
| 685 | 714 | ||
| 686 | 715 | func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |
M internal/usecase/daemon/prompt.go +29 -3
| @@ -23,10 +23,21 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s | |||
| 23 | 23 | d.invalidateRender(sess, ac, true, "prompt.go") | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | func (d *Daemon) enterTransitionPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string, attachmentRoleToken) error) { | |
| 27 | + | d.closePrompt(ac) | |
| 28 | + | ac.overlays.promptMu.Lock() | |
| 29 | + | ac.overlays.prompt = promptui.New(title, initial) | |
| 30 | + | ac.overlays.promptTransitionSubmit = submit | |
| 31 | + | ac.overlays.promptPending = nil | |
| 32 | + | ac.overlays.promptMu.Unlock() | |
| 33 | + | d.invalidateRender(sess, ac, true, "prompt.go") | |
| 34 | + | } | |
| 35 | + | ||
| 26 | 36 | func (d *Daemon) closePrompt(ac *attachedClient) { | |
| 27 | 37 | ac.overlays.promptMu.Lock() | |
| 28 | 38 | ac.overlays.prompt = nil | |
| 29 | 39 | ac.overlays.promptSubmit = nil | |
| 40 | + | ac.overlays.promptTransitionSubmit = nil | |
| 30 | 41 | ac.overlays.promptPending = nil | |
| 31 | 42 | ac.overlays.promptMu.Unlock() | |
| 32 | 43 | } | |
| @@ -39,7 +50,7 @@ func promptValidationError(err error) bool { | |||
| 39 | 50 | errors.Is(err, errSessionNameInUse) | |
| 40 | 51 | } | |
| 41 | 52 | ||
| 42 | - | func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |
| 53 | + | func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | |
| 43 | 54 | sess := ac.currentSession() | |
| 44 | 55 | if sess == nil { | |
| 45 | 56 | return | |
| @@ -55,6 +66,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||
| 55 | 66 | changed := false | |
| 56 | 67 | exit := false | |
| 57 | 68 | var submit func(string) error | |
| 69 | + | var transitionSubmit func(string, attachmentRoleToken) error | |
| 58 | 70 | var submittedPrompt *promptui.Model | |
| 59 | 71 | var value string | |
| 60 | 72 | ||
| @@ -69,6 +81,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||
| 69 | 81 | }, | |
| 70 | 82 | enter: func() { | |
| 71 | 83 | submit = ac.overlays.promptSubmit | |
| 84 | + | transitionSubmit = ac.overlays.promptTransitionSubmit | |
| 72 | 85 | submittedPrompt = ac.overlays.prompt | |
| 73 | 86 | value = strings.TrimSpace(ac.overlays.prompt.Value()) | |
| 74 | 87 | }, | |
| @@ -78,8 +91,21 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||
| 78 | 91 | }) | |
| 79 | 92 | ac.overlays.promptMu.Unlock() | |
| 80 | 93 | ||
| 81 | - | if submit != nil { | |
| 82 | - | if err := submit(value); err != nil { | |
| 94 | + | if submit != nil || transitionSubmit != nil { | |
| 95 | + | var err error | |
| 96 | + | if transitionSubmit != nil { | |
| 97 | + | var token attachmentRoleToken | |
| 98 | + | if len(effects) != 0 && effects[0] != nil { | |
| 99 | + | token = effects[0].roleToken() | |
| 100 | + | } | |
| 101 | + | err = transitionSubmit(value, token) | |
| 102 | + | } else { | |
| 103 | + | err = submit(value) | |
| 104 | + | } | |
| 105 | + | if errors.Is(err, errAttachmentTransition) { | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | if err != nil { | |
| 83 | 109 | ac.overlays.promptMu.Lock() | |
| 84 | 110 | if ac.overlays.prompt == submittedPrompt { | |
| 85 | 111 | ac.overlays.prompt.SetError(err.Error()) | |
M internal/usecase/daemon/render.go +13 -2
| @@ -210,8 +210,9 @@ func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid | |||
| 210 | 210 | // paint owns attachment sendMu while it captures, composes, and emits; JSONL | |
| 211 | 211 | // observer I/O must therefore happen only after that ownership is released. | |
| 212 | 212 | type runtimeMarkBatch struct { | |
| 213 | - | observer ports.RuntimeObserver | |
| 214 | - | marks []ports.RuntimeMark | |
| 213 | + | observer ports.RuntimeObserver | |
| 214 | + | marks []ports.RuntimeMark | |
| 215 | + | roleEffect *roleEffectTicket | |
| 215 | 216 | } | |
| 216 | 217 | ||
| 217 | 218 | func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch { | |
| @@ -243,6 +244,16 @@ func (b *runtimeMarkBatch) flush() { | |||
| 243 | 244 | ||
| 244 | 245 | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) { | |
| 245 | 246 | marks := d.newRuntimeMarkBatch() | |
| 247 | + | if lease != nil { | |
| 248 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 249 | + | token.lease = lease | |
| 250 | + | ticket, admitted := ac.beginRoleEffect(token) | |
| 251 | + | if !admitted { | |
| 252 | + | return | |
| 253 | + | } | |
| 254 | + | marks.roleEffect = ticket | |
| 255 | + | defer ticket.End() | |
| 256 | + | } | |
| 246 | 257 | tb := sess.activeTab() | |
| 247 | 258 | if tb == nil { | |
| 248 | 259 | return | |
M internal/usecase/daemon/render_coordinator.go +60 -46
| @@ -242,8 +242,17 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie | |||
| 242 | 242 | // notifyAck reports that the client acknowledged an output state, releasing | |
| 243 | 243 | // at most one deadline/watchdog-deferred wake. It never bypasses an unexpired | |
| 244 | 244 | // normal or urgent deadline. | |
| 245 | - | func (c *renderCoordinator) notifyAck() { | |
| 245 | + | func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) } | |
| 246 | + | ||
| 247 | + | // notifyAckForLease prevents an acknowledgement captured by an obsolete active | |
| 248 | + | // frame from flushing the replacement attachment's render queue. A nil lease is | |
| 249 | + | // reserved for coordinator-owned/direct test callers. | |
| 250 | + | func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) { | |
| 246 | 251 | c.mu.Lock() | |
| 252 | + | if lease != nil && !c.leaseCurrentLocked(lease, true) { | |
| 253 | + | c.mu.Unlock() | |
| 254 | + | return | |
| 255 | + | } | |
| 247 | 256 | if (!c.ackDeferred && !c.deadlineDue) || !c.pending { | |
| 248 | 257 | c.mu.Unlock() | |
| 249 | 258 | return | |
| @@ -558,29 +567,41 @@ func (cleanup renderLifecycleCleanup) finish() { | |||
| 558 | 567 | // beginDetach invalidates pending work. It never waits for detached workers. | |
| 559 | 568 | func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup { | |
| 560 | 569 | c.mu.Lock() | |
| 570 | + | cleanup := c.beginDetachLocked(ac) | |
| 571 | + | c.mu.Unlock() | |
| 572 | + | return cleanup | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | // beginDetachLocked is the prelocked lifecycle seam used when registry, | |
| 576 | + | // current-session, and lease publication must share one commit boundary. | |
| 577 | + | func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycleCleanup { | |
| 561 | 578 | var cleanup renderLifecycleCleanup | |
| 562 | 579 | if c.lease != nil && c.lease.active && c.lease.attachment == ac { | |
| 563 | 580 | c.lease.active = false | |
| 564 | - | c.pending = false | |
| 565 | - | c.pendingReset = false | |
| 566 | - | c.pendingUrgent = false | |
| 567 | - | c.ackDeferred = false | |
| 568 | - | c.pendingPreview = false | |
| 569 | - | c.coalesced = 0 | |
| 570 | - | _, timer := c.normalLane.replaceLocked() | |
| 571 | - | c.armed = false | |
| 572 | - | _, resizeTimer := c.resizeLane.replaceLocked() | |
| 573 | - | _, retryTimer := c.retryLane.replaceLocked() | |
| 574 | - | cleanup = renderLifecycleCleanup{ | |
| 575 | - | tokens: []*timerToken{timer, resizeTimer, retryTimer}, | |
| 576 | - | observer: c.opts.observer, | |
| 577 | - | queueCorrelation: c.queueCorrelation, | |
| 578 | - | queueMarked: c.queueMarked, | |
| 579 | - | ackBlocked: c.ackBlocked, | |
| 580 | - | } | |
| 581 | - | c.queueMarked, c.ackBlocked = false, nil | |
| 581 | + | cleanup = c.resetAttachmentLifecycleLocked() | |
| 582 | 582 | } | |
| 583 | - | c.mu.Unlock() | |
| 583 | + | return cleanup | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleCleanup { | |
| 587 | + | c.pending = false | |
| 588 | + | c.pendingReset = false | |
| 589 | + | c.pendingUrgent = false | |
| 590 | + | c.ackDeferred = false | |
| 591 | + | c.pendingPreview = false | |
| 592 | + | c.coalesced = 0 | |
| 593 | + | _, timer := c.normalLane.replaceLocked() | |
| 594 | + | c.armed = false | |
| 595 | + | _, resizeTimer := c.resizeLane.replaceLocked() | |
| 596 | + | _, retryTimer := c.retryLane.replaceLocked() | |
| 597 | + | cleanup := renderLifecycleCleanup{ | |
| 598 | + | tokens: []*timerToken{timer, resizeTimer, retryTimer}, | |
| 599 | + | observer: c.opts.observer, | |
| 600 | + | queueCorrelation: c.queueCorrelation, | |
| 601 | + | queueMarked: c.queueMarked, | |
| 602 | + | ackBlocked: c.ackBlocked, | |
| 603 | + | } | |
| 604 | + | c.queueMarked, c.ackBlocked = false, nil | |
| 584 | 605 | return cleanup | |
| 585 | 606 | } | |
| 586 | 607 | ||
| @@ -593,36 +614,29 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |||
| 593 | 614 | // for detached workers. | |
| 594 | 615 | func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup { | |
| 595 | 616 | c.mu.Lock() | |
| 596 | - | var cleanup renderLifecycleCleanup | |
| 597 | - | // A coordinator may be installed while replacing a legacy attachment | |
| 598 | - | // which predated coordinator ownership. In that case nil has no pending | |
| 599 | - | // identity to invalidate, and the replacement becomes the first bound | |
| 600 | - | // attachment atomically with this lifecycle transition. | |
| 601 | - | if c.lease == nil || c.lease.attachment == old { | |
| 602 | - | c.installLeaseLocked(replacement, ready) | |
| 603 | - | c.pending = false | |
| 604 | - | c.pendingReset = false | |
| 605 | - | c.pendingUrgent = false | |
| 606 | - | c.ackDeferred = false | |
| 607 | - | c.pendingPreview = false | |
| 608 | - | c.coalesced = 0 | |
| 609 | - | _, timer := c.normalLane.replaceLocked() | |
| 610 | - | c.armed = false | |
| 611 | - | _, resizeTimer := c.resizeLane.replaceLocked() | |
| 612 | - | _, retryTimer := c.retryLane.replaceLocked() | |
| 613 | - | cleanup = renderLifecycleCleanup{ | |
| 614 | - | tokens: []*timerToken{timer, resizeTimer, retryTimer}, | |
| 615 | - | observer: c.opts.observer, | |
| 616 | - | queueCorrelation: c.queueCorrelation, | |
| 617 | - | queueMarked: c.queueMarked, | |
| 618 | - | ackBlocked: c.ackBlocked, | |
| 619 | - | } | |
| 620 | - | c.queueMarked, c.ackBlocked = false, nil | |
| 621 | - | } | |
| 617 | + | cleanup, _ := c.beginReplaceLocked(old, replacement, ready) | |
| 622 | 618 | c.mu.Unlock() | |
| 623 | 619 | return cleanup | |
| 624 | 620 | } | |
| 625 | 621 | ||
| 622 | + | // beginReplaceLocked requires c.mu. Callers validate canReplaceLocked before | |
| 623 | + | // their ownership commit, making lease installation non-failing afterward. | |
| 624 | + | func (c *renderCoordinator) beginReplaceLocked(old, replacement *attachedClient, ready bool) (renderLifecycleCleanup, *attachmentLease) { | |
| 625 | + | if !c.canReplaceLocked(old, replacement) { | |
| 626 | + | return renderLifecycleCleanup{}, nil | |
| 627 | + | } | |
| 628 | + | lease := c.installLeaseLocked(replacement, ready) | |
| 629 | + | return c.resetAttachmentLifecycleLocked(), lease | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | func (c *renderCoordinator) canReplaceLocked(old, replacement *attachedClient) bool { | |
| 633 | + | // A coordinator may be installed while replacing a legacy attachment which | |
| 634 | + | // predated coordinator ownership. Any inactive lease is detached historical | |
| 635 | + | // state; a resume may also replace the same client object because registry and | |
| 636 | + | // transport validation proved a new role incarnation. | |
| 637 | + | return !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment == old || c.lease.attachment == replacement) | |
| 638 | + | } | |
| 639 | + | ||
| 626 | 640 | // noteReplace is for callers that hold no outer lifecycle locks. | |
| 627 | 641 | func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) { | |
| 628 | 642 | ready := true | |
M internal/usecase/daemon/render_pipeline.go +26 -12
| @@ -415,17 +415,10 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||
| 415 | 415 | return false | |
| 416 | 416 | } | |
| 417 | 417 | } | |
| 418 | - | ac.sess.mu.Lock() | |
| 419 | - | if ac.sess.v != sess { | |
| 420 | - | ac.sess.mu.Unlock() | |
| 421 | - | ac.sendMu.Unlock() | |
| 422 | - | return false | |
| 423 | - | } | |
| 424 | 418 | endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0) | |
| 425 | 419 | prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset) | |
| 426 | 420 | endDiff(0, err == nil) | |
| 427 | 421 | if err != nil { | |
| 428 | - | ac.sess.mu.Unlock() | |
| 429 | 422 | ac.sendMu.Unlock() | |
| 430 | 423 | d.log.Error("render draw failed", "err", err, "session", sess.name) | |
| 431 | 424 | // Without a coordinator reportError repaints synchronously. Suppress only | |
| @@ -448,7 +441,8 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||
| 448 | 441 | var sendTr ports.Transport | |
| 449 | 442 | var sendErr error | |
| 450 | 443 | if len(data) > 0 { | |
| 451 | - | sendTr = ac.transport() | |
| 444 | + | sendTransport := ac.transportSnapshot() | |
| 445 | + | sendTr = sendTransport.transport | |
| 452 | 446 | endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data))) | |
| 453 | 447 | if sendTr == nil { | |
| 454 | 448 | sendErr = errors.New("client transport is nil") | |
| @@ -457,18 +451,25 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||
| 457 | 451 | if async, ok := sendTr.(ports.AsyncTransport); ok { | |
| 458 | 452 | send = async.SendAsync | |
| 459 | 453 | } | |
| 454 | + | interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport) | |
| 460 | 455 | sendErr = prepared.send(data, ac.echoAck.Load(), send) | |
| 456 | + | if interruptible { | |
| 457 | + | if sendErr != nil { | |
| 458 | + | marks.roleEffect.reportTransportFailure(sendTransport) | |
| 459 | + | } | |
| 460 | + | marks.roleEffect.endTransportSend() | |
| 461 | + | } | |
| 461 | 462 | } | |
| 462 | 463 | endEmit(uint64(len(data)), sendErr == nil) | |
| 463 | 464 | } | |
| 464 | 465 | if sendErr == nil { | |
| 465 | 466 | // Publish only after output preparation and transport emission both | |
| 466 | - | // succeed. Receipt acknowledgement follows after releasing the attachment | |
| 467 | - | // session guard, while sendMu still owns this transaction. | |
| 467 | + | // succeed. A cross-session transition may publish concurrently, but its | |
| 468 | + | // mandatory first-paint rebase waits for sendMu and therefore follows this | |
| 469 | + | // completed output transaction. | |
| 468 | 470 | ac.pipelineScratch = ac.pipelineCache | |
| 469 | 471 | ac.pipelineCache = composed.cache | |
| 470 | 472 | } | |
| 471 | - | ac.sess.mu.Unlock() | |
| 472 | 473 | if sendErr == nil { | |
| 473 | 474 | // A successful no-byte emission also commits: its renderer shadow still | |
| 474 | 475 | // represents the captured frame. Lock panes only under sendMu and with no | |
| @@ -480,7 +481,20 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||
| 480 | 481 | } | |
| 481 | 482 | ac.sendMu.Unlock() | |
| 482 | 483 | if sendErr != nil { | |
| 483 | - | d.detachOnSendError(sess, ac, sendTr) | |
| 484 | + | // A transport failure may invalidate the role gate. Release this render's | |
| 485 | + | // admission first. Detachment freezes the gate and therefore cannot mutate | |
| 486 | + | // ownership until any enclosing admitted operation has also ended. | |
| 487 | + | if marks.roleEffect == nil { | |
| 488 | + | d.detachOnSendError(sess, ac, sendTr) | |
| 489 | + | } else { | |
| 490 | + | // Capture the exact admitted capability, including its coordinator | |
| 491 | + | // lease, before End permits a replacement publication. Reserve cleanup | |
| 492 | + | // accounting before End so terminal Wait cannot race a later Add. | |
| 493 | + | token := marks.roleEffect.roleToken() | |
| 494 | + | launchCleanup := d.reserveRoleSendErrorCleanup(token, sendTr) | |
| 495 | + | marks.roleEffect.End() | |
| 496 | + | launchCleanup() | |
| 497 | + | } | |
| 484 | 498 | } | |
| 485 | 499 | return true | |
| 486 | 500 | } | |
M internal/usecase/daemon/resume.go +19 -3
| @@ -183,15 +183,31 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 183 | 183 | ac.size = sz | |
| 184 | 184 | ac.resumeToken = d.nextResumeTokenLocked() | |
| 185 | 185 | ac.parked = false | |
| 186 | - | ac.setSession(sess) | |
| 187 | 186 | // The resumed session's snapshot is the sole source for future PTY children. | |
| 188 | 187 | // Existing PTYs retain the environment they were started with. | |
| 189 | 188 | sess.mu.Lock() | |
| 190 | 189 | sess.env = copyEnvironment(h.Env) | |
| 191 | 190 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | |
| 192 | - | sess.client = ac | |
| 193 | 191 | sess.mu.Unlock() | |
| 194 | - | d.attachCoordinator(sess, nil, ac, false) | |
| 192 | + | // Resume preparation used sendMu -> d.mu, but freeze/drain must run with | |
| 193 | + | // neither held. Reacquire them before returning to preserve this helper's | |
| 194 | + | // locked-caller contract. | |
| 195 | + | d.mu.Unlock() | |
| 196 | + | ac.sendMu.Unlock() | |
| 197 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 198 | + | target: sess, | |
| 199 | + | next: ac, | |
| 200 | + | expectedRole: attachmentDetached, | |
| 201 | + | targetRole: attachmentActive, | |
| 202 | + | expectedTransport: ac.transportSnapshot(), | |
| 203 | + | ready: false, | |
| 204 | + | }) | |
| 205 | + | ac.sendMu.Lock() | |
| 206 | + | d.mu.Lock() | |
| 207 | + | if err != nil { | |
| 208 | + | return nil, nil, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"} | |
| 209 | + | } | |
| 210 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 195 | 211 | d.touchMRU(sess) | |
| 196 | 212 | d.log.Info("client resumed", "session", sess.name) | |
| 197 | 213 | return sess, ac, true, nil | |
M internal/usecase/daemon/resume_test.go +2 -0
| @@ -308,6 +308,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing. | |||
| 308 | 308 | token := ac.resumeToken | |
| 309 | 309 | require.True(t, sess.detachIfCurrent(ac)) | |
| 310 | 310 | require.True(t, d.parkAttachment(sess, ac)) | |
| 311 | + | generation := ac.roleGeneration.Load() | |
| 311 | 312 | ||
| 312 | 313 | newTr := &closeTrackingTransport{} | |
| 313 | 314 | resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24}) | |
| @@ -315,6 +316,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing. | |||
| 315 | 316 | require.True(t, ok) | |
| 316 | 317 | require.Same(t, sess, resumedSess) | |
| 317 | 318 | require.Same(t, ac, resumedAC) | |
| 319 | + | require.Greater(t, ac.roleGeneration.Load(), generation, "resume must publish active ownership through the attachment transition") | |
| 318 | 320 | ||
| 319 | 321 | _ = ac.closeCapturedTransport(oldTr) | |
| 320 | 322 | require.True(t, oldTr.Closed(), "old transport is closed") | |
A internal/usecase/daemon/role_effect_gate.go +492 -0
| @@ -0,0 +1,492 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sort" | |
| 5 | + | "sync" | |
| 6 | + | "sync/atomic" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type roleEffectPhase uint8 | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | roleEffectsStable roleEffectPhase = iota | |
| 15 | + | roleEffectsFrozen | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // roleCapability is the single attachment-local publication consumed by role | |
| 19 | + | // effect admission. It deliberately contains no independently mutable state: | |
| 20 | + | // a ticket is admitted only when every field matches the frame's token. | |
| 21 | + | type roleCapability struct { | |
| 22 | + | sess *session | |
| 23 | + | role attachmentRole | |
| 24 | + | generation uint64 | |
| 25 | + | transport transportSnapshot | |
| 26 | + | lease *attachmentLease | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func capabilityFromToken(token attachmentRoleToken) roleCapability { | |
| 30 | + | return roleCapability{ | |
| 31 | + | sess: token.sess, | |
| 32 | + | role: token.role, | |
| 33 | + | generation: token.generation, | |
| 34 | + | transport: token.transport, | |
| 35 | + | lease: token.lease, | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (c roleCapability) matches(token attachmentRoleToken) bool { | |
| 40 | + | return token.ac != nil && c.sess == token.sess && c.role == token.role && | |
| 41 | + | c.generation == token.generation && c.transport.transport == token.transport.transport && | |
| 42 | + | c.transport.incarnation == token.transport.incarnation && c.lease == token.lease | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | type roleEffectGate struct { | |
| 46 | + | mu sync.Mutex | |
| 47 | + | cond *sync.Cond | |
| 48 | + | changed chan struct{} | |
| 49 | + | phase roleEffectPhase | |
| 50 | + | capability roleCapability | |
| 51 | + | inFlight uint64 | |
| 52 | + | transportEffects map[*roleEffectTicket]transportSnapshot | |
| 53 | + | failedTransport transportSnapshot | |
| 54 | + | order atomic.Uint64 | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | var nextRoleEffectGateOrder atomic.Uint64 | |
| 58 | + | ||
| 59 | + | type roleEffectDrainDeadline struct { | |
| 60 | + | clock ports.Clock | |
| 61 | + | done chan struct{} | |
| 62 | + | stopCh chan struct{} | |
| 63 | + | finished chan struct{} | |
| 64 | + | startOnce sync.Once | |
| 65 | + | stopOnce sync.Once | |
| 66 | + | started atomic.Bool | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline { | |
| 70 | + | return &roleEffectDrainDeadline{ | |
| 71 | + | clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}), | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (d *roleEffectDrainDeadline) stop() { | |
| 76 | + | if d == nil { | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | d.stopOnce.Do(func() { | |
| 80 | + | // Claim an unstarted deadline without allocating a timer. Done is never | |
| 81 | + | // called after the owning freeze/drain operation returns. | |
| 82 | + | d.startOnce.Do(func() {}) | |
| 83 | + | if !d.started.Load() { | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | close(d.stopCh) | |
| 87 | + | <-d.finished | |
| 88 | + | }) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (d *roleEffectDrainDeadline) Done() <-chan struct{} { | |
| 92 | + | if d == nil { | |
| 93 | + | return nil | |
| 94 | + | } | |
| 95 | + | d.startOnce.Do(func() { | |
| 96 | + | d.started.Store(true) | |
| 97 | + | timer := d.clock.NewTimer(detachNotifyTimeout) | |
| 98 | + | go func() { | |
| 99 | + | defer close(d.finished) | |
| 100 | + | select { | |
| 101 | + | case <-timer.C(): | |
| 102 | + | close(d.done) | |
| 103 | + | case <-d.stopCh: | |
| 104 | + | timer.Stop() | |
| 105 | + | } | |
| 106 | + | }() | |
| 107 | + | }) | |
| 108 | + | return d.done | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (g *roleEffectGate) initLocked() { | |
| 112 | + | if g.cond == nil { | |
| 113 | + | g.cond = sync.NewCond(&g.mu) | |
| 114 | + | } | |
| 115 | + | if g.changed == nil { | |
| 116 | + | g.changed = make(chan struct{}) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (g *roleEffectGate) signalLocked() { | |
| 121 | + | g.initLocked() | |
| 122 | + | close(g.changed) | |
| 123 | + | g.changed = make(chan struct{}) | |
| 124 | + | g.cond.Broadcast() | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (g *roleEffectGate) immutableOrder() uint64 { | |
| 128 | + | if order := g.order.Load(); order != 0 { | |
| 129 | + | return order | |
| 130 | + | } | |
| 131 | + | order := nextRoleEffectGateOrder.Add(1) | |
| 132 | + | if g.order.CompareAndSwap(0, order) { | |
| 133 | + | return order | |
| 134 | + | } | |
| 135 | + | return g.order.Load() | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // roleEffectTicket reserves the exact published capability until End. End is | |
| 139 | + | // idempotent so error paths can safely defer it before doing any observable I/O. | |
| 140 | + | type roleEffectTicket struct { | |
| 141 | + | gate *roleEffectGate | |
| 142 | + | token attachmentRoleToken | |
| 143 | + | actionDaemon *Daemon | |
| 144 | + | action string | |
| 145 | + | ended atomic.Bool | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (t *roleEffectTicket) bindActionEnd(d *Daemon, action string) { | |
| 149 | + | if t == nil || t.ended.Load() { | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | t.actionDaemon = d | |
| 153 | + | t.action = action | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (t *roleEffectTicket) roleToken() attachmentRoleToken { | |
| 157 | + | if t == nil { | |
| 158 | + | return attachmentRoleToken{} | |
| 159 | + | } | |
| 160 | + | token := t.token | |
| 161 | + | token.effect = t | |
| 162 | + | return token | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (t *roleEffectTicket) End() { | |
| 166 | + | if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) { | |
| 167 | + | return | |
| 168 | + | } | |
| 169 | + | g := t.gate | |
| 170 | + | g.mu.Lock() | |
| 171 | + | g.initLocked() | |
| 172 | + | if g.inFlight == 0 { | |
| 173 | + | g.mu.Unlock() | |
| 174 | + | panic("daemon: role effect ticket ended without admission") | |
| 175 | + | } | |
| 176 | + | delete(g.transportEffects, t) | |
| 177 | + | g.inFlight-- | |
| 178 | + | g.signalLocked() | |
| 179 | + | g.mu.Unlock() | |
| 180 | + | if t.actionDaemon != nil && t.actionDaemon.afterActionRoleEffectEnded != nil { | |
| 181 | + | t.actionDaemon.afterActionRoleEffectEnded(t.action) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (d *Daemon) endActionRoleEffect(effect *roleEffectTicket, _ string) { | |
| 186 | + | if effect != nil { | |
| 187 | + | effect.End() | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // beginTransportSend marks only the interval spent in an external transport | |
| 192 | + | // send as interruptible. A replacement may close this exact published link to | |
| 193 | + | // release the send, but cannot retire an idle healthy snatched connection. | |
| 194 | + | func (t *roleEffectTicket) beginTransportSend(expected transportSnapshot) bool { | |
| 195 | + | if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil { | |
| 196 | + | return false | |
| 197 | + | } | |
| 198 | + | g := t.gate | |
| 199 | + | g.mu.Lock() | |
| 200 | + | g.initLocked() | |
| 201 | + | if t.ended.Load() || g.capability.transport.transport != expected.transport || | |
| 202 | + | g.capability.transport.incarnation != expected.incarnation { | |
| 203 | + | g.mu.Unlock() | |
| 204 | + | return false | |
| 205 | + | } | |
| 206 | + | if g.transportEffects == nil { | |
| 207 | + | g.transportEffects = make(map[*roleEffectTicket]transportSnapshot) | |
| 208 | + | } | |
| 209 | + | g.transportEffects[t] = expected | |
| 210 | + | g.signalLocked() | |
| 211 | + | g.mu.Unlock() | |
| 212 | + | return true | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (t *roleEffectTicket) reportTransportFailure(expected transportSnapshot) { | |
| 216 | + | if t == nil || t.gate == nil || expected.transport == nil { | |
| 217 | + | return | |
| 218 | + | } | |
| 219 | + | g := t.gate | |
| 220 | + | g.mu.Lock() | |
| 221 | + | if !t.ended.Load() && g.capability.transport.transport == expected.transport && | |
| 222 | + | g.capability.transport.incarnation == expected.incarnation { | |
| 223 | + | g.failedTransport = expected | |
| 224 | + | g.signalLocked() | |
| 225 | + | } | |
| 226 | + | g.mu.Unlock() | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func (t *roleEffectTicket) endTransportSend() { | |
| 230 | + | if t == nil || t.gate == nil { | |
| 231 | + | return | |
| 232 | + | } | |
| 233 | + | g := t.gate | |
| 234 | + | g.mu.Lock() | |
| 235 | + | if _, exists := g.transportEffects[t]; exists { | |
| 236 | + | delete(g.transportEffects, t) | |
| 237 | + | g.signalLocked() | |
| 238 | + | } | |
| 239 | + | g.mu.Unlock() | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // beginRoleEffect is the sole role-bound effect admission point. The gate is | |
| 243 | + | // held only long enough to reserve the capability; the effect itself must run | |
| 244 | + | // without the gate mutex. | |
| 245 | + | func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) { | |
| 246 | + | if ac == nil || token.ac != ac { | |
| 247 | + | return nil, false | |
| 248 | + | } | |
| 249 | + | g := &ac.roleEffects | |
| 250 | + | g.mu.Lock() | |
| 251 | + | g.initLocked() | |
| 252 | + | if g.phase != roleEffectsStable || !g.capability.matches(token) { | |
| 253 | + | g.mu.Unlock() | |
| 254 | + | return nil, false | |
| 255 | + | } | |
| 256 | + | g.inFlight++ | |
| 257 | + | g.mu.Unlock() | |
| 258 | + | captured := token | |
| 259 | + | captured.effect = nil | |
| 260 | + | return &roleEffectTicket{gate: g, token: captured}, true | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // publishRoleCapability is used by direct/headless setup and by transition | |
| 264 | + | // publication while the gate is frozen. Production role changes publish only | |
| 265 | + | // through publishFrozenRoleCapability. | |
| 266 | + | func (ac *attachedClient) publishRoleCapability(token attachmentRoleToken) { | |
| 267 | + | if ac == nil { | |
| 268 | + | return | |
| 269 | + | } | |
| 270 | + | g := &ac.roleEffects | |
| 271 | + | g.mu.Lock() | |
| 272 | + | g.initLocked() | |
| 273 | + | g.capability = capabilityFromToken(token) | |
| 274 | + | g.failedTransport = transportSnapshot{} | |
| 275 | + | g.mu.Unlock() | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // bootstrapRoleCapability supports direct/headless session construction. It | |
| 279 | + | // never changes an existing or frozen production publication. | |
| 280 | + | func (ac *attachedClient) bootstrapRoleCapability(token attachmentRoleToken) { | |
| 281 | + | if ac == nil || token.sess == nil || token.role == attachmentDetached { | |
| 282 | + | return | |
| 283 | + | } | |
| 284 | + | g := &ac.roleEffects | |
| 285 | + | g.mu.Lock() | |
| 286 | + | g.initLocked() | |
| 287 | + | if g.phase == roleEffectsStable && g.capability.sess == nil { | |
| 288 | + | g.capability = capabilityFromToken(token) | |
| 289 | + | g.failedTransport = transportSnapshot{} | |
| 290 | + | } | |
| 291 | + | g.mu.Unlock() | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func (ac *attachedClient) publishFrozenRoleCapability(token attachmentRoleToken) { | |
| 295 | + | g := &ac.roleEffects | |
| 296 | + | g.mu.Lock() | |
| 297 | + | g.initLocked() | |
| 298 | + | if g.phase != roleEffectsFrozen { | |
| 299 | + | g.mu.Unlock() | |
| 300 | + | panic("daemon: publishing role capability without transition freeze") | |
| 301 | + | } | |
| 302 | + | g.capability = capabilityFromToken(token) | |
| 303 | + | g.failedTransport = transportSnapshot{} | |
| 304 | + | g.mu.Unlock() | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func (ac *attachedClient) invalidateFrozenRoleCapability() { | |
| 308 | + | g := &ac.roleEffects | |
| 309 | + | g.mu.Lock() | |
| 310 | + | g.initLocked() | |
| 311 | + | if g.phase != roleEffectsFrozen { | |
| 312 | + | g.mu.Unlock() | |
| 313 | + | panic("daemon: invalidating role capability without transition freeze") | |
| 314 | + | } | |
| 315 | + | g.capability = roleCapability{} | |
| 316 | + | g.failedTransport = transportSnapshot{} | |
| 317 | + | g.mu.Unlock() | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | type frozenRoleEffectGates struct { | |
| 321 | + | clients []*attachedClient | |
| 322 | + | interruptions map[*attachedClient]transportSnapshot | |
| 323 | + | acquired bool | |
| 324 | + | drained bool | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool { | |
| 328 | + | interrupted, ok := f.interruptions[ac] | |
| 329 | + | return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | type roleTransportInterrupt struct { | |
| 333 | + | ac *attachedClient | |
| 334 | + | transport transportSnapshot | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | // freezeRoleEffectGates establishes transition priority in immutable attachment | |
| 338 | + | // order, then drains earlier admissions. It must be called without daemon, | |
| 339 | + | // routing, session, coordinator, send, overlay, router, or pane locks held. | |
| 340 | + | func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates { | |
| 341 | + | return freezeRoleEffectGatesInterrupting(nil, clients...) | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // freezeRoleEffectGatesInterrupting may close an exact captured transport only | |
| 345 | + | // while an earlier admitted effect is inside that transport's Send. Closing and | |
| 346 | + | // all drain waits happen without the gate mutex or an architecture lock held. | |
| 347 | + | func freezeRoleEffectGatesInterrupting(interrupts []roleTransportInterrupt, clients ...*attachedClient) frozenRoleEffectGates { | |
| 348 | + | return freezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...) | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates { | |
| 352 | + | return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound | |
| 356 | + | // for ordered acquisition and drain. Acquisition is all-or-nothing: if the | |
| 357 | + | // bound expires behind another owner, only gates acquired by this call are | |
| 358 | + | // rolled back, in reverse order. Once the complete set is owned, a terminal | |
| 359 | + | // teardown may proceed when done closes during drain; late tickets then retire | |
| 360 | + | // against an invalidated capability without pinning the session owner | |
| 361 | + | // indefinitely. | |
| 362 | + | func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates { | |
| 363 | + | unique := make(map[*attachedClient]struct{}, len(clients)) | |
| 364 | + | ordered := make([]*attachedClient, 0, len(clients)) | |
| 365 | + | for _, ac := range clients { | |
| 366 | + | if ac == nil { | |
| 367 | + | continue | |
| 368 | + | } | |
| 369 | + | if _, exists := unique[ac]; exists { | |
| 370 | + | continue | |
| 371 | + | } | |
| 372 | + | unique[ac] = struct{}{} | |
| 373 | + | ac.roleEffects.immutableOrder() | |
| 374 | + | ordered = append(ordered, ac) | |
| 375 | + | } | |
| 376 | + | sort.Slice(ordered, func(i, j int) bool { | |
| 377 | + | return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load() | |
| 378 | + | }) | |
| 379 | + | ||
| 380 | + | frozenClients := make([]*attachedClient, 0, len(ordered)) | |
| 381 | + | for _, ac := range ordered { | |
| 382 | + | g := &ac.roleEffects | |
| 383 | + | g.mu.Lock() | |
| 384 | + | g.initLocked() | |
| 385 | + | for g.phase == roleEffectsFrozen { | |
| 386 | + | if done == nil { | |
| 387 | + | g.cond.Wait() | |
| 388 | + | continue | |
| 389 | + | } | |
| 390 | + | changed := g.changed | |
| 391 | + | doneCh := done() | |
| 392 | + | g.mu.Unlock() | |
| 393 | + | select { | |
| 394 | + | case <-changed: | |
| 395 | + | case <-doneCh: | |
| 396 | + | } | |
| 397 | + | g.mu.Lock() | |
| 398 | + | select { | |
| 399 | + | case <-doneCh: | |
| 400 | + | g.mu.Unlock() | |
| 401 | + | partial := frozenRoleEffectGates{clients: frozenClients} | |
| 402 | + | partial.unfreeze() | |
| 403 | + | return frozenRoleEffectGates{} | |
| 404 | + | default: | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | g.phase = roleEffectsFrozen | |
| 408 | + | g.mu.Unlock() | |
| 409 | + | frozenClients = append(frozenClients, ac) | |
| 410 | + | if afterFrozen != nil { | |
| 411 | + | afterFrozen(ac) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | interruptByClient := make(map[*attachedClient]transportSnapshot, len(interrupts)) | |
| 415 | + | for _, interrupt := range interrupts { | |
| 416 | + | if interrupt.ac != nil && interrupt.transport.transport != nil { | |
| 417 | + | interruptByClient[interrupt.ac] = interrupt.transport | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | // Snapshot every exact in-flight link while all participant gates are frozen, | |
| 421 | + | // then close the complete set before waiting on any one ticket. One | |
| 422 | + | // uncooperative Send therefore cannot prevent another participant's Send | |
| 423 | + | // from being interrupted. | |
| 424 | + | interruptions := make(map[*attachedClient]transportSnapshot, len(interruptByClient)) | |
| 425 | + | toClose := make([]roleTransportInterrupt, 0, len(interruptByClient)) | |
| 426 | + | for _, ac := range ordered { | |
| 427 | + | expected, mayInterrupt := interruptByClient[ac] | |
| 428 | + | if !mayInterrupt { | |
| 429 | + | continue | |
| 430 | + | } | |
| 431 | + | g := &ac.roleEffects | |
| 432 | + | g.mu.Lock() | |
| 433 | + | if gateHasTransportEffectLocked(g, expected) { | |
| 434 | + | toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected}) | |
| 435 | + | } | |
| 436 | + | g.mu.Unlock() | |
| 437 | + | } | |
| 438 | + | for _, interrupt := range toClose { | |
| 439 | + | _ = interrupt.ac.closeCapturedTransport(interrupt.transport.transport) | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | for _, ac := range ordered { | |
| 443 | + | g := &ac.roleEffects | |
| 444 | + | g.mu.Lock() | |
| 445 | + | for g.inFlight != 0 { | |
| 446 | + | if done == nil { | |
| 447 | + | g.cond.Wait() | |
| 448 | + | continue | |
| 449 | + | } | |
| 450 | + | changed := g.changed | |
| 451 | + | doneCh := done() | |
| 452 | + | g.mu.Unlock() | |
| 453 | + | select { | |
| 454 | + | case <-changed: | |
| 455 | + | case <-doneCh: | |
| 456 | + | } | |
| 457 | + | g.mu.Lock() | |
| 458 | + | select { | |
| 459 | + | case <-doneCh: | |
| 460 | + | g.mu.Unlock() | |
| 461 | + | return frozenRoleEffectGates{clients: frozenClients, interruptions: interruptions, acquired: true} | |
| 462 | + | default: | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | if expected, mayInterrupt := interruptByClient[ac]; mayInterrupt && | |
| 466 | + | g.failedTransport.transport == expected.transport && g.failedTransport.incarnation == expected.incarnation { | |
| 467 | + | interruptions[ac] = expected | |
| 468 | + | } | |
| 469 | + | g.mu.Unlock() | |
| 470 | + | } | |
| 471 | + | return frozenRoleEffectGates{clients: frozenClients, interruptions: interruptions, acquired: true, drained: true} | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool { | |
| 475 | + | for _, active := range g.transportEffects { | |
| 476 | + | if active.transport == expected.transport && active.incarnation == expected.incarnation { | |
| 477 | + | return true | |
| 478 | + | } | |
| 479 | + | } | |
| 480 | + | return false | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | func (f frozenRoleEffectGates) unfreeze() { | |
| 484 | + | for i := len(f.clients) - 1; i >= 0; i-- { | |
| 485 | + | g := &f.clients[i].roleEffects | |
| 486 | + | g.mu.Lock() | |
| 487 | + | g.initLocked() | |
| 488 | + | g.phase = roleEffectsStable | |
| 489 | + | g.signalLocked() | |
| 490 | + | g.mu.Unlock() | |
| 491 | + | } | |
| 492 | + | } | |
A internal/usecase/daemon/role_effect_gate_test.go +1448 -0
| @@ -0,0 +1,1448 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "io" | |
| 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 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 16 | + | "github.com/bnema/vev/pkg/renderer" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type blockedRenderReplacementTransport struct { | |
| 20 | + | sendEntered chan struct{} | |
| 21 | + | closed chan struct{} | |
| 22 | + | sendOnce sync.Once | |
| 23 | + | closeOnce sync.Once | |
| 24 | + | locksFree chan bool | |
| 25 | + | d *Daemon | |
| 26 | + | sess *session | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func newBlockedRenderReplacementTransport(d *Daemon, sess *session) *blockedRenderReplacementTransport { | |
| 30 | + | return &blockedRenderReplacementTransport{ | |
| 31 | + | sendEntered: make(chan struct{}), | |
| 32 | + | closed: make(chan struct{}), | |
| 33 | + | locksFree: make(chan bool, 1), | |
| 34 | + | d: d, | |
| 35 | + | sess: sess, | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (t *blockedRenderReplacementTransport) Send(ports.Frame) error { | |
| 40 | + | t.sendOnce.Do(func() { close(t.sendEntered) }) | |
| 41 | + | <-t.closed | |
| 42 | + | return errors.New("transport closed") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (*blockedRenderReplacementTransport) Recv() (ports.Frame, error) { | |
| 46 | + | return ports.Frame{}, io.EOF | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (t *blockedRenderReplacementTransport) Close() error { | |
| 50 | + | locksFree := t.d.mu.TryLock() | |
| 51 | + | if locksFree { | |
| 52 | + | t.d.mu.Unlock() | |
| 53 | + | } | |
| 54 | + | if locksFree { | |
| 55 | + | locksFree = t.d.notices.routingMu.TryLock() | |
| 56 | + | if locksFree { | |
| 57 | + | t.d.notices.routingMu.Unlock() | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | if locksFree { | |
| 61 | + | locksFree = t.sess.mu.TryLock() | |
| 62 | + | if locksFree { | |
| 63 | + | t.sess.mu.Unlock() | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | if locksFree { | |
| 67 | + | if rc := t.sess.renderCoordinator(); rc != nil { | |
| 68 | + | locksFree = rc.mu.TryLock() | |
| 69 | + | if locksFree { | |
| 70 | + | rc.mu.Unlock() | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | t.closeOnce.Do(func() { | |
| 75 | + | t.locksFree <- locksFree | |
| 76 | + | close(t.closed) | |
| 77 | + | }) | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | type teardownBlockingTransport struct { | |
| 82 | + | sendEntered chan struct{} | |
| 83 | + | closed chan struct{} | |
| 84 | + | sendOnce sync.Once | |
| 85 | + | closeOnce sync.Once | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func newTeardownBlockingTransport() *teardownBlockingTransport { | |
| 89 | + | return &teardownBlockingTransport{sendEntered: make(chan struct{}), closed: make(chan struct{})} | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (t *teardownBlockingTransport) Send(ports.Frame) error { | |
| 93 | + | t.sendOnce.Do(func() { close(t.sendEntered) }) | |
| 94 | + | <-t.closed | |
| 95 | + | return errors.New("transport closed") | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (*teardownBlockingTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 99 | + | ||
| 100 | + | func (t *teardownBlockingTransport) Close() error { | |
| 101 | + | t.closeOnce.Do(func() { close(t.closed) }) | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (t *teardownBlockingTransport) Closed() bool { | |
| 106 | + | select { | |
| 107 | + | case <-t.closed: | |
| 108 | + | return true | |
| 109 | + | default: | |
| 110 | + | return false | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func startBlockedRoleSend(t *testing.T, token attachmentRoleToken) <-chan struct{} { | |
| 115 | + | t.Helper() | |
| 116 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 117 | + | require.True(t, admitted) | |
| 118 | + | done := make(chan struct{}) | |
| 119 | + | go func() { | |
| 120 | + | defer close(done) | |
| 121 | + | defer ticket.End() | |
| 122 | + | _ = token.ac.sendExpectedTransportForRole(token.transport, framePong(), ticket) | |
| 123 | + | }() | |
| 124 | + | return done | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func requireRoleGateRetired(t *testing.T, ac *attachedClient) { | |
| 128 | + | t.Helper() | |
| 129 | + | ac.roleEffects.mu.Lock() | |
| 130 | + | defer ac.roleEffects.mu.Unlock() | |
| 131 | + | require.Equal(t, roleEffectsStable, ac.roleEffects.phase) | |
| 132 | + | require.Zero(t, ac.roleEffects.inFlight) | |
| 133 | + | require.Empty(t, ac.roleEffects.transportEffects) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func roleGatePublicationSnapshot(ac *attachedClient) (roleEffectPhase, roleCapability) { | |
| 137 | + | ac.roleEffects.mu.Lock() | |
| 138 | + | defer ac.roleEffects.mu.Unlock() | |
| 139 | + | return ac.roleEffects.phase, ac.roleEffects.capability | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) { | |
| 143 | + | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 144 | + | firstTransport := &closeTrackingTransport{} | |
| 145 | + | first.replaceTransport(firstTransport) | |
| 146 | + | ||
| 147 | + | blockedTransport := &closeTrackingTransport{} | |
| 148 | + | blocked := &attachedClient{tr: blockedTransport, output: newOutputStateStream(), size: first.size} | |
| 149 | + | blocked.initOverlays() | |
| 150 | + | blocked.setSession(sess) | |
| 151 | + | laterTransport := &closeTrackingTransport{} | |
| 152 | + | later := &attachedClient{tr: laterTransport, output: newOutputStateStream(), size: first.size} | |
| 153 | + | later.initOverlays() | |
| 154 | + | later.setSession(sess) | |
| 155 | + | sess.mu.Lock() | |
| 156 | + | sess.addSnatchedLocked(blocked) | |
| 157 | + | sess.addSnatchedLocked(later) | |
| 158 | + | sess.mu.Unlock() | |
| 159 | + | ||
| 160 | + | rc := d.attachCoordinator(sess, nil, first, true) | |
| 161 | + | firstToken := sess.attachmentToken(first, firstTransport) | |
| 162 | + | firstToken.lease = rc.attachmentLease(first) | |
| 163 | + | first.publishRoleCapability(firstToken) | |
| 164 | + | blockedToken := sess.attachmentToken(blocked, blockedTransport) | |
| 165 | + | blocked.publishRoleCapability(blockedToken) | |
| 166 | + | laterToken := sess.attachmentToken(later, laterTransport) | |
| 167 | + | later.publishRoleCapability(laterToken) | |
| 168 | + | ||
| 169 | + | // Fix the canonical order so teardown acquires first, waits behind the gate | |
| 170 | + | // owned by this test, and never reaches the still-stable later participant. | |
| 171 | + | first.roleEffects.immutableOrder() | |
| 172 | + | blocked.roleEffects.immutableOrder() | |
| 173 | + | later.roleEffects.immutableOrder() | |
| 174 | + | require.Less(t, first.roleEffects.order.Load(), blocked.roleEffects.order.Load()) | |
| 175 | + | require.Less(t, blocked.roleEffects.order.Load(), later.roleEffects.order.Load()) | |
| 176 | + | ||
| 177 | + | blockedOwner := freezeRoleEffectGates(blocked) | |
| 178 | + | blockedOwnerHeld := true | |
| 179 | + | t.Cleanup(func() { | |
| 180 | + | if blockedOwnerHeld { | |
| 181 | + | blockedOwner.unfreeze() | |
| 182 | + | } | |
| 183 | + | }) | |
| 184 | + | ||
| 185 | + | firstPhase, firstCapability := roleGatePublicationSnapshot(first) | |
| 186 | + | blockedPhase, blockedCapability := roleGatePublicationSnapshot(blocked) | |
| 187 | + | laterPhase, laterCapability := roleGatePublicationSnapshot(later) | |
| 188 | + | require.Equal(t, roleEffectsStable, firstPhase) | |
| 189 | + | require.Equal(t, roleEffectsFrozen, blockedPhase) | |
| 190 | + | require.Equal(t, roleEffectsStable, laterPhase) | |
| 191 | + | firstGeneration := first.roleGeneration.Load() | |
| 192 | + | blockedGeneration := blocked.roleGeneration.Load() | |
| 193 | + | laterGeneration := later.roleGeneration.Load() | |
| 194 | + | firstSnapshot := first.transportSnapshot() | |
| 195 | + | blockedSnapshot := blocked.transportSnapshot() | |
| 196 | + | laterSnapshot := later.transportSnapshot() | |
| 197 | + | ||
| 198 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 199 | + | d.clock = clock | |
| 200 | + | frozenByKill := make(chan *attachedClient, 3) | |
| 201 | + | d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) { frozenByKill <- ac } | |
| 202 | + | killDone := make(chan error, 1) | |
| 203 | + | go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }() | |
| 204 | + | ||
| 205 | + | require.Same(t, first, awaitTestValue(t, frozenByKill, "teardown did not acquire the first role gate")) | |
| 206 | + | deadline := awaitTestValue(t, clock.timers, "teardown did not arm the gate acquisition deadline") | |
| 207 | + | require.Equal(t, detachNotifyTimeout, deadline.duration) | |
| 208 | + | deadline.ch <- time.Now() | |
| 209 | + | select { | |
| 210 | + | case err := <-killDone: | |
| 211 | + | require.NoError(t, err) | |
| 212 | + | case <-time.After(time.Second): | |
| 213 | + | t.Fatal("teardown did not abort after partial gate acquisition timed out") | |
| 214 | + | } | |
| 215 | + | require.Empty(t, frozenByKill, "teardown acquired a participant after its deadline expired") | |
| 216 | + | ||
| 217 | + | d.mu.Lock() | |
| 218 | + | require.Same(t, sess, d.sessions[sess.id], "partial gate acquisition removed the session registry owner") | |
| 219 | + | d.mu.Unlock() | |
| 220 | + | sess.mu.Lock() | |
| 221 | + | require.Same(t, first, sess.client) | |
| 222 | + | require.Contains(t, sess.snatched, blocked) | |
| 223 | + | require.Contains(t, sess.snatched, later) | |
| 224 | + | sess.mu.Unlock() | |
| 225 | + | for _, ac := range []*attachedClient{first, blocked, later} { | |
| 226 | + | require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership") | |
| 227 | + | } | |
| 228 | + | require.Equal(t, firstGeneration, first.roleGeneration.Load()) | |
| 229 | + | require.Equal(t, blockedGeneration, blocked.roleGeneration.Load()) | |
| 230 | + | require.Equal(t, laterGeneration, later.roleGeneration.Load()) | |
| 231 | + | require.True(t, first.transportSnapshotCurrent(firstSnapshot)) | |
| 232 | + | require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot)) | |
| 233 | + | require.True(t, later.transportSnapshotCurrent(laterSnapshot)) | |
| 234 | + | require.False(t, firstTransport.Closed()) | |
| 235 | + | require.False(t, blockedTransport.Closed()) | |
| 236 | + | require.False(t, laterTransport.Closed()) | |
| 237 | + | ||
| 238 | + | firstPhase, gotFirstCapability := roleGatePublicationSnapshot(first) | |
| 239 | + | blockedPhase, gotBlockedCapability := roleGatePublicationSnapshot(blocked) | |
| 240 | + | laterPhase, gotLaterCapability := roleGatePublicationSnapshot(later) | |
| 241 | + | require.Equal(t, roleEffectsStable, firstPhase, "partially acquired gate was not rolled back") | |
| 242 | + | require.Equal(t, roleEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition") | |
| 243 | + | require.Equal(t, roleEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated") | |
| 244 | + | require.Equal(t, firstCapability, gotFirstCapability) | |
| 245 | + | require.Equal(t, blockedCapability, gotBlockedCapability) | |
| 246 | + | require.Equal(t, laterCapability, gotLaterCapability) | |
| 247 | + | ||
| 248 | + | blockedOwner.unfreeze() | |
| 249 | + | blockedOwnerHeld = false | |
| 250 | + | complete := freezeRoleEffectGates(first, blocked, later) | |
| 251 | + | require.True(t, complete.acquired, "rolled-back gate set could not be acquired again") | |
| 252 | + | require.True(t, complete.drained) | |
| 253 | + | complete.unfreeze() | |
| 254 | + | for _, token := range []attachmentRoleToken{firstToken, blockedToken, laterToken} { | |
| 255 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 256 | + | require.True(t, admitted, "pre-publication abort changed a role capability") | |
| 257 | + | ticket.End() | |
| 258 | + | requireRoleGateRetired(t, token.ac) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |
| 263 | + | d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 264 | + | snatchedTransport := newTeardownBlockingTransport() | |
| 265 | + | snatched.replaceTransport(snatchedTransport) | |
| 266 | + | d.attachCoordinator(sess, nil, snatched, true) | |
| 267 | + | snatched.publishRoleCapability(sess.attachmentToken(snatched, snatchedTransport)) | |
| 268 | + | ||
| 269 | + | activeTransport := newTeardownBlockingTransport() | |
| 270 | + | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size} | |
| 271 | + | active.initOverlays() | |
| 272 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 273 | + | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 274 | + | expectedTransport: active.transportSnapshot(), ready: true, | |
| 275 | + | }) | |
| 276 | + | require.NoError(t, err) | |
| 277 | + | defer d.deferAttachmentTransitionCleanups(transition) | |
| 278 | + | ||
| 279 | + | unrelatedTransport := &closeTrackingTransport{} | |
| 280 | + | unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size} | |
| 281 | + | unrelatedClient.initOverlays() | |
| 282 | + | unrelated := &session{id: "unrelated", name: "unrelated", ctx: sess.ctx, cancel: func() {}, client: unrelatedClient} | |
| 283 | + | unrelatedClient.setSession(unrelated) | |
| 284 | + | d.mu.Lock() | |
| 285 | + | d.sessions[unrelated.id] = unrelated | |
| 286 | + | d.mu.Unlock() | |
| 287 | + | d.attachCoordinator(unrelated, nil, unrelatedClient, true) | |
| 288 | + | ||
| 289 | + | snatchedToken := sess.attachmentToken(snatched, snatchedTransport) | |
| 290 | + | activeToken := sess.attachmentToken(active, activeTransport) | |
| 291 | + | snatchedDone := startBlockedRoleSend(t, snatchedToken) | |
| 292 | + | activeDone := startBlockedRoleSend(t, activeToken) | |
| 293 | + | awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block") | |
| 294 | + | awaitTestCompletion(t, activeTransport.sendEntered, "active send did not block") | |
| 295 | + | ||
| 296 | + | killDone := make(chan error, 1) | |
| 297 | + | go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }() | |
| 298 | + | select { | |
| 299 | + | case err := <-killDone: | |
| 300 | + | require.NoError(t, err) | |
| 301 | + | case <-time.After(500 * time.Millisecond): | |
| 302 | + | _ = snatchedTransport.Close() | |
| 303 | + | _ = activeTransport.Close() | |
| 304 | + | t.Fatal("session kill remained pinned behind admitted participant sends") | |
| 305 | + | } | |
| 306 | + | awaitTestCompletion(t, snatchedDone, "snatched role ticket did not retire") | |
| 307 | + | awaitTestCompletion(t, activeDone, "active role ticket did not retire") | |
| 308 | + | d.waitNotifies() | |
| 309 | + | ||
| 310 | + | require.True(t, snatchedTransport.Closed()) | |
| 311 | + | require.True(t, activeTransport.Closed()) | |
| 312 | + | require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted") | |
| 313 | + | require.Same(t, unrelatedClient, unrelated.client) | |
| 314 | + | require.Same(t, unrelated, unrelatedClient.currentSession()) | |
| 315 | + | requireRoleGateRetired(t, snatched) | |
| 316 | + | requireRoleGateRetired(t, active) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *testing.T) { | |
| 320 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 321 | + | stale := newTeardownBlockingTransport() | |
| 322 | + | ac.replaceTransport(stale) | |
| 323 | + | d.attachCoordinator(sess, nil, ac, true) | |
| 324 | + | ac.publishRoleCapability(sess.attachmentToken(ac, stale)) | |
| 325 | + | ||
| 326 | + | token := sess.attachmentToken(ac, stale) | |
| 327 | + | sendDone := startBlockedRoleSend(t, token) | |
| 328 | + | awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block") | |
| 329 | + | fresh := &closeTrackingTransport{} | |
| 330 | + | d.afterRoleEffectParticipantsSnapshotted = func(string, []*attachedClient) { | |
| 331 | + | ac.replaceTransport(fresh) | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 335 | + | awaitTestCompletion(t, sendDone, "captured incarnation role ticket did not retire") | |
| 336 | + | require.True(t, stale.Closed(), "exact captured in-flight transport was not interrupted") | |
| 337 | + | require.False(t, fresh.Closed(), "newer transport incarnation was interrupted") | |
| 338 | + | require.Same(t, fresh, ac.transport()) | |
| 339 | + | d.mu.Lock() | |
| 340 | + | require.Same(t, sess, d.sessions[sess.id], "transport incarnation change should abort stale teardown publication") | |
| 341 | + | d.mu.Unlock() | |
| 342 | + | sess.mu.Lock() | |
| 343 | + | require.Same(t, ac, sess.client) | |
| 344 | + | sess.mu.Unlock() | |
| 345 | + | requireRoleGateRetired(t, ac) | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) { | |
| 349 | + | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 350 | + | blocked := newTeardownBlockingTransport() | |
| 351 | + | active.replaceTransport(blocked) | |
| 352 | + | d.attachCoordinator(sess, nil, active, true) | |
| 353 | + | active.publishRoleCapability(sess.attachmentToken(active, blocked)) | |
| 354 | + | ||
| 355 | + | token := sess.attachmentToken(active, blocked) | |
| 356 | + | sendDone := startBlockedRoleSend(t, token) | |
| 357 | + | awaitTestCompletion(t, blocked.sendEntered, "active send did not block") | |
| 358 | + | ||
| 359 | + | shutdownDone := make(chan bool, 1) | |
| 360 | + | go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }() | |
| 361 | + | select { | |
| 362 | + | case incomplete := <-shutdownDone: | |
| 363 | + | require.False(t, incomplete) | |
| 364 | + | case <-time.After(500 * time.Millisecond): | |
| 365 | + | _ = blocked.Close() | |
| 366 | + | t.Fatal("shutdown remained pinned behind admitted participant send") | |
| 367 | + | } | |
| 368 | + | awaitTestCompletion(t, sendDone, "shutdown-interrupted role ticket did not retire") | |
| 369 | + | d.waitNotifies() | |
| 370 | + | require.True(t, blocked.Closed()) | |
| 371 | + | requireRoleGateRetired(t, active) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | type renderFailureTransport struct { | |
| 375 | + | mu sync.Mutex | |
| 376 | + | closed bool | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | func (*renderFailureTransport) Send(ports.Frame) error { return errors.New("render send failed") } | |
| 380 | + | func (*renderFailureTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 381 | + | func (t *renderFailureTransport) Close() error { | |
| 382 | + | t.mu.Lock() | |
| 383 | + | t.closed = true | |
| 384 | + | t.mu.Unlock() | |
| 385 | + | return nil | |
| 386 | + | } | |
| 387 | + | func (t *renderFailureTransport) Closed() bool { | |
| 388 | + | t.mu.Lock() | |
| 389 | + | defer t.mu.Unlock() | |
| 390 | + | return t.closed | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing.T) { | |
| 394 | + | d, sess, original, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 395 | + | failedTransport := &renderFailureTransport{} | |
| 396 | + | original.replaceTransport(failedTransport) | |
| 397 | + | rc := d.attachCoordinator(sess, nil, original, true) | |
| 398 | + | originalToken := sess.attachmentToken(original, failedTransport) | |
| 399 | + | originalToken.lease = rc.attachmentLease(original) | |
| 400 | + | original.publishRoleCapability(originalToken) | |
| 401 | + | ||
| 402 | + | cleanupStarted := make(chan struct{}) | |
| 403 | + | releaseCleanup := make(chan struct{}) | |
| 404 | + | cleanupDone := make(chan struct{}) | |
| 405 | + | d.beforeRoleSendErrorCleanup = func(token attachmentRoleToken) { | |
| 406 | + | require.Equal(t, originalToken.generation, token.generation) | |
| 407 | + | require.Equal(t, originalToken.transport, token.transport) | |
| 408 | + | require.Same(t, originalToken.lease, token.lease) | |
| 409 | + | close(cleanupStarted) | |
| 410 | + | <-releaseCleanup | |
| 411 | + | } | |
| 412 | + | d.afterRoleSendErrorCleanup = func() { close(cleanupDone) } | |
| 413 | + | ||
| 414 | + | d.paint(sess, original, true, originalToken.lease) | |
| 415 | + | awaitTestCompletion(t, cleanupStarted, "render failure cleanup did not retain the captured role token") | |
| 416 | + | cleanupTracked := waitGroupDone(&d.attachmentCleanupWg) | |
| 417 | + | select { | |
| 418 | + | case <-cleanupTracked: | |
| 419 | + | t.Fatal("paused render failure cleanup was not tracked by attachmentCleanupWg") | |
| 420 | + | default: | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | replacementTransport := &closeTrackingTransport{} | |
| 424 | + | replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: original.size} | |
| 425 | + | replacement.initOverlays() | |
| 426 | + | displaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 427 | + | target: sess, next: replacement, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 428 | + | expectedTransport: replacement.transportSnapshot(), ready: true, | |
| 429 | + | }) | |
| 430 | + | require.NoError(t, err) | |
| 431 | + | ||
| 432 | + | resumed, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 433 | + | target: sess, next: original, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 434 | + | expectedTransport: original.transportSnapshot(), ready: true, | |
| 435 | + | }) | |
| 436 | + | require.NoError(t, err) | |
| 437 | + | resumedGeneration := original.roleGeneration.Load() | |
| 438 | + | resumedLease := resumed.published.lease | |
| 439 | + | require.NotSame(t, originalToken.lease, resumedLease) | |
| 440 | + | ||
| 441 | + | close(releaseCleanup) | |
| 442 | + | awaitTestCompletion(t, cleanupDone, "render failure cleanup did not finish") | |
| 443 | + | awaitTestCompletion(t, cleanupTracked, "tracked render failure cleanup did not retire") | |
| 444 | + | ||
| 445 | + | sess.mu.Lock() | |
| 446 | + | require.Same(t, original, sess.client, "stale render cleanup detached the resumed incarnation") | |
| 447 | + | sess.mu.Unlock() | |
| 448 | + | require.Equal(t, resumedGeneration, original.roleGeneration.Load()) | |
| 449 | + | require.True(t, resumed.published.activeCurrent()) | |
| 450 | + | require.False(t, failedTransport.Closed(), "stale cleanup closed the resumed incarnation's transport") | |
| 451 | + | ||
| 452 | + | d.deferAttachmentTransitionCleanups(displaced) | |
| 453 | + | d.deferAttachmentTransitionCleanups(resumed) | |
| 454 | + | d.attachmentCleanupWg.Wait() | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing.T) { | |
| 458 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 459 | + | transport := &closeTrackingTransport{} | |
| 460 | + | ac.replaceTransport(transport) | |
| 461 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 462 | + | token := sess.attachmentToken(ac, transport) | |
| 463 | + | token.lease = rc.attachmentLease(ac) | |
| 464 | + | ac.publishRoleCapability(token) | |
| 465 | + | ||
| 466 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 467 | + | d.clock = clock | |
| 468 | + | frozen := freezeRoleEffectGates(ac) | |
| 469 | + | defer frozen.unfreeze() | |
| 470 | + | cleanupDone := make(chan struct{}) | |
| 471 | + | d.afterRoleSendErrorCleanup = func() { close(cleanupDone) } | |
| 472 | + | ||
| 473 | + | launchCleanup := d.reserveRoleSendErrorCleanup(token, transport) | |
| 474 | + | launchCleanup() | |
| 475 | + | deadline := awaitTestValue(t, clock.timers, "render cleanup did not arm its deadline") | |
| 476 | + | require.Equal(t, detachNotifyTimeout, deadline.duration) | |
| 477 | + | deadline.ch <- time.Now() | |
| 478 | + | awaitTestCompletion(t, cleanupDone, "deadline did not release blocked render cleanup") | |
| 479 | + | awaitTestCompletion(t, waitGroupDone(&d.attachmentCleanupWg), "bounded render cleanup leaked its tracked goroutine") | |
| 480 | + | ||
| 481 | + | sess.mu.Lock() | |
| 482 | + | require.Same(t, ac, sess.client) | |
| 483 | + | sess.mu.Unlock() | |
| 484 | + | require.False(t, transport.Closed()) | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t *testing.T) { | |
| 488 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 489 | + | oldTransport := newBlockedRenderReplacementTransport(d, sess) | |
| 490 | + | old.replaceTransport(oldTransport) | |
| 491 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 492 | + | lease := rc.attachmentLease(old) | |
| 493 | + | token := sess.attachmentToken(old, oldTransport) | |
| 494 | + | token.lease = lease | |
| 495 | + | old.publishRoleCapability(token) | |
| 496 | + | ||
| 497 | + | paintDone := make(chan struct{}) | |
| 498 | + | go func() { | |
| 499 | + | d.paint(sess, old, true, lease) | |
| 500 | + | close(paintDone) | |
| 501 | + | }() | |
| 502 | + | <-oldTransport.sendEntered | |
| 503 | + | ||
| 504 | + | newTransport := &closeTrackingTransport{} | |
| 505 | + | next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size} | |
| 506 | + | next.initOverlays() | |
| 507 | + | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 508 | + | transitionErr := make(chan error, 1) | |
| 509 | + | go func() { | |
| 510 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 511 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 512 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 513 | + | }) | |
| 514 | + | transitionDone <- result | |
| 515 | + | transitionErr <- err | |
| 516 | + | }() | |
| 517 | + | ||
| 518 | + | var result attachmentTransitionResult | |
| 519 | + | select { | |
| 520 | + | case result = <-transitionDone: | |
| 521 | + | case <-time.After(time.Second): | |
| 522 | + | t.Fatal("replacement publication remained blocked behind the old render send") | |
| 523 | + | } | |
| 524 | + | require.NoError(t, <-transitionErr) | |
| 525 | + | require.True(t, <-oldTransport.locksFree, "transport close ran while an architecture lock was held") | |
| 526 | + | select { | |
| 527 | + | case <-paintDone: | |
| 528 | + | case <-time.After(time.Second): | |
| 529 | + | t.Fatal("closing the exact old transport did not unblock its render") | |
| 530 | + | } | |
| 531 | + | require.Same(t, next, sess.client) | |
| 532 | + | require.False(t, newTransport.Closed(), "replacement transport was affected by old-link interruption") | |
| 533 | + | ||
| 534 | + | d.deferAttachmentTransitionCleanups(result) | |
| 535 | + | d.attachmentCleanupWg.Wait() | |
| 536 | + | require.Nil(t, old.transport(), "the unhealthy old link was not retired") | |
| 537 | + | require.Same(t, next, sess.client) | |
| 538 | + | require.False(t, newTransport.Closed()) | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | func TestDisplacedBlockedRenderCleanupWaitsForStaleDetachFreeze(t *testing.T) { | |
| 542 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 543 | + | oldTransport := newBlockedRenderReplacementTransport(d, sess) | |
| 544 | + | old.replaceTransport(oldTransport) | |
| 545 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 546 | + | lease := rc.attachmentLease(old) | |
| 547 | + | token := sess.attachmentToken(old, oldTransport) | |
| 548 | + | token.lease = lease | |
| 549 | + | old.publishRoleCapability(token) | |
| 550 | + | old.sendMu.Lock() | |
| 551 | + | old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}} | |
| 552 | + | old.sendMu.Unlock() | |
| 553 | + | ||
| 554 | + | staleDetachFrozen := make(chan struct{}) | |
| 555 | + | releaseStaleDetach := make(chan struct{}) | |
| 556 | + | var releaseOnce sync.Once | |
| 557 | + | t.Cleanup(func() { releaseOnce.Do(func() { close(releaseStaleDetach) }) }) | |
| 558 | + | d.afterDetachRoleEffectsFrozen = func() { | |
| 559 | + | close(staleDetachFrozen) | |
| 560 | + | <-releaseStaleDetach | |
| 561 | + | } | |
| 562 | + | cleanupStarted := make(chan struct{}) | |
| 563 | + | d.afterDisplacedCleanupStarted = func() { close(cleanupStarted) } | |
| 564 | + | ||
| 565 | + | paintDone := make(chan struct{}) | |
| 566 | + | go func() { | |
| 567 | + | d.paint(sess, old, true, lease) | |
| 568 | + | close(paintDone) | |
| 569 | + | }() | |
| 570 | + | awaitTestCompletion(t, oldTransport.sendEntered, "old render did not block in transport send") | |
| 571 | + | ||
| 572 | + | newTransport := &closeTrackingTransport{} | |
| 573 | + | next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size} | |
| 574 | + | next.initOverlays() | |
| 575 | + | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 576 | + | transitionErr := make(chan error, 1) | |
| 577 | + | go func() { | |
| 578 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 579 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 580 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 581 | + | }) | |
| 582 | + | transitionDone <- result | |
| 583 | + | transitionErr <- err | |
| 584 | + | }() | |
| 585 | + | ||
| 586 | + | var result attachmentTransitionResult | |
| 587 | + | select { | |
| 588 | + | case result = <-transitionDone: | |
| 589 | + | require.NoError(t, <-transitionErr) | |
| 590 | + | case <-time.After(time.Second): | |
| 591 | + | t.Fatal("replacement did not interrupt the blocked render") | |
| 592 | + | } | |
| 593 | + | awaitTestCompletion(t, paintDone, "interrupted render did not retire") | |
| 594 | + | awaitTestCompletion(t, staleDetachFrozen, "stale send-error detach did not freeze the role gate") | |
| 595 | + | ||
| 596 | + | d.deferAttachmentTransitionCleanups(result) | |
| 597 | + | awaitTestCompletion(t, cleanupStarted, "displaced cleanup did not start during stale detach freeze") | |
| 598 | + | releaseOnce.Do(func() { close(releaseStaleDetach) }) | |
| 599 | + | select { | |
| 600 | + | case <-waitGroupDone(&d.attachmentCleanupWg): | |
| 601 | + | case <-time.After(time.Second): | |
| 602 | + | t.Fatal("displaced cleanup deadlocked behind stale detach") | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | sess.mu.Lock() | |
| 606 | + | require.Same(t, next, sess.client) | |
| 607 | + | require.NotContains(t, sess.snatched, old, "closed displaced incarnation remained registered") | |
| 608 | + | sess.mu.Unlock() | |
| 609 | + | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 610 | + | require.Nil(t, old.transport(), "closed displaced transport remained attached") | |
| 611 | + | require.Nil(t, old.currentSession(), "terminal displaced cleanup retained session ownership") | |
| 612 | + | old.sendMu.Lock() | |
| 613 | + | require.Empty(t, old.captureFrames, "terminal displaced cleanup retained captures") | |
| 614 | + | old.sendMu.Unlock() | |
| 615 | + | require.False(t, newTransport.Closed(), "stale cleanup affected the new active owner") | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | func TestInterruptedDisplacedCleanupRejectsNewerReclaimedIncarnation(t *testing.T) { | |
| 619 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 620 | + | staleTransport := &closeTrackingTransport{} | |
| 621 | + | old.replaceTransport(staleTransport) | |
| 622 | + | d.attachCoordinator(sess, nil, old, true) | |
| 623 | + | ||
| 624 | + | nextTransport := &closeTrackingTransport{} | |
| 625 | + | next := &attachedClient{tr: nextTransport, output: newOutputStateStream(), size: old.size} | |
| 626 | + | next.initOverlays() | |
| 627 | + | displaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 628 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 629 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 630 | + | }) | |
| 631 | + | require.NoError(t, err) | |
| 632 | + | stale := displaced.displaced | |
| 633 | + | ||
| 634 | + | _, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 635 | + | target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 636 | + | expectedTransport: old.transportSnapshot(), ready: true, | |
| 637 | + | }) | |
| 638 | + | require.NoError(t, err) | |
| 639 | + | freshTransport := &closeTrackingTransport{} | |
| 640 | + | old.replaceTransport(freshTransport) | |
| 641 | + | _, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 642 | + | target: sess, next: old, expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 643 | + | expectedTransport: old.transportSnapshot(), ready: true, | |
| 644 | + | }) | |
| 645 | + | require.NoError(t, err) | |
| 646 | + | require.NoError(t, staleTransport.Close()) | |
| 647 | + | ||
| 648 | + | require.False(t, d.cleanupInterruptedSnatchedAttachment(stale)) | |
| 649 | + | sess.mu.Lock() | |
| 650 | + | require.Same(t, old, sess.client) | |
| 651 | + | require.Contains(t, sess.snatched, next) | |
| 652 | + | sess.mu.Unlock() | |
| 653 | + | require.Equal(t, attachmentActive, sess.attachmentRole(old)) | |
| 654 | + | require.Same(t, freshTransport, old.transport()) | |
| 655 | + | require.False(t, freshTransport.Closed(), "stale terminal cleanup closed the newer transport") | |
| 656 | + | require.False(t, nextTransport.Closed(), "stale terminal cleanup affected the newer displaced owner") | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) { | |
| 660 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 661 | + | oldTransport := newDatagramTestTransport() | |
| 662 | + | old.replaceTransport(oldTransport) | |
| 663 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 664 | + | token := sess.attachmentToken(old, oldTransport) | |
| 665 | + | token.lease = rc.attachmentLease(old) | |
| 666 | + | old.publishRoleCapability(token) | |
| 667 | + | ||
| 668 | + | ticket, ok := old.beginRoleEffect(token) | |
| 669 | + | require.True(t, ok) | |
| 670 | + | ||
| 671 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 672 | + | next.initOverlays() | |
| 673 | + | done := make(chan error, 1) | |
| 674 | + | go func() { | |
| 675 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 676 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 677 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 678 | + | }) | |
| 679 | + | done <- err | |
| 680 | + | }() | |
| 681 | + | ||
| 682 | + | select { | |
| 683 | + | case err := <-done: | |
| 684 | + | t.Fatalf("transition committed before admitted effect ended: %v", err) | |
| 685 | + | case <-time.After(20 * time.Millisecond): | |
| 686 | + | } | |
| 687 | + | ticket.End() | |
| 688 | + | require.NoError(t, <-done) | |
| 689 | + | } | |
| 690 | + | ||
| 691 | + | func TestRoleEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T) { | |
| 692 | + | tests := []struct { | |
| 693 | + | name string | |
| 694 | + | setup func(*Daemon, *session, *attachedClient, *transactionalResizePTY) | |
| 695 | + | frame ports.Frame | |
| 696 | + | assert func(*testing.T, *attachedClient, *transactionalResizePTY) | |
| 697 | + | }{ | |
| 698 | + | { | |
| 699 | + | name: "PTY input", | |
| 700 | + | frame: frameInput([]byte("reserved")), | |
| 701 | + | assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) { | |
| 702 | + | require.Equal(t, [][]byte{[]byte("reserved")}, pty.writes()) | |
| 703 | + | }, | |
| 704 | + | }, | |
| 705 | + | { | |
| 706 | + | name: "mouse input", | |
| 707 | + | setup: func(_ *Daemon, sess *session, _ *attachedClient, _ *transactionalResizePTY) { | |
| 708 | + | p := sess.activeTab().focusedPane() | |
| 709 | + | p.mu.Lock() | |
| 710 | + | p.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 711 | + | p.mu.Unlock() | |
| 712 | + | }, | |
| 713 | + | frame: frameInput([]byte("\x1b[<0;1;2M")), | |
| 714 | + | assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) { | |
| 715 | + | require.NotEmpty(t, pty.writes()) | |
| 716 | + | }, | |
| 717 | + | }, | |
| 718 | + | { | |
| 719 | + | name: "key action", | |
| 720 | + | frame: frameInput([]byte("\x1b ")), | |
| 721 | + | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 722 | + | require.True(t, ac.overlays.paletteActive()) | |
| 723 | + | }, | |
| 724 | + | }, | |
| 725 | + | { | |
| 726 | + | name: "overlay mutation", | |
| 727 | + | setup: func(d *Daemon, sess *session, ac *attachedClient, _ *transactionalResizePTY) { | |
| 728 | + | d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil }) | |
| 729 | + | }, | |
| 730 | + | frame: frameInput([]byte("x")), | |
| 731 | + | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 732 | + | ac.overlays.promptMu.Lock() | |
| 733 | + | defer ac.overlays.promptMu.Unlock() | |
| 734 | + | require.Equal(t, "x", ac.overlays.prompt.Value()) | |
| 735 | + | }, | |
| 736 | + | }, | |
| 737 | + | { | |
| 738 | + | name: "theme", | |
| 739 | + | frame: ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{ | |
| 740 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 741 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 742 | + | })}, | |
| 743 | + | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 744 | + | require.True(t, ac.getClientTheme().HasFG) | |
| 745 | + | }, | |
| 746 | + | }, | |
| 747 | + | { | |
| 748 | + | name: "ACK", | |
| 749 | + | setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) { | |
| 750 | + | ac.output.next = 3 | |
| 751 | + | }, | |
| 752 | + | frame: ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})}, | |
| 753 | + | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 754 | + | ac.sendMu.Lock() | |
| 755 | + | defer ac.sendMu.Unlock() | |
| 756 | + | require.Equal(t, uint64(1), ac.output.acked) | |
| 757 | + | }, | |
| 758 | + | }, | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | for _, tt := range tests { | |
| 762 | + | t.Run(tt.name, func(t *testing.T) { | |
| 763 | + | pty := &transactionalResizePTY{} | |
| 764 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 765 | + | oldTransport := newDatagramTestTransport() | |
| 766 | + | old.replaceTransport(oldTransport) | |
| 767 | + | old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 768 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 769 | + | token := sess.attachmentToken(old, oldTransport) | |
| 770 | + | token.lease = rc.attachmentLease(old) | |
| 771 | + | if tt.setup != nil { | |
| 772 | + | tt.setup(d, sess, old, pty) | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | admitted := make(chan struct{}) | |
| 776 | + | release := make(chan struct{}) | |
| 777 | + | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 778 | + | close(admitted) | |
| 779 | + | <-release | |
| 780 | + | } | |
| 781 | + | effectDone := make(chan struct{}) | |
| 782 | + | go func() { | |
| 783 | + | d.handleActiveClientFrame(token, tt.frame) | |
| 784 | + | close(effectDone) | |
| 785 | + | }() | |
| 786 | + | <-admitted | |
| 787 | + | ||
| 788 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 789 | + | next.initOverlays() | |
| 790 | + | transitionDone := make(chan error, 1) | |
| 791 | + | go func() { | |
| 792 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 793 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 794 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 795 | + | }) | |
| 796 | + | transitionDone <- err | |
| 797 | + | }() | |
| 798 | + | select { | |
| 799 | + | case err := <-transitionDone: | |
| 800 | + | t.Fatalf("transition overtook admitted effect: %v", err) | |
| 801 | + | case <-time.After(20 * time.Millisecond): | |
| 802 | + | } | |
| 803 | + | close(release) | |
| 804 | + | <-effectDone | |
| 805 | + | require.NoError(t, <-transitionDone) | |
| 806 | + | tt.assert(t, old, pty) | |
| 807 | + | }) | |
| 808 | + | } | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T) { | |
| 812 | + | d, sess, old, _, releases := newManualTabSession(t, 2) | |
| 813 | + | defer releaseAll(releases) | |
| 814 | + | sess.mu.Lock() | |
| 815 | + | sess.tabs[1].attention = true | |
| 816 | + | sess.tabs[1].attentionAt = time.Unix(1, 0) | |
| 817 | + | sess.mu.Unlock() | |
| 818 | + | ||
| 819 | + | oldTransport := old.transport() | |
| 820 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 821 | + | token := sess.attachmentToken(old, oldTransport) | |
| 822 | + | token.lease = rc.attachmentLease(old) | |
| 823 | + | old.publishRoleCapability(token) | |
| 824 | + | ||
| 825 | + | admissionEnded := make(chan struct{}) | |
| 826 | + | releaseAction := make(chan struct{}) | |
| 827 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 828 | + | if action != "jump-attention" { | |
| 829 | + | return | |
| 830 | + | } | |
| 831 | + | close(admissionEnded) | |
| 832 | + | <-releaseAction | |
| 833 | + | } | |
| 834 | + | actionDone := make(chan struct{}) | |
| 835 | + | go func() { | |
| 836 | + | d.handleActiveClientFrame(token, frameInput([]byte("\x1ba"))) | |
| 837 | + | close(actionDone) | |
| 838 | + | }() | |
| 839 | + | <-admissionEnded | |
| 840 | + | ||
| 841 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 842 | + | next.initOverlays() | |
| 843 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 844 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 845 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 846 | + | }) | |
| 847 | + | require.NoError(t, err) | |
| 848 | + | sess.mu.Lock() | |
| 849 | + | sess.active = 0 | |
| 850 | + | sess.mu.Unlock() | |
| 851 | + | close(releaseAction) | |
| 852 | + | <-actionDone | |
| 853 | + | ||
| 854 | + | require.Equal(t, 0, activeTabIndex(sess), "the replaced initiator mutated source focus after losing its role") | |
| 855 | + | d.deferAttachmentTransitionCleanups(result) | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |
| 859 | + | p1, release1 := newBlockingPTY(t) | |
| 860 | + | p2, release2 := newBlockingPTY(t) | |
| 861 | + | p3, release3 := newBlockingPTY(t) | |
| 862 | + | defer release1() | |
| 863 | + | defer release2() | |
| 864 | + | defer release3() | |
| 865 | + | d, source, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 866 | + | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 867 | + | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | |
| 868 | + | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | |
| 869 | + | }} | |
| 870 | + | target.tabs[1].attention = true | |
| 871 | + | target.tabs[1].attentionAt = time.Unix(1, 0) | |
| 872 | + | d.sessions[target.id] = target | |
| 873 | + | ||
| 874 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 875 | + | token := source.attachmentToken(ac, ac.transport()) | |
| 876 | + | token.lease = rc.attachmentLease(ac) | |
| 877 | + | ac.publishRoleCapability(token) | |
| 878 | + | ||
| 879 | + | d.handleActiveClientFrame(token, frameInput([]byte("\x1ba"))) | |
| 880 | + | ||
| 881 | + | require.Same(t, target, ac.currentSession()) | |
| 882 | + | require.Equal(t, 1, activeTabIndex(target)) | |
| 883 | + | } | |
| 884 | + | ||
| 885 | + | func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T) { | |
| 886 | + | p1, release1 := newBlockingPTY(t) | |
| 887 | + | p2, release2 := newBlockingPTY(t) | |
| 888 | + | p3, release3 := newBlockingPTY(t) | |
| 889 | + | defer release1() | |
| 890 | + | defer release2() | |
| 891 | + | defer release3() | |
| 892 | + | d, source, old, _ := newManualSessionWithPTYs(t, p1) | |
| 893 | + | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 894 | + | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | |
| 895 | + | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | |
| 896 | + | }} | |
| 897 | + | target.tabs[1].attention = true | |
| 898 | + | target.tabs[1].attentionAt = time.Unix(1, 0) | |
| 899 | + | d.sessions[target.id] = target | |
| 900 | + | ||
| 901 | + | oldTransport := old.transport() | |
| 902 | + | rc := d.attachCoordinator(source, nil, old, true) | |
| 903 | + | token := source.attachmentToken(old, oldTransport) | |
| 904 | + | token.lease = rc.attachmentLease(old) | |
| 905 | + | old.publishRoleCapability(token) | |
| 906 | + | ||
| 907 | + | admissionEnded := make(chan struct{}) | |
| 908 | + | releaseAction := make(chan struct{}) | |
| 909 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 910 | + | if action == "jump-attention" { | |
| 911 | + | close(admissionEnded) | |
| 912 | + | <-releaseAction | |
| 913 | + | } | |
| 914 | + | } | |
| 915 | + | actionDone := make(chan struct{}) | |
| 916 | + | go func() { | |
| 917 | + | d.handleActiveClientFrame(token, frameInput([]byte("\x1ba"))) | |
| 918 | + | close(actionDone) | |
| 919 | + | }() | |
| 920 | + | <-admissionEnded | |
| 921 | + | ||
| 922 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 923 | + | next.initOverlays() | |
| 924 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 925 | + | target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 926 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 927 | + | }) | |
| 928 | + | require.NoError(t, err) | |
| 929 | + | close(releaseAction) | |
| 930 | + | <-actionDone | |
| 931 | + | ||
| 932 | + | require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus") | |
| 933 | + | target.mu.Lock() | |
| 934 | + | targetClient := target.client | |
| 935 | + | target.mu.Unlock() | |
| 936 | + | require.Nil(t, targetClient, "stale handoff attached the replaced initiator") | |
| 937 | + | d.deferAttachmentTransitionCleanups(result) | |
| 938 | + | } | |
| 939 | + | ||
| 940 | + | func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) { | |
| 941 | + | p, releasePTY := newBlockingPTY(t) | |
| 942 | + | defer releasePTY() | |
| 943 | + | d, sess, old, _ := newManualSessionWithPTYs(t, p) | |
| 944 | + | d.enterPicker(sess, old) | |
| 945 | + | ||
| 946 | + | oldTransport := old.transport() | |
| 947 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 948 | + | token := sess.attachmentToken(old, oldTransport) | |
| 949 | + | token.lease = rc.attachmentLease(old) | |
| 950 | + | old.publishRoleCapability(token) | |
| 951 | + | ||
| 952 | + | admissionEnded := make(chan struct{}) | |
| 953 | + | releaseAction := make(chan struct{}) | |
| 954 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 955 | + | if action != "picker-delete" { | |
| 956 | + | return | |
| 957 | + | } | |
| 958 | + | close(admissionEnded) | |
| 959 | + | <-releaseAction | |
| 960 | + | } | |
| 961 | + | actionDone := make(chan struct{}) | |
| 962 | + | go func() { | |
| 963 | + | d.handleActiveClientFrame(token, frameInput([]byte("x"))) | |
| 964 | + | close(actionDone) | |
| 965 | + | }() | |
| 966 | + | <-admissionEnded | |
| 967 | + | ||
| 968 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 969 | + | next.initOverlays() | |
| 970 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 971 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 972 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 973 | + | }) | |
| 974 | + | require.NoError(t, err) | |
| 975 | + | close(releaseAction) | |
| 976 | + | <-actionDone | |
| 977 | + | ||
| 978 | + | d.mu.Lock() | |
| 979 | + | registered := d.sessions[sess.id] | |
| 980 | + | d.mu.Unlock() | |
| 981 | + | require.Same(t, sess, registered, "the replaced initiator deleted its former source session") | |
| 982 | + | d.deferAttachmentTransitionCleanups(result) | |
| 983 | + | } | |
| 984 | + | ||
| 985 | + | func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testing.T) { | |
| 986 | + | p1, release1 := newBlockingPTY(t) | |
| 987 | + | p2, release2 := newBlockingPTY(t) | |
| 988 | + | defer release1() | |
| 989 | + | defer release2() | |
| 990 | + | d, source, old, _ := newManualSessionWithPTYs(t, p1) | |
| 991 | + | source.name = "source" | |
| 992 | + | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} | |
| 993 | + | d.sessions[target.id] = target | |
| 994 | + | d.enterPicker(source, old) | |
| 995 | + | old.overlays.pickerMu.Lock() | |
| 996 | + | for { | |
| 997 | + | selected, ok := old.overlays.picker.Selected() | |
| 998 | + | if ok && selected.Session == target.id { | |
| 999 | + | break | |
| 1000 | + | } | |
| 1001 | + | old.overlays.picker.Down() | |
| 1002 | + | } | |
| 1003 | + | old.overlays.pickerMu.Unlock() | |
| 1004 | + | ||
| 1005 | + | oldTransport := old.transport() | |
| 1006 | + | rc := d.attachCoordinator(source, nil, old, true) | |
| 1007 | + | token := source.attachmentToken(old, oldTransport) | |
| 1008 | + | token.lease = rc.attachmentLease(old) | |
| 1009 | + | old.publishRoleCapability(token) | |
| 1010 | + | ||
| 1011 | + | admissionEnded := make(chan struct{}) | |
| 1012 | + | releaseAction := make(chan struct{}) | |
| 1013 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 1014 | + | if action == "picker-delete" { | |
| 1015 | + | close(admissionEnded) | |
| 1016 | + | <-releaseAction | |
| 1017 | + | } | |
| 1018 | + | } | |
| 1019 | + | actionDone := make(chan struct{}) | |
| 1020 | + | go func() { | |
| 1021 | + | d.handleActiveClientFrame(token, frameInput([]byte("x"))) | |
| 1022 | + | close(actionDone) | |
| 1023 | + | }() | |
| 1024 | + | <-admissionEnded | |
| 1025 | + | ||
| 1026 | + | d.mu.Lock() | |
| 1027 | + | _, targetStillRegistered := d.sessions[target.id] | |
| 1028 | + | d.mu.Unlock() | |
| 1029 | + | require.True(t, targetStillRegistered, "picker deletion mutated the target before source-token preflight") | |
| 1030 | + | ||
| 1031 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1032 | + | next.initOverlays() | |
| 1033 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1034 | + | target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1035 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1036 | + | }) | |
| 1037 | + | require.NoError(t, err) | |
| 1038 | + | close(releaseAction) | |
| 1039 | + | <-actionDone | |
| 1040 | + | d.mu.Lock() | |
| 1041 | + | _, targetStillRegistered = d.sessions[target.id] | |
| 1042 | + | d.mu.Unlock() | |
| 1043 | + | require.True(t, targetStillRegistered, "stale picker deletion mutated the target session") | |
| 1044 | + | d.deferAttachmentTransitionCleanups(result) | |
| 1045 | + | } | |
| 1046 | + | ||
| 1047 | + | func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) { | |
| 1048 | + | p, release := newBlockingPTY(t) | |
| 1049 | + | defer release() | |
| 1050 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1051 | + | d.enterPicker(sess, ac) | |
| 1052 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 1053 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 1054 | + | token.lease = rc.attachmentLease(ac) | |
| 1055 | + | ac.publishRoleCapability(token) | |
| 1056 | + | ||
| 1057 | + | d.handleActiveClientFrame(token, frameInput([]byte("x"))) | |
| 1058 | + | ||
| 1059 | + | d.mu.Lock() | |
| 1060 | + | _, registered := d.sessions[sess.id] | |
| 1061 | + | d.mu.Unlock() | |
| 1062 | + | require.False(t, registered) | |
| 1063 | + | } | |
| 1064 | + | ||
| 1065 | + | func TestDelayedKeyCallbackAdmittedBeforeReplacementCompletesFirst(t *testing.T) { | |
| 1066 | + | writes := make(chan []byte, 1) | |
| 1067 | + | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 1068 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 1069 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 1070 | + | d.clock = clock | |
| 1071 | + | oldTransport := newDatagramTestTransport() | |
| 1072 | + | old.replaceTransport(oldTransport) | |
| 1073 | + | old.keys = keys.NewRouter(clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 1074 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 1075 | + | token := sess.attachmentToken(old, oldTransport) | |
| 1076 | + | token.lease = rc.attachmentLease(old) | |
| 1077 | + | old.publishRoleCapability(token) | |
| 1078 | + | ||
| 1079 | + | d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC})) | |
| 1080 | + | timer := <-clock.timers | |
| 1081 | + | admitted := make(chan struct{}) | |
| 1082 | + | release := make(chan struct{}) | |
| 1083 | + | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 1084 | + | close(admitted) | |
| 1085 | + | <-release | |
| 1086 | + | } | |
| 1087 | + | timer.ch <- time.Time{} | |
| 1088 | + | select { | |
| 1089 | + | case <-admitted: | |
| 1090 | + | case <-time.After(time.Second): | |
| 1091 | + | t.Fatal("delayed key callback did not acquire a fresh role-effect ticket") | |
| 1092 | + | } | |
| 1093 | + | ||
| 1094 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1095 | + | next.initOverlays() | |
| 1096 | + | transitionDone := make(chan error, 1) | |
| 1097 | + | go func() { | |
| 1098 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1099 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1100 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1101 | + | }) | |
| 1102 | + | transitionDone <- err | |
| 1103 | + | }() | |
| 1104 | + | select { | |
| 1105 | + | case err := <-transitionDone: | |
| 1106 | + | t.Fatalf("replacement overtook admitted delayed key callback: %v", err) | |
| 1107 | + | case <-time.After(20 * time.Millisecond): | |
| 1108 | + | } | |
| 1109 | + | close(release) | |
| 1110 | + | require.Equal(t, []byte{keys.ESC}, <-writes) | |
| 1111 | + | require.NoError(t, <-transitionDone) | |
| 1112 | + | } | |
| 1113 | + | ||
| 1114 | + | func TestDelayedKeyCallbackAfterReplacementDropsStaleWork(t *testing.T) { | |
| 1115 | + | writes := make(chan []byte, 1) | |
| 1116 | + | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 1117 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 1118 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 1119 | + | d.clock = clock | |
| 1120 | + | oldTransport := newDatagramTestTransport() | |
| 1121 | + | old.replaceTransport(oldTransport) | |
| 1122 | + | old.keys = keys.NewRouter(clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 1123 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 1124 | + | token := sess.attachmentToken(old, oldTransport) | |
| 1125 | + | token.lease = rc.attachmentLease(old) | |
| 1126 | + | old.publishRoleCapability(token) | |
| 1127 | + | ||
| 1128 | + | d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC})) | |
| 1129 | + | timer := <-clock.timers | |
| 1130 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1131 | + | next.initOverlays() | |
| 1132 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1133 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1134 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1135 | + | }) | |
| 1136 | + | require.NoError(t, err) | |
| 1137 | + | ||
| 1138 | + | attempted := make(chan bool, 1) | |
| 1139 | + | d.afterDelayedKeyEffectAttempt = func(admitted bool) { attempted <- admitted } | |
| 1140 | + | timer.ch <- time.Time{} | |
| 1141 | + | require.False(t, <-attempted, "stale delayed callback acquired authority after replacement") | |
| 1142 | + | select { | |
| 1143 | + | case got := <-writes: | |
| 1144 | + | t.Fatalf("stale delayed key callback reached the PTY: %q", got) | |
| 1145 | + | default: | |
| 1146 | + | } | |
| 1147 | + | } | |
| 1148 | + | ||
| 1149 | + | func TestRoleEffectGateAdmittedSnatchedPanelFinishesBeforePromotion(t *testing.T) { | |
| 1150 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1151 | + | oldTransport := newDatagramTestTransport() | |
| 1152 | + | old.replaceTransport(oldTransport) | |
| 1153 | + | d.attachCoordinator(sess, nil, old, true) | |
| 1154 | + | ||
| 1155 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1156 | + | next.initOverlays() | |
| 1157 | + | replaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1158 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1159 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1160 | + | }) | |
| 1161 | + | require.NoError(t, err) | |
| 1162 | + | token := replaced.displaced | |
| 1163 | + | ||
| 1164 | + | admitted := make(chan struct{}) | |
| 1165 | + | release := make(chan struct{}) | |
| 1166 | + | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 1167 | + | close(admitted) | |
| 1168 | + | <-release | |
| 1169 | + | } | |
| 1170 | + | effectDone := make(chan struct{}) | |
| 1171 | + | wantSize := domain.Size{Cols: 100, Rows: 30} | |
| 1172 | + | go func() { | |
| 1173 | + | d.handleSnatchedClientFrame(token, ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: wantSize})}) | |
| 1174 | + | close(effectDone) | |
| 1175 | + | }() | |
| 1176 | + | <-admitted | |
| 1177 | + | ||
| 1178 | + | transitionDone := make(chan error, 1) | |
| 1179 | + | go func() { | |
| 1180 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1181 | + | target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 1182 | + | expectedTransport: old.transportSnapshot(), ready: true, | |
| 1183 | + | }) | |
| 1184 | + | transitionDone <- err | |
| 1185 | + | }() | |
| 1186 | + | select { | |
| 1187 | + | case err := <-transitionDone: | |
| 1188 | + | t.Fatalf("promotion overtook admitted snatched panel: %v", err) | |
| 1189 | + | case <-time.After(20 * time.Millisecond): | |
| 1190 | + | } | |
| 1191 | + | close(release) | |
| 1192 | + | <-effectDone | |
| 1193 | + | require.NoError(t, <-transitionDone) | |
| 1194 | + | require.Equal(t, wantSize, old.size) | |
| 1195 | + | } | |
| 1196 | + | ||
| 1197 | + | func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) { | |
| 1198 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1199 | + | oldTransport := newDatagramTestTransport() | |
| 1200 | + | old.replaceTransport(oldTransport) | |
| 1201 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 1202 | + | token := sess.attachmentToken(old, oldTransport) | |
| 1203 | + | token.lease = rc.attachmentLease(old) | |
| 1204 | + | ||
| 1205 | + | admitted := make(chan struct{}) | |
| 1206 | + | release := make(chan struct{}) | |
| 1207 | + | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 1208 | + | close(admitted) | |
| 1209 | + | <-release | |
| 1210 | + | } | |
| 1211 | + | paintDone := make(chan bool, 1) | |
| 1212 | + | go func() { paintDone <- d.firstPaintForTransition(token) }() | |
| 1213 | + | <-admitted | |
| 1214 | + | ||
| 1215 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1216 | + | next.initOverlays() | |
| 1217 | + | transitionDone := make(chan error, 1) | |
| 1218 | + | go func() { | |
| 1219 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1220 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1221 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1222 | + | }) | |
| 1223 | + | transitionDone <- err | |
| 1224 | + | }() | |
| 1225 | + | select { | |
| 1226 | + | case err := <-transitionDone: | |
| 1227 | + | t.Fatalf("replacement overtook admitted first paint: %v", err) | |
| 1228 | + | case <-time.After(20 * time.Millisecond): | |
| 1229 | + | } | |
| 1230 | + | close(release) | |
| 1231 | + | require.True(t, <-paintDone) | |
| 1232 | + | require.NoError(t, <-transitionDone) | |
| 1233 | + | } | |
| 1234 | + | ||
| 1235 | + | func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) { | |
| 1236 | + | d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1237 | + | aTransport := newDatagramTestTransport() | |
| 1238 | + | a.replaceTransport(aTransport) | |
| 1239 | + | d.attachCoordinator(first, nil, a, true) | |
| 1240 | + | first.attachmentToken(a, aTransport) | |
| 1241 | + | ||
| 1242 | + | bTransport := newDatagramTestTransport() | |
| 1243 | + | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | |
| 1244 | + | b.initOverlays() | |
| 1245 | + | second := &session{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})} | |
| 1246 | + | b.setSession(second) | |
| 1247 | + | d.mu.Lock() | |
| 1248 | + | d.sessions[second.id] = second | |
| 1249 | + | d.mu.Unlock() | |
| 1250 | + | d.attachCoordinator(second, nil, b, true) | |
| 1251 | + | second.attachmentToken(b, bTransport) | |
| 1252 | + | ||
| 1253 | + | start := make(chan struct{}) | |
| 1254 | + | results := make(chan error, 2) | |
| 1255 | + | go func() { | |
| 1256 | + | <-start | |
| 1257 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1258 | + | source: first, target: second, next: a, | |
| 1259 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1260 | + | expectedTransport: a.transportSnapshot(), ready: true, | |
| 1261 | + | }) | |
| 1262 | + | results <- err | |
| 1263 | + | }() | |
| 1264 | + | go func() { | |
| 1265 | + | <-start | |
| 1266 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1267 | + | source: second, target: first, next: b, | |
| 1268 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1269 | + | expectedTransport: b.transportSnapshot(), ready: true, | |
| 1270 | + | }) | |
| 1271 | + | results <- err | |
| 1272 | + | }() | |
| 1273 | + | close(start) | |
| 1274 | + | ||
| 1275 | + | errs := make([]error, 0, 2) | |
| 1276 | + | for range 2 { | |
| 1277 | + | select { | |
| 1278 | + | case err := <-results: | |
| 1279 | + | errs = append(errs, err) | |
| 1280 | + | case <-time.After(time.Second): | |
| 1281 | + | t.Fatal("reversed transitions deadlocked") | |
| 1282 | + | } | |
| 1283 | + | } | |
| 1284 | + | successes := 0 | |
| 1285 | + | for _, err := range errs { | |
| 1286 | + | if err == nil { | |
| 1287 | + | successes++ | |
| 1288 | + | } | |
| 1289 | + | } | |
| 1290 | + | require.Equal(t, 1, successes) | |
| 1291 | + | } | |
| 1292 | + | ||
| 1293 | + | func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) { | |
| 1294 | + | d, source, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1295 | + | aTransport := newDatagramTestTransport() | |
| 1296 | + | a.replaceTransport(aTransport) | |
| 1297 | + | aCoordinator := d.attachCoordinator(source, nil, a, true) | |
| 1298 | + | ||
| 1299 | + | bTransport := newDatagramTestTransport() | |
| 1300 | + | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | |
| 1301 | + | b.initOverlays() | |
| 1302 | + | target := &session{ | |
| 1303 | + | id: "picker-delete-target", name: "picker-delete-target", ctx: source.ctx, cancel: func() {}, | |
| 1304 | + | client: b, snatched: make(map[*attachedClient]struct{}), | |
| 1305 | + | } | |
| 1306 | + | b.setSession(target) | |
| 1307 | + | d.mu.Lock() | |
| 1308 | + | d.sessions[target.id] = target | |
| 1309 | + | d.mu.Unlock() | |
| 1310 | + | d.attachCoordinator(target, nil, b, true) | |
| 1311 | + | ||
| 1312 | + | // Give B the earlier immutable identity. The reverse transition therefore | |
| 1313 | + | // freezes B then A, while the old picker-delete path froze A separately | |
| 1314 | + | // before discovering and freezing B. | |
| 1315 | + | b.roleEffects.immutableOrder() | |
| 1316 | + | a.roleEffects.immutableOrder() | |
| 1317 | + | require.Less(t, b.roleEffects.order.Load(), a.roleEffects.order.Load()) | |
| 1318 | + | ||
| 1319 | + | token := source.attachmentToken(a, aTransport) | |
| 1320 | + | token.lease = aCoordinator.attachmentLease(a) | |
| 1321 | + | a.publishRoleCapability(token) | |
| 1322 | + | effect, admitted := a.beginRoleEffect(token) | |
| 1323 | + | require.True(t, admitted) | |
| 1324 | + | token = effect.roleToken() | |
| 1325 | + | ||
| 1326 | + | deleteDiscovered := make(chan []*attachedClient, 1) | |
| 1327 | + | transitionDiscovered := make(chan []*attachedClient, 1) | |
| 1328 | + | releaseDelete := make(chan struct{}) | |
| 1329 | + | releaseTransition := make(chan struct{}) | |
| 1330 | + | transitionBFrozen := make(chan struct{}) | |
| 1331 | + | deleteFrozen := make(chan *attachedClient, 2) | |
| 1332 | + | d.afterRoleEffectParticipantsSnapshotted = func(action string, participants []*attachedClient) { | |
| 1333 | + | snapshot := append([]*attachedClient(nil), participants...) | |
| 1334 | + | switch action { | |
| 1335 | + | case "picker-delete": | |
| 1336 | + | deleteDiscovered <- snapshot | |
| 1337 | + | <-releaseDelete | |
| 1338 | + | case "reverse-delete-transition": | |
| 1339 | + | transitionDiscovered <- snapshot | |
| 1340 | + | <-releaseTransition | |
| 1341 | + | } | |
| 1342 | + | } | |
| 1343 | + | d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) { | |
| 1344 | + | switch action { | |
| 1345 | + | case "reverse-delete-transition": | |
| 1346 | + | if ac == b { | |
| 1347 | + | close(transitionBFrozen) | |
| 1348 | + | } | |
| 1349 | + | case "picker-delete": | |
| 1350 | + | deleteFrozen <- ac | |
| 1351 | + | } | |
| 1352 | + | } | |
| 1353 | + | ||
| 1354 | + | deleteDone := make(chan error, 1) | |
| 1355 | + | go func() { | |
| 1356 | + | deleteDone <- d.killPickerTargetForRole(picker.Target{Session: target.id}, token) | |
| 1357 | + | }() | |
| 1358 | + | deleteParticipants := <-deleteDiscovered | |
| 1359 | + | require.ElementsMatch(t, []*attachedClient{a, b}, deleteParticipants) | |
| 1360 | + | ||
| 1361 | + | transitionDone := make(chan error, 1) | |
| 1362 | + | go func() { | |
| 1363 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1364 | + | source: target, target: source, next: b, | |
| 1365 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1366 | + | expectedTransport: b.transportSnapshot(), action: "reverse-delete-transition", ready: true, | |
| 1367 | + | }) | |
| 1368 | + | if err == nil { | |
| 1369 | + | d.deferAttachmentTransitionCleanups(result) | |
| 1370 | + | } | |
| 1371 | + | transitionDone <- err | |
| 1372 | + | }() | |
| 1373 | + | transitionParticipants := <-transitionDiscovered | |
| 1374 | + | require.ElementsMatch(t, []*attachedClient{a, b}, transitionParticipants) | |
| 1375 | + | ||
| 1376 | + | close(releaseTransition) | |
| 1377 | + | select { | |
| 1378 | + | case <-transitionBFrozen: | |
| 1379 | + | case <-time.After(time.Second): | |
| 1380 | + | t.Fatal("reverse transition did not freeze B first") | |
| 1381 | + | } | |
| 1382 | + | close(releaseDelete) | |
| 1383 | + | ||
| 1384 | + | select { | |
| 1385 | + | case err := <-transitionDone: | |
| 1386 | + | require.NoError(t, err) | |
| 1387 | + | case <-time.After(time.Second): | |
| 1388 | + | t.Fatal("reverse transition deadlocked with picker deletion") | |
| 1389 | + | } | |
| 1390 | + | select { | |
| 1391 | + | case err := <-deleteDone: | |
| 1392 | + | require.NoError(t, err) | |
| 1393 | + | case <-time.After(time.Second): | |
| 1394 | + | t.Fatal("picker deletion deadlocked with reverse transition") | |
| 1395 | + | } | |
| 1396 | + | frozenByDelete := make([]*attachedClient, 0, 2) | |
| 1397 | + | for range 2 { | |
| 1398 | + | select { | |
| 1399 | + | case ac := <-deleteFrozen: | |
| 1400 | + | frozenByDelete = append(frozenByDelete, ac) | |
| 1401 | + | case <-time.After(time.Second): | |
| 1402 | + | t.Fatal("picker deletion did not freeze every participant") | |
| 1403 | + | } | |
| 1404 | + | } | |
| 1405 | + | require.ElementsMatch(t, []*attachedClient{a, b}, frozenByDelete, "picker deletion must freeze each deduplicated participant exactly once") | |
| 1406 | + | require.Empty(t, deleteFrozen) | |
| 1407 | + | ||
| 1408 | + | d.mu.Lock() | |
| 1409 | + | registered := d.sessions[target.id] | |
| 1410 | + | d.mu.Unlock() | |
| 1411 | + | require.Same(t, target, registered, "stale picker deletion mutated its target lifecycle") | |
| 1412 | + | require.Equal(t, attachmentSnatched, source.attachmentRole(a)) | |
| 1413 | + | require.Equal(t, attachmentActive, source.attachmentRole(b)) | |
| 1414 | + | } | |
| 1415 | + | ||
| 1416 | + | func TestRoleEffectGateFrozenTransitionRejectsLateEffect(t *testing.T) { | |
| 1417 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1418 | + | oldTransport := newDatagramTestTransport() | |
| 1419 | + | old.replaceTransport(oldTransport) | |
| 1420 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 1421 | + | token := sess.attachmentToken(old, oldTransport) | |
| 1422 | + | token.lease = rc.attachmentLease(old) | |
| 1423 | + | old.publishRoleCapability(token) | |
| 1424 | + | ||
| 1425 | + | frozen := make(chan struct{}) | |
| 1426 | + | release := make(chan struct{}) | |
| 1427 | + | d.afterRoleEffectsFrozen = func() { | |
| 1428 | + | close(frozen) | |
| 1429 | + | <-release | |
| 1430 | + | } | |
| 1431 | + | ||
| 1432 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1433 | + | next.initOverlays() | |
| 1434 | + | done := make(chan error, 1) | |
| 1435 | + | go func() { | |
| 1436 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1437 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1438 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1439 | + | }) | |
| 1440 | + | done <- err | |
| 1441 | + | }() | |
| 1442 | + | <-frozen | |
| 1443 | + | ||
| 1444 | + | _, ok := old.beginRoleEffect(token) | |
| 1445 | + | require.False(t, ok, "a frozen capability must admit no new effect") | |
| 1446 | + | close(release) | |
| 1447 | + | require.NoError(t, <-done) | |
| 1448 | + | } | |
M internal/usecase/daemon/session.go +375 -55
| @@ -52,7 +52,7 @@ type session struct { | |||
| 52 | 52 | teardownWaiters uint | |
| 53 | 53 | teardownChanged chan struct{} | |
| 54 | 54 | lifecycleStopOnce sync.Once | |
| 55 | - | mu sync.Mutex // guards tabs, active, client, restoreDone, clipFiles, and clipboard queue state | |
| 55 | + | mu sync.Mutex // guards tabs, active, client, snatched, restoreDone, clipFiles, and clipboard queue state | |
| 56 | 56 | // restoreDone remains attached to a restored session after it is published in | |
| 57 | 57 | // the live registry, so racing attaches cannot bypass restoration completion. | |
| 58 | 58 | restoreDone chan struct{} | |
| @@ -60,6 +60,7 @@ type session struct { | |||
| 60 | 60 | tabs []*tab | |
| 61 | 61 | active int | |
| 62 | 62 | client *attachedClient | |
| 63 | + | snatched map[*attachedClient]struct{} | |
| 63 | 64 | clipboardQueue []clipboardForward | |
| 64 | 65 | clipboardWorkerRunning bool | |
| 65 | 66 | cwd string | |
| @@ -361,55 +362,95 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 361 | 362 | d.mu.Unlock() | |
| 362 | 363 | return errSessionNameInUse | |
| 363 | 364 | } | |
| 364 | - | // The caller owns d.mu. Make the entire ownership handoff atomic with | |
| 365 | - | // global routing so it cannot select a client between source removal and | |
| 366 | - | // destination publication. | |
| 367 | - | d.notices.routingMu.Lock() | |
| 368 | 365 | from.mu.Lock() | |
| 369 | 366 | cwd := from.cwd | |
| 370 | 367 | term := from.terminal | |
| 371 | 368 | env := copyEnvironment(from.env) | |
| 372 | 369 | if from.client != ac { | |
| 373 | 370 | from.mu.Unlock() | |
| 374 | - | d.notices.routingMu.Unlock() | |
| 375 | 371 | d.mu.Unlock() | |
| 376 | 372 | return errors.New("client detached") | |
| 377 | 373 | } | |
| 378 | 374 | from.mu.Unlock() | |
| 379 | 375 | ||
| 380 | 376 | newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env) | |
| 377 | + | d.mu.Unlock() | |
| 381 | 378 | if err != nil { | |
| 382 | - | d.notices.routingMu.Unlock() | |
| 383 | - | d.mu.Unlock() | |
| 384 | 379 | return err | |
| 385 | 380 | } | |
| 386 | - | from.mu.Lock() | |
| 387 | - | if from.client != ac { | |
| 388 | - | from.mu.Unlock() | |
| 389 | - | d.notices.routingMu.Unlock() | |
| 390 | - | d.mu.Unlock() | |
| 381 | + | // The newly created target is registered before the role transition, but | |
| 382 | + | // remains headless. Freeze/drain must occur with no daemon/routing/session | |
| 383 | + | // lock held; the transition revalidates both lifecycles before publication. | |
| 384 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 385 | + | source: from, | |
| 386 | + | target: newSess, | |
| 387 | + | next: ac, | |
| 388 | + | expectedRole: attachmentActive, | |
| 389 | + | targetRole: attachmentActive, | |
| 390 | + | expectedTransport: ac.transportSnapshot(), | |
| 391 | + | ready: true, | |
| 392 | + | }) | |
| 393 | + | if err != nil { | |
| 391 | 394 | _ = d.killSession(newSess, ports.ReasonSessionKilled, true) | |
| 392 | 395 | return errors.New("client detached") | |
| 393 | 396 | } | |
| 394 | - | from.client = nil | |
| 395 | - | ac.setSession(nil) | |
| 396 | - | from.mu.Unlock() | |
| 397 | - | ||
| 398 | - | // Prepare source invalidation, output rebase, and destination coordinator | |
| 399 | - | // identity before publishing the destination attachment. | |
| 400 | - | cleanups := d.handoffCoordinator(from, newSess, nil, ac) | |
| 401 | - | ac.setSession(newSess) | |
| 402 | - | newSess.mu.Lock() | |
| 403 | - | newSess.client = ac | |
| 404 | - | newSess.mu.Unlock() | |
| 405 | 397 | ||
| 406 | 398 | d.touchMRU(newSess) | |
| 407 | 399 | ac.recordPreviousSession(from) | |
| 408 | 400 | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | |
| 409 | - | d.notices.routingMu.Unlock() | |
| 410 | - | d.mu.Unlock() | |
| 411 | - | finishRenderLifecycleCleanups(cleanups) | |
| 412 | - | d.firstPaint(newSess, ac, sz) | |
| 401 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 402 | + | d.firstPaintForTransition(transition.published) | |
| 403 | + | return nil | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | // createSessionAndSwitchForRole creates no target until the initiating role | |
| 407 | + | // ticket has ended and the exact source capability is frozen and revalidated. | |
| 408 | + | func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name string) error { | |
| 409 | + | if name == "" { | |
| 410 | + | return errSessionNameRequired | |
| 411 | + | } | |
| 412 | + | if err := domain.ValidateSessionName(name); err != nil { | |
| 413 | + | return err | |
| 414 | + | } | |
| 415 | + | if token.sess == nil || token.ac == nil || token.effect == nil || token.role != attachmentActive { | |
| 416 | + | return errAttachmentTransition | |
| 417 | + | } | |
| 418 | + | token.effect.bindActionEnd(d, "create-session") | |
| 419 | + | token.effect.End() | |
| 420 | + | ||
| 421 | + | var created *session | |
| 422 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 423 | + | source: token.sess, next: token.ac, | |
| 424 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 425 | + | expectedTransport: token.transport, sourceToken: &token, action: "create-session", | |
| 426 | + | copySourceEnvironment: true, ready: true, | |
| 427 | + | createTargetLocked: func() (*session, error) { | |
| 428 | + | if d.closing { | |
| 429 | + | return nil, errors.New("daemon is shutting down") | |
| 430 | + | } | |
| 431 | + | if d.nameLiveOrStoppedLocked(name) { | |
| 432 | + | return nil, errSessionNameInUse | |
| 433 | + | } | |
| 434 | + | token.sess.mu.Lock() | |
| 435 | + | cwd, term, env := token.sess.cwd, token.sess.terminal, copyEnvironment(token.sess.env) | |
| 436 | + | token.sess.mu.Unlock() | |
| 437 | + | var createErr error | |
| 438 | + | created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env) | |
| 439 | + | return created, createErr | |
| 440 | + | }, | |
| 441 | + | }) | |
| 442 | + | if err != nil { | |
| 443 | + | if created != nil { | |
| 444 | + | _ = d.killSession(created, ports.ReasonSessionKilled, true) | |
| 445 | + | } | |
| 446 | + | return err | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | d.touchMRU(created) | |
| 450 | + | token.ac.recordPreviousSession(token.sess) | |
| 451 | + | d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable) | |
| 452 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 453 | + | d.firstPaintForTransition(transition.published) | |
| 413 | 454 | return nil | |
| 414 | 455 | } | |
| 415 | 456 | ||
| @@ -598,13 +639,79 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool { | |||
| 598 | 639 | return false | |
| 599 | 640 | } | |
| 600 | 641 | ||
| 601 | - | // detachIfCurrent serializes client removal with global notice routing. The | |
| 602 | - | // routing lock is released before the caller performs teardown, so it covers | |
| 603 | - | // only attachment ownership and is never held across transport work. | |
| 642 | + | // detachIfCurrent publishes terminal role invalidation through the attachment | |
| 643 | + | // gate. Freeze/drain happens before routing or session locks are acquired. | |
| 604 | 644 | func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool { | |
| 645 | + | if sess == nil || ac == nil { | |
| 646 | + | return false | |
| 647 | + | } | |
| 648 | + | frozen := freezeRoleEffectGates(ac) | |
| 649 | + | defer frozen.unfreeze() | |
| 650 | + | if d.afterDetachRoleEffectsFrozen != nil { | |
| 651 | + | d.afterDetachRoleEffectsFrozen() | |
| 652 | + | } | |
| 605 | 653 | d.notices.routingMu.Lock() | |
| 606 | - | defer d.notices.routingMu.Unlock() | |
| 607 | - | return sess.detachIfCurrent(ac) | |
| 654 | + | sess.mu.Lock() | |
| 655 | + | current := sess.client == ac && ac.currentSession() == sess | |
| 656 | + | if current { | |
| 657 | + | sess.client = nil | |
| 658 | + | ac.roleGeneration.Add(1) | |
| 659 | + | ac.setSession(nil) | |
| 660 | + | ac.invalidateFrozenRoleCapability() | |
| 661 | + | } | |
| 662 | + | sess.mu.Unlock() | |
| 663 | + | d.notices.routingMu.Unlock() | |
| 664 | + | return current | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | // detachIfRoleCurrent invalidates only the exact role generation and transport | |
| 668 | + | // whose admitted send failed. A queued stale error cannot detach a later role | |
| 669 | + | // publication or a rebound transport on the same attachment object. | |
| 670 | + | func (d *Daemon) detachIfRoleCurrent(token attachmentRoleToken) bool { | |
| 671 | + | return d.detachIfRoleCurrentUntil(token, nil) | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() <-chan struct{}) bool { | |
| 675 | + | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil { | |
| 676 | + | return false | |
| 677 | + | } | |
| 678 | + | frozen := freezeRoleEffectGatesInterruptingObservedUntil(nil, done, nil, token.ac) | |
| 679 | + | defer frozen.unfreeze() | |
| 680 | + | if !frozen.acquired || !frozen.drained { | |
| 681 | + | return false | |
| 682 | + | } | |
| 683 | + | if d.afterDetachRoleEffectsFrozen != nil { | |
| 684 | + | d.afterDetachRoleEffectsFrozen() | |
| 685 | + | } | |
| 686 | + | d.mu.Lock() | |
| 687 | + | if d.sessions[token.sess.id] != token.sess { | |
| 688 | + | d.mu.Unlock() | |
| 689 | + | return false | |
| 690 | + | } | |
| 691 | + | d.notices.routingMu.Lock() | |
| 692 | + | token.sess.mu.Lock() | |
| 693 | + | coordinator := token.sess.renderCoordinator() | |
| 694 | + | if coordinator != nil { | |
| 695 | + | coordinator.mu.Lock() | |
| 696 | + | } | |
| 697 | + | req := attachmentTransitionRequest{ | |
| 698 | + | source: token.sess, next: token.ac, expectedRole: token.role, | |
| 699 | + | expectedTransport: token.transport, | |
| 700 | + | } | |
| 701 | + | current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 702 | + | if current { | |
| 703 | + | token.sess.client = nil | |
| 704 | + | token.ac.roleGeneration.Add(1) | |
| 705 | + | token.ac.setSession(nil) | |
| 706 | + | token.ac.invalidateFrozenRoleCapability() | |
| 707 | + | } | |
| 708 | + | if coordinator != nil { | |
| 709 | + | coordinator.mu.Unlock() | |
| 710 | + | } | |
| 711 | + | token.sess.mu.Unlock() | |
| 712 | + | d.notices.routingMu.Unlock() | |
| 713 | + | d.mu.Unlock() | |
| 714 | + | return current | |
| 608 | 715 | } | |
| 609 | 716 | ||
| 610 | 717 | func (s *session) activeTab() *tab { | |
| @@ -921,7 +1028,145 @@ func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) erro | |||
| 921 | 1028 | } | |
| 922 | 1029 | ||
| 923 | 1030 | func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |
| 924 | - | return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil) | |
| 1031 | + | return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, nil) | |
| 1032 | + | } | |
| 1033 | + | ||
| 1034 | + | type sessionKillAdmission struct { | |
| 1035 | + | token attachmentRoleToken | |
| 1036 | + | action string | |
| 1037 | + | } | |
| 1038 | + | ||
| 1039 | + | type sessionKillParticipants struct { | |
| 1040 | + | active *attachedClient | |
| 1041 | + | snatched map[*attachedClient]struct{} | |
| 1042 | + | transports map[*attachedClient]transportSnapshot | |
| 1043 | + | roleGates []*attachedClient | |
| 1044 | + | interrupts []roleTransportInterrupt | |
| 1045 | + | source *session | |
| 1046 | + | sourceToken *attachmentRoleToken | |
| 1047 | + | target *session | |
| 1048 | + | } | |
| 1049 | + | ||
| 1050 | + | func (d *Daemon) killSessionForRole(sess *session, reason uint8, purge bool, token attachmentRoleToken, action string) error { | |
| 1051 | + | return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action}) | |
| 1052 | + | } | |
| 1053 | + | ||
| 1054 | + | // snapshotSessionKillParticipants captures role membership under the canonical | |
| 1055 | + | // architecture locks, but releases every lock before any role gate is frozen or | |
| 1056 | + | // drained. The initiator is part of the same deduplicated global gate set even | |
| 1057 | + | // when it belongs to a different source session. | |
| 1058 | + | func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) { | |
| 1059 | + | snapshot := sessionKillParticipants{target: target} | |
| 1060 | + | if admission != nil { | |
| 1061 | + | token := admission.token | |
| 1062 | + | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil { | |
| 1063 | + | return sessionKillParticipants{}, false | |
| 1064 | + | } | |
| 1065 | + | snapshot.source = token.sess | |
| 1066 | + | snapshot.sourceToken = &admission.token | |
| 1067 | + | } | |
| 1068 | + | ||
| 1069 | + | d.mu.Lock() | |
| 1070 | + | if d.sessions[target.id] != target || snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source { | |
| 1071 | + | d.mu.Unlock() | |
| 1072 | + | return sessionKillParticipants{}, false | |
| 1073 | + | } | |
| 1074 | + | d.notices.routingMu.Lock() | |
| 1075 | + | source := snapshot.source | |
| 1076 | + | if source == nil { | |
| 1077 | + | source = target | |
| 1078 | + | } | |
| 1079 | + | unlockSessions := lockAttachmentSessions(source, target) | |
| 1080 | + | snapshot.active = target.client | |
| 1081 | + | snapshot.snatched = make(map[*attachedClient]struct{}, len(target.snatched)) | |
| 1082 | + | snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.snatched)) | |
| 1083 | + | if snapshot.active != nil { | |
| 1084 | + | snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot() | |
| 1085 | + | } | |
| 1086 | + | for ac := range target.snatched { | |
| 1087 | + | snapshot.snatched[ac] = struct{}{} | |
| 1088 | + | snapshot.transports[ac] = ac.transportSnapshot() | |
| 1089 | + | } | |
| 1090 | + | unlockSessions() | |
| 1091 | + | d.notices.routingMu.Unlock() | |
| 1092 | + | d.mu.Unlock() | |
| 1093 | + | ||
| 1094 | + | seen := make(map[*attachedClient]struct{}, 2+len(snapshot.snatched)) | |
| 1095 | + | add := func(ac *attachedClient) { | |
| 1096 | + | if ac == nil { | |
| 1097 | + | return | |
| 1098 | + | } | |
| 1099 | + | if _, duplicate := seen[ac]; duplicate { | |
| 1100 | + | return | |
| 1101 | + | } | |
| 1102 | + | seen[ac] = struct{}{} | |
| 1103 | + | snapshot.roleGates = append(snapshot.roleGates, ac) | |
| 1104 | + | } | |
| 1105 | + | if admission != nil { | |
| 1106 | + | add(admission.token.ac) | |
| 1107 | + | } | |
| 1108 | + | add(snapshot.active) | |
| 1109 | + | for ac := range snapshot.snatched { | |
| 1110 | + | add(ac) | |
| 1111 | + | } | |
| 1112 | + | for ac, transport := range snapshot.transports { | |
| 1113 | + | if transport.transport != nil { | |
| 1114 | + | snapshot.interrupts = append(snapshot.interrupts, roleTransportInterrupt{ac: ac, transport: transport}) | |
| 1115 | + | } | |
| 1116 | + | } | |
| 1117 | + | return snapshot, true | |
| 1118 | + | } | |
| 1119 | + | ||
| 1120 | + | // sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact | |
| 1121 | + | // target-membership validation while every discovered role gate is frozen. | |
| 1122 | + | func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool { | |
| 1123 | + | d.mu.Lock() | |
| 1124 | + | defer d.mu.Unlock() | |
| 1125 | + | if d.sessions[snapshot.target.id] != snapshot.target || | |
| 1126 | + | snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source { | |
| 1127 | + | return false | |
| 1128 | + | } | |
| 1129 | + | d.notices.routingMu.Lock() | |
| 1130 | + | defer d.notices.routingMu.Unlock() | |
| 1131 | + | source := snapshot.source | |
| 1132 | + | if source == nil { | |
| 1133 | + | source = snapshot.target | |
| 1134 | + | } | |
| 1135 | + | sourceCoordinator := source.renderCoordinator() | |
| 1136 | + | targetCoordinator := snapshot.target.renderCoordinator() | |
| 1137 | + | unlockSessions := lockAttachmentSessions(source, snapshot.target) | |
| 1138 | + | defer unlockSessions() | |
| 1139 | + | unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, snapshot.target, targetCoordinator) | |
| 1140 | + | defer unlockCoordinators() | |
| 1141 | + | return sessionKillParticipantsCurrentLocked(snapshot, sourceCoordinator) | |
| 1142 | + | } | |
| 1143 | + | ||
| 1144 | + | // sessionKillParticipantsCurrentLocked requires the ordered source/target | |
| 1145 | + | // session and coordinator locks. | |
| 1146 | + | func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool { | |
| 1147 | + | target := snapshot.target | |
| 1148 | + | if target.client != snapshot.active || len(target.snatched) != len(snapshot.snatched) { | |
| 1149 | + | return false | |
| 1150 | + | } | |
| 1151 | + | for ac := range snapshot.snatched { | |
| 1152 | + | if _, current := target.snatched[ac]; !current { | |
| 1153 | + | return false | |
| 1154 | + | } | |
| 1155 | + | } | |
| 1156 | + | for ac, transport := range snapshot.transports { | |
| 1157 | + | if !ac.transportSnapshotCurrent(transport) { | |
| 1158 | + | return false | |
| 1159 | + | } | |
| 1160 | + | } | |
| 1161 | + | if snapshot.sourceToken == nil { | |
| 1162 | + | return true | |
| 1163 | + | } | |
| 1164 | + | token := *snapshot.sourceToken | |
| 1165 | + | req := attachmentTransitionRequest{ | |
| 1166 | + | source: token.sess, next: token.ac, expectedRole: token.role, | |
| 1167 | + | expectedTransport: token.transport, | |
| 1168 | + | } | |
| 1169 | + | return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req) | |
| 925 | 1170 | } | |
| 926 | 1171 | ||
| 927 | 1172 | // stopInMemoryLifecycle cancels every session producer and closes every PTY | |
| @@ -1023,10 +1268,52 @@ func (s *session) finishTeardown() { | |||
| 1023 | 1268 | // terminal checkpoint and coordinator join. A timed-out repository call keeps | |
| 1024 | 1269 | // only immutable capture state; it never observes closed worker channels or | |
| 1025 | 1270 | // session-owned cache that teardown has released. | |
| 1026 | - | func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline) error { | |
| 1271 | + | func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission) error { | |
| 1027 | 1272 | if sess == nil { | |
| 1028 | 1273 | return nil | |
| 1029 | 1274 | } | |
| 1275 | + | // Discover every role whose membership can be invalidated before freezing | |
| 1276 | + | // any one of them. The common gate helper deduplicates and freezes the whole | |
| 1277 | + | // immutable identity set in the same global order used by transitions. | |
| 1278 | + | participants, ok := d.snapshotSessionKillParticipants(sess, admission) | |
| 1279 | + | if !ok { | |
| 1280 | + | return nil | |
| 1281 | + | } | |
| 1282 | + | if admission != nil { | |
| 1283 | + | d.endActionRoleEffect(admission.token.effect, admission.action) | |
| 1284 | + | } | |
| 1285 | + | action := "" | |
| 1286 | + | if admission != nil { | |
| 1287 | + | action = admission.action | |
| 1288 | + | } | |
| 1289 | + | if d.afterRoleEffectParticipantsSnapshotted != nil { | |
| 1290 | + | d.afterRoleEffectParticipantsSnapshotted(action, participants.roleGates) | |
| 1291 | + | } | |
| 1292 | + | var drainDone func() <-chan struct{} | |
| 1293 | + | var drainDeadline *roleEffectDrainDeadline | |
| 1294 | + | if deadline != nil { | |
| 1295 | + | drainDone = deadline.Done | |
| 1296 | + | } else { | |
| 1297 | + | drainDeadline = newRoleEffectDrainDeadline(d.clock) | |
| 1298 | + | drainDone = drainDeadline.Done | |
| 1299 | + | defer drainDeadline.stop() | |
| 1300 | + | } | |
| 1301 | + | frozen := freezeRoleEffectGatesInterruptingObservedUntil(participants.interrupts, drainDone, func(ac *attachedClient) { | |
| 1302 | + | if d.afterRoleEffectGateFrozen != nil { | |
| 1303 | + | d.afterRoleEffectGateFrozen(action, ac) | |
| 1304 | + | } | |
| 1305 | + | }, participants.roleGates...) | |
| 1306 | + | defer func() { frozen.unfreeze() }() | |
| 1307 | + | if !frozen.acquired { | |
| 1308 | + | // The acquisition helper has already rolled back exactly the gates it | |
| 1309 | + | // owned. Do not enter architecture locks or publish terminal ownership | |
| 1310 | + | // changes unless the complete canonical participant set is frozen. | |
| 1311 | + | return nil | |
| 1312 | + | } | |
| 1313 | + | if !d.sessionKillParticipantsCurrent(participants) { | |
| 1314 | + | return nil | |
| 1315 | + | } | |
| 1316 | + | ||
| 1030 | 1317 | // Pane EOF, explicit close, and daemon shutdown can all converge on the same | |
| 1031 | 1318 | // session. Only one owner may create the terminal mutation and checkpoint; | |
| 1032 | 1319 | // later callers observe the completed registry transition instead of | |
| @@ -1119,21 +1406,62 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1119 | 1406 | sess.snapshotMu.Unlock() | |
| 1120 | 1407 | } | |
| 1121 | 1408 | } | |
| 1409 | + | // Revalidate the exact source capability, target lifecycle, and active plus | |
| 1410 | + | // snatched memberships under canonical architecture locks immediately before | |
| 1411 | + | // publication. A changed preflight aborts without mutating either lifecycle. | |
| 1122 | 1412 | d.mu.Lock() | |
| 1123 | - | if _, ok := d.sessions[sess.id]; !ok { | |
| 1413 | + | if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source { | |
| 1124 | 1414 | d.mu.Unlock() | |
| 1125 | 1415 | return nil | |
| 1126 | 1416 | } | |
| 1417 | + | d.notices.routingMu.Lock() | |
| 1418 | + | source := participants.source | |
| 1419 | + | if source == nil { | |
| 1420 | + | source = sess | |
| 1421 | + | } | |
| 1422 | + | sourceCoordinator := source.renderCoordinator() | |
| 1423 | + | targetCoordinator := sess.renderCoordinator() | |
| 1424 | + | unlockSessions := lockAttachmentSessions(source, sess) | |
| 1425 | + | unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, sess, targetCoordinator) | |
| 1426 | + | if !sessionKillParticipantsCurrentLocked(participants, sourceCoordinator) { | |
| 1427 | + | unlockCoordinators() | |
| 1428 | + | unlockSessions() | |
| 1429 | + | d.notices.routingMu.Unlock() | |
| 1430 | + | d.mu.Unlock() | |
| 1431 | + | return nil | |
| 1432 | + | } | |
| 1433 | + | ||
| 1434 | + | // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held | |
| 1435 | + | // here. Registry removal and every role invalidation are one publication | |
| 1436 | + | // while the complete immutable gate set remains frozen. | |
| 1127 | 1437 | delete(d.sessions, sess.id) | |
| 1128 | 1438 | d.clearBarScriptsForSession(sess.id) | |
| 1129 | 1439 | d.purgeParkedForSessionLocked(sess) | |
| 1130 | - | sess.mu.Lock() | |
| 1440 | + | detachedActive := sess.client | |
| 1441 | + | attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates)) | |
| 1442 | + | capture := func(attached *attachedClient) { | |
| 1443 | + | if attached == nil { | |
| 1444 | + | return | |
| 1445 | + | } | |
| 1446 | + | attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]}) | |
| 1447 | + | attached.roleGeneration.Add(1) | |
| 1448 | + | attached.setSession(nil) | |
| 1449 | + | attached.invalidateFrozenRoleCapability() | |
| 1450 | + | } | |
| 1451 | + | capture(sess.client) | |
| 1452 | + | for snatched := range sess.snatched { | |
| 1453 | + | capture(snatched) | |
| 1454 | + | } | |
| 1455 | + | sess.client = nil | |
| 1456 | + | clear(sess.snatched) | |
| 1131 | 1457 | stoppedName := sess.name | |
| 1132 | 1458 | stoppedCwd := sess.cwd | |
| 1133 | 1459 | createdAt := sess.createdAt | |
| 1134 | 1460 | tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames | |
| 1135 | 1461 | ephemeral := sess.ephemeral | |
| 1136 | - | sess.mu.Unlock() | |
| 1462 | + | unlockCoordinators() | |
| 1463 | + | unlockSessions() | |
| 1464 | + | d.notices.routingMu.Unlock() | |
| 1137 | 1465 | if !ephemeral { | |
| 1138 | 1466 | stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge} | |
| 1139 | 1467 | d.stopped[stoppedName] = stopped | |
| @@ -1146,18 +1474,11 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1146 | 1474 | d.closing = true | |
| 1147 | 1475 | } | |
| 1148 | 1476 | d.mu.Unlock() | |
| 1477 | + | frozen.unfreeze() | |
| 1478 | + | frozen = frozenRoleEffectGates{} | |
| 1149 | 1479 | d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason) | |
| 1150 | 1480 | ||
| 1151 | 1481 | var purgeErr error | |
| 1152 | - | ||
| 1153 | - | // A global route that selected this attachment must publish before terminal | |
| 1154 | - | // teardown makes it stale. | |
| 1155 | - | d.notices.routingMu.Lock() | |
| 1156 | - | sess.mu.Lock() | |
| 1157 | - | ac := sess.client | |
| 1158 | - | sess.client = nil | |
| 1159 | - | sess.mu.Unlock() | |
| 1160 | - | d.notices.routingMu.Unlock() | |
| 1161 | 1482 | if rc := sess.renderCoordinator(); rc != nil { | |
| 1162 | 1483 | // Terminal teardown has two phases: this session owner first prevents any | |
| 1163 | 1484 | // new worker registration and detaches all tokens, then stops and waits | |
| @@ -1165,11 +1486,10 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1165 | 1486 | rc.beginSessionTeardown().finish() | |
| 1166 | 1487 | rc.waitForTimerWorkers() | |
| 1167 | 1488 | } | |
| 1168 | - | if ac != nil { | |
| 1169 | - | d.unregisterPreview(ac) | |
| 1170 | - | ac.clearPreviousSession() | |
| 1171 | - | ac.setSession(nil) | |
| 1172 | - | ac.clearCaptureFrames() | |
| 1489 | + | for _, attachment := range attachments { | |
| 1490 | + | d.unregisterPreview(attachment.ac) | |
| 1491 | + | attachment.ac.clearPreviousSession() | |
| 1492 | + | attachment.ac.clearCaptureFrames() | |
| 1173 | 1493 | } | |
| 1174 | 1494 | ||
| 1175 | 1495 | // Prevent queued launches from entering Open, cancel the parent context, and | |
| @@ -1183,7 +1503,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1183 | 1503 | sess.mu.Unlock() | |
| 1184 | 1504 | for _, tb := range tabs { | |
| 1185 | 1505 | d.clearDestroyedTabPreview(tb) | |
| 1186 | - | d.teardownFloating(tb, ac) | |
| 1506 | + | d.teardownFloating(tb, detachedActive) | |
| 1187 | 1507 | } | |
| 1188 | 1508 | for _, path := range clipFiles { | |
| 1189 | 1509 | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| @@ -1212,8 +1532,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1212 | 1532 | d.repaintAllAttachedClients() | |
| 1213 | 1533 | } | |
| 1214 | 1534 | ||
| 1215 | - | if ac != nil { | |
| 1216 | - | d.notifyDetachedAsync(ac, reason) | |
| 1535 | + | for _, attachment := range attachments { | |
| 1536 | + | d.notifyDetachedSnapshotAsync(attachment, reason) | |
| 1217 | 1537 | } | |
| 1218 | 1538 | return errors.Join(purgeErr, terminalSnapshotErr) | |
| 1219 | 1539 | } | |
M internal/usecase/daemon/session_back.go +12 -0
| @@ -4,6 +4,18 @@ import "github.com/bnema/vev/internal/usecase/picker" | |||
| 4 | 4 | ||
| 5 | 5 | // backSession toggles this attachment between its current session and the | |
| 6 | 6 | // immediately preceding successfully activated session. | |
| 7 | + | func (d *Daemon) backSessionForRole(token attachmentRoleToken) error { | |
| 8 | + | if d == nil || token.sess == nil || token.ac == nil { | |
| 9 | + | return nil | |
| 10 | + | } | |
| 11 | + | target := token.ac.previousSession.Get() | |
| 12 | + | if target == nil || target == token.sess || d.sessionByID(target.id) != target { | |
| 13 | + | token.ac.clearPreviousSessionIf(target) | |
| 14 | + | return nil | |
| 15 | + | } | |
| 16 | + | return d.switchToTargetForRole(token, picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "back-session") | |
| 17 | + | } | |
| 18 | + | ||
| 7 | 19 | func (d *Daemon) backSession(current *session, ac *attachedClient) { | |
| 8 | 20 | if d == nil || current == nil || ac == nil { | |
| 9 | 21 | return | |
M internal/usecase/daemon/session_test.go +9 -7
| @@ -283,7 +283,7 @@ func TestEphemeralNumberingReuse(t *testing.T) { | |||
| 283 | 283 | ||
| 284 | 284 | // --- attach replace --------------------------------------------------------- | |
| 285 | 285 | ||
| 286 | - | func TestAttachReplaceDetachesOld(t *testing.T) { | |
| 286 | + | func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) { | |
| 287 | 287 | p, releasePTY := newBlockingPTY(t) | |
| 288 | 288 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 289 | 289 | ||
| @@ -295,21 +295,23 @@ func TestAttachReplaceDetachesOld(t *testing.T) { | |||
| 295 | 295 | awaitFrame(t, sendsA, ports.MsgOutput) | |
| 296 | 296 | sess := firstSession(d) | |
| 297 | 297 | require.NotNil(t, sess) | |
| 298 | + | sess.mu.Lock() | |
| 299 | + | acA := sess.client | |
| 300 | + | sess.mu.Unlock() | |
| 301 | + | require.NotNil(t, acA) | |
| 298 | 302 | ||
| 299 | 303 | // Client B attaches to the same session, displacing A. | |
| 300 | 304 | trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24})) | |
| 301 | 305 | hg.Go(func() { d.handleConn(trB) }) | |
| 302 | 306 | awaitFrame(t, sendsB, ports.MsgWelcome) | |
| 303 | 307 | ||
| 304 | - | // A is notified it was detached. | |
| 305 | - | dA := awaitFrame(t, sendsA, ports.MsgDetached) | |
| 306 | - | det, _ := ports.UnmarshalDetached(dA.Payload) | |
| 307 | - | require.Equal(t, ports.ReasonReplaced, det.Reason) | |
| 308 | - | ||
| 309 | - | // B is now the current client. | |
| 308 | + | // B is now the sole active client while A remains a live snatched member. | |
| 310 | 309 | sess.mu.Lock() | |
| 311 | 310 | require.NotNil(t, sess.client) | |
| 311 | + | require.NotSame(t, acA, sess.client) | |
| 312 | + | require.Contains(t, sess.snatched, acA) | |
| 312 | 313 | sess.mu.Unlock() | |
| 314 | + | require.Same(t, sess, acA.currentSession()) | |
| 313 | 315 | ||
| 314 | 316 | releaseA() | |
| 315 | 317 | releaseB() | |
A internal/usecase/daemon/snatched_client.go +741 -0
| @@ -0,0 +1,741 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type attachmentRole uint8 | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | attachmentDetached attachmentRole = iota | |
| 13 | + | attachmentActive | |
| 14 | + | attachmentSnatched | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // attachmentRole derives the attachment's role from the session-owned | |
| 18 | + | // registries. There is deliberately no second role field that could drift. | |
| 19 | + | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | |
| 20 | + | if s == nil || ac == nil { | |
| 21 | + | return attachmentDetached | |
| 22 | + | } | |
| 23 | + | s.mu.Lock() | |
| 24 | + | defer s.mu.Unlock() | |
| 25 | + | return s.attachmentRoleLocked(ac) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // attachmentRoleLocked requires s.mu. Active membership wins if an invalid | |
| 29 | + | // intermediate state contains the same attachment in both registries. | |
| 30 | + | func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole { | |
| 31 | + | if ac == nil { | |
| 32 | + | return attachmentDetached | |
| 33 | + | } | |
| 34 | + | if s.client == ac { | |
| 35 | + | return attachmentActive | |
| 36 | + | } | |
| 37 | + | if _, ok := s.snatched[ac]; ok { | |
| 38 | + | return attachmentSnatched | |
| 39 | + | } | |
| 40 | + | return attachmentDetached | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (s *session) addSnatchedLocked(ac *attachedClient) { | |
| 44 | + | if ac == nil { | |
| 45 | + | return | |
| 46 | + | } | |
| 47 | + | if s.snatched == nil { | |
| 48 | + | s.snatched = make(map[*attachedClient]struct{}) | |
| 49 | + | } | |
| 50 | + | s.snatched[ac] = struct{}{} | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | var errAttachmentTransition = errors.New("attachment transition is no longer valid") | |
| 54 | + | ||
| 55 | + | type attachmentTransitionRequest struct { | |
| 56 | + | source *session | |
| 57 | + | target *session | |
| 58 | + | next *attachedClient | |
| 59 | + | expectedRole attachmentRole | |
| 60 | + | targetRole attachmentRole | |
| 61 | + | expectedTransport transportSnapshot | |
| 62 | + | sourceToken *attachmentRoleToken | |
| 63 | + | action string | |
| 64 | + | activateTargetTab bool | |
| 65 | + | targetTabIndex int | |
| 66 | + | copySourceEnvironment bool | |
| 67 | + | // preserveRole commits navigation state without replacing attachment | |
| 68 | + | // ownership. It is used for same-session picker selections after the exact | |
| 69 | + | // initiating capability has been frozen and revalidated. | |
| 70 | + | preserveRole bool | |
| 71 | + | expectedSourceTab *tab | |
| 72 | + | createTargetLocked func() (*session, error) | |
| 73 | + | ready bool | |
| 74 | + | expectedTargetCurrent *attachedClient | |
| 75 | + | expectedTargetTransport transportSnapshot | |
| 76 | + | preflighted bool | |
| 77 | + | roleEffectsFrozen bool | |
| 78 | + | expectedTargetTransportInterrupted bool | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool { | |
| 82 | + | return token.sess == source && token.ac == req.next && token.role == req.expectedRole && | |
| 83 | + | token.transport.transport == req.expectedTransport.transport && | |
| 84 | + | token.transport.incarnation == req.expectedTransport.incarnation | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // transitionSourceTokenCurrentLocked requires source.mu and, for an active | |
| 88 | + | // source, sourceCoordinator.mu. This is the canonical handoff preflight: every | |
| 89 | + | // client-originated navigation, delete, or create intent is bound to one exact | |
| 90 | + | // role generation, transport incarnation, and coordinator lease. | |
| 91 | + | func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | |
| 92 | + | if token.sess != source || token.ac != req.next || token.role != req.expectedRole || | |
| 93 | + | token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role || | |
| 94 | + | req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) { | |
| 95 | + | return false | |
| 96 | + | } | |
| 97 | + | if token.role != attachmentActive { | |
| 98 | + | return true | |
| 99 | + | } | |
| 100 | + | return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next && | |
| 101 | + | sourceCoordinator.leaseCurrentLocked(token.lease, true) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | type attachmentRoleToken struct { | |
| 105 | + | sess *session | |
| 106 | + | ac *attachedClient | |
| 107 | + | role attachmentRole | |
| 108 | + | generation uint64 | |
| 109 | + | transport transportSnapshot | |
| 110 | + | lease *attachmentLease | |
| 111 | + | effect *roleEffectTicket | |
| 112 | + | // rebase marks a cross-session ownership move whose first paint must start a | |
| 113 | + | // dependency-free output chain. The rebase is deliberately deferred until | |
| 114 | + | // after transition publication releases all architecture locks. | |
| 115 | + | rebase bool | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |
| 119 | + | if s == nil || ac == nil || tr == nil { | |
| 120 | + | return attachmentRoleToken{} | |
| 121 | + | } | |
| 122 | + | s.mu.Lock() | |
| 123 | + | transport := ac.transportSnapshot() | |
| 124 | + | if transport.transport != tr { | |
| 125 | + | s.mu.Unlock() | |
| 126 | + | return attachmentRoleToken{} | |
| 127 | + | } | |
| 128 | + | token := attachmentRoleToken{ | |
| 129 | + | sess: s, | |
| 130 | + | ac: ac, | |
| 131 | + | role: s.attachmentRoleLocked(ac), | |
| 132 | + | generation: ac.roleGeneration.Load(), | |
| 133 | + | transport: transport, | |
| 134 | + | } | |
| 135 | + | s.mu.Unlock() | |
| 136 | + | if token.role == attachmentActive { | |
| 137 | + | if rc := s.renderCoordinator(); rc != nil { | |
| 138 | + | token.lease = rc.attachmentLease(ac) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ac.bootstrapRoleCapability(token) | |
| 142 | + | return token | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (t attachmentRoleToken) current() bool { | |
| 146 | + | return t.sess != nil && t.ac != nil && | |
| 147 | + | t.ac.roleGeneration.Load() == t.generation && | |
| 148 | + | t.ac.transportSnapshotCurrent(t.transport) && | |
| 149 | + | t.sess.attachmentRole(t.ac) == t.role | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // activeCurrent admits post-transition effects only for the exact published | |
| 153 | + | // role, transport incarnation, current-session link, and coordinator lease. | |
| 154 | + | func (t attachmentRoleToken) activeCurrent() bool { | |
| 155 | + | if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess { | |
| 156 | + | return false | |
| 157 | + | } | |
| 158 | + | rc := t.sess.renderCoordinator() | |
| 159 | + | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // activeEffect is the canonical admission check for a client-frame mutation. | |
| 163 | + | // Tokens bind role generation, transport incarnation, session ownership, and | |
| 164 | + | // coordinator lease, so handlers never rediscover authority from mutable state. | |
| 165 | + | func (t attachmentRoleToken) activeEffect() bool { | |
| 166 | + | if t.effect != nil && !t.effect.ended.Load() { | |
| 167 | + | return true | |
| 168 | + | } | |
| 169 | + | return t.activeCurrent() | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) { | |
| 173 | + | if sess == nil || ac == nil || lease == nil { | |
| 174 | + | return nil, false | |
| 175 | + | } | |
| 176 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 177 | + | token.lease = lease | |
| 178 | + | return ac.beginRoleEffect(token) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func (t *attachmentRoleToken) endRoleEffect() { | |
| 182 | + | if t == nil || t.effect == nil { | |
| 183 | + | return | |
| 184 | + | } | |
| 185 | + | t.effect.End() | |
| 186 | + | t.effect = nil | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // activeEffectSessionLocked is the same admission check while t.sess.mu is | |
| 190 | + | // already held at a session-owned mutation boundary. | |
| 191 | + | func (t attachmentRoleToken) activeEffectSessionLocked() bool { | |
| 192 | + | if t.effect != nil && !t.effect.ended.Load() { | |
| 193 | + | return true | |
| 194 | + | } | |
| 195 | + | if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil || | |
| 196 | + | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 197 | + | t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive { | |
| 198 | + | return false | |
| 199 | + | } | |
| 200 | + | rc := t.sess.renderCoordinator() | |
| 201 | + | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error { | |
| 205 | + | if t.ac == nil { | |
| 206 | + | return errAttachmentTransition | |
| 207 | + | } | |
| 208 | + | t.ac.sendMu.Lock() | |
| 209 | + | defer t.ac.sendMu.Unlock() | |
| 210 | + | if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 211 | + | !t.effect.beginTransportSend(t.transport) { | |
| 212 | + | return errAttachmentTransition | |
| 213 | + | } | |
| 214 | + | err := t.transport.transport.Send(frame) | |
| 215 | + | if err != nil { | |
| 216 | + | t.effect.reportTransportFailure(t.transport) | |
| 217 | + | } | |
| 218 | + | t.effect.endTransportSend() | |
| 219 | + | return err | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | type attachmentTransitionResult struct { | |
| 223 | + | published attachmentRoleToken | |
| 224 | + | displaced attachmentRoleToken | |
| 225 | + | displacedInterrupted bool | |
| 226 | + | cleanups []renderLifecycleCleanup | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 230 | + | // Preflight identifies every gate the publication can affect. No gate is | |
| 231 | + | // frozen while an architecture lock is held. | |
| 232 | + | d.mu.Lock() | |
| 233 | + | source := req.source | |
| 234 | + | if source == nil { | |
| 235 | + | source = req.target | |
| 236 | + | } | |
| 237 | + | if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing || | |
| 238 | + | d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target { | |
| 239 | + | d.mu.Unlock() | |
| 240 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 241 | + | } | |
| 242 | + | if req.target != nil { | |
| 243 | + | req.target.mu.Lock() | |
| 244 | + | req.expectedTargetCurrent = req.target.client | |
| 245 | + | if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive { | |
| 246 | + | req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot() | |
| 247 | + | } | |
| 248 | + | req.target.mu.Unlock() | |
| 249 | + | } | |
| 250 | + | if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) { | |
| 251 | + | d.mu.Unlock() | |
| 252 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 253 | + | } | |
| 254 | + | req.preflighted = true | |
| 255 | + | d.mu.Unlock() | |
| 256 | + | ||
| 257 | + | if req.sourceToken != nil { | |
| 258 | + | d.endActionRoleEffect(req.sourceToken.effect, req.action) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | participants := []*attachedClient{req.next, req.expectedTargetCurrent} | |
| 262 | + | if d.afterRoleEffectParticipantsSnapshotted != nil { | |
| 263 | + | d.afterRoleEffectParticipantsSnapshotted(req.action, participants) | |
| 264 | + | } | |
| 265 | + | interrupts := []roleTransportInterrupt{{ | |
| 266 | + | ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport, | |
| 267 | + | }} | |
| 268 | + | frozen := freezeRoleEffectGatesInterruptingObserved(interrupts, func(ac *attachedClient) { | |
| 269 | + | if d.afterRoleEffectGateFrozen != nil { | |
| 270 | + | d.afterRoleEffectGateFrozen(req.action, ac) | |
| 271 | + | } | |
| 272 | + | }, participants...) | |
| 273 | + | defer frozen.unfreeze() | |
| 274 | + | req.expectedTargetTransportInterrupted = frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport) | |
| 275 | + | if d.afterRoleEffectsFrozen != nil { | |
| 276 | + | d.afterRoleEffectsFrozen() | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | d.mu.Lock() | |
| 280 | + | req.roleEffectsFrozen = true | |
| 281 | + | if req.target == nil { | |
| 282 | + | if !d.transitionSourcePreflightLocked(req) { | |
| 283 | + | d.mu.Unlock() | |
| 284 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 285 | + | } | |
| 286 | + | var err error | |
| 287 | + | req.target, err = req.createTargetLocked() | |
| 288 | + | if err != nil { | |
| 289 | + | d.mu.Unlock() | |
| 290 | + | return attachmentTransitionResult{}, err | |
| 291 | + | } | |
| 292 | + | req.expectedTargetCurrent = nil | |
| 293 | + | } | |
| 294 | + | result, err := d.transitionAttachmentLocked(req) | |
| 295 | + | d.mu.Unlock() | |
| 296 | + | return result, err | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // transitionSourcePreflightLocked validates an initiating role while its gate | |
| 300 | + | // is frozen and d.mu is held. No caller may create or delete a target before | |
| 301 | + | // this succeeds. | |
| 302 | + | func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool { | |
| 303 | + | if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source { | |
| 304 | + | return false | |
| 305 | + | } | |
| 306 | + | d.notices.routingMu.Lock() | |
| 307 | + | defer d.notices.routingMu.Unlock() | |
| 308 | + | req.source.mu.Lock() | |
| 309 | + | defer req.source.mu.Unlock() | |
| 310 | + | coordinator := req.source.renderCoordinator() | |
| 311 | + | if coordinator == nil { | |
| 312 | + | return false | |
| 313 | + | } | |
| 314 | + | coordinator.mu.Lock() | |
| 315 | + | defer coordinator.mu.Unlock() | |
| 316 | + | if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) { | |
| 317 | + | return false | |
| 318 | + | } | |
| 319 | + | return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab) | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |
| 323 | + | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | |
| 324 | + | return false | |
| 325 | + | } | |
| 326 | + | d.mu.Lock() | |
| 327 | + | defer d.mu.Unlock() | |
| 328 | + | if d.closing || d.sessions[token.sess.id] != token.sess { | |
| 329 | + | return false | |
| 330 | + | } | |
| 331 | + | d.notices.routingMu.Lock() | |
| 332 | + | defer d.notices.routingMu.Unlock() | |
| 333 | + | token.sess.mu.Lock() | |
| 334 | + | defer token.sess.mu.Unlock() | |
| 335 | + | coordinator := token.sess.renderCoordinator() | |
| 336 | + | if coordinator == nil { | |
| 337 | + | return false | |
| 338 | + | } | |
| 339 | + | coordinator.mu.Lock() | |
| 340 | + | defer coordinator.mu.Unlock() | |
| 341 | + | req := attachmentTransitionRequest{ | |
| 342 | + | source: token.sess, next: token.ac, expectedRole: token.role, | |
| 343 | + | expectedTransport: token.transport, | |
| 344 | + | } | |
| 345 | + | return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | |
| 349 | + | if expected == nil { | |
| 350 | + | return true | |
| 351 | + | } | |
| 352 | + | if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected { | |
| 353 | + | return false | |
| 354 | + | } | |
| 355 | + | expected.mu.Lock() | |
| 356 | + | defer expected.mu.Unlock() | |
| 357 | + | return expected.floating.state != floatingVisible | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // transitionAttachmentLocked is the publication half of transitionAttachment. | |
| 361 | + | // Caller holds d.mu after the lock-free gate freeze/drain has completed. | |
| 362 | + | func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 363 | + | d.notices.routingMu.Lock() | |
| 364 | + | defer d.notices.routingMu.Unlock() | |
| 365 | + | return d.transitionAttachmentRoutedLocked(req) | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // transitionAttachmentRoutedLocked performs the centralized role publication. | |
| 369 | + | // Caller holds d.mu and notices.routingMu. Every fallible lifecycle, role, and | |
| 370 | + | // transport check occurs before session.client is changed. Coordinator | |
| 371 | + | // finalization after that commit point cannot roll ownership back. | |
| 372 | + | func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 373 | + | source := req.source | |
| 374 | + | if source == nil { | |
| 375 | + | source = req.target | |
| 376 | + | } | |
| 377 | + | if !req.preflighted || !req.roleEffectsFrozen || | |
| 378 | + | !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) || | |
| 379 | + | source == nil || req.target == nil || req.next == nil || d.closing || | |
| 380 | + | d.sessions[source.id] != source || d.sessions[req.target.id] != req.target || | |
| 381 | + | req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) { | |
| 382 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 383 | + | } | |
| 384 | + | // Install the target coordinator object before taking both session locks; | |
| 385 | + | // lease binding itself remains part of the atomic publication below. | |
| 386 | + | var targetCoordinator *renderCoordinator | |
| 387 | + | if req.targetRole == attachmentActive { | |
| 388 | + | targetCoordinator = d.ensureRenderCoordinator(req.target) | |
| 389 | + | } | |
| 390 | + | sourceCoordinator := source.renderCoordinator() | |
| 391 | + | ||
| 392 | + | unlockSessions := lockAttachmentSessions(source, req.target) | |
| 393 | + | if source.attachmentRoleLocked(req.next) != req.expectedRole || | |
| 394 | + | !req.next.transportSnapshotCurrent(req.expectedTransport) { | |
| 395 | + | unlockSessions() | |
| 396 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | old := req.target.client | |
| 400 | + | if req.preflighted && old != req.expectedTargetCurrent { | |
| 401 | + | unlockSessions() | |
| 402 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 403 | + | } | |
| 404 | + | if source != req.target && old == req.next { | |
| 405 | + | unlockSessions() | |
| 406 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 407 | + | } | |
| 408 | + | unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator) | |
| 409 | + | if req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | |
| 410 | + | !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) { | |
| 411 | + | unlockCoordinators() | |
| 412 | + | unlockSessions() | |
| 413 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 414 | + | } | |
| 415 | + | if targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) { | |
| 416 | + | unlockCoordinators() | |
| 417 | + | unlockSessions() | |
| 418 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 419 | + | } | |
| 420 | + | if req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | |
| 421 | + | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) { | |
| 422 | + | unlockCoordinators() | |
| 423 | + | unlockSessions() | |
| 424 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 425 | + | } | |
| 426 | + | if req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) { | |
| 427 | + | unlockCoordinators() | |
| 428 | + | unlockSessions() | |
| 429 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | if req.copySourceEnvironment { | |
| 433 | + | req.target.terminal = source.terminal | |
| 434 | + | req.target.env = copyEnvironment(source.env) | |
| 435 | + | } | |
| 436 | + | if req.activateTargetTab { | |
| 437 | + | req.target.active = req.targetTabIndex | |
| 438 | + | } | |
| 439 | + | if req.preserveRole { | |
| 440 | + | unlockCoordinators() | |
| 441 | + | unlockSessions() | |
| 442 | + | return attachmentTransitionResult{published: *req.sourceToken}, nil | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | var displacedTransport transportSnapshot | |
| 446 | + | if req.targetRole == attachmentActive && old != nil && old != req.next { | |
| 447 | + | displacedTransport = old.transportSnapshot() | |
| 448 | + | } | |
| 449 | + | if source != req.target { | |
| 450 | + | if source.client == req.next { | |
| 451 | + | source.client = nil | |
| 452 | + | } | |
| 453 | + | delete(source.snatched, req.next) | |
| 454 | + | } | |
| 455 | + | if req.targetRole == attachmentActive { | |
| 456 | + | delete(req.target.snatched, req.next) | |
| 457 | + | if old != nil && old != req.next { | |
| 458 | + | req.target.addSnatchedLocked(old) | |
| 459 | + | } | |
| 460 | + | req.target.client = req.next | |
| 461 | + | } else { | |
| 462 | + | req.target.addSnatchedLocked(req.next) | |
| 463 | + | } | |
| 464 | + | nextGeneration := req.next.roleGeneration.Add(1) | |
| 465 | + | var displacedGeneration uint64 | |
| 466 | + | if old != nil && old != req.next && req.targetRole == attachmentActive { | |
| 467 | + | displacedGeneration = old.roleGeneration.Add(1) | |
| 468 | + | } | |
| 469 | + | req.next.setSession(req.target) | |
| 470 | + | ||
| 471 | + | result := attachmentTransitionResult{} | |
| 472 | + | if source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil { | |
| 473 | + | result.cleanups = append(result.cleanups, sourceCoordinator.beginDetachLocked(req.next)) | |
| 474 | + | } | |
| 475 | + | var lease *attachmentLease | |
| 476 | + | if targetCoordinator != nil { | |
| 477 | + | var cleanup renderLifecycleCleanup | |
| 478 | + | cleanup, lease = targetCoordinator.beginReplaceLocked(old, req.next, req.ready) | |
| 479 | + | result.cleanups = append(result.cleanups, cleanup) | |
| 480 | + | } | |
| 481 | + | result.published = attachmentRoleToken{ | |
| 482 | + | sess: req.target, | |
| 483 | + | ac: req.next, | |
| 484 | + | role: req.targetRole, | |
| 485 | + | generation: nextGeneration, | |
| 486 | + | transport: req.expectedTransport, | |
| 487 | + | lease: lease, | |
| 488 | + | rebase: source != req.target && req.expectedRole == attachmentActive, | |
| 489 | + | } | |
| 490 | + | if displacedGeneration != 0 { | |
| 491 | + | result.displaced = attachmentRoleToken{ | |
| 492 | + | sess: req.target, | |
| 493 | + | ac: old, | |
| 494 | + | role: attachmentSnatched, | |
| 495 | + | generation: displacedGeneration, | |
| 496 | + | transport: displacedTransport, | |
| 497 | + | } | |
| 498 | + | result.displacedInterrupted = req.expectedTargetTransportInterrupted | |
| 499 | + | } | |
| 500 | + | // Membership, currentSession, lease, generation, and both exact admission | |
| 501 | + | // capabilities become visible as one frozen publication. The gates remain | |
| 502 | + | // frozen until every architecture lock below has been released. | |
| 503 | + | if req.roleEffectsFrozen { | |
| 504 | + | req.next.publishFrozenRoleCapability(result.published) | |
| 505 | + | if result.displaced.ac != nil { | |
| 506 | + | result.displaced.ac.publishFrozenRoleCapability(result.displaced) | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | unlockCoordinators() | |
| 510 | + | unlockSessions() | |
| 511 | + | return result, nil | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool { | |
| 515 | + | ac := token.ac | |
| 516 | + | ac.sendMu.Lock() | |
| 517 | + | defer ac.sendMu.Unlock() | |
| 518 | + | if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) { | |
| 519 | + | return false | |
| 520 | + | } | |
| 521 | + | ac.captureFrames = nil | |
| 522 | + | return true | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | func (d *Daemon) sendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error { | |
| 526 | + | ac := token.ac | |
| 527 | + | ac.sendMu.Lock() | |
| 528 | + | defer ac.sendMu.Unlock() | |
| 529 | + | if ac.roleGeneration.Load() != token.generation { | |
| 530 | + | return errSnatchedOutputStale | |
| 531 | + | } | |
| 532 | + | if !ac.transportSnapshotCurrent(token.transport) { | |
| 533 | + | return errTransportReplaced | |
| 534 | + | } | |
| 535 | + | if token.effect == nil || !token.effect.beginTransportSend(token.transport) { | |
| 536 | + | return errAttachmentTransition | |
| 537 | + | } | |
| 538 | + | err := token.transport.transport.Send(frame) | |
| 539 | + | if err != nil { | |
| 540 | + | token.effect.reportTransportFailure(token.transport) | |
| 541 | + | } | |
| 542 | + | token.effect.endTransportSend() | |
| 543 | + | return err | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | // removeSnatchedAttachment removes exactly one role generation and transport | |
| 547 | + | // from session routing. It never touches the active attachment or closes a | |
| 548 | + | // transport, allowing callers that already interrupted a blocked send to avoid | |
| 549 | + | // closing the same link twice. | |
| 550 | + | func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool { | |
| 551 | + | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 552 | + | return false | |
| 553 | + | } | |
| 554 | + | frozen := freezeRoleEffectGates(token.ac) | |
| 555 | + | defer frozen.unfreeze() | |
| 556 | + | d.notices.routingMu.Lock() | |
| 557 | + | token.sess.mu.Lock() | |
| 558 | + | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 559 | + | token.ac.roleGeneration.Load() == token.generation && | |
| 560 | + | token.ac.transportSnapshotCurrent(token.transport) | |
| 561 | + | if current { | |
| 562 | + | delete(token.sess.snatched, token.ac) | |
| 563 | + | token.ac.roleGeneration.Add(1) | |
| 564 | + | token.ac.invalidateFrozenRoleCapability() | |
| 565 | + | } | |
| 566 | + | token.sess.mu.Unlock() | |
| 567 | + | d.notices.routingMu.Unlock() | |
| 568 | + | if !current { | |
| 569 | + | return false | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | d.unregisterPreview(token.ac) | |
| 573 | + | token.ac.clearPreviousSession() | |
| 574 | + | token.ac.setSession(nil) | |
| 575 | + | token.ac.clearCaptureFrames() | |
| 576 | + | return true | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | // cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced | |
| 580 | + | // transport that was closed to drain an admitted send. It waits on the gate's | |
| 581 | + | // condition rather than ordinary effect admission, then removes only the exact | |
| 582 | + | // published snatched incarnation. Attachment-local cleanup runs without routing | |
| 583 | + | // or session locks while the terminal capability remains frozen. | |
| 584 | + | func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool { | |
| 585 | + | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 586 | + | return false | |
| 587 | + | } | |
| 588 | + | frozen := freezeRoleEffectGates(token.ac) | |
| 589 | + | defer frozen.unfreeze() | |
| 590 | + | ||
| 591 | + | d.notices.routingMu.Lock() | |
| 592 | + | token.sess.mu.Lock() | |
| 593 | + | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 594 | + | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 595 | + | token.ac.transportSnapshotCurrent(token.transport) | |
| 596 | + | token.sess.mu.Unlock() | |
| 597 | + | d.notices.routingMu.Unlock() | |
| 598 | + | if !current { | |
| 599 | + | return false | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | // The frozen gate prevents a promotion from publishing between overlay | |
| 603 | + | // cleanup and terminal registry publication. | |
| 604 | + | d.clearForSnatch(token) | |
| 605 | + | ||
| 606 | + | d.notices.routingMu.Lock() | |
| 607 | + | token.sess.mu.Lock() | |
| 608 | + | current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 609 | + | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 610 | + | token.ac.transportSnapshotCurrent(token.transport) | |
| 611 | + | if current { | |
| 612 | + | delete(token.sess.snatched, token.ac) | |
| 613 | + | token.ac.roleGeneration.Add(1) | |
| 614 | + | token.ac.invalidateFrozenRoleCapability() | |
| 615 | + | } | |
| 616 | + | token.sess.mu.Unlock() | |
| 617 | + | d.notices.routingMu.Unlock() | |
| 618 | + | if !current { | |
| 619 | + | return false | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 623 | + | d.unregisterPreview(token.ac) | |
| 624 | + | token.ac.clearPreviousSession() | |
| 625 | + | token.ac.setSession(nil) | |
| 626 | + | token.ac.clearCaptureFrames() | |
| 627 | + | return true | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | // dropSnatchedAttachment removes and closes only the captured snatched link. | |
| 631 | + | func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool { | |
| 632 | + | if !d.removeSnatchedAttachment(token) { | |
| 633 | + | return false | |
| 634 | + | } | |
| 635 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 636 | + | return true | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | func validAttachmentTransitionRole(role attachmentRole, expected bool) bool { | |
| 640 | + | if role == attachmentActive || role == attachmentSnatched { | |
| 641 | + | return true | |
| 642 | + | } | |
| 643 | + | return expected && role == attachmentDetached | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | // lockAttachmentSessions gives every two-session transition one stable order. | |
| 647 | + | // Session IDs are immutable and unique while their lifecycles are registered. | |
| 648 | + | func lockAttachmentSessions(a, b *session) func() { | |
| 649 | + | if a == b { | |
| 650 | + | a.mu.Lock() | |
| 651 | + | return a.mu.Unlock | |
| 652 | + | } | |
| 653 | + | first, second := a, b | |
| 654 | + | if first.id > second.id { | |
| 655 | + | first, second = second, first | |
| 656 | + | } | |
| 657 | + | first.mu.Lock() | |
| 658 | + | second.mu.Lock() | |
| 659 | + | return func() { | |
| 660 | + | second.mu.Unlock() | |
| 661 | + | first.mu.Unlock() | |
| 662 | + | } | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | // lockAttachmentCoordinators follows the same immutable session-ID order as | |
| 666 | + | // lockAttachmentSessions. Callers already hold the ordered session locks. | |
| 667 | + | func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() { | |
| 668 | + | if aCoordinator == nil && bCoordinator == nil { | |
| 669 | + | return func() {} | |
| 670 | + | } | |
| 671 | + | if a == b || aCoordinator == bCoordinator { | |
| 672 | + | coordinator := aCoordinator | |
| 673 | + | if coordinator == nil { | |
| 674 | + | coordinator = bCoordinator | |
| 675 | + | } | |
| 676 | + | coordinator.mu.Lock() | |
| 677 | + | return coordinator.mu.Unlock | |
| 678 | + | } | |
| 679 | + | first, second := aCoordinator, bCoordinator | |
| 680 | + | if a.id > b.id { | |
| 681 | + | first, second = second, first | |
| 682 | + | } | |
| 683 | + | if first != nil { | |
| 684 | + | first.mu.Lock() | |
| 685 | + | } | |
| 686 | + | if second != nil { | |
| 687 | + | second.mu.Lock() | |
| 688 | + | } | |
| 689 | + | return func() { | |
| 690 | + | if second != nil { | |
| 691 | + | second.mu.Unlock() | |
| 692 | + | } | |
| 693 | + | if first != nil { | |
| 694 | + | first.mu.Unlock() | |
| 695 | + | } | |
| 696 | + | } | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | // deferAttachmentTransitionCleanups retires coordinator timers and accounting | |
| 700 | + | // after ownership publication. Cleanup never gates the attaching handshake. A | |
| 701 | + | // displaced render that already owns sendMu is interrupted by closing only its | |
| 702 | + | // captured transport; an idle healthy snatched transport remains connected and | |
| 703 | + | // receives a dependency-free reset panel. | |
| 704 | + | func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) { | |
| 705 | + | if token := result.displaced; token.ac != nil && token.transport.transport != nil { | |
| 706 | + | blockedRender := result.displacedInterrupted | |
| 707 | + | if !blockedRender { | |
| 708 | + | blockedRender = !token.ac.sendMu.TryLock() | |
| 709 | + | if !blockedRender { | |
| 710 | + | token.ac.sendMu.Unlock() | |
| 711 | + | } | |
| 712 | + | } | |
| 713 | + | d.attachmentCleanupWg.Go(func() { | |
| 714 | + | if d.afterDisplacedCleanupStarted != nil { | |
| 715 | + | d.afterDisplacedCleanupStarted() | |
| 716 | + | } | |
| 717 | + | if blockedRender { | |
| 718 | + | // This path owns terminal cleanup independently of ordinary role | |
| 719 | + | // admission: a stale send-error detach may transiently freeze the gate. | |
| 720 | + | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 721 | + | d.cleanupInterruptedSnatchedAttachment(token) | |
| 722 | + | return | |
| 723 | + | } | |
| 724 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 725 | + | if !admitted { | |
| 726 | + | return | |
| 727 | + | } | |
| 728 | + | defer ticket.End() | |
| 729 | + | if !token.current() || !d.clearForSnatch(token) || !d.clearCaptureFramesForSnatch(token) { | |
| 730 | + | return | |
| 731 | + | } | |
| 732 | + | if err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "", ticket); err != nil { | |
| 733 | + | ticket.End() | |
| 734 | + | d.dropSnatchedAttachment(token) | |
| 735 | + | } | |
| 736 | + | }) | |
| 737 | + | } | |
| 738 | + | for _, cleanup := range result.cleanups { | |
| 739 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 740 | + | } | |
| 741 | + | } | |
A internal/usecase/daemon/snatched_client_test.go +960 -0
| @@ -0,0 +1,960 @@ | |||
| 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{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{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{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 TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(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{id: "other", name: "other", ctx: otherCtx, cancel: otherCancel, client: otherClient} | |
| 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 | + | } | |
| 338 | + | require.Same(t, otherClient, other.client) | |
| 339 | + | require.Same(t, other, otherClient.currentSession()) | |
| 340 | + | require.Equal(t, attachmentActive, other.attachmentRole(otherClient)) | |
| 341 | + | ||
| 342 | + | otherTransport.recv <- ports.Frame{Type: ports.MsgPing} | |
| 343 | + | require.Equal(t, ports.MsgPong, awaitFrame(t, otherTransport.sends, ports.MsgPong).Type) | |
| 344 | + | select { | |
| 345 | + | case <-otherDone: | |
| 346 | + | t.Fatal("killing another session retired the active client's connection") | |
| 347 | + | default: | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) { | |
| 352 | + | writes := make(chan []byte, 1) | |
| 353 | + | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 354 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 355 | + | oldTransport := newDatagramTestTransport() | |
| 356 | + | old.replaceTransport(oldTransport) | |
| 357 | + | ||
| 358 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 359 | + | next.initOverlays() | |
| 360 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 361 | + | target: sess, | |
| 362 | + | next: next, | |
| 363 | + | expectedRole: attachmentDetached, | |
| 364 | + | targetRole: attachmentActive, | |
| 365 | + | expectedTransport: next.transportSnapshot(), | |
| 366 | + | }) | |
| 367 | + | require.NoError(t, err) | |
| 368 | + | d.deferAttachmentTransitionCleanups(result) | |
| 369 | + | d.attachmentCleanupWg.Wait() | |
| 370 | + | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 371 | + | ||
| 372 | + | oldTransport.recv <- frameInput([]byte("restricted")) | |
| 373 | + | require.NoError(t, oldTransport.Close()) | |
| 374 | + | d.runConnLoop(old) | |
| 375 | + | ||
| 376 | + | select { | |
| 377 | + | case got := <-writes: | |
| 378 | + | t.Fatalf("snatched input reached PTY: %q", got) | |
| 379 | + | default: | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func TestStaleActiveKeyForwardCannotReachPTYAfterSnatch(t *testing.T) { | |
| 384 | + | writes := make(chan []byte, 1) | |
| 385 | + | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 386 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 387 | + | old.replaceTransport(&closeTrackingTransport{}) | |
| 388 | + | ||
| 389 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 390 | + | next.initOverlays() | |
| 391 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 392 | + | target: sess, | |
| 393 | + | next: next, | |
| 394 | + | expectedRole: attachmentDetached, | |
| 395 | + | targetRole: attachmentActive, | |
| 396 | + | expectedTransport: next.transportSnapshot(), | |
| 397 | + | }) | |
| 398 | + | require.NoError(t, err) | |
| 399 | + | ||
| 400 | + | (daemonKeyHandler{d: d, ac: old}).Forward([]byte("stale")) | |
| 401 | + | ||
| 402 | + | select { | |
| 403 | + | case got := <-writes: | |
| 404 | + | t.Fatalf("stale active key forward reached PTY: %q", got) | |
| 405 | + | default: | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | func TestSnatchedConnectionResizeUpdatesPanelWithoutResizingSession(t *testing.T) { | |
| 410 | + | pty := &transactionalResizePTY{} | |
| 411 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 412 | + | oldTransport := newDatagramTestTransport() | |
| 413 | + | old.replaceTransport(oldTransport) | |
| 414 | + | ||
| 415 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 416 | + | next.initOverlays() | |
| 417 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 418 | + | target: sess, | |
| 419 | + | next: next, | |
| 420 | + | expectedRole: attachmentDetached, | |
| 421 | + | targetRole: attachmentActive, | |
| 422 | + | expectedTransport: next.transportSnapshot(), | |
| 423 | + | }) | |
| 424 | + | require.NoError(t, err) | |
| 425 | + | d.deferAttachmentTransitionCleanups(result) | |
| 426 | + | d.attachmentCleanupWg.Wait() | |
| 427 | + | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 428 | + | ||
| 429 | + | want := domain.Size{Cols: 100, Rows: 30} | |
| 430 | + | oldTransport.recv <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: want})} | |
| 431 | + | require.NoError(t, oldTransport.Close()) | |
| 432 | + | d.runConnLoop(old) | |
| 433 | + | ||
| 434 | + | require.Equal(t, want, old.size) | |
| 435 | + | require.Empty(t, pty.requested(), "snatched resize must not resize session PTYs") | |
| 436 | + | panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 437 | + | output, err := ports.UnmarshalOutput(panel.Payload) | |
| 438 | + | require.NoError(t, err) | |
| 439 | + | require.Contains(t, string(output.Data), "Session snatched") | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | func TestSnatchedConnectionAcknowledgesPanelAndAnswersPing(t *testing.T) { | |
| 443 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 444 | + | oldTransport := newDatagramTestTransport() | |
| 445 | + | old.replaceTransport(oldTransport) | |
| 446 | + | ||
| 447 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 448 | + | next.initOverlays() | |
| 449 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 450 | + | target: sess, | |
| 451 | + | next: next, | |
| 452 | + | expectedRole: attachmentDetached, | |
| 453 | + | targetRole: attachmentActive, | |
| 454 | + | expectedTransport: next.transportSnapshot(), | |
| 455 | + | }) | |
| 456 | + | require.NoError(t, err) | |
| 457 | + | d.deferAttachmentTransitionCleanups(result) | |
| 458 | + | d.attachmentCleanupWg.Wait() | |
| 459 | + | panelFrame := awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 460 | + | panel, err := ports.UnmarshalOutput(panelFrame.Payload) | |
| 461 | + | require.NoError(t, err) | |
| 462 | + | require.Equal(t, uint64(1), old.output.outstanding()) | |
| 463 | + | ||
| 464 | + | oldTransport.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: panel.NewStateNum})} | |
| 465 | + | oldTransport.recv <- ports.Frame{Type: ports.MsgPing} | |
| 466 | + | require.NoError(t, oldTransport.Close()) | |
| 467 | + | d.runConnLoop(old) | |
| 468 | + | ||
| 469 | + | require.Zero(t, old.output.outstanding()) | |
| 470 | + | require.Equal(t, ports.MsgPong, awaitFrame(t, oldTransport.sends, ports.MsgPong).Type) | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func TestSnatchedConnectionThemeRepaintsPanelWithoutRecoloringSession(t *testing.T) { | |
| 474 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 475 | + | oldTransport := newDatagramTestTransport() | |
| 476 | + | old.replaceTransport(oldTransport) | |
| 477 | + | ||
| 478 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 479 | + | next.initOverlays() | |
| 480 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 481 | + | target: sess, | |
| 482 | + | next: next, | |
| 483 | + | expectedRole: attachmentDetached, | |
| 484 | + | targetRole: attachmentActive, | |
| 485 | + | expectedTransport: next.transportSnapshot(), | |
| 486 | + | }) | |
| 487 | + | require.NoError(t, err) | |
| 488 | + | d.deferAttachmentTransitionCleanups(result) | |
| 489 | + | d.attachmentCleanupWg.Wait() | |
| 490 | + | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 491 | + | ||
| 492 | + | activeTheme := ports.Theme{ | |
| 493 | + | HasForeground: true, Foreground: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 494 | + | HasBackground: true, Background: renderer.RGB{R: 40, G: 50, B: 60}, | |
| 495 | + | TrueColor: true, | |
| 496 | + | } | |
| 497 | + | d.applyTheme(sess, next, activeTheme) | |
| 498 | + | snatchedTheme := ports.Theme{ | |
| 499 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 500 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 501 | + | TrueColor: true, | |
| 502 | + | } | |
| 503 | + | oldTransport.recv <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snatchedTheme)} | |
| 504 | + | require.NoError(t, oldTransport.Close()) | |
| 505 | + | d.runConnLoop(old) | |
| 506 | + | ||
| 507 | + | require.Equal(t, snatchedTheme.Foreground, old.getAppliedTheme().Raw.Foreground) | |
| 508 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 509 | + | panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 510 | + | output, err := ports.UnmarshalOutput(panel.Payload) | |
| 511 | + | require.NoError(t, err) | |
| 512 | + | require.Contains(t, string(output.Data), "Session snatched") | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | func TestSnatchedConnectionDetachRemovesOnlySnatchedAttachment(t *testing.T) { | |
| 516 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 517 | + | oldTransport := newDatagramTestTransport() | |
| 518 | + | old.replaceTransport(oldTransport) | |
| 519 | + | ||
| 520 | + | activeTransport := &closeTrackingTransport{} | |
| 521 | + | next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size} | |
| 522 | + | next.initOverlays() | |
| 523 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 524 | + | target: sess, | |
| 525 | + | next: next, | |
| 526 | + | expectedRole: attachmentDetached, | |
| 527 | + | targetRole: attachmentActive, | |
| 528 | + | expectedTransport: next.transportSnapshot(), | |
| 529 | + | }) | |
| 530 | + | require.NoError(t, err) | |
| 531 | + | d.deferAttachmentTransitionCleanups(result) | |
| 532 | + | d.attachmentCleanupWg.Wait() | |
| 533 | + | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 534 | + | ||
| 535 | + | oldTransport.recv <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})} | |
| 536 | + | d.runConnLoop(old) | |
| 537 | + | ||
| 538 | + | detached := awaitFrame(t, oldTransport.sends, ports.MsgDetached) | |
| 539 | + | message, err := ports.UnmarshalDetached(detached.Payload) | |
| 540 | + | require.NoError(t, err) | |
| 541 | + | require.Equal(t, ports.ReasonDetach, message.Reason) | |
| 542 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(old)) | |
| 543 | + | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 544 | + | require.Same(t, next, sess.client) | |
| 545 | + | require.False(t, activeTransport.Closed()) | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | func TestReplacementClearsSnatchedOverlaysPreviewAndCaptures(t *testing.T) { | |
| 549 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 550 | + | oldTransport := &closeTrackingTransport{} | |
| 551 | + | old.replaceTransport(oldTransport) | |
| 552 | + | ||
| 553 | + | d.enterPicker(sess, old) | |
| 554 | + | d.enterPalette(sess, old) | |
| 555 | + | d.enterPrompt(sess, old, " Rename ", "work", func(string) error { return nil }) | |
| 556 | + | d.enterCopyMode(sess, old) | |
| 557 | + | d.enterNotices(sess, old) | |
| 558 | + | old.overlays.resizeMu.Lock() | |
| 559 | + | old.overlays.resizeActive = true | |
| 560 | + | old.overlays.resizePending = []byte("pending") | |
| 561 | + | old.overlays.resizeMu.Unlock() | |
| 562 | + | old.sendMu.Lock() | |
| 563 | + | old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}} | |
| 564 | + | old.sendMu.Unlock() | |
| 565 | + | require.True(t, old.overlays.Active()) | |
| 566 | + | ||
| 567 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 568 | + | next.initOverlays() | |
| 569 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 570 | + | target: sess, | |
| 571 | + | next: next, | |
| 572 | + | expectedRole: attachmentDetached, | |
| 573 | + | targetRole: attachmentActive, | |
| 574 | + | expectedTransport: next.transportSnapshot(), | |
| 575 | + | }) | |
| 576 | + | require.NoError(t, err) | |
| 577 | + | d.deferAttachmentTransitionCleanups(result) | |
| 578 | + | d.attachmentCleanupWg.Wait() | |
| 579 | + | ||
| 580 | + | require.False(t, old.overlays.Active()) | |
| 581 | + | old.overlays.pickerMu.Lock() | |
| 582 | + | require.Nil(t, old.overlays.pickerPreview) | |
| 583 | + | require.Nil(t, old.overlays.pickerPreviewSession) | |
| 584 | + | old.overlays.pickerMu.Unlock() | |
| 585 | + | old.sendMu.Lock() | |
| 586 | + | require.Empty(t, old.captureFrames) | |
| 587 | + | old.sendMu.Unlock() | |
| 588 | + | require.False(t, oldTransport.Closed()) | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | type failingSnatchedTransport struct { | |
| 592 | + | mu sync.Mutex | |
| 593 | + | closed bool | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | func (*failingSnatchedTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 597 | + | func (*failingSnatchedTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 598 | + | func (t *failingSnatchedTransport) Close() error { | |
| 599 | + | t.mu.Lock() | |
| 600 | + | t.closed = true | |
| 601 | + | t.mu.Unlock() | |
| 602 | + | return nil | |
| 603 | + | } | |
| 604 | + | func (t *failingSnatchedTransport) Closed() bool { | |
| 605 | + | t.mu.Lock() | |
| 606 | + | defer t.mu.Unlock() | |
| 607 | + | return t.closed | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | func TestSnatchedPanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | |
| 611 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 612 | + | oldTransport := &failingSnatchedTransport{} | |
| 613 | + | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 614 | + | old.initOverlays() | |
| 615 | + | sess := &session{id: "work", client: old} | |
| 616 | + | old.setSession(sess) | |
| 617 | + | d.sessions[sess.id] = sess | |
| 618 | + | ||
| 619 | + | activeTransport := &closeTrackingTransport{} | |
| 620 | + | next := &attachedClient{tr: activeTransport, 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 | + | ||
| 633 | + | require.True(t, oldTransport.Closed()) | |
| 634 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(old)) | |
| 635 | + | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 636 | + | require.Same(t, next, sess.client) | |
| 637 | + | require.False(t, activeTransport.Closed()) | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | |
| 641 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 642 | + | oldTransport := &failingSnatchedTransport{} | |
| 643 | + | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 644 | + | old.initOverlays() | |
| 645 | + | sess := &session{id: "work", client: old} | |
| 646 | + | old.setSession(sess) | |
| 647 | + | d.sessions[sess.id] = sess | |
| 648 | + | ||
| 649 | + | activeTransport := &closeTrackingTransport{} | |
| 650 | + | next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size} | |
| 651 | + | next.initOverlays() | |
| 652 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 653 | + | target: sess, | |
| 654 | + | next: next, | |
| 655 | + | expectedRole: attachmentDetached, | |
| 656 | + | targetRole: attachmentActive, | |
| 657 | + | expectedTransport: next.transportSnapshot(), | |
| 658 | + | }) | |
| 659 | + | require.NoError(t, err) | |
| 660 | + | for _, cleanup := range result.cleanups { | |
| 661 | + | cleanup.finish() | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | theme := ports.Theme{ | |
| 665 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 666 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 667 | + | TrueColor: true, | |
| 668 | + | } | |
| 669 | + | require.True(t, d.handleSnatchedClientFrame(result.displaced, ports.Frame{ | |
| 670 | + | Type: ports.MsgTheme, Payload: ports.MarshalTheme(theme), | |
| 671 | + | })) | |
| 672 | + | ||
| 673 | + | require.True(t, oldTransport.Closed()) | |
| 674 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(old)) | |
| 675 | + | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 676 | + | require.Same(t, next, sess.client) | |
| 677 | + | require.False(t, activeTransport.Closed()) | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | func TestClearForSnatchReleasesEachOverlayFamilyBeforeTakingNext(t *testing.T) { | |
| 681 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 682 | + | tr := &closeTrackingTransport{} | |
| 683 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 684 | + | ac.initOverlays() | |
| 685 | + | ac.roleGeneration.Store(1) | |
| 686 | + | sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}} | |
| 687 | + | ac.setSession(sess) | |
| 688 | + | token := attachmentRoleToken{ | |
| 689 | + | sess: sess, ac: ac, role: attachmentSnatched, | |
| 690 | + | generation: 1, transport: ac.transportSnapshot(), | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | pickerCleared := make(chan struct{}) | |
| 694 | + | d.afterSnatchOverlayFamily = func(family string) { | |
| 695 | + | if family == "picker" { | |
| 696 | + | close(pickerCleared) | |
| 697 | + | } | |
| 698 | + | } | |
| 699 | + | ac.overlays.resizeMu.Lock() | |
| 700 | + | resizeLocked := true | |
| 701 | + | defer func() { | |
| 702 | + | if resizeLocked { | |
| 703 | + | ac.overlays.resizeMu.Unlock() | |
| 704 | + | } | |
| 705 | + | }() | |
| 706 | + | cleared := make(chan bool, 1) | |
| 707 | + | go func() { cleared <- d.clearForSnatch(token) }() | |
| 708 | + | select { | |
| 709 | + | case <-pickerCleared: | |
| 710 | + | case <-time.After(2 * time.Second): | |
| 711 | + | t.Fatal("picker family did not clear before blocked resize family") | |
| 712 | + | } | |
| 713 | + | require.True(t, ac.overlays.pickerMu.TryLock(), "clear retained pickerMu while waiting for resizeMu") | |
| 714 | + | ac.overlays.pickerMu.Unlock() | |
| 715 | + | ||
| 716 | + | ac.overlays.resizeMu.Unlock() | |
| 717 | + | resizeLocked = false | |
| 718 | + | require.True(t, <-cleared) | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | func TestClearForSnatchRejectsStaleGenerationWithoutClearingNewOverlay(t *testing.T) { | |
| 722 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 723 | + | tr := &closeTrackingTransport{} | |
| 724 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 725 | + | ac.initOverlays() | |
| 726 | + | ac.roleGeneration.Store(1) | |
| 727 | + | sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}} | |
| 728 | + | ac.setSession(sess) | |
| 729 | + | token := attachmentRoleToken{ | |
| 730 | + | sess: sess, ac: ac, role: attachmentSnatched, | |
| 731 | + | generation: 1, transport: ac.transportSnapshot(), | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | ac.overlays.pickerMu.Lock() | |
| 735 | + | started := make(chan struct{}) | |
| 736 | + | cleared := make(chan bool, 1) | |
| 737 | + | go func() { | |
| 738 | + | close(started) | |
| 739 | + | cleared <- d.clearForSnatch(token) | |
| 740 | + | }() | |
| 741 | + | <-started | |
| 742 | + | ac.roleGeneration.Add(1) | |
| 743 | + | ac.overlays.resizeMu.Lock() | |
| 744 | + | ac.overlays.resizeActive = true | |
| 745 | + | ac.overlays.resizeMu.Unlock() | |
| 746 | + | ac.overlays.pickerMu.Unlock() | |
| 747 | + | ||
| 748 | + | require.False(t, <-cleared) | |
| 749 | + | require.True(t, ac.overlays.resizeModeActive(), "stale cleanup cleared a newer generation") | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) { | |
| 753 | + | tests := []struct { | |
| 754 | + | name string | |
| 755 | + | mutate func(*Daemon, *session, *attachedClient) | |
| 756 | + | }{ | |
| 757 | + | { | |
| 758 | + | name: "stale transport", | |
| 759 | + | mutate: func(_ *Daemon, _ *session, next *attachedClient) { | |
| 760 | + | next.replaceTransport(&closeTrackingTransport{}) | |
| 761 | + | }, | |
| 762 | + | }, | |
| 763 | + | { | |
| 764 | + | name: "session lifecycle ended", | |
| 765 | + | mutate: func(d *Daemon, sess *session, _ *attachedClient) { | |
| 766 | + | delete(d.sessions, sess.id) | |
| 767 | + | }, | |
| 768 | + | }, | |
| 769 | + | { | |
| 770 | + | name: "unexpected role", | |
| 771 | + | mutate: func(_ *Daemon, sess *session, next *attachedClient) { | |
| 772 | + | sess.mu.Lock() | |
| 773 | + | sess.addSnatchedLocked(next) | |
| 774 | + | sess.mu.Unlock() | |
| 775 | + | }, | |
| 776 | + | }, | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | for _, tt := range tests { | |
| 780 | + | t.Run(tt.name, func(t *testing.T) { | |
| 781 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 782 | + | sess := &session{id: domain.SessionID("work")} | |
| 783 | + | old := &attachedClient{tr: &closeTrackingTransport{}} | |
| 784 | + | old.setSession(sess) | |
| 785 | + | sess.client = old | |
| 786 | + | d.sessions[sess.id] = sess | |
| 787 | + | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 788 | + | expected := next.transportSnapshot() | |
| 789 | + | tt.mutate(d, sess, next) | |
| 790 | + | ||
| 791 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 792 | + | target: sess, | |
| 793 | + | next: next, | |
| 794 | + | expectedRole: attachmentDetached, | |
| 795 | + | targetRole: attachmentActive, | |
| 796 | + | expectedTransport: expected, | |
| 797 | + | }) | |
| 798 | + | require.ErrorIs(t, err, errAttachmentTransition) | |
| 799 | + | require.Same(t, old, sess.client) | |
| 800 | + | require.Zero(t, old.roleGeneration.Load()) | |
| 801 | + | require.Zero(t, next.roleGeneration.Load()) | |
| 802 | + | }) | |
| 803 | + | } | |
| 804 | + | } | |
| 805 | + | ||
| 806 | + | func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) { | |
| 807 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 808 | + | from := &session{id: "z-source"} | |
| 809 | + | target := &session{id: "a-target"} | |
| 810 | + | moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 811 | + | displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size} | |
| 812 | + | moving.initOverlays() | |
| 813 | + | displaced.initOverlays() | |
| 814 | + | moving.setSession(from) | |
| 815 | + | displaced.setSession(target) | |
| 816 | + | from.client = moving | |
| 817 | + | target.client = displaced | |
| 818 | + | d.sessions[from.id] = from | |
| 819 | + | d.sessions[target.id] = target | |
| 820 | + | sourceCoordinator := d.attachCoordinator(from, nil, moving, true) | |
| 821 | + | targetCoordinator := d.attachCoordinator(target, nil, displaced, true) | |
| 822 | + | ||
| 823 | + | moving.sendMu.Lock() | |
| 824 | + | locked := true | |
| 825 | + | defer func() { | |
| 826 | + | if locked { | |
| 827 | + | moving.sendMu.Unlock() | |
| 828 | + | } | |
| 829 | + | }() | |
| 830 | + | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 831 | + | transitionErr := make(chan error, 1) | |
| 832 | + | go func() { | |
| 833 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 834 | + | source: from, target: target, next: moving, | |
| 835 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 836 | + | expectedTransport: moving.transportSnapshot(), ready: true, | |
| 837 | + | }) | |
| 838 | + | transitionDone <- result | |
| 839 | + | transitionErr <- err | |
| 840 | + | }() | |
| 841 | + | ||
| 842 | + | var result attachmentTransitionResult | |
| 843 | + | select { | |
| 844 | + | case result = <-transitionDone: | |
| 845 | + | require.NoError(t, <-transitionErr) | |
| 846 | + | case <-time.After(2 * time.Second): | |
| 847 | + | t.Fatal("ownership publication waited for blocked sendMu") | |
| 848 | + | } | |
| 849 | + | ||
| 850 | + | from.mu.Lock() | |
| 851 | + | require.Nil(t, from.client) | |
| 852 | + | from.mu.Unlock() | |
| 853 | + | target.mu.Lock() | |
| 854 | + | require.Same(t, moving, target.client) | |
| 855 | + | require.Contains(t, target.snatched, displaced) | |
| 856 | + | target.mu.Unlock() | |
| 857 | + | require.Same(t, target, moving.currentSession()) | |
| 858 | + | sourceCoordinator.mu.Lock() | |
| 859 | + | require.NotNil(t, sourceCoordinator.lease) | |
| 860 | + | require.False(t, sourceCoordinator.lease.active) | |
| 861 | + | sourceCoordinator.mu.Unlock() | |
| 862 | + | targetCoordinator.mu.Lock() | |
| 863 | + | require.Same(t, result.published.lease, targetCoordinator.lease) | |
| 864 | + | require.True(t, targetCoordinator.lease.active) | |
| 865 | + | require.Same(t, moving, targetCoordinator.lease.attachment) | |
| 866 | + | targetCoordinator.mu.Unlock() | |
| 867 | + | require.True(t, result.published.activeCurrent()) | |
| 868 | + | ||
| 869 | + | paintStarted := make(chan struct{}) | |
| 870 | + | paintDone := make(chan bool, 1) | |
| 871 | + | go func() { | |
| 872 | + | close(paintStarted) | |
| 873 | + | paintDone <- d.firstPaintForTransition(result.published) | |
| 874 | + | }() | |
| 875 | + | <-paintStarted | |
| 876 | + | require.True(t, d.mu.TryLock(), "first paint retained daemon lock while waiting for sendMu") | |
| 877 | + | d.mu.Unlock() | |
| 878 | + | require.True(t, d.notices.routingMu.TryLock(), "first paint retained routing lock while waiting for sendMu") | |
| 879 | + | d.notices.routingMu.Unlock() | |
| 880 | + | require.True(t, from.mu.TryLock(), "first paint retained source session lock while waiting for sendMu") | |
| 881 | + | from.mu.Unlock() | |
| 882 | + | require.True(t, target.mu.TryLock(), "first paint retained target session lock while waiting for sendMu") | |
| 883 | + | target.mu.Unlock() | |
| 884 | + | require.True(t, sourceCoordinator.mu.TryLock(), "first paint retained source coordinator lock while waiting for sendMu") | |
| 885 | + | sourceCoordinator.mu.Unlock() | |
| 886 | + | require.True(t, targetCoordinator.mu.TryLock(), "first paint retained target coordinator lock while waiting for sendMu") | |
| 887 | + | targetCoordinator.mu.Unlock() | |
| 888 | + | ||
| 889 | + | moving.sendMu.Unlock() | |
| 890 | + | locked = false | |
| 891 | + | require.True(t, <-paintDone) | |
| 892 | + | } | |
| 893 | + | ||
| 894 | + | func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *testing.T) { | |
| 895 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 896 | + | tr := &closeTrackingTransport{} | |
| 897 | + | ac := &attachedClient{ | |
| 898 | + | tr: tr, | |
| 899 | + | output: newOutputStateStream(), | |
| 900 | + | size: domain.Size{Cols: 80, Rows: 24}, | |
| 901 | + | } | |
| 902 | + | ac.initOverlays() | |
| 903 | + | ac.roleGeneration.Store(2) | |
| 904 | + | ac.output.next = 4 | |
| 905 | + | ac.output.acked = 1 | |
| 906 | + | sess := &session{id: "work", client: ac} | |
| 907 | + | ac.setSession(sess) | |
| 908 | + | d.sessions[sess.id] = sess | |
| 909 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 910 | + | rebased := false | |
| 911 | + | ac.renderStages.handoffRebase = func() { rebased = true } | |
| 912 | + | ||
| 913 | + | stale := attachmentRoleToken{ | |
| 914 | + | sess: sess, ac: ac, role: attachmentActive, | |
| 915 | + | generation: 1, transport: ac.transportSnapshot(), lease: rc.attachmentLease(ac), rebase: true, | |
| 916 | + | } | |
| 917 | + | require.False(t, d.firstPaintForTransition(stale)) | |
| 918 | + | ||
| 919 | + | require.False(t, rebased) | |
| 920 | + | require.Equal(t, uint64(4), ac.output.next) | |
| 921 | + | require.Equal(t, uint64(1), ac.output.acked) | |
| 922 | + | require.Empty(t, tr.Sends(), "stale first paint emitted a reset") | |
| 923 | + | } | |
| 924 | + | ||
| 925 | + | func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) { | |
| 926 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 927 | + | from := &session{id: domain.SessionID("z-source")} | |
| 928 | + | target := &session{id: domain.SessionID("a-target")} | |
| 929 | + | moving := &attachedClient{tr: &closeTrackingTransport{}} | |
| 930 | + | displaced := &attachedClient{tr: &closeTrackingTransport{}} | |
| 931 | + | moving.setSession(from) | |
| 932 | + | displaced.setSession(target) | |
| 933 | + | from.client = moving | |
| 934 | + | target.client = displaced | |
| 935 | + | d.sessions[from.id] = from | |
| 936 | + | d.sessions[target.id] = target | |
| 937 | + | ||
| 938 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 939 | + | source: from, | |
| 940 | + | target: target, | |
| 941 | + | next: moving, | |
| 942 | + | expectedRole: attachmentActive, | |
| 943 | + | targetRole: attachmentActive, | |
| 944 | + | expectedTransport: moving.transportSnapshot(), | |
| 945 | + | ready: true, | |
| 946 | + | }) | |
| 947 | + | require.NoError(t, err) | |
| 948 | + | ||
| 949 | + | from.mu.Lock() | |
| 950 | + | require.Nil(t, from.client) | |
| 951 | + | require.NotContains(t, from.snatched, moving) | |
| 952 | + | from.mu.Unlock() | |
| 953 | + | target.mu.Lock() | |
| 954 | + | require.Same(t, moving, target.client) | |
| 955 | + | require.Contains(t, target.snatched, displaced) | |
| 956 | + | target.mu.Unlock() | |
| 957 | + | require.True(t, result.published.current()) | |
| 958 | + | require.True(t, result.displaced.current()) | |
| 959 | + | require.Same(t, target, moving.currentSession()) | |
| 960 | + | } | |
M internal/usecase/daemon/snatched_render.go +16 -2
| @@ -59,10 +59,14 @@ var errSnatchedOutputStale = errors.New("snatched output role changed") | |||
| 59 | 59 | // sendSnatchedPanel serializes a dependency-free panel reset onto one exact | |
| 60 | 60 | // attachment role and transport lifetime. It does not inspect session state or | |
| 61 | 61 | // capture pane content. | |
| 62 | - | func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapshot, generation uint64, feedback string) error { | |
| 62 | + | func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapshot, generation uint64, feedback string, effects ...*roleEffectTicket) error { | |
| 63 | 63 | if expected.transport == nil { | |
| 64 | 64 | return errors.New("snatched client transport is nil") | |
| 65 | 65 | } | |
| 66 | + | var effect *roleEffectTicket | |
| 67 | + | if len(effects) != 0 { | |
| 68 | + | effect = effects[0] | |
| 69 | + | } | |
| 66 | 70 | tr, err := d.boundedSendWith(expected.transport, func() error { | |
| 67 | 71 | ac.sendMu.Lock() | |
| 68 | 72 | defer ac.sendMu.Unlock() | |
| @@ -81,7 +85,17 @@ func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapsho | |||
| 81 | 85 | return err | |
| 82 | 86 | } | |
| 83 | 87 | data := append(append([]byte(nil), prepared.data...), ac.encodeCursorTail(cursorOut{hidden: true}, true)...) | |
| 84 | - | return prepared.send(data, ac.echoAck.Load(), expected.transport.Send) | |
| 88 | + | if effect != nil && !effect.beginTransportSend(expected) { | |
| 89 | + | return errAttachmentTransition | |
| 90 | + | } | |
| 91 | + | err = prepared.send(data, ac.echoAck.Load(), expected.transport.Send) | |
| 92 | + | if effect != nil { | |
| 93 | + | if err != nil { | |
| 94 | + | effect.reportTransportFailure(expected) | |
| 95 | + | } | |
| 96 | + | effect.endTransportSend() | |
| 97 | + | } | |
| 98 | + | return err | |
| 85 | 99 | }) | |
| 86 | 100 | if errors.Is(err, errSendTimedOut) { | |
| 87 | 101 | _ = ac.closeCapturedTransport(tr) | |
M internal/usecase/daemon/stalled_attachment_test.go +25 -18
| @@ -1,8 +1,6 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "io" | |
| 5 | - | "sync" | |
| 6 | 4 | "testing" | |
| 7 | 5 | "time" | |
| 8 | 6 | ||
| @@ -14,10 +12,10 @@ import ( | |||
| 14 | 12 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | 13 | ) | |
| 16 | 14 | ||
| 17 | - | // TestFinishAttachDoesNotWaitForBlockedOldRenderWorker verifies the replacement | |
| 18 | - | // boundary rather than relying on a scheduler delay: an old coordinator worker | |
| 19 | - | // is already blocked in Send when finishAttach publishes its replacement. | |
| 20 | - | func TestFinishAttachDoesNotWaitForBlockedOldRenderWorker(t *testing.T) { | |
| 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) { | |
| 21 | 19 | pty, releasePTY := newBlockingPTY(t) | |
| 22 | 20 | defer releasePTY() | |
| 23 | 21 | clock := newCoordinatorMockClock(t, 4) | |
| @@ -25,17 +23,14 @@ func TestFinishAttachDoesNotWaitForBlockedOldRenderWorker(t *testing.T) { | |||
| 25 | 23 | ||
| 26 | 24 | oldTransport := portsmocks.NewMockTransport(t) | |
| 27 | 25 | oldSendEntered := make(chan struct{}) | |
| 28 | - | oldClosed := make(chan struct{}) | |
| 29 | - | var closeOnce sync.Once | |
| 26 | + | releaseOldSend := make(chan struct{}) | |
| 30 | 27 | oldTransport.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error { | |
| 31 | 28 | close(oldSendEntered) | |
| 32 | - | <-oldClosed | |
| 33 | - | return io.EOF | |
| 34 | - | }).Once() | |
| 35 | - | oldTransport.EXPECT().Close().RunAndReturn(func() error { | |
| 36 | - | closeOnce.Do(func() { close(oldClosed) }) | |
| 29 | + | <-releaseOldSend | |
| 37 | 30 | return nil | |
| 38 | 31 | }).Once() | |
| 32 | + | oldTransport.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 33 | + | oldTransport.EXPECT().Close().Return(nil).Maybe() | |
| 39 | 34 | ||
| 40 | 35 | hello := ports.Hello{ | |
| 41 | 36 | Version: ports.ProtocolVersion, | |
| @@ -60,19 +55,31 @@ func TestFinishAttachDoesNotWaitForBlockedOldRenderWorker(t *testing.T) { | |||
| 60 | 55 | attached := make(chan *attachedClient, 1) | |
| 61 | 56 | go func() { | |
| 62 | 57 | d.mu.Lock() | |
| 63 | - | attached <- d.finishAttach(sess, replacement, hello.Size, terminalEnv{}, hello) | |
| 58 | + | ac, err := d.finishAttach(sess, replacement, hello.Size, terminalEnv{}, hello) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | attached <- ac | |
| 64 | 61 | }() | |
| 65 | 62 | ||
| 63 | + | select { | |
| 64 | + | case <-attached: | |
| 65 | + | t.Fatal("replacement published before the admitted old render completed") | |
| 66 | + | case <-time.After(20 * time.Millisecond): | |
| 67 | + | } | |
| 68 | + | close(releaseOldSend) | |
| 66 | 69 | select { | |
| 67 | 70 | case ac := <-attached: | |
| 68 | 71 | require.Same(t, replacement, ac.transport()) | |
| 69 | 72 | case <-time.After(time.Second): | |
| 70 | - | t.Fatal("replacement attach waited for the blocked old render worker") | |
| 73 | + | t.Fatal("replacement did not publish after the admitted render completed") | |
| 71 | 74 | } | |
| 72 | 75 | ||
| 73 | - | // The exact old link is revoked and closed; it cannot be mistaken for the | |
| 74 | - | // replacement even after the retired worker finally leaves Send. | |
| 75 | 76 | d.attachmentCleanupWg.Wait() | |
| 76 | - | require.Nil(t, old.transport()) | |
| 77 | + | require.Same(t, oldTransport, old.transport(), "the displaced attachment remains snatched") | |
| 77 | 78 | replacement.AssertNotCalled(t, "Close") | |
| 79 | + | ||
| 80 | + | // Retire the session before mock cleanup so asynchronous terminal Detached | |
| 81 | + | // notification cannot race the clock's C expectation after the test returns. | |
| 82 | + | releasePTY() | |
| 83 | + | awaitTestCompletion(t, d.done, "session did not retire after its final PTY closed") | |
| 84 | + | d.waitNotifies() | |
| 78 | 85 | } | |
M internal/usecase/daemon/status_test.go +2 -2
| @@ -528,7 +528,7 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) { | |||
| 528 | 528 | } | |
| 529 | 529 | tr, _ := newCapturingTransport(t) | |
| 530 | 530 | ||
| 531 | - | ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 531 | + | ac, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 532 | 532 | ||
| 533 | 533 | require.Equal(t, tc.want, ac.getAppliedTheme().Raw) | |
| 534 | 534 | assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background) | |
| @@ -717,7 +717,7 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) { | |||
| 717 | 717 | require.True(t, sess.detachIfCurrent(ac)) | |
| 718 | 718 | ||
| 719 | 719 | tr, _ := newCapturingTransport(t) | |
| 720 | - | newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 720 | + | newAC, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 721 | 721 | d.applyTheme(sess, newAC, ports.Theme{ | |
| 722 | 722 | HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22}, | |
| 723 | 723 | HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25}, | |
M internal/usecase/daemon/transactional_resize.go +10 -0
| @@ -553,6 +553,11 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 553 | 553 | } | |
| 554 | 554 | ||
| 555 | 555 | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | |
| 556 | + | ticket, admitted := beginActiveLeaseEffect(sess, ac, lease) | |
| 557 | + | if !admitted { | |
| 558 | + | return false | |
| 559 | + | } | |
| 560 | + | defer ticket.End() | |
| 556 | 561 | rc := sess.renderCoordinator() | |
| 557 | 562 | if rc == nil { | |
| 558 | 563 | return false | |
| @@ -593,6 +598,11 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 593 | 598 | // only: their rectangles must never cross this delayed boundary, because any | |
| 594 | 599 | // later layout mutation may have changed their geometry or removed them. | |
| 595 | 600 | func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) { | |
| 601 | + | ticket, admitted := beginActiveLeaseEffect(sess, ac, lease) | |
| 602 | + | if !admitted { | |
| 603 | + | return | |
| 604 | + | } | |
| 605 | + | defer ticket.End() | |
| 596 | 606 | rc := sess.renderCoordinator() | |
| 597 | 607 | if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) { | |
| 598 | 608 | return | |
M internal/usecase/daemon/transactional_resize_test.go +33 -11
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "io" | |
| 6 | + | "strings" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| @@ -18,11 +19,12 @@ import ( | |||
| 18 | 19 | // transactionalResizePTY is deliberately channel-scripted: a test can stop a | |
| 19 | 20 | // transaction at PTY.Resize without relying on scheduler timing. | |
| 20 | 21 | type transactionalResizePTY struct { | |
| 21 | - | mu sync.Mutex | |
| 22 | - | sizes []domain.Size | |
| 23 | - | errs []error | |
| 24 | - | onResize func() | |
| 25 | - | onWrite func([]byte) | |
| 22 | + | mu sync.Mutex | |
| 23 | + | sizes []domain.Size | |
| 24 | + | writeData [][]byte | |
| 25 | + | errs []error | |
| 26 | + | onResize func() | |
| 27 | + | onWrite func([]byte) | |
| 26 | 28 | } | |
| 27 | 29 | ||
| 28 | 30 | func (p *transactionalResizePTY) Resize(size domain.Size) error { | |
| @@ -43,10 +45,12 @@ func (p *transactionalResizePTY) Resize(size domain.Size) error { | |||
| 43 | 45 | func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF } | |
| 44 | 46 | func (p *transactionalResizePTY) Write(b []byte) (int, error) { | |
| 45 | 47 | p.mu.Lock() | |
| 48 | + | copied := append([]byte(nil), b...) | |
| 49 | + | p.writeData = append(p.writeData, copied) | |
| 46 | 50 | hook := p.onWrite | |
| 47 | 51 | p.mu.Unlock() | |
| 48 | 52 | if hook != nil { | |
| 49 | - | hook(append([]byte(nil), b...)) | |
| 53 | + | hook(copied) | |
| 50 | 54 | } | |
| 51 | 55 | return len(b), nil | |
| 52 | 56 | } | |
| @@ -59,6 +63,16 @@ func (p *transactionalResizePTY) requested() []domain.Size { | |||
| 59 | 63 | return append([]domain.Size(nil), p.sizes...) | |
| 60 | 64 | } | |
| 61 | 65 | ||
| 66 | + | func (p *transactionalResizePTY) writes() [][]byte { | |
| 67 | + | p.mu.Lock() | |
| 68 | + | defer p.mu.Unlock() | |
| 69 | + | writes := make([][]byte, len(p.writeData)) | |
| 70 | + | for i := range p.writeData { | |
| 71 | + | writes[i] = append([]byte(nil), p.writeData[i]...) | |
| 72 | + | } | |
| 73 | + | return writes | |
| 74 | + | } | |
| 75 | + | ||
| 62 | 76 | // resizeReaderPTY is a deterministic child: Resize releases its redraw only | |
| 63 | 77 | // after the daemon has installed resizeApplying, and waits until ptyReader has | |
| 64 | 78 | // accepted that read. It deliberately has no timing dependency. | |
| @@ -154,7 +168,8 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) { | |||
| 154 | 168 | func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) { | |
| 155 | 169 | responses := make(chan []byte, 1) | |
| 156 | 170 | pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }} | |
| 157 | - | d, sess, _, sends := newManualSessionWithPTYs(t, pty) | |
| 171 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |
| 172 | + | publishActiveClipboardCapability(d, sess, ac, ac.transport()) | |
| 158 | 173 | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 159 | 174 | p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) } | |
| 160 | 175 | p.screen.OnBell = func() { p.ptyAttention = true } | |
| @@ -171,10 +186,17 @@ func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) { | |||
| 171 | 186 | require.NotEmpty(t, <-responses, "DSR response must be flushed back to the child") | |
| 172 | 187 | // DSR is flushed back to the child and OSC 52 is forwarded through the | |
| 173 | 188 | // normal asynchronous client path, proving replay uses processPTYData. | |
| 174 | - | frame := awaitFrame(t, sends, ports.MsgOutput) | |
| 175 | - | out, err := ports.UnmarshalOutput(frame.Payload) | |
| 176 | - | require.NoError(t, err) | |
| 177 | - | require.Contains(t, string(out.Data), "\x1b]52;c;YQ==\a") | |
| 189 | + | var clipboardOutput string | |
| 190 | + | for range 3 { | |
| 191 | + | frame := awaitFrame(t, sends, ports.MsgOutput) | |
| 192 | + | out, err := ports.UnmarshalOutput(frame.Payload) | |
| 193 | + | require.NoError(t, err) | |
| 194 | + | clipboardOutput = string(out.Data) | |
| 195 | + | if strings.Contains(clipboardOutput, "\x1b]52;c;YQ==\a") { | |
| 196 | + | break | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | require.Contains(t, clipboardOutput, "\x1b]52;c;YQ==\a") | |
| 178 | 200 | } | |
| 179 | 201 | ||
| 180 | 202 | // S3 acceptance: prepare may inspect layout under its locks, but apply must | |
M internal/usecase/keys/router.go +43 -29
| @@ -61,11 +61,12 @@ type Router struct { | |||
| 61 | 61 | h Handler | |
| 62 | 62 | bindings *atomic.Pointer[Bindings] | |
| 63 | 63 | ||
| 64 | - | mu sync.Mutex | |
| 65 | - | pending bool | |
| 66 | - | pendingAlt []byte | |
| 67 | - | timer ports.Timer | |
| 68 | - | pendingDone chan struct{} | |
| 64 | + | mu sync.Mutex | |
| 65 | + | pending bool | |
| 66 | + | pendingAlt []byte | |
| 67 | + | pendingHandler Handler | |
| 68 | + | timer ports.Timer | |
| 69 | + | pendingDone chan struct{} | |
| 69 | 70 | } | |
| 70 | 71 | ||
| 71 | 72 | // NewRouter constructs a Router. bindings may be nil, in which case | |
| @@ -74,33 +75,43 @@ func NewRouter(clock ports.Clock, h Handler, bindings *atomic.Pointer[Bindings]) | |||
| 74 | 75 | return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindings} | |
| 75 | 76 | } | |
| 76 | 77 | ||
| 77 | - | // Route routes one transport read. | |
| 78 | - | func (r *Router) Route(data []byte) { | |
| 78 | + | // Route routes one transport read with the router's default handler. | |
| 79 | + | func (r *Router) Route(data []byte) { r.RouteWithHandler(data, r.h) } | |
| 80 | + | ||
| 81 | + | // RouteWithHandler binds every synchronous or delayed result from data to the | |
| 82 | + | // supplied handler. A retained ESC therefore cannot gain authority from a | |
| 83 | + | // later transport frame after the original frame's ownership token is stale. | |
| 84 | + | func (r *Router) RouteWithHandler(data []byte, h Handler) { | |
| 79 | 85 | if len(data) == 0 { | |
| 80 | 86 | return | |
| 81 | 87 | } | |
| 88 | + | if h == nil { | |
| 89 | + | h = r.h | |
| 90 | + | } | |
| 82 | 91 | r.mu.Lock() | |
| 83 | 92 | defer r.mu.Unlock() | |
| 84 | 93 | if r.pending { | |
| 85 | 94 | pendingAlt := r.pendingAlt | |
| 95 | + | pendingHandler := r.pendingHandler | |
| 86 | 96 | r.stopTimer() | |
| 87 | 97 | r.pending = false | |
| 88 | 98 | r.pendingAlt = nil | |
| 99 | + | r.pendingHandler = nil | |
| 89 | 100 | combined := append(append([]byte(nil), pendingAlt...), data...) | |
| 90 | - | if consumed := r.routeAfterPendingESC(combined, len(pendingAlt)); consumed > len(pendingAlt) { | |
| 101 | + | if consumed := r.routeAfterPendingESC(combined, len(pendingAlt), pendingHandler); consumed > len(pendingAlt) { | |
| 91 | 102 | data = data[consumed-len(pendingAlt):] | |
| 92 | 103 | } | |
| 93 | 104 | } | |
| 94 | - | r.route(data) | |
| 105 | + | r.route(data, h) | |
| 95 | 106 | } | |
| 96 | 107 | ||
| 97 | - | func (r *Router) route(data []byte) { | |
| 108 | + | func (r *Router) route(data []byte, h Handler) { | |
| 98 | 109 | buf := make([]byte, 0, len(data)) | |
| 99 | 110 | flush := func() { | |
| 100 | 111 | if len(buf) == 0 { | |
| 101 | 112 | return | |
| 102 | 113 | } | |
| 103 | - | r.forward(buf) | |
| 114 | + | r.forward(buf, h) | |
| 104 | 115 | buf = buf[:0] | |
| 105 | 116 | } | |
| 106 | 117 | for i := 0; i < len(data); { | |
| @@ -111,7 +122,7 @@ func (r *Router) route(data []byte) { | |||
| 111 | 122 | } | |
| 112 | 123 | if i == len(data)-1 { | |
| 113 | 124 | flush() | |
| 114 | - | r.retainESC() | |
| 125 | + | r.retainESC(h) | |
| 115 | 126 | return | |
| 116 | 127 | } | |
| 117 | 128 | remaining := data[i+1:] | |
| @@ -132,12 +143,12 @@ func (r *Router) route(data []byte) { | |||
| 132 | 143 | } | |
| 133 | 144 | if action, size, ok, partial := r.altArrowCSI(remaining); ok { | |
| 134 | 145 | flush() | |
| 135 | - | r.h.Action(action) | |
| 146 | + | h.Action(action) | |
| 136 | 147 | i += 1 + size | |
| 137 | 148 | continue | |
| 138 | 149 | } else if partial { | |
| 139 | 150 | flush() | |
| 140 | - | r.retainESC(remaining) | |
| 151 | + | r.retainESC(h, remaining) | |
| 141 | 152 | return | |
| 142 | 153 | } | |
| 143 | 154 | next := data[i+1] | |
| @@ -148,13 +159,13 @@ func (r *Router) route(data []byte) { | |||
| 148 | 159 | } | |
| 149 | 160 | if action, size, ok := r.binding(remaining); ok { | |
| 150 | 161 | flush() | |
| 151 | - | r.h.Action(action) | |
| 162 | + | h.Action(action) | |
| 152 | 163 | i += 1 + size | |
| 153 | 164 | continue | |
| 154 | 165 | } | |
| 155 | 166 | if partialUTF8Rune(remaining) { | |
| 156 | 167 | flush() | |
| 157 | - | r.retainESC(remaining) | |
| 168 | + | r.retainESC(h, remaining) | |
| 158 | 169 | return | |
| 159 | 170 | } | |
| 160 | 171 | buf = append(buf, ESC) | |
| @@ -163,43 +174,44 @@ func (r *Router) route(data []byte) { | |||
| 163 | 174 | flush() | |
| 164 | 175 | } | |
| 165 | 176 | ||
| 166 | - | func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int { | |
| 177 | + | func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int { | |
| 167 | 178 | if action, size, ok, partial := r.altArrowCSI(data); ok { | |
| 168 | - | r.h.Action(action) | |
| 179 | + | h.Action(action) | |
| 169 | 180 | return size | |
| 170 | 181 | } else if partial { | |
| 171 | - | r.retainESC(data) | |
| 182 | + | r.retainESC(h, data) | |
| 172 | 183 | return len(data) | |
| 173 | 184 | } | |
| 174 | 185 | next := data[0] | |
| 175 | 186 | if action, size, ok := r.binding(data); ok { | |
| 176 | - | r.h.Action(action) | |
| 187 | + | h.Action(action) | |
| 177 | 188 | return size | |
| 178 | 189 | } | |
| 179 | 190 | if partialUTF8Rune(data) { | |
| 180 | - | r.retainESC(data) | |
| 191 | + | r.retainESC(h, data) | |
| 181 | 192 | return len(data) | |
| 182 | 193 | } | |
| 183 | 194 | _, size := utf8.DecodeRune(data) | |
| 184 | 195 | if size > 1 { | |
| 185 | - | r.forward(append([]byte{ESC}, data[:size]...)) | |
| 196 | + | r.forward(append([]byte{ESC}, data[:size]...), h) | |
| 186 | 197 | return size | |
| 187 | 198 | } | |
| 188 | 199 | if pendingAltLen > 0 { | |
| 189 | - | r.forward(append([]byte{ESC}, data[:pendingAltLen]...)) | |
| 200 | + | r.forward(append([]byte{ESC}, data[:pendingAltLen]...), h) | |
| 190 | 201 | return pendingAltLen | |
| 191 | 202 | } | |
| 192 | 203 | if passThroughPrefix(next) { | |
| 193 | - | r.forward([]byte{ESC, next}) | |
| 204 | + | r.forward([]byte{ESC, next}, h) | |
| 194 | 205 | return 1 | |
| 195 | 206 | } | |
| 196 | - | r.forward([]byte{ESC}) | |
| 207 | + | r.forward([]byte{ESC}, h) | |
| 197 | 208 | return 0 | |
| 198 | 209 | } | |
| 199 | 210 | ||
| 200 | - | func (r *Router) retainESC(altBytes ...[]byte) { | |
| 211 | + | func (r *Router) retainESC(h Handler, altBytes ...[]byte) { | |
| 201 | 212 | r.pending = true | |
| 202 | 213 | r.pendingAlt = nil | |
| 214 | + | r.pendingHandler = h | |
| 203 | 215 | if len(altBytes) > 0 { | |
| 204 | 216 | r.pendingAlt = append([]byte(nil), altBytes[0]...) | |
| 205 | 217 | } | |
| @@ -216,8 +228,10 @@ func (r *Router) retainESC(altBytes ...[]byte) { | |||
| 216 | 228 | if r.pending && r.timer == timer { | |
| 217 | 229 | r.pending = false | |
| 218 | 230 | data := append([]byte{ESC}, r.pendingAlt...) | |
| 231 | + | h := r.pendingHandler | |
| 219 | 232 | r.pendingAlt = nil | |
| 220 | - | r.forward(data) | |
| 233 | + | r.pendingHandler = nil | |
| 234 | + | r.forward(data, h) | |
| 221 | 235 | } | |
| 222 | 236 | }(r.timer, r.pendingDone) | |
| 223 | 237 | } | |
| @@ -241,9 +255,9 @@ func partialUTF8Rune(data []byte) bool { | |||
| 241 | 255 | return key == utf8.RuneError && size == 1 | |
| 242 | 256 | } | |
| 243 | 257 | ||
| 244 | - | func (r *Router) forward(data []byte) { | |
| 258 | + | func (r *Router) forward(data []byte, h Handler) { | |
| 245 | 259 | cp := append([]byte(nil), data...) | |
| 246 | - | r.h.Forward(cp) | |
| 260 | + | h.Forward(cp) | |
| 247 | 261 | } | |
| 248 | 262 | ||
| 249 | 263 | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | |