detached ddc019c

feat(daemon): keep replaced clients in snatched state

commit
ddc019c48694ed0fb5628417f4940894d821f6db
author
brice · · 2026-07-27 23:09 UTC
parent
b70b3b4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
4747 // happened — it also covers "no target exists", which is routine and not an
4848 // error. Only a failure to reach a target that does exist is a genuine error.
4949 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 {
5054 if sess == nil || ac == nil {
5155 return nil
5256 }
@@ -62,7 +66,11 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
6266 if !ok {
6367 return nil
6468 }
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)
6674 }
6775
6876 func oldestAttentionTab(sess *session) (int, bool) {
M internal/usecase/daemon/client.go +515 -205
@@ -39,10 +39,12 @@ type attachedClient struct {
3939 overlays *overlayRuntime
4040 overlayOnce sync.Once
4141 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.
4546 roleGeneration atomic.Uint64
47+ roleEffects roleEffectGate
4648 resumeCapable bool
4749 resumeToken uint64
4850 parked bool
@@ -236,14 +238,6 @@ func (ac *attachedClient) transportSnapshotCurrent(expected transportSnapshot) b
236238 return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation
237239 }
238240
239-func (ac *attachedClient) closeTransport() error {
240- tr := ac.transport()
241- if tr == nil {
242- return nil
243- }
244- return tr.Close()
245-}
246-
247241 func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {
248242 if tr == nil {
249243 return nil
@@ -267,22 +261,6 @@ func (ac *attachedClient) ackOutputState(state uint64) {
267261 ac.output.ack(state)
268262 }
269263
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-
286264 var errTransportReplaced = errors.New("client transport was replaced")
287265
288266 // sendExpectedTransport writes only when expected is still the attachment's
@@ -299,6 +277,21 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po
299277 return expected.transport.Send(f)
300278 }
301279
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+
302295 // boundedSend sends f to ac with a deadline watchdog: if the send (including
303296 // waiting on sendMu behind a wedged paint) does not complete within
304297 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -349,6 +342,45 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
349342 })
350343 }
351344
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+
352384 var errSendTimedOut = errors.New("send timed out")
353385
354386 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
376408 }
377409 }
378410
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+ }
385423 done := make(chan struct{})
386424 d.mu.Lock()
387425 // Prune completed entries so the slice stays bounded by the number of
@@ -399,8 +437,17 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
399437
400438 go func() {
401439 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)
404451 d.log.Info("client detached", "reason", reason)
405452 }()
406453 }
@@ -428,13 +475,39 @@ type attachClientOptions struct {
428475 maxOutputInFlight uint8
429476 }
430477
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
435499 }
436500
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 {
438511 resumeToken := uint64(0)
439512 if opts.resumeCapable {
440513 resumeToken = d.nextResumeTokenLocked()
@@ -448,54 +521,8 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
448521 resumeToken: resumeToken,
449522 }
450523 ac.initOverlays()
451- ac.setSession(sess)
452524 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
499526 }
500527
501528 // attachCoordinator is the sole direct attachment handoff. It creates at
@@ -508,37 +535,7 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
508535 }
509536
510537 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)
542539 var cleanup renderLifecycleCleanup
543540 if old != nil {
544541 cleanup = rc.beginReplace(old, current, ready)
@@ -548,6 +545,45 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached
548545 return rc, cleanup
549546 }
550547
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+
551587 // resetScreenDefaultColors clears the known default foreground/background
552588 // colors on every tab in sess. Called once a client has been detached, this
553589 // makes child OSC 10/11 queries go back to being swallowed (Known=false)
@@ -563,43 +599,48 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) {
563599 d.applyHostTheme(sess, nil, themeui.Theme{}, true)
564600 }
565601
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
574608 }
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
579612 }
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()
586622 }
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
596633 }
597634
598635 // firstPaint guarantees the freshly attached client sees the full screen: if
599636 // 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.
602639 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) {
603644 // Global notices raised while nothing was attached surface on this client.
604645 // Drained before the early return below so a session without an active tab
605646 // cannot swallow the queue.
@@ -614,20 +655,30 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
614655
615656 outerResizeAccepted := false
616657 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+ }
618663 }
619664 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
620665 // Activation can synchronously resize a retained floating pane. An accepted
621666 // synchronous outer request already includes that pane and emits the reset,
622667 // but activation still performs its warmup work.
623- activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted)
668+ activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease)
624669 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+ }
626675 }
627676 }
628677
629678 // 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.
631682 func (d *Daemon) runConnLoop(ac *attachedClient) {
632683 tr := ac.transport()
633684 if tr == nil {
@@ -643,7 +694,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
643694 }
644695 f, err := tr.Recv()
645696 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+ }
647703 return
648704 }
649705 if !ac.currentTransportIs(tr) {
@@ -653,46 +709,177 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
653709 if sess == nil {
654710 return
655711 }
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
660717 }
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
664721 }
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
668832 }
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
672839 }
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
678848 }
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
688857 }
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.
694858 }
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
695874 }
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
696883 }
697884
698885 // 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
704891 if !d.detachIfCurrent(sess, ac) {
705892 return // already displaced by a newer client; nothing to do
706893 }
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) {
707911 if rc := sess.renderCoordinator(); rc != nil {
708912 rc.noteDetach(ac)
709913 }
@@ -740,8 +944,8 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
740944
741945 // detachIfCurrent clears the client iff ac is the current one, reporting
742946
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{
745949 Foreground: msg.Foreground,
746950 Background: msg.Background,
747951 Palette: msg.Palette,
@@ -753,6 +957,10 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
753957 SchemeKnown: msg.SchemeKnown,
754958 Light: msg.Light,
755959 }
960+}
961+
962+func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
963+ clientTheme := themeFromMessage(msg)
756964 ac.setClientTheme(clientTheme)
757965
758966 if !d.applyHostTheme(sess, ac, clientTheme, false) {
@@ -761,6 +969,24 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
761969 d.invalidateRender(sess, ac, true, "client.go")
762970 }
763971
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+
764990 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
765991 d.requestTransactionalResize(sess, ac, sz, false)
766992 }
@@ -771,32 +997,78 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
771997 // publishing sess.client. Never retain sess.mu while touching notice or overlay
772998 // state, and retain the routingMu -> sess.mu order used by attachment paths.
773999 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) {
7741015 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+}
7761033
1034+func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) {
1035+ d.notices.routingMu.Lock()
7771036 sess.mu.Lock()
7781037 current := sess.client == ac
7791038 sess.mu.Unlock()
7801039 if !current {
1040+ d.notices.routingMu.Unlock()
7811041 return
7821042 }
7831043 if d.notices.beforeClientNoticeMutation != nil {
7841044 d.notices.beforeClientNoticeMutation()
7851045 }
1046+ repaint := d.mutateClientNotice(sess, ac, notice)
1047+ d.notices.routingMu.Unlock()
1048+ if repaint {
1049+ d.repaintForNotice(ac)
1050+ }
1051+}
7861052
1053+func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool {
7871054 switch notice.Action {
7881055 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")
7901057 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")
7921059 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")
7941061 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
7961065 }
7971066 }
7981067
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 {
8001072 n := d.notices.record(domain.Notification{
8011073 Code: code,
8021074 Severity: sev,
@@ -804,7 +1076,7 @@ func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domai
8041076 Time: d.clock.Now(),
8051077 SessionID: sess.id,
8061078 })
807- d.showToast(ac, n)
1079+ return d.publishToast(ac, n)
8081080 }
8091081
8101082 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
@@ -820,20 +1092,58 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
8201092 return
8211093 }
8221094 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) {
8341139 _ = 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
8361142 }
1143+ d.resetScreenDefaultColors(sess)
1144+ ac.clearPreviousSession()
1145+ _ = ac.closeCapturedTransport(failed)
1146+ d.log.Warn("detached client after send error", "session", sess.name)
8371147 }
8381148
8391149 // killSession removes a session and tears down its resources. It is
M internal/usecase/daemon/clipboard.go +96 -14
@@ -4,6 +4,7 @@
44 package daemon
55
66 import (
7+ "errors"
78 "os"
89
910 "github.com/bnema/vev/internal/ports"
@@ -25,12 +26,27 @@ var (
2526 clipPasteCloseMarker = []byte("\x1b[201~")
2627 )
2728
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)
3450 }
3551
3652 // 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) {
5874 // writeClipboardImage writes ip's bytes to a new 0600 temp file and records
5975 // the path on sess for best-effort cleanup at session end (killSession).
6076 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) {
61104 dir := d.tempDir
62105 if dir == "" {
63106 dir = os.TempDir()
@@ -76,9 +119,6 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
76119 _ = os.Remove(path)
77120 return "", err
78121 }
79- sess.mu.Lock()
80- sess.clipFiles = append(sess.clipFiles, path)
81- sess.mu.Unlock()
82122 return path, nil
83123 }
84124
@@ -86,6 +126,17 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
86126 // the same PTY write path as ordinary input, wrapped in bracketed-paste
87127 // markers iff the pane's Screen reports bracketed-paste mode enabled.
88128 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) {
89140 tb := sess.activeTab()
90141 if tb == nil {
91142 return
@@ -109,6 +160,9 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
109160 wrapped = append(wrapped, clipPasteCloseMarker...)
110161 data = wrapped
111162 }
163+ if token != nil && !token.activeEffect() {
164+ return
165+ }
112166 d.writeToPane(sess, p, data)
113167 }
114168
@@ -132,8 +186,8 @@ func clipboardExt(mime string) string {
132186 }
133187
134188 type clipboardForward struct {
135- ac *attachedClient
136- seq []byte
189+ token attachmentRoleToken
190+ seq []byte
137191 }
138192
139193 // forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
@@ -161,11 +215,31 @@ func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
161215 }
162216 }
163217 ac := sess.client
218+ sess.mu.Unlock()
164219 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 {
165239 sess.mu.Unlock()
166240 return
167241 }
168- sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq})
242+ sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{token: token, seq: seq})
169243 if !sess.clipboardWorkerRunning {
170244 sess.clipboardWorkerRunning = true
171245 go d.clipboardWorker(sess)
@@ -179,9 +253,17 @@ func (d *Daemon) clipboardWorker(sess *session) {
179253 if !ok {
180254 return
181255 }
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()
183265 if err != nil {
184- d.detachOnSendError(sess, item.ac, failed)
266+ d.detachOnRoleSendError(item.token, failed)
185267 }
186268 }
187269 }
M internal/usecase/daemon/clipboard_test.go +74 -4
@@ -3,10 +3,12 @@ package daemon
33 import (
44 "context"
55 "encoding/base64"
6+ "errors"
67 "io"
78 "os"
89 "path/filepath"
910 "strings"
11+ "sync"
1012 "testing"
1113 "time"
1214
@@ -200,6 +202,13 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
200202 return p
201203 }
202204
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+
203212 func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
204213 d := newTestDaemon(t, nil, stubClock{})
205214 p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
@@ -213,14 +222,22 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
213222 sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
214223 ac.setSession(sess)
215224 d.sessions[sess.id] = sess
225+ publishActiveClipboardCapability(d, sess, ac, tr)
216226
217227 d.sessWg.Add(1)
218228 d.ptyReader(sess, win, win.focusedPane())
219229
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")
224241 }
225242
226243 func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
@@ -289,6 +306,57 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
289306 })
290307 }
291308
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+
292360 func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
293361 d := newTestDaemon(t, nil, stubClock{})
294362 tr := newBlockingClipboardTransport()
@@ -298,6 +366,8 @@ func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
298366 defer cancel()
299367 sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}
300368 ac.setSession(sess)
369+ d.sessions[sess.id] = sess
370+ publishActiveClipboardCapability(d, sess, ac, tr)
301371
302372 first := base64.StdEncoding.EncodeToString([]byte("first"))
303373 second := base64.StdEncoding.EncodeToString([]byte("second"))
M internal/usecase/daemon/daemon.go +93 -27
@@ -112,18 +112,53 @@ type Daemon struct {
112112 // and before coordinator epoch admission. It is a deterministic regression
113113 // seam for stale resize publication.
114114 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
127162 // snapshotRepository is the sole checkpoint storage contract.
128163 snapshotRepository ports.SnapshotRepository
129164 recovery *recoveryusecase.Coordinator
@@ -656,7 +691,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
656691 ephemeral := s.ephemeral
657692 s.mu.Unlock()
658693 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 {
660695 checkpointIncomplete = true
661696 d.log.Error("closing session with unpersisted terminal state", "err", err)
662697 }
@@ -918,21 +953,32 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
918953 lease = rc.attachmentLease(ac)
919954 }
920955 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 {
922960 d.clientGone(sess, ac, tr, false)
923961 return
924962 }
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()
926965 d.clientGone(sess, ac, tr, false)
927966 return
928967 }
929968 if !rc.markAttachmentReady(lease) {
969+ welcomeTicket.End()
930970 // The attachment was displaced or detached while Welcome was in flight;
931971 // never let this stale handshake emit an Output frame.
932972 d.clientGone(sess, ac, tr, false)
933973 return
934974 }
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+ }
936982 d.runConnLoop(ac)
937983 _ = tr.Close()
938984 }
@@ -946,23 +992,39 @@ type protoErr struct {
946992 func (e *protoErr) Error() string { return e.text }
947993
948994 // 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) {
952998 // Session state is the sole source for future PTY children. Update it before
953999 // publishing the attachment; existing PTYs keep their original environment.
9541000 sess.mu.Lock()
9551001 sess.env = copyEnvironment(h.Env)
9561002 sess.terminal = term
9571003 sess.mu.Unlock()
958- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{
1004+ opts := attachClientOptions{
9591005 clientID: h.ClientID,
9601006 resumeCapable: true,
9611007 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
962- })
1008+ }
1009+ ac := d.prepareAttachedClientLocked(tr, sz, opts)
9631010 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
9661028 }
9671029
9681030 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
10641126 }
10651127 }
10661128 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
10681131
10691132 case ports.IntentEphemeral:
10701133 name := d.allocEphemeralNameLocked()
@@ -1074,7 +1137,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
10741137 return nil, nil, err
10751138 }
10761139 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
10781142
10791143 case ports.IntentNew:
10801144 if h.Name == "" {
@@ -1095,7 +1159,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
10951159 return nil, nil, err
10961160 }
10971161 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
10991164
11001165 case ports.IntentAttach:
11011166 sess := d.findByNameLocked(h.Name)
@@ -1114,7 +1179,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
11141179 }
11151180 }
11161181 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
11181184
11191185 default:
11201186 d.mu.Unlock()
M internal/usecase/daemon/floating.go +20 -3
@@ -160,15 +160,29 @@ func (d *Daemon) activateTab(sess *session, tb *tab) bool {
160160 // activateTabAfterResize retains tab activation's warmup work while avoiding a
161161 // second resize when a synchronous outer resize request was already accepted.
162162 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 {
163170 if d == nil || sess == nil || tb == nil {
164171 return false
165172 }
166173 // A headless session has no actual terminal destination. Deferring warmup
167174 // until firstPaint keeps restored tabs cold and avoids launching children
168175 // 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+ }
172186 d.exitCopyMode(ac)
173187 if ac == nil {
174188 return false
@@ -182,6 +196,9 @@ func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccep
182196 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
183197 tb.mu.Unlock()
184198 if hasFloating {
199+ if lease != nil {
200+ return d.requestTransactionalResizeForLease(sess, ac, lease, size, true)
201+ }
185202 return d.requestTransactionalResize(sess, ac, size, true)
186203 }
187204 return false
M internal/usecase/daemon/input.go +80 -10
@@ -19,6 +19,13 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
1919 d.handleInput(sess, ac, data)
2020 }
2121
22+func (d *Daemon) handleSequencedInputForRole(token attachmentRoleToken, _ uint64, data []byte) {
23+ if !token.activeEffect() {
24+ return
25+ }
26+ d.handleInputForRole(token, data)
27+}
28+
2229 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
2330 ac.initOverlays()
2431 ac.mouseScan.Scan(data,
@@ -32,6 +39,30 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
3239 )
3340 }
3441
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+
3566 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
3667 frameEvent := ev
3768 ac.initOverlays()
@@ -266,16 +297,49 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
266297 }
267298
268299 type daemonKeyHandler struct {
269- d *Daemon
270- ac *attachedClient
271- actions daemonActionRunner
300+ d *Daemon
301+ ac *attachedClient
302+ actions daemonActionRunner
303+ roleToken attachmentRoleToken
272304 }
273305
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+ }
275328 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()
276337 if sess == nil {
277338 return
278339 }
340+ if owned {
341+ defer effect.End()
342+ }
279343 tb := sess.activeTab()
280344 if tb == nil {
281345 return
@@ -287,10 +351,13 @@ func (h daemonKeyHandler) Forward(data []byte) {
287351 }
288352
289353 func (h daemonKeyHandler) Action(action keys.Action) {
290- sess := h.ac.currentSession()
354+ sess, effect, owned := h.acquireRoleEffect()
291355 if sess == nil {
292356 return
293357 }
358+ if owned {
359+ defer effect.End()
360+ }
294361 runResizeAction := func(request daemonActionRequest) {
295362 request.target = resolveDaemonActionTarget(sess)
296363 runner := h.actions
@@ -309,7 +376,10 @@ func (h daemonKeyHandler) Action(action keys.Action) {
309376 case keys.ActionOpenPalette:
310377 h.d.enterPalette(sess, h.ac)
311378 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 {
313383 h.d.reportError(sess, err)
314384 }
315385 case keys.ActionToggleFloatingPane:
@@ -318,19 +388,19 @@ func (h daemonKeyHandler) Action(action keys.Action) {
318388 h.d.reportError(sess, err)
319389 }
320390 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 {
322392 h.d.reportError(sess, err)
323393 }
324394 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 {
326396 h.d.reportError(sess, err)
327397 }
328398 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 {
330400 h.d.reportError(sess, err)
331401 }
332402 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 {
334404 h.d.reportError(sess, err)
335405 }
336406 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.
443443
444444 // dismissToast removes only the specified visible toast. In particular, a
445445 // 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 {
447447 if ac == nil || ac.overlays == nil {
448- return
448+ return false
449449 }
450450 rt := ac.overlays
451451 rt.noticeMu.Lock()
452+ defer rt.noticeMu.Unlock()
452453 i := rt.indexOfToastLocked(code, sid)
453454 if i < 0 {
454- rt.noticeMu.Unlock()
455- return
455+ return false
456456 }
457457 rt.noticeToasts[i].timer.stop()
458458 rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...)
459459 if len(rt.noticeToasts) == 0 {
460460 rt.noticeOverflow = 0
461461 }
462- rt.noticeMu.Unlock()
463- d.repaintForNotice(ac)
462+ return true
464463 }
465464
466465 // 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) {
7171 tr, _ := newCapturingTransport(t)
7272 replaced := make(chan *attachedClient, 1)
7373 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{})
7575 replaced <- current
7676 }()
7777
7878 // 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.
8181 close(releaseMutation)
8282 <-noticeDone
8383 current := <-replaced
84+ d.attachmentCleanupWg.Wait()
8485
8586 history := d.notices.history()
8687 require.Len(t, history, 1)
8788 require.Equal(t, domain.NoticeClipboard, history[0].Code)
8889 oldToasts, _ := visibleToasts(old)
89- require.Len(t, oldToasts, 1)
90- require.Equal(t, domain.NoticeClipboard, oldToasts[0].Code)
90+ require.Empty(t, oldToasts)
9191 newToasts, _ := visibleToasts(current)
9292 require.Empty(t, newToasts)
9393 sess.mu.Lock()
@@ -97,10 +97,9 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) {
9797
9898 func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
9999 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
104103 }{
105104 {
106105 name: "record",
@@ -111,28 +110,23 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
111110 setup: func(d *Daemon, ac *attachedClient, sid domain.SessionID) {
112111 d.showToast(ac, notice(domain.NoticeConnection, "connection degraded", sid))
113112 },
114- notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected},
115- wantToast: domain.NoticeConnection,
113+ notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected},
116114 },
117115 } {
118116 t.Run(tt.name, func(t *testing.T) {
119117 d, sess, old, _ := newNoticeFixture(t, newNoticeClock())
120- tr, _ := newCapturingTransport(t)
121- current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
122118 if tt.setup != nil {
123119 tt.setup(d, old, sess.id)
124120 }
121+ tr, _ := newCapturingTransport(t)
122+ current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
123+ d.attachmentCleanupWg.Wait()
125124
126125 d.handleClientNotice(sess, old, tt.notice)
127126
128127 require.Empty(t, d.notices.history(), "a replaced client must not record notices")
129128 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")
136130 currentToasts, _ := visibleToasts(current)
137131 require.Empty(t, currentToasts)
138132 })
@@ -140,7 +134,8 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
140134 }
141135
142136 func TestMalformedClientNoticeIsIgnored(t *testing.T) {
143- d, _, ac, _ := newNoticeFixture(t, &noticeClock{})
137+ d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
138+ d.attachCoordinator(sess, nil, ac, true)
144139 tr, ok := ac.tr.(*portsmocks.MockTransport)
145140 require.True(t, ok, "attached client transport must be a MockTransport")
146141 frames := []ports.Frame{
@@ -757,17 +752,18 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
757752 tr, _ := newCapturingTransport(t)
758753 replaced := make(chan *attachedClient, 1)
759754 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{})
761756 replaced <- ac
762757 }()
763758 close(releaseRoute)
764759 <-globalDone
765760 current := <-replaced
761+ d.attachmentCleanupWg.Wait()
766762
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.
769765 oldToasts, _ := visibleToasts(old)
770- require.Len(t, oldToasts, 1)
766+ require.Empty(t, oldToasts)
771767 newToasts, _ := visibleToasts(current)
772768 require.Empty(t, newToasts)
773769 sess.mu.Lock()
M internal/usecase/daemon/overlay_runtime.go +113 -8
@@ -32,10 +32,11 @@ type overlayRuntime struct {
3232 paletteFeedback string
3333 palettePending []byte
3434
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
3940
4041 resizeMu sync.Mutex
4142 resizeActive bool
@@ -228,21 +229,125 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
228229 return active || prePublication
229230 }
230231
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 {
232333 if rt == nil || rt.ac == nil {
233334 return false
234335 }
235336 ac := rt.ac
337+ var effect *roleEffectTicket
338+ if len(effects) != 0 {
339+ effect = effects[0]
340+ }
236341 if rt.promptActive() {
237- d.handlePromptInput(ac, data)
342+ d.handlePromptInput(ac, data, effect)
238343 return true
239344 }
240345 if rt.paletteActive() {
241- d.handlePaletteInput(ac, data)
346+ d.handlePaletteInput(ac, data, effect)
242347 return true
243348 }
244349 if rt.pickerActive() {
245- d.handlePickerInput(ac, data)
350+ d.handlePickerInput(ac, data, effect)
246351 return true
247352 }
248353 if rt.noticesActive() {
M internal/usecase/daemon/palette.go +65 -8
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "errors"
45 "sort"
56 "time"
67
@@ -147,7 +148,7 @@ func (d *Daemon) recordPaletteUse(code string) {
147148 d.paletteRecent = recent
148149 }
149150
150-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
151+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
151152 sess := ac.currentSession()
152153 if sess == nil {
153154 return
@@ -160,6 +161,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
160161 var generation uint64
161162 var rawQuery string
162163 changed, cancel, execute := false, false, false
164+ var effect *roleEffectTicket
165+ if len(effects) != 0 {
166+ effect = effects[0]
167+ }
163168
164169 ac.overlays.paletteMu.Lock()
165170 if ac.overlays.palette == nil {
@@ -270,7 +275,16 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
270275 } else {
271276 target.Stopped = true
272277 }
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 {
274288 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
275289 d.invalidateRender(sess, ac, true, "palette.go")
276290 return
@@ -288,8 +302,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
288302 d.invalidateRender(sess, ac, true, "palette.go")
289303 return
290304 }
291- exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
305+ exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent, effect: effect}
292306 if err := exec.JumpRecentSession(rank); err != nil {
307+ if errors.Is(err, errAttachmentTransition) {
308+ return
309+ }
293310 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
294311 d.invalidateRender(sess, ac, true, "palette.go")
295312 return
@@ -300,12 +317,30 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
300317 }
301318 return
302319 }
303- if !ac.closeExecutedPalette(generation, rawQuery) {
320+ roleHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
321+ if !roleHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
304322 return
305323 }
306324 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)
308326 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+ }
309344 if err != nil {
310345 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
311346 d.reportError(sess, err)
@@ -356,6 +391,7 @@ type paletteExec struct {
356391 ac *attachedClient
357392 recent []recentSession
358393 actions daemonActionRunner
394+ effect *roleEffectTicket
359395 }
360396
361397 func (e paletteExec) runAction(request daemonActionRequest) error {
@@ -378,8 +414,11 @@ func (e paletteExec) CreateTab() error {
378414 }
379415
380416 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)
383422 })
384423 return nil
385424 }
@@ -443,11 +482,20 @@ func (e paletteExec) ToggleFloatingPane() error {
443482 }
444483
445484 func (e paletteExec) BackSession() error {
485+ if e.effect != nil {
486+ return e.d.backSessionForRole(e.effect.roleToken())
487+ }
446488 e.d.backSession(e.sess, e.ac)
447489 return nil
448490 }
449491
450492 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+ }
451499 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
452500 return nil
453501 }
@@ -515,7 +563,16 @@ func (e paletteExec) JumpRecentSession(rank int) error {
515563 if e.d.beforeRecentSessionHandoff != nil {
516564 e.d.beforeRecentSessionHandoff()
517565 }
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 {
519576 return command.ErrInvalidArguments
520577 }
521578 return nil
M internal/usecase/daemon/palette_test.go +17 -4
@@ -287,12 +287,19 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
287287
288288 d.handleInput(current, ac, []byte("\x1b "))
289289 awaitFrame(t, sends, ports.MsgOutput)
290+ generation := ac.roleGeneration.Load()
290291 d.handleInput(current, ac, []byte("stopped\r"))
291292
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")
294298 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")
296303 awaitFrame(t, sends, ports.MsgOutput)
297304 }
298305
@@ -530,11 +537,15 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
530537 require.True(t, ac.overlays.promptActive())
531538 require.Contains(t, string(promptOutput.Data), "Create session")
532539
540+ generation := ac.roleGeneration.Load()
533541 d.handleInput(sess, ac, []byte("scratch\r"))
534542 // The submit first paints the newly attached session while the prompt is
535543 // still open, then handlePromptInput closes the prompt and repaints the
536544 // 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")
538549 finalRepaint := awaitFrame(t, sends, ports.MsgOutput)
539550 finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)
540551 require.NoError(t, err)
@@ -547,6 +558,8 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
547558 require.Equal(t, "scratch", newSess.name)
548559 require.False(t, newSess.ephemeral)
549560 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")
550563 require.Contains(t, string(finalOutput.Data), "scratch")
551564 require.NotContains(t, string(finalOutput.Data), "Create session")
552565 }
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
254254 return nil
255255 }
256256
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 {
258258 target := resolveDaemonActionTarget(sess)
259259 oldFocus := layout.PaneID("")
260260 if target.pane != nil {
@@ -279,6 +279,9 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
279279 if ac == nil {
280280 return errNoNeighbor
281281 }
282+ if len(effects) != 0 && effects[0] != nil {
283+ return d.switchToTargetForRole(effects[0].roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
284+ }
282285 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
283286 }
284287
M internal/usecase/daemon/picker.go +259 -107
@@ -3,6 +3,7 @@ package daemon
33
44 import (
55 "context"
6+ "errors"
67 "sort"
78
89 "github.com/bnema/vev/internal/domain"
@@ -117,7 +118,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
117118 }
118119 }
119120
120-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
121+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
121122 sess := ac.currentSession()
122123 if sess == nil {
123124 return
@@ -148,11 +149,23 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
148149 rt.pickerMu.Unlock()
149150
150151 if result.action == 'x' {
152+ var effect *roleEffectTicket
153+ if len(effects) != 0 {
154+ effect = effects[0]
155+ }
156+ effect.bindActionEnd(d, "picker-delete")
151157 if ok {
152- if err := d.killPickerTarget(target); err != nil {
158+ if err := d.killPickerTargetForRole(target, effect.roleToken()); err != nil {
153159 d.reportError(sess, err)
154160 }
155161 }
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+ }
156169 d.refreshPicker(ac)
157170 d.invalidateRender(sess, ac, true, "picker.go")
158171 return
@@ -160,12 +173,24 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
160173 if result.changed {
161174 d.registerPreviewForSelection(ac)
162175 }
163- if result.exit {
176+ navigating := (result.action == '\r' || result.action == '\n') && ok
177+ if result.exit && !navigating {
164178 d.closePicker(ac)
165179 }
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 {
168192 d.reportError(sess, err)
193+ return
169194 }
170195 return
171196 }
@@ -347,6 +372,43 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
347372 // command switching behavior.
348373 type sessionHandoffGuard struct {
349374 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
350412 }
351413
352414 // switchToTarget resolves named lifecycle targets and commits their transition
@@ -356,15 +418,34 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
356418 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
357419 }
358420
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.
361435 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 {
362440 if target.Name != "" {
363441 ctx := d.serveCtx
364442 if ctx == nil {
365443 ctx = context.Background()
366444 }
367445 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
446+ if sourceToken != nil && !sourceToken.activeCurrent() {
447+ return errAttachmentTransition
448+ }
368449 d.invalidateRender(from, ac, true, "picker.go")
369450 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
370451 }
@@ -373,8 +454,7 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target
373454 d.mu.Lock()
374455 var (
375456 targetSess *session
376- old *attachedClient
377- cleanups []renderLifecycleCleanup
457+ transition attachmentTransitionResult
378458 switched bool
379459 cause error
380460 )
@@ -382,39 +462,56 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target
382462 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
383463 if ok {
384464 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)
386466 } else {
387467 // The snapshot ID may describe the stopped representation. The
388468 // locked name/lifecycle resolver chose this active incarnation.
389469 resolvedTarget := target
390470 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)
392472 }
393473 }
394474 } 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)
396476 }
397477 d.mu.Unlock()
398478
399479 if !switched {
480+ if sourceToken != nil && !sourceToken.activeCurrent() {
481+ return errAttachmentTransition
482+ }
400483 if guard.expectedSource != nil {
401484 return errNoNeighbor
402485 }
403486 d.invalidateRender(from, ac, true, "picker.go")
404487 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
405488 }
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+ }
412499 }
500+ d.deferAttachmentTransitionCleanups(transition)
413501 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+ }
416513 } else {
417- d.firstPaint(targetSess, ac, ac.size)
514+ d.firstPaintForTransition(transition.published)
418515 }
419516 return nil
420517 }
@@ -439,121 +536,154 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
439536 return nil, stopped, true, true
440537 }
441538
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) {
449542 if d.sessions[target.Session] != targetSess {
450- return nil, nil, nil, false
543+ return nil, attachmentTransitionResult{}, false
451544 }
452545 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
457554 }
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
460564 }
461565
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
467570 }
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
475571 if guard.expectedSource != nil {
476572 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
480575 }
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
488582 }
489583 }
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()
498585
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+ }
514612 d.touchMRU(targetSess)
515613 ac.recordPreviousSession(from)
516- return targetSess, old, cleanups, true
614+ return targetSess, transition, true
517615 }
518616
519617 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
520618 // the handoff while d.mu is held. Creation failure leaves the source client
521619 // 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) {
523621 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
525657 }
526658
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()
531659 from.mu.Lock()
532660 if from.client != ac {
533661 from.mu.Unlock()
534- return nil, nil, false, nil
662+ return nil, attachmentTransitionResult{}, false, nil
535663 }
536664 term := from.terminal
537665 env := copyEnvironment(from.env)
666+ from.mu.Unlock()
538667 cwd := d.dirOrHome(stopped.cwd)
539668 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)
540669 if err != nil {
541- from.mu.Unlock()
542670 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
543- return nil, nil, false, err
671+ return nil, attachmentTransitionResult{}, false, err
544672 }
545- from.client = nil
546- ac.setSession(nil)
547- from.mu.Unlock()
548673
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+ }
554684 d.touchMRU(targetSess)
555685 ac.recordPreviousSession(from)
556- return targetSess, cleanups, true, nil
686+ return targetSess, transition, true, nil
557687 }
558688
559689 func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
@@ -564,13 +694,13 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
564694 // targets must use switchToTarget so resolution and commit share d.mu.
565695 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
566696 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, "")
568698 d.mu.Unlock()
569699 if !switched {
570700 return nil
571701 }
572- finishRenderLifecycleCleanups(cleanups)
573- return old
702+ d.deferAttachmentTransitionCleanups(transition)
703+ return transition.displaced.ac
574704 }
575705
576706 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
@@ -579,23 +709,45 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
579709 d.mu.Lock()
580710 stopped, ok := d.stopped[target.Name]
581711 var (
582- targetSess *session
583- cleanups []renderLifecycleCleanup
712+ transition attachmentTransitionResult
584713 switched bool
585714 )
586715 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{}, "")
588717 }
589718 d.mu.Unlock()
590719 if !switched {
591720 d.invalidateRender(from, ac, true, "picker.go")
592721 return false
593722 }
594- finishRenderLifecycleCleanups(cleanups)
595- d.firstPaint(targetSess, ac, ac.size)
723+ d.deferAttachmentTransitionCleanups(transition)
724+ d.firstPaintForTransition(transition.published)
596725 return true
597726 }
598727
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+
599751 func (d *Daemon) killPickerTarget(target picker.Target) error {
600752 if target.Stopped {
601753 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) {
505505 require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
506506 }
507507
508-func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
508+func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) {
509509 p1, releasePTY1 := newBlockingPTY(t)
510510 p2, releasePTY2 := newBlockingPTY(t)
511511 defer releasePTY1()
@@ -540,11 +540,24 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
540540 require.Same(t, ac1, sess2.client)
541541 require.Nil(t, sess1.client)
542542 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)
545552 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")
548561 }
549562
550563 func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
@@ -623,7 +636,7 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
623636 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")
624637 }
625638
626-func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
639+func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing.T) {
627640 p1, releasePTY1 := newBlockingPTY(t)
628641 p2, releasePTY2 := newBlockingPTY(t)
629642 defer releasePTY1()
@@ -662,25 +675,41 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
662675 }()
663676 <-enteredSend
664677
665- switchDone := make(chan struct{})
678+ switchDone := make(chan attachmentTransitionResult, 1)
679+ switchErr := make(chan error, 1)
666680 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
670692 }()
671- awaitTestCompletion(t, handoffAtSendMu, "session switch did not reach the handoff sendMu boundary")
672693
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())
675702 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")
678705 default:
679706 }
680707
681708 close(releaseSend)
682709 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+ }
684713 }
685714
686715 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
2323 d.invalidateRender(sess, ac, true, "prompt.go")
2424 }
2525
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+
2636 func (d *Daemon) closePrompt(ac *attachedClient) {
2737 ac.overlays.promptMu.Lock()
2838 ac.overlays.prompt = nil
2939 ac.overlays.promptSubmit = nil
40+ ac.overlays.promptTransitionSubmit = nil
3041 ac.overlays.promptPending = nil
3142 ac.overlays.promptMu.Unlock()
3243 }
@@ -39,7 +50,7 @@ func promptValidationError(err error) bool {
3950 errors.Is(err, errSessionNameInUse)
4051 }
4152
42-func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
53+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
4354 sess := ac.currentSession()
4455 if sess == nil {
4556 return
@@ -55,6 +66,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
5566 changed := false
5667 exit := false
5768 var submit func(string) error
69+ var transitionSubmit func(string, attachmentRoleToken) error
5870 var submittedPrompt *promptui.Model
5971 var value string
6072
@@ -69,6 +81,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
6981 },
7082 enter: func() {
7183 submit = ac.overlays.promptSubmit
84+ transitionSubmit = ac.overlays.promptTransitionSubmit
7285 submittedPrompt = ac.overlays.prompt
7386 value = strings.TrimSpace(ac.overlays.prompt.Value())
7487 },
@@ -78,8 +91,21 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
7891 })
7992 ac.overlays.promptMu.Unlock()
8093
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 {
83109 ac.overlays.promptMu.Lock()
84110 if ac.overlays.prompt == submittedPrompt {
85111 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
210210 // paint owns attachment sendMu while it captures, composes, and emits; JSONL
211211 // observer I/O must therefore happen only after that ownership is released.
212212 type runtimeMarkBatch struct {
213- observer ports.RuntimeObserver
214- marks []ports.RuntimeMark
213+ observer ports.RuntimeObserver
214+ marks []ports.RuntimeMark
215+ roleEffect *roleEffectTicket
215216 }
216217
217218 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
@@ -243,6 +244,16 @@ func (b *runtimeMarkBatch) flush() {
243244
244245 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) {
245246 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+ }
246257 tb := sess.activeTab()
247258 if tb == nil {
248259 return
M internal/usecase/daemon/render_coordinator.go +60 -46
@@ -242,8 +242,17 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
242242 // notifyAck reports that the client acknowledged an output state, releasing
243243 // at most one deadline/watchdog-deferred wake. It never bypasses an unexpired
244244 // 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) {
246251 c.mu.Lock()
252+ if lease != nil && !c.leaseCurrentLocked(lease, true) {
253+ c.mu.Unlock()
254+ return
255+ }
247256 if (!c.ackDeferred && !c.deadlineDue) || !c.pending {
248257 c.mu.Unlock()
249258 return
@@ -558,29 +567,41 @@ func (cleanup renderLifecycleCleanup) finish() {
558567 // beginDetach invalidates pending work. It never waits for detached workers.
559568 func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup {
560569 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 {
561578 var cleanup renderLifecycleCleanup
562579 if c.lease != nil && c.lease.active && c.lease.attachment == ac {
563580 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()
582582 }
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
584605 return cleanup
585606 }
586607
@@ -593,36 +614,29 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
593614 // for detached workers.
594615 func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {
595616 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)
622618 c.mu.Unlock()
623619 return cleanup
624620 }
625621
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+
626640 // noteReplace is for callers that hold no outer lifecycle locks.
627641 func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {
628642 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
415415 return false
416416 }
417417 }
418- ac.sess.mu.Lock()
419- if ac.sess.v != sess {
420- ac.sess.mu.Unlock()
421- ac.sendMu.Unlock()
422- return false
423- }
424418 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
425419 prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset)
426420 endDiff(0, err == nil)
427421 if err != nil {
428- ac.sess.mu.Unlock()
429422 ac.sendMu.Unlock()
430423 d.log.Error("render draw failed", "err", err, "session", sess.name)
431424 // Without a coordinator reportError repaints synchronously. Suppress only
@@ -448,7 +441,8 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
448441 var sendTr ports.Transport
449442 var sendErr error
450443 if len(data) > 0 {
451- sendTr = ac.transport()
444+ sendTransport := ac.transportSnapshot()
445+ sendTr = sendTransport.transport
452446 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
453447 if sendTr == nil {
454448 sendErr = errors.New("client transport is nil")
@@ -457,18 +451,25 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
457451 if async, ok := sendTr.(ports.AsyncTransport); ok {
458452 send = async.SendAsync
459453 }
454+ interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)
460455 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+ }
461462 }
462463 endEmit(uint64(len(data)), sendErr == nil)
463464 }
464465 if sendErr == nil {
465466 // 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.
468470 ac.pipelineScratch = ac.pipelineCache
469471 ac.pipelineCache = composed.cache
470472 }
471- ac.sess.mu.Unlock()
472473 if sendErr == nil {
473474 // A successful no-byte emission also commits: its renderer shadow still
474475 // 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
480481 }
481482 ac.sendMu.Unlock()
482483 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+ }
484498 }
485499 return true
486500 }
M internal/usecase/daemon/resume.go +19 -3
@@ -183,15 +183,31 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
183183 ac.size = sz
184184 ac.resumeToken = d.nextResumeTokenLocked()
185185 ac.parked = false
186- ac.setSession(sess)
187186 // The resumed session's snapshot is the sole source for future PTY children.
188187 // Existing PTYs retain the environment they were started with.
189188 sess.mu.Lock()
190189 sess.env = copyEnvironment(h.Env)
191190 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
192- sess.client = ac
193191 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)
195211 d.touchMRU(sess)
196212 d.log.Info("client resumed", "session", sess.name)
197213 return sess, ac, true, nil
M internal/usecase/daemon/resume_test.go +2 -0
@@ -308,6 +308,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
308308 token := ac.resumeToken
309309 require.True(t, sess.detachIfCurrent(ac))
310310 require.True(t, d.parkAttachment(sess, ac))
311+ generation := ac.roleGeneration.Load()
311312
312313 newTr := &closeTrackingTransport{}
313314 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.
315316 require.True(t, ok)
316317 require.Same(t, sess, resumedSess)
317318 require.Same(t, ac, resumedAC)
319+ require.Greater(t, ac.roleGeneration.Load(), generation, "resume must publish active ownership through the attachment transition")
318320
319321 _ = ac.closeCapturedTransport(oldTr)
320322 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 {
5252 teardownWaiters uint
5353 teardownChanged chan struct{}
5454 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
5656 // restoreDone remains attached to a restored session after it is published in
5757 // the live registry, so racing attaches cannot bypass restoration completion.
5858 restoreDone chan struct{}
@@ -60,6 +60,7 @@ type session struct {
6060 tabs []*tab
6161 active int
6262 client *attachedClient
63+ snatched map[*attachedClient]struct{}
6364 clipboardQueue []clipboardForward
6465 clipboardWorkerRunning bool
6566 cwd string
@@ -361,55 +362,95 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
361362 d.mu.Unlock()
362363 return errSessionNameInUse
363364 }
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()
368365 from.mu.Lock()
369366 cwd := from.cwd
370367 term := from.terminal
371368 env := copyEnvironment(from.env)
372369 if from.client != ac {
373370 from.mu.Unlock()
374- d.notices.routingMu.Unlock()
375371 d.mu.Unlock()
376372 return errors.New("client detached")
377373 }
378374 from.mu.Unlock()
379375
380376 newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)
377+ d.mu.Unlock()
381378 if err != nil {
382- d.notices.routingMu.Unlock()
383- d.mu.Unlock()
384379 return err
385380 }
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 {
391394 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
392395 return errors.New("client detached")
393396 }
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()
405397
406398 d.touchMRU(newSess)
407399 ac.recordPreviousSession(from)
408400 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)
413454 return nil
414455 }
415456
@@ -598,13 +639,79 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
598639 return false
599640 }
600641
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.
604644 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+ }
605653 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
608715 }
609716
610717 func (s *session) activeTab() *tab {
@@ -921,7 +1028,145 @@ func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) erro
9211028 }
9221029
9231030 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)
9251170 }
9261171
9271172 // stopInMemoryLifecycle cancels every session producer and closes every PTY
@@ -1023,10 +1268,52 @@ func (s *session) finishTeardown() {
10231268 // terminal checkpoint and coordinator join. A timed-out repository call keeps
10241269 // only immutable capture state; it never observes closed worker channels or
10251270 // 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 {
10271272 if sess == nil {
10281273 return nil
10291274 }
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+
10301317 // Pane EOF, explicit close, and daemon shutdown can all converge on the same
10311318 // session. Only one owner may create the terminal mutation and checkpoint;
10321319 // later callers observe the completed registry transition instead of
@@ -1119,21 +1406,62 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
11191406 sess.snapshotMu.Unlock()
11201407 }
11211408 }
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.
11221412 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 {
11241414 d.mu.Unlock()
11251415 return nil
11261416 }
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.
11271437 delete(d.sessions, sess.id)
11281438 d.clearBarScriptsForSession(sess.id)
11291439 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)
11311457 stoppedName := sess.name
11321458 stoppedCwd := sess.cwd
11331459 createdAt := sess.createdAt
11341460 tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames
11351461 ephemeral := sess.ephemeral
1136- sess.mu.Unlock()
1462+ unlockCoordinators()
1463+ unlockSessions()
1464+ d.notices.routingMu.Unlock()
11371465 if !ephemeral {
11381466 stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
11391467 d.stopped[stoppedName] = stopped
@@ -1146,18 +1474,11 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
11461474 d.closing = true
11471475 }
11481476 d.mu.Unlock()
1477+ frozen.unfreeze()
1478+ frozen = frozenRoleEffectGates{}
11491479 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
11501480
11511481 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()
11611482 if rc := sess.renderCoordinator(); rc != nil {
11621483 // Terminal teardown has two phases: this session owner first prevents any
11631484 // new worker registration and detaches all tokens, then stops and waits
@@ -1165,11 +1486,10 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
11651486 rc.beginSessionTeardown().finish()
11661487 rc.waitForTimerWorkers()
11671488 }
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()
11731493 }
11741494
11751495 // Prevent queued launches from entering Open, cancel the parent context, and
@@ -1183,7 +1503,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
11831503 sess.mu.Unlock()
11841504 for _, tb := range tabs {
11851505 d.clearDestroyedTabPreview(tb)
1186- d.teardownFloating(tb, ac)
1506+ d.teardownFloating(tb, detachedActive)
11871507 }
11881508 for _, path := range clipFiles {
11891509 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
12121532 d.repaintAllAttachedClients()
12131533 }
12141534
1215- if ac != nil {
1216- d.notifyDetachedAsync(ac, reason)
1535+ for _, attachment := range attachments {
1536+ d.notifyDetachedSnapshotAsync(attachment, reason)
12171537 }
12181538 return errors.Join(purgeErr, terminalSnapshotErr)
12191539 }
M internal/usecase/daemon/session_back.go +12 -0
@@ -4,6 +4,18 @@ import "github.com/bnema/vev/internal/usecase/picker"
44
55 // backSession toggles this attachment between its current session and the
66 // 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+
719 func (d *Daemon) backSession(current *session, ac *attachedClient) {
820 if d == nil || current == nil || ac == nil {
921 return
M internal/usecase/daemon/session_test.go +9 -7
@@ -283,7 +283,7 @@ func TestEphemeralNumberingReuse(t *testing.T) {
283283
284284 // --- attach replace ---------------------------------------------------------
285285
286-func TestAttachReplaceDetachesOld(t *testing.T) {
286+func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) {
287287 p, releasePTY := newBlockingPTY(t)
288288 d := newTestDaemon(t, newFactory(t, p), stubClock{})
289289
@@ -295,21 +295,23 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
295295 awaitFrame(t, sendsA, ports.MsgOutput)
296296 sess := firstSession(d)
297297 require.NotNil(t, sess)
298+ sess.mu.Lock()
299+ acA := sess.client
300+ sess.mu.Unlock()
301+ require.NotNil(t, acA)
298302
299303 // Client B attaches to the same session, displacing A.
300304 trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))
301305 hg.Go(func() { d.handleConn(trB) })
302306 awaitFrame(t, sendsB, ports.MsgWelcome)
303307
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.
310309 sess.mu.Lock()
311310 require.NotNil(t, sess.client)
311+ require.NotSame(t, acA, sess.client)
312+ require.Contains(t, sess.snatched, acA)
312313 sess.mu.Unlock()
314+ require.Same(t, sess, acA.currentSession())
313315
314316 releaseA()
315317 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")
5959 // sendSnatchedPanel serializes a dependency-free panel reset onto one exact
6060 // attachment role and transport lifetime. It does not inspect session state or
6161 // 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 {
6363 if expected.transport == nil {
6464 return errors.New("snatched client transport is nil")
6565 }
66+ var effect *roleEffectTicket
67+ if len(effects) != 0 {
68+ effect = effects[0]
69+ }
6670 tr, err := d.boundedSendWith(expected.transport, func() error {
6771 ac.sendMu.Lock()
6872 defer ac.sendMu.Unlock()
@@ -81,7 +85,17 @@ func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapsho
8185 return err
8286 }
8387 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
8599 })
86100 if errors.Is(err, errSendTimedOut) {
87101 _ = ac.closeCapturedTransport(tr)
M internal/usecase/daemon/stalled_attachment_test.go +25 -18
@@ -1,8 +1,6 @@
11 package daemon
22
33 import (
4- "io"
5- "sync"
64 "testing"
75 "time"
86
@@ -14,10 +12,10 @@ import (
1412 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1513 )
1614
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) {
2119 pty, releasePTY := newBlockingPTY(t)
2220 defer releasePTY()
2321 clock := newCoordinatorMockClock(t, 4)
@@ -25,17 +23,14 @@ func TestFinishAttachDoesNotWaitForBlockedOldRenderWorker(t *testing.T) {
2523
2624 oldTransport := portsmocks.NewMockTransport(t)
2725 oldSendEntered := make(chan struct{})
28- oldClosed := make(chan struct{})
29- var closeOnce sync.Once
26+ releaseOldSend := make(chan struct{})
3027 oldTransport.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error {
3128 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
3730 return nil
3831 }).Once()
32+ oldTransport.EXPECT().Send(mock.Anything).Return(nil).Maybe()
33+ oldTransport.EXPECT().Close().Return(nil).Maybe()
3934
4035 hello := ports.Hello{
4136 Version: ports.ProtocolVersion,
@@ -60,19 +55,31 @@ func TestFinishAttachDoesNotWaitForBlockedOldRenderWorker(t *testing.T) {
6055 attached := make(chan *attachedClient, 1)
6156 go func() {
6257 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
6461 }()
6562
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)
6669 select {
6770 case ac := <-attached:
6871 require.Same(t, replacement, ac.transport())
6972 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")
7174 }
7275
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.
7576 d.attachmentCleanupWg.Wait()
76- require.Nil(t, old.transport())
77+ require.Same(t, oldTransport, old.transport(), "the displaced attachment remains snatched")
7778 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()
7885 }
M internal/usecase/daemon/status_test.go +2 -2
@@ -528,7 +528,7 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
528528 }
529529 tr, _ := newCapturingTransport(t)
530530
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{})
532532
533533 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
534534 assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
@@ -717,7 +717,7 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
717717 require.True(t, sess.detachIfCurrent(ac))
718718
719719 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{})
721721 d.applyTheme(sess, newAC, ports.Theme{
722722 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
723723 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
553553 }
554554
555555 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()
556561 rc := sess.renderCoordinator()
557562 if rc == nil {
558563 return false
@@ -593,6 +598,11 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
593598 // only: their rectangles must never cross this delayed boundary, because any
594599 // later layout mutation may have changed their geometry or removed them.
595600 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()
596606 rc := sess.renderCoordinator()
597607 if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
598608 return
M internal/usecase/daemon/transactional_resize_test.go +33 -11
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "errors"
55 "io"
6+ "strings"
67 "sync"
78 "testing"
89 "time"
@@ -18,11 +19,12 @@ import (
1819 // transactionalResizePTY is deliberately channel-scripted: a test can stop a
1920 // transaction at PTY.Resize without relying on scheduler timing.
2021 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)
2628 }
2729
2830 func (p *transactionalResizePTY) Resize(size domain.Size) error {
@@ -43,10 +45,12 @@ func (p *transactionalResizePTY) Resize(size domain.Size) error {
4345 func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }
4446 func (p *transactionalResizePTY) Write(b []byte) (int, error) {
4547 p.mu.Lock()
48+ copied := append([]byte(nil), b...)
49+ p.writeData = append(p.writeData, copied)
4650 hook := p.onWrite
4751 p.mu.Unlock()
4852 if hook != nil {
49- hook(append([]byte(nil), b...))
53+ hook(copied)
5054 }
5155 return len(b), nil
5256 }
@@ -59,6 +63,16 @@ func (p *transactionalResizePTY) requested() []domain.Size {
5963 return append([]domain.Size(nil), p.sizes...)
6064 }
6165
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+
6276 // resizeReaderPTY is a deterministic child: Resize releases its redraw only
6377 // after the daemon has installed resizeApplying, and waits until ptyReader has
6478 // accepted that read. It deliberately has no timing dependency.
@@ -154,7 +168,8 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
154168 func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
155169 responses := make(chan []byte, 1)
156170 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())
158173 tb, p := sess.activeTab(), sess.activeTab().focusedPane()
159174 p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
160175 p.screen.OnBell = func() { p.ptyAttention = true }
@@ -171,10 +186,17 @@ func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
171186 require.NotEmpty(t, <-responses, "DSR response must be flushed back to the child")
172187 // DSR is flushed back to the child and OSC 52 is forwarded through the
173188 // 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")
178200 }
179201
180202 // 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 {
6161 h Handler
6262 bindings *atomic.Pointer[Bindings]
6363
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{}
6970 }
7071
7172 // 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])
7475 return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindings}
7576 }
7677
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) {
7985 if len(data) == 0 {
8086 return
8187 }
88+ if h == nil {
89+ h = r.h
90+ }
8291 r.mu.Lock()
8392 defer r.mu.Unlock()
8493 if r.pending {
8594 pendingAlt := r.pendingAlt
95+ pendingHandler := r.pendingHandler
8696 r.stopTimer()
8797 r.pending = false
8898 r.pendingAlt = nil
99+ r.pendingHandler = nil
89100 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) {
91102 data = data[consumed-len(pendingAlt):]
92103 }
93104 }
94- r.route(data)
105+ r.route(data, h)
95106 }
96107
97-func (r *Router) route(data []byte) {
108+func (r *Router) route(data []byte, h Handler) {
98109 buf := make([]byte, 0, len(data))
99110 flush := func() {
100111 if len(buf) == 0 {
101112 return
102113 }
103- r.forward(buf)
114+ r.forward(buf, h)
104115 buf = buf[:0]
105116 }
106117 for i := 0; i < len(data); {
@@ -111,7 +122,7 @@ func (r *Router) route(data []byte) {
111122 }
112123 if i == len(data)-1 {
113124 flush()
114- r.retainESC()
125+ r.retainESC(h)
115126 return
116127 }
117128 remaining := data[i+1:]
@@ -132,12 +143,12 @@ func (r *Router) route(data []byte) {
132143 }
133144 if action, size, ok, partial := r.altArrowCSI(remaining); ok {
134145 flush()
135- r.h.Action(action)
146+ h.Action(action)
136147 i += 1 + size
137148 continue
138149 } else if partial {
139150 flush()
140- r.retainESC(remaining)
151+ r.retainESC(h, remaining)
141152 return
142153 }
143154 next := data[i+1]
@@ -148,13 +159,13 @@ func (r *Router) route(data []byte) {
148159 }
149160 if action, size, ok := r.binding(remaining); ok {
150161 flush()
151- r.h.Action(action)
162+ h.Action(action)
152163 i += 1 + size
153164 continue
154165 }
155166 if partialUTF8Rune(remaining) {
156167 flush()
157- r.retainESC(remaining)
168+ r.retainESC(h, remaining)
158169 return
159170 }
160171 buf = append(buf, ESC)
@@ -163,43 +174,44 @@ func (r *Router) route(data []byte) {
163174 flush()
164175 }
165176
166-func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
177+func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int {
167178 if action, size, ok, partial := r.altArrowCSI(data); ok {
168- r.h.Action(action)
179+ h.Action(action)
169180 return size
170181 } else if partial {
171- r.retainESC(data)
182+ r.retainESC(h, data)
172183 return len(data)
173184 }
174185 next := data[0]
175186 if action, size, ok := r.binding(data); ok {
176- r.h.Action(action)
187+ h.Action(action)
177188 return size
178189 }
179190 if partialUTF8Rune(data) {
180- r.retainESC(data)
191+ r.retainESC(h, data)
181192 return len(data)
182193 }
183194 _, size := utf8.DecodeRune(data)
184195 if size > 1 {
185- r.forward(append([]byte{ESC}, data[:size]...))
196+ r.forward(append([]byte{ESC}, data[:size]...), h)
186197 return size
187198 }
188199 if pendingAltLen > 0 {
189- r.forward(append([]byte{ESC}, data[:pendingAltLen]...))
200+ r.forward(append([]byte{ESC}, data[:pendingAltLen]...), h)
190201 return pendingAltLen
191202 }
192203 if passThroughPrefix(next) {
193- r.forward([]byte{ESC, next})
204+ r.forward([]byte{ESC, next}, h)
194205 return 1
195206 }
196- r.forward([]byte{ESC})
207+ r.forward([]byte{ESC}, h)
197208 return 0
198209 }
199210
200-func (r *Router) retainESC(altBytes ...[]byte) {
211+func (r *Router) retainESC(h Handler, altBytes ...[]byte) {
201212 r.pending = true
202213 r.pendingAlt = nil
214+ r.pendingHandler = h
203215 if len(altBytes) > 0 {
204216 r.pendingAlt = append([]byte(nil), altBytes[0]...)
205217 }
@@ -216,8 +228,10 @@ func (r *Router) retainESC(altBytes ...[]byte) {
216228 if r.pending && r.timer == timer {
217229 r.pending = false
218230 data := append([]byte{ESC}, r.pendingAlt...)
231+ h := r.pendingHandler
219232 r.pendingAlt = nil
220- r.forward(data)
233+ r.pendingHandler = nil
234+ r.forward(data, h)
221235 }
222236 }(r.timer, r.pendingDone)
223237 }
@@ -241,9 +255,9 @@ func partialUTF8Rune(data []byte) bool {
241255 return key == utf8.RuneError && size == 1
242256 }
243257
244-func (r *Router) forward(data []byte) {
258+func (r *Router) forward(data []byte, h Handler) {
245259 cp := append([]byte(nil), data...)
246- r.h.Forward(cp)
260+ h.Forward(cp)
247261 }
248262
249263 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close